mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-06-03 15:02:56 +00:00
refactor(frontend): restructure project layout and update API schema
Relocate frontend source code from `next-app/` to `frontend/` to align with the new project structure. This includes removing the old Next.js boilerplate files and establishing a cleaner workspace. Additionally, updates the OpenAPI specification to include support for the `immich` widget type and its corresponding configuration schema. - Move frontend files to `frontend/` - Delete obsolete `next-app/` directory and its configuration - Add `immich` widget type to `openapi.yaml` - Update `FrontendPlan.md` with dashboard refactor and UX direction
This commit is contained in:
+821
@@ -440,3 +440,824 @@ Use the package manager chosen during app scaffold. If using pnpm, scripts remai
|
|||||||
- Regenerate API client after any OpenAPI update.
|
- Regenerate API client after any OpenAPI update.
|
||||||
- Keep all frontend work isolated inside `/frontend`.
|
- Keep all frontend work isolated inside `/frontend`.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Dashboard Refactor & UX Plan
|
||||||
|
|
||||||
|
## Core Product Direction
|
||||||
|
|
||||||
|
The dashboard should feel empty, intentional, and flexible on first launch.
|
||||||
|
|
||||||
|
Current issue:
|
||||||
|
|
||||||
|
* The app starts with too much structure and too many assumptions.
|
||||||
|
* Users feel boxed into layouts before they build their own workspace.
|
||||||
|
|
||||||
|
New direction:
|
||||||
|
|
||||||
|
* Start with a clean canvas.
|
||||||
|
* Let users create widgets and apps only when needed.
|
||||||
|
* Prioritize drag-and-drop, layout freedom, responsiveness, and visual clarity.
|
||||||
|
* Make the dashboard feel closer to CasaOS in usability and visual hierarchy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. First Launch Experience
|
||||||
|
|
||||||
|
## Current Problem
|
||||||
|
|
||||||
|
Dashboard launches with widgets/groups already visible.
|
||||||
|
|
||||||
|
## New Behavior
|
||||||
|
|
||||||
|
On first launch:
|
||||||
|
|
||||||
|
* No widgets
|
||||||
|
* No pre-created services
|
||||||
|
* No placeholder cards
|
||||||
|
* No fake demo groups
|
||||||
|
|
||||||
|
Only show:
|
||||||
|
|
||||||
|
### Section 1 — Widgets
|
||||||
|
|
||||||
|
Top-right:
|
||||||
|
|
||||||
|
* Small `+ Add Widget` button
|
||||||
|
|
||||||
|
### Section 2 — Apps / Services
|
||||||
|
|
||||||
|
Top-right:
|
||||||
|
|
||||||
|
* Small `+ Add App` button
|
||||||
|
|
||||||
|
Layout example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Widgets ------------------------------------- [+]
|
||||||
|
(empty state)
|
||||||
|
|
||||||
|
Apps ---------------------------------------- [+]
|
||||||
|
(empty state)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Empty State Design
|
||||||
|
|
||||||
|
Empty states should feel premium.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
No widgets yet
|
||||||
|
Create your first widget to customize your dashboard.
|
||||||
|
```
|
||||||
|
|
||||||
|
and:
|
||||||
|
|
||||||
|
```text
|
||||||
|
No apps added
|
||||||
|
Start by adding your first app or service.
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoid giant centered buttons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Layout Architecture
|
||||||
|
|
||||||
|
## Dashboard Sections
|
||||||
|
|
||||||
|
The dashboard should always contain:
|
||||||
|
|
||||||
|
1. Widgets Section
|
||||||
|
2. Apps Section
|
||||||
|
|
||||||
|
These are not groups.
|
||||||
|
|
||||||
|
These are permanent layout containers.
|
||||||
|
|
||||||
|
Groups belong inside Apps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Widget System Improvements
|
||||||
|
|
||||||
|
## Current Problems
|
||||||
|
|
||||||
|
* Hard to resize
|
||||||
|
* Limited placement
|
||||||
|
* Dragging feels disconnected
|
||||||
|
* Drag icon placement is awkward
|
||||||
|
* Widgets feel static
|
||||||
|
|
||||||
|
## Required Improvements
|
||||||
|
|
||||||
|
### Fully Resizable Widgets
|
||||||
|
|
||||||
|
Users should be able to:
|
||||||
|
|
||||||
|
* Resize width
|
||||||
|
* Resize height
|
||||||
|
* Stretch across columns
|
||||||
|
* Fill entire section width
|
||||||
|
* Create masonry/grid layouts
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
* Clock widget = small
|
||||||
|
* Pi-hole widget = large
|
||||||
|
* Analytics widget = full width
|
||||||
|
|
||||||
|
Recommended implementation:
|
||||||
|
|
||||||
|
### Use Grid-Based Resizing
|
||||||
|
|
||||||
|
Strong recommendation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
react-grid-layout
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
|
||||||
|
* Resize handles
|
||||||
|
* Dragging support
|
||||||
|
* Collision detection
|
||||||
|
* Snap-to-grid
|
||||||
|
* Persistent positions
|
||||||
|
* Responsive layouts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Drag Handle
|
||||||
|
|
||||||
|
Current problem:
|
||||||
|
|
||||||
|
* Drag handle outside widget feels detached.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
* Drag handle should exist inside widget card.
|
||||||
|
* Top-right or top-left.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ Widget Title ⋮⋮ ]
|
||||||
|
```
|
||||||
|
|
||||||
|
Users should instantly understand:
|
||||||
|
|
||||||
|
* drag
|
||||||
|
* settings
|
||||||
|
* resize
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Responsiveness
|
||||||
|
|
||||||
|
Widgets must:
|
||||||
|
|
||||||
|
* Reflow on smaller screens
|
||||||
|
* Collapse naturally on mobile
|
||||||
|
* Maintain resize ratios
|
||||||
|
* Support multiple breakpoints
|
||||||
|
|
||||||
|
Recommended breakpoints:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Desktop: 12-column grid
|
||||||
|
Tablet: 6-column grid
|
||||||
|
Mobile: 1-column stack
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Clock Widget Improvements
|
||||||
|
|
||||||
|
## Current Problem
|
||||||
|
|
||||||
|
Timezone entry requires manual input.
|
||||||
|
|
||||||
|
## Better UX
|
||||||
|
|
||||||
|
Replace manual timezone input with:
|
||||||
|
|
||||||
|
### Searchable Dropdown
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Europe/Prague
|
||||||
|
Europe/London
|
||||||
|
America/New_York
|
||||||
|
Asia/Tokyo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Better Option
|
||||||
|
|
||||||
|
Checkbox multi-select dropdown:
|
||||||
|
|
||||||
|
User can:
|
||||||
|
|
||||||
|
* Add multiple clocks
|
||||||
|
* Select timezone quickly
|
||||||
|
* Remove timezone instantly
|
||||||
|
|
||||||
|
Recommended libraries:
|
||||||
|
|
||||||
|
```text
|
||||||
|
react-select
|
||||||
|
shadcn Command + Popover
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Widget Reliability
|
||||||
|
|
||||||
|
## Pi-hole Widget
|
||||||
|
|
||||||
|
### Must Validate:
|
||||||
|
|
||||||
|
* API reachable
|
||||||
|
* Token valid
|
||||||
|
* IP correct
|
||||||
|
* Live refresh updates
|
||||||
|
* Error states visible
|
||||||
|
|
||||||
|
Show:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cannot reach Pi-hole instance
|
||||||
|
Check URL or API key
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Memos Widget
|
||||||
|
|
||||||
|
### Must Validate:
|
||||||
|
|
||||||
|
Correct fields:
|
||||||
|
|
||||||
|
* API endpoint
|
||||||
|
* token/auth
|
||||||
|
* user scope
|
||||||
|
* response parsing
|
||||||
|
|
||||||
|
Must not silently fail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refresh Button Issue
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
Refresh button exists but cannot be clicked.
|
||||||
|
|
||||||
|
Likely causes:
|
||||||
|
|
||||||
|
* z-index overlap
|
||||||
|
* pointer-events disabled
|
||||||
|
* absolute layer blocking
|
||||||
|
* drag overlay intercepting clicks
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
```css
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
Drag handles should not block interaction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Drag & Drop — Highest Priority
|
||||||
|
|
||||||
|
## Core Principle
|
||||||
|
|
||||||
|
Drag-and-drop is the main feature.
|
||||||
|
|
||||||
|
It must feel effortless.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Problems
|
||||||
|
|
||||||
|
* Dragging unreliable
|
||||||
|
* No placement preview
|
||||||
|
* Group movement broken
|
||||||
|
* Cross-group movement inconsistent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Behavior
|
||||||
|
|
||||||
|
### Apps Should Be:
|
||||||
|
|
||||||
|
* Fully draggable
|
||||||
|
* Reorderable
|
||||||
|
* Group movable
|
||||||
|
* Cross-group movable
|
||||||
|
* Smooth animations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Placement Preview
|
||||||
|
|
||||||
|
When dragging:
|
||||||
|
|
||||||
|
Show:
|
||||||
|
|
||||||
|
* Highlight insertion slot
|
||||||
|
* Ghost preview
|
||||||
|
* Position indicator
|
||||||
|
|
||||||
|
Users must know exactly where the app will land.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Library
|
||||||
|
|
||||||
|
### Strong Recommendation
|
||||||
|
|
||||||
|
```text
|
||||||
|
@dnd-kit
|
||||||
|
```
|
||||||
|
|
||||||
|
Why:
|
||||||
|
|
||||||
|
* Best React drag library currently
|
||||||
|
* Excellent collision detection
|
||||||
|
* Smooth performance
|
||||||
|
* Group nesting support
|
||||||
|
* Sortable containers
|
||||||
|
* Keyboard accessible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App Dragging Behavior
|
||||||
|
|
||||||
|
Allow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ungrouped → Group
|
||||||
|
Group → Group
|
||||||
|
Group → Ungrouped
|
||||||
|
Reorder inside same group
|
||||||
|
Move group itself
|
||||||
|
```
|
||||||
|
|
||||||
|
Must be instant.
|
||||||
|
|
||||||
|
No modal.
|
||||||
|
|
||||||
|
No confirmation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Apps Section Improvements
|
||||||
|
|
||||||
|
## Card View Problems
|
||||||
|
|
||||||
|
Current card view:
|
||||||
|
|
||||||
|
* Too rectangular
|
||||||
|
* Icon too small
|
||||||
|
* Doesn't feel visual enough
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Card View
|
||||||
|
|
||||||
|
Make app cards square.
|
||||||
|
|
||||||
|
Inspired by CasaOS.
|
||||||
|
|
||||||
|
### New Card Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌───────────────┐
|
||||||
|
│ │
|
||||||
|
│ ICON │
|
||||||
|
│ │
|
||||||
|
│ App Name │
|
||||||
|
└───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
* Larger icons
|
||||||
|
* Centered content
|
||||||
|
* Better spacing
|
||||||
|
* More visual identity
|
||||||
|
* Hover interaction
|
||||||
|
* Rounded corners
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
```css
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## List View
|
||||||
|
|
||||||
|
Keep mostly unchanged.
|
||||||
|
|
||||||
|
List view already works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Groups System
|
||||||
|
|
||||||
|
## Problems
|
||||||
|
|
||||||
|
* Poor naming
|
||||||
|
* Not visually distinct
|
||||||
|
* Dragging unreliable
|
||||||
|
* Cannot collapse
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Group Requirements
|
||||||
|
|
||||||
|
### Groups Should Support
|
||||||
|
|
||||||
|
* Expand/collapse
|
||||||
|
* Rename
|
||||||
|
* Drag reorder
|
||||||
|
* Nested app sorting
|
||||||
|
* Instant moving between groups
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group Header Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
Infrastructure ▼ [⋮]
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GRP2
|
||||||
|
```
|
||||||
|
|
||||||
|
Groups must feel human.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group Dragging
|
||||||
|
|
||||||
|
Group itself should be draggable.
|
||||||
|
|
||||||
|
Move entire group section vertically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. Modal Improvements
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Modals have transparent backgrounds.
|
||||||
|
|
||||||
|
This reduces readability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
Use proper modal surface.
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
```css
|
||||||
|
background: var(--surface);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255,255,255,.08);
|
||||||
|
```
|
||||||
|
|
||||||
|
No transparent forms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. Add App Flow
|
||||||
|
|
||||||
|
## Current Problem
|
||||||
|
|
||||||
|
Feels generic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rename
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Add Service
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Add App
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Better Flow
|
||||||
|
|
||||||
|
### Modal Structure
|
||||||
|
|
||||||
|
Step 1:
|
||||||
|
|
||||||
|
* Choose app type
|
||||||
|
|
||||||
|
Step 2:
|
||||||
|
|
||||||
|
* Configure details
|
||||||
|
|
||||||
|
Step 3:
|
||||||
|
|
||||||
|
* Add icon/logo
|
||||||
|
|
||||||
|
Step 4:
|
||||||
|
|
||||||
|
* Select group
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add App Card
|
||||||
|
|
||||||
|
When adding in-grid:
|
||||||
|
|
||||||
|
Small add tile.
|
||||||
|
|
||||||
|
Not giant button.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
+ Add App
|
||||||
|
```
|
||||||
|
|
||||||
|
Should visually match app cards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. URL Improvements
|
||||||
|
|
||||||
|
## Current Problem
|
||||||
|
|
||||||
|
URLs are visually weak.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Better URL Display
|
||||||
|
|
||||||
|
Show:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://app.domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
* favicon
|
||||||
|
* hostname extraction
|
||||||
|
* quick open
|
||||||
|
* copy button
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
🌐 jellyfin.local
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. CasaOS-Inspired Theme
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add an optional theme.
|
||||||
|
|
||||||
|
Not replacing current dark/light.
|
||||||
|
|
||||||
|
Add third style:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CasaOS Inspired
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CasaOS Characteristics
|
||||||
|
|
||||||
|
### Visual Style
|
||||||
|
|
||||||
|
* Large spacing
|
||||||
|
* Rounded containers
|
||||||
|
* Soft shadows
|
||||||
|
* Glassmorphism feel
|
||||||
|
* Bigger cards
|
||||||
|
* Centered icons
|
||||||
|
* Calm background
|
||||||
|
* Floating panels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CasaOS Dashboard Characteristics
|
||||||
|
|
||||||
|
### Keep
|
||||||
|
|
||||||
|
* App grid focus
|
||||||
|
* Icon-first navigation
|
||||||
|
* Background image
|
||||||
|
* Floating sections
|
||||||
|
* Minimal chrome
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remove from CasaOS Reference
|
||||||
|
|
||||||
|
Do NOT include:
|
||||||
|
|
||||||
|
* Search bar
|
||||||
|
* Storage sync banner
|
||||||
|
* Drive discovery cards
|
||||||
|
|
||||||
|
Only use:
|
||||||
|
|
||||||
|
* Layout feel
|
||||||
|
* Card structure
|
||||||
|
* App sizing
|
||||||
|
* Background styling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CasaOS Theme Structure
|
||||||
|
|
||||||
|
### Background
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
* gradient
|
||||||
|
* blurred wallpaper
|
||||||
|
* ambient overlay
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Panels
|
||||||
|
|
||||||
|
```css
|
||||||
|
background: rgba(18, 24, 40, 0.65);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border-radius: 24px;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### App Cards
|
||||||
|
|
||||||
|
```css
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 28px;
|
||||||
|
transition: transform .2s ease;
|
||||||
|
```
|
||||||
|
|
||||||
|
Hover:
|
||||||
|
|
||||||
|
```css
|
||||||
|
transform: translateY(-3px);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Theme Switcher
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Light
|
||||||
|
Dark
|
||||||
|
CasaOS Inspired
|
||||||
|
```
|
||||||
|
|
||||||
|
Store in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
localStorage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. Recommended Tech Stack Improvements
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
react-grid-layout
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Drag & Drop
|
||||||
|
|
||||||
|
```text
|
||||||
|
@dnd-kit
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animations
|
||||||
|
|
||||||
|
```text
|
||||||
|
framer-motion
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Components
|
||||||
|
|
||||||
|
```text
|
||||||
|
shadcn/ui
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
```text
|
||||||
|
zustand
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 14. Priority Order
|
||||||
|
|
||||||
|
## Phase 1 — Critical UX
|
||||||
|
|
||||||
|
1. Empty dashboard state
|
||||||
|
2. Widgets section + apps section
|
||||||
|
3. Smaller add buttons
|
||||||
|
4. Drag-and-drop fixes
|
||||||
|
5. Placement preview
|
||||||
|
6. Group movement
|
||||||
|
7. App movement between groups
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Widget System
|
||||||
|
|
||||||
|
1. Resizable widgets
|
||||||
|
2. Widget drag handles
|
||||||
|
3. Better timezone picker
|
||||||
|
4. Fix refresh buttons
|
||||||
|
5. Widget validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Visual Improvements
|
||||||
|
|
||||||
|
1. Square app cards
|
||||||
|
2. Better icon sizing
|
||||||
|
3. Modal redesign
|
||||||
|
4. Better group styling
|
||||||
|
5. URL redesign
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — CasaOS Theme
|
||||||
|
|
||||||
|
1. Theme architecture
|
||||||
|
2. Background system
|
||||||
|
3. Glass panels
|
||||||
|
4. CasaOS grid cards
|
||||||
|
5. Theme switcher
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 15. Biggest Product Rule
|
||||||
|
|
||||||
|
The dashboard should feel like:
|
||||||
|
|
||||||
|
* A workspace
|
||||||
|
* A customizable OS
|
||||||
|
* A clean home-lab control center
|
||||||
|
* A visual launcher
|
||||||
|
* Not a traditional admin panel
|
||||||
|
|
||||||
|
Users should instantly understand:
|
||||||
|
|
||||||
|
* Add
|
||||||
|
* Move
|
||||||
|
* Resize
|
||||||
|
* Organize
|
||||||
|
* Customize
|
||||||
|
|
||||||
|
without reading instructions.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
to be honest the app looks shit, dont use pure black that does not fit, make it more colorfull, better. it does not work fully check @Design.md @FrontendPlan.md @frontend-design @tdvorak-fullstack . Take your time restyle it completely, style it like casa os, make it cleaner, nice ui/ux, fix modals they dont have bg, completely redo/restyle it. also fully use @shadcn @shadcn fully use it to full extend restyle it completely the current is shit, change everything make everything better, drag and drop fix, modal fix, drag and drop place visualization fix
|
||||||
@@ -26,11 +26,14 @@
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files
|
# env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
# typescript
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# types
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Dash Frontend – Agent Rules
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- This agent may edit: `/frontend`
|
||||||
|
- This agent must not edit `/backend`, `/db`, or `/openapi`
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- Next.js 15 App Router + React 19 + TypeScript strict
|
||||||
|
- Tailwind CSS v4 + shadcn/ui (new-york style)
|
||||||
|
- `@tanstack/react-query` for server state
|
||||||
|
- `@dnd-kit` for drag-and-drop
|
||||||
|
- `openapi-typescript` + `openapi-fetch` for API client (generated from `../openapi/openapi.yaml`)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
- `npm run dev` — start dev server (Turbopack)
|
||||||
|
- `npm run build` — production build
|
||||||
|
- `npm run typecheck` — `tsc --noEmit`
|
||||||
|
- `npm run lint` — Next.js lint
|
||||||
|
- `npm run api:generate` — regenerate API types from OpenAPI spec
|
||||||
|
|
||||||
|
## Design
|
||||||
|
- Dark-first, Vercel-inspired aesthetic
|
||||||
|
- 3 themes: light, dark, casaos (glassmorphism)
|
||||||
|
- Geist Sans + Geist Mono fonts
|
||||||
|
- Shadow-as-border technique (no visible borders, use box-shadow)
|
||||||
|
- See `../Design.md` for full design system
|
||||||
|
|
||||||
|
## API Contract
|
||||||
|
- All types come from `../openapi/openapi.yaml`
|
||||||
|
- Do not invent contract fields outside OpenAPI
|
||||||
|
- API base URL: `NEXT_PUBLIC_API_BASE_URL` (default `http://localhost:8080`)
|
||||||
|
|
||||||
|
## Component Rules
|
||||||
|
- Use shadcn/ui primitives, do not rebuild from scratch
|
||||||
|
- All interactive elements must have focus rings
|
||||||
|
- Prefer `font-mono uppercase tracking-wide` for labels/badges
|
||||||
|
- Service cards are square aspect-ratio, icon + name + URL badges
|
||||||
|
- Groups are collapsible sections with chevron toggle
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Dash Frontend – Claude Context
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
- Framework: Next.js 15 App Router (standalone output)
|
||||||
|
- Styling: Tailwind v4 + shadcn/ui + CSS custom properties for theming
|
||||||
|
- State: @tanstack/react-query (staleTime 30s)
|
||||||
|
- DnD: @dnd-kit/core + @dnd-kit/sortable
|
||||||
|
- API: openapi-fetch client generated from ../openapi/openapi.yaml
|
||||||
|
- Fonts: Geist Sans + Geist Mono (next/font/google)
|
||||||
|
|
||||||
|
## Theme System
|
||||||
|
3 themes via `data-theme` attribute on `<html>`:
|
||||||
|
- `light` — Vercel-inspired white
|
||||||
|
- `dark` — OLED black (default)
|
||||||
|
- `casaos` — Glassmorphism with backdrop-blur
|
||||||
|
|
||||||
|
## Key Paths
|
||||||
|
- `app/layout.tsx` — root layout with Providers
|
||||||
|
- `app/page.tsx` — renders DashboardPage
|
||||||
|
- `components/dashboard/dashboard-page.tsx` — main composition
|
||||||
|
- `lib/api/client.ts` — fetch wrapper for all API calls
|
||||||
|
- `lib/api/hooks.ts` — React Query hooks
|
||||||
|
- `lib/api/schema.ts` — TypeScript types (hand-written, matches OpenAPI)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=base /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where([data-theme="dark"], [data-theme="casaos"]));
|
||||||
|
|
||||||
|
/* ── Light (Vercel-inspired) ── */
|
||||||
|
:root,
|
||||||
|
[data-theme="light"] {
|
||||||
|
--color-background: #ffffff;
|
||||||
|
--color-foreground: #171717;
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #171717;
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #171717;
|
||||||
|
--color-primary: #171717;
|
||||||
|
--color-primary-foreground: #ffffff;
|
||||||
|
--color-secondary: #f5f5f5;
|
||||||
|
--color-secondary-foreground: #171717;
|
||||||
|
--color-muted: #f5f5f5;
|
||||||
|
--color-muted-foreground: #737373;
|
||||||
|
--color-accent: #f5f5f5;
|
||||||
|
--color-accent-foreground: #171717;
|
||||||
|
--color-destructive: #ef4444;
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-border: rgba(0, 0, 0, 0.08);
|
||||||
|
--color-ring: #0072f5;
|
||||||
|
--color-signal: #ff5b4f;
|
||||||
|
--color-input: rgba(0, 0, 0, 0.08);
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--font-geist-sans: "Geist", "Arial", "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
|
||||||
|
--font-geist-mono: "Geist Mono", "ui-monospace", "SFMono-Regular", "Roboto Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dark (Rich warm dark — not pure black) ── */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--color-background: #1b1b1b;
|
||||||
|
--color-foreground: #ececec;
|
||||||
|
--color-card: #222222;
|
||||||
|
--color-card-foreground: #ececec;
|
||||||
|
--color-popover: #262626;
|
||||||
|
--color-popover-foreground: #ececec;
|
||||||
|
--color-primary: #ececec;
|
||||||
|
--color-primary-foreground: #1b1b1b;
|
||||||
|
--color-secondary: #2a2a2a;
|
||||||
|
--color-secondary-foreground: #ececec;
|
||||||
|
--color-muted: #2a2a2a;
|
||||||
|
--color-muted-foreground: #888888;
|
||||||
|
--color-accent: #2a2a2a;
|
||||||
|
--color-accent-foreground: #ececec;
|
||||||
|
--color-destructive: #f43f5e;
|
||||||
|
--color-destructive-foreground: #ececec;
|
||||||
|
--color-border: #333333;
|
||||||
|
--color-ring: #3b82f6;
|
||||||
|
--color-signal: #f43f5e;
|
||||||
|
--color-input: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CasaOS (Colorful dark) ── */
|
||||||
|
[data-theme="casaos"] {
|
||||||
|
--color-background: #1b1b2e;
|
||||||
|
--color-foreground: #f1f5f9;
|
||||||
|
--color-card: #22223a;
|
||||||
|
--color-card-foreground: #f1f5f9;
|
||||||
|
--color-popover: #26264a;
|
||||||
|
--color-popover-foreground: #f1f5f9;
|
||||||
|
--color-primary: #60a5fa;
|
||||||
|
--color-primary-foreground: #1b1b2e;
|
||||||
|
--color-secondary: #2a2a4a;
|
||||||
|
--color-secondary-foreground: #f1f5f9;
|
||||||
|
--color-muted: #2a2a4a;
|
||||||
|
--color-muted-foreground: #94a3b8;
|
||||||
|
--color-accent: #2a2a4a;
|
||||||
|
--color-accent-foreground: #60a5fa;
|
||||||
|
--color-destructive: #f43f5e;
|
||||||
|
--color-destructive-foreground: #f1f5f9;
|
||||||
|
--color-border: #333355;
|
||||||
|
--color-ring: #60a5fa;
|
||||||
|
--color-signal: #f43f5e;
|
||||||
|
--color-input: #333355;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CasaOS background gradient ── */
|
||||||
|
[data-theme="casaos"] body {
|
||||||
|
background: #1b1b2e;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base ── */
|
||||||
|
* {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-family: var(--font-geist-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Focus ring ── */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ── */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-muted-foreground);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Selection ── */
|
||||||
|
::selection {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-accent-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shadow-as-border utility ── */
|
||||||
|
.shadow-border {
|
||||||
|
box-shadow: 0px 0px 0px 1px var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-border-card {
|
||||||
|
box-shadow:
|
||||||
|
0px 0px 0px 1px var(--color-border),
|
||||||
|
0px 2px 4px rgba(0, 0, 0, 0.04),
|
||||||
|
0px 8px 8px -8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-border-hover {
|
||||||
|
box-shadow:
|
||||||
|
0px 0px 0px 1px var(--color-border),
|
||||||
|
0px 4px 8px rgba(0, 0, 0, 0.08),
|
||||||
|
0px 8px 16px -4px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Service card hover (all themes) ── */
|
||||||
|
.service-card {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.service-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CasaOS card hover ── */
|
||||||
|
[data-theme="casaos"] .service-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drag overlay ── */
|
||||||
|
.drag-overlay {
|
||||||
|
opacity: 0.95;
|
||||||
|
transform: scale(1.03);
|
||||||
|
box-shadow:
|
||||||
|
0px 0px 0px 2px var(--color-ring),
|
||||||
|
0px 12px 32px rgba(0, 0, 0, 0.25);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drop indicator ── */
|
||||||
|
.drop-indicator {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.drop-indicator::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px;
|
||||||
|
border-radius: inherit;
|
||||||
|
border: 2px dashed var(--color-ring);
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drop target line ── */
|
||||||
|
.drop-target-line {
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-ring);
|
||||||
|
box-shadow: 0 0 8px var(--color-ring);
|
||||||
|
margin: 4px 0;
|
||||||
|
animation: pulse-line 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-line {
|
||||||
|
0%, 100% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dialog / Modal backdrop ── */
|
||||||
|
[data-state="open"] > [data-radix-dialog-overlay] {
|
||||||
|
background: rgba(0, 0, 0, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dialog content surface ── */
|
||||||
|
.dialog-surface {
|
||||||
|
background: var(--color-popover);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow:
|
||||||
|
0px 0px 0px 1px var(--color-border),
|
||||||
|
0px 8px 32px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Colorful badge variants ── */
|
||||||
|
.badge-local {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
.badge-external {
|
||||||
|
background: rgba(96, 165, 250, 0.15);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
.badge-custom {
|
||||||
|
background: rgba(139, 92, 246, 0.15);
|
||||||
|
color: #a78bfa;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none"><rect width="32" height="32" rx="6" fill="#000"/><text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle" fill="#fff" font-family="monospace" font-size="18" font-weight="600">D</text></svg>
|
||||||
|
After Width: | Height: | Size: 275 B |
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { Providers } from "@/components/providers";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Dash",
|
||||||
|
description: "Your services, organized beautifully.",
|
||||||
|
icons: { icon: "/icon.svg" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" data-theme="dark" suppressHydrationWarning>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased min-h-screen`}>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import DashboardPage from "@/components/dashboard/dashboard-page";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <DashboardPage />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { Service, Group, WidgetInstance, Dashboard } from "@/lib/api/schema";
|
||||||
|
import { useDashboard, useDeleteService, useDeleteWidget, useUpdateLayout } from "@/lib/api/hooks";
|
||||||
|
import { Header } from "@/components/shell/header";
|
||||||
|
import { ServiceCard } from "@/components/services/service-card";
|
||||||
|
import { ServiceForm } from "@/components/services/service-form";
|
||||||
|
import { GroupSection } from "@/components/groups/group-section";
|
||||||
|
import { GroupForm } from "@/components/groups/group-form";
|
||||||
|
import { WidgetCard } from "@/components/widgets/widget-card";
|
||||||
|
import { WidgetForm } from "@/components/widgets/widget-form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, Loader2, AlertCircle, LayoutGrid, List, Pencil, Trash2, GripVertical } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
DragOverlay,
|
||||||
|
DragStartEvent,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverEvent,
|
||||||
|
PointerSensor,
|
||||||
|
KeyboardSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
MeasuringStrategy,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
rectSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/* ---------- Sortable wrappers ---------- */
|
||||||
|
|
||||||
|
function SortableGroup({
|
||||||
|
group,
|
||||||
|
onEditService,
|
||||||
|
onDeleteService,
|
||||||
|
onEditGroup,
|
||||||
|
}: {
|
||||||
|
group: Group;
|
||||||
|
onEditService: (s: Service) => void;
|
||||||
|
onDeleteService: (id: string) => void;
|
||||||
|
onEditGroup: (g: Group) => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: group.id,
|
||||||
|
data: { type: "group" },
|
||||||
|
});
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.4 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
|
<GroupSection
|
||||||
|
group={group}
|
||||||
|
onEditService={onEditService}
|
||||||
|
onDeleteService={onDeleteService}
|
||||||
|
onEditGroup={onEditGroup}
|
||||||
|
dragHandleProps={listeners}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableService({
|
||||||
|
service,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
service: Service;
|
||||||
|
onEdit: (s: Service) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: service.id,
|
||||||
|
data: { type: "service", groupId: service.groupId },
|
||||||
|
});
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.4 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
|
<ServiceCard service={service} onEdit={onEdit} onDelete={onDelete} dragHandleProps={listeners} isDragging={isDragging} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableWidget({
|
||||||
|
widget,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
onEdit: (w: WidgetInstance) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: widget.id,
|
||||||
|
data: { type: "widget" },
|
||||||
|
});
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.4 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
|
<WidgetCard widget={widget} onEdit={onEdit} onDelete={onDelete} dragHandleProps={listeners} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Add-app tile ---------- */
|
||||||
|
|
||||||
|
function AddAppTile({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="service-card group flex aspect-square flex-col items-center justify-center gap-2.5 rounded-[24px] border border-dashed border-border bg-card p-4 transition-all duration-300 hover:-translate-y-1 hover:bg-accent hover:border-ring/40 hover:shadow-border-hover"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-secondary transition-colors group-hover:bg-accent">
|
||||||
|
<Plus className="h-5 w-5 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground transition-colors group-hover:text-foreground">Add App</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ---------- Service List Item ---------- */
|
||||||
|
|
||||||
|
function ServiceListItem({
|
||||||
|
service,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
service: Service;
|
||||||
|
onEdit: (s: Service) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const primaryUrl = service.urls.find((u) => u.isPrimary) || service.urls[0];
|
||||||
|
return (
|
||||||
|
<div className="group flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 transition-all hover:bg-accent hover:border-border hover:shadow-border">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-secondary font-mono text-sm font-semibold text-secondary-foreground">
|
||||||
|
{service.name.slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-semibold truncate">{service.name}</div>
|
||||||
|
{primaryUrl && (
|
||||||
|
<a
|
||||||
|
href={primaryUrl.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground truncate block transition-colors"
|
||||||
|
>
|
||||||
|
{primaryUrl.url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg hover:bg-accent" onClick={() => onEdit(service)}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg text-destructive hover:bg-destructive/10" onClick={() => onDelete(service.id)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Drag Overlay ---------- */
|
||||||
|
|
||||||
|
function DashboardDragOverlay({ activeId, dashboard }: { activeId: string; dashboard: Dashboard }) {
|
||||||
|
const allServices = [
|
||||||
|
...dashboard.ungroupedServices,
|
||||||
|
...dashboard.groups.flatMap((g) => g.services),
|
||||||
|
];
|
||||||
|
const service = allServices.find((s) => s.id === activeId);
|
||||||
|
const group = dashboard.groups.find((g) => g.id === activeId);
|
||||||
|
const widget = dashboard.widgets.find((w) => w.id === activeId);
|
||||||
|
|
||||||
|
if (service) {
|
||||||
|
return (
|
||||||
|
<div className="drag-overlay flex aspect-square w-28 flex-col items-center justify-center gap-2 rounded-2xl bg-card border border-ring/50 p-3 shadow-2xl">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-secondary to-accent font-mono text-sm font-bold text-secondary-foreground">
|
||||||
|
{service.name.slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-center truncate w-full">{service.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group) {
|
||||||
|
return (
|
||||||
|
<div className="drag-overlay flex w-64 items-center gap-3 rounded-xl bg-card border border-ring/50 px-4 py-3 shadow-2xl">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent">
|
||||||
|
<GripVertical className="h-4 w-4 text-accent-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-semibold">{group.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">{group.services.length} apps</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget) {
|
||||||
|
return (
|
||||||
|
<div className="drag-overlay flex w-56 items-center gap-3 rounded-xl bg-card border border-ring/50 px-4 py-3 shadow-2xl">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent">
|
||||||
|
<GripVertical className="h-4 w-4 text-accent-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-semibold">{widget.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2 uppercase">{widget.type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="drag-overlay rounded-xl bg-card p-4 shadow-2xl border border-ring/50">Moving…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Main Dashboard ---------- */
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data: dashboard, isLoading, error } = useDashboard();
|
||||||
|
const deleteService = useDeleteService();
|
||||||
|
const deleteWidget = useDeleteWidget();
|
||||||
|
const updateLayout = useUpdateLayout();
|
||||||
|
|
||||||
|
const [serviceFormOpen, setServiceFormOpen] = useState(false);
|
||||||
|
const [editingService, setEditingService] = useState<Service | null>(null);
|
||||||
|
const [groupFormOpen, setGroupFormOpen] = useState(false);
|
||||||
|
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
|
||||||
|
const [widgetFormOpen, setWidgetFormOpen] = useState(false);
|
||||||
|
const [editingWidget, setEditingWidget] = useState<WidgetInstance | null>(null);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
useSensor(KeyboardSensor),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
setActiveId(String(event.active.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (_event: DragOverEvent) => {
|
||||||
|
void _event;
|
||||||
|
// Visual feedback placeholder
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
setActiveId(null);
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id || !dashboard) return;
|
||||||
|
|
||||||
|
const activeIdStr = String(active.id);
|
||||||
|
const overIdStr = String(over.id);
|
||||||
|
|
||||||
|
const allServiceIds = [
|
||||||
|
...dashboard.ungroupedServices.map((s) => s.id),
|
||||||
|
...dashboard.groups.flatMap((g) => g.services.map((s) => s.id)),
|
||||||
|
];
|
||||||
|
const groupIds = dashboard.groups.map((g) => g.id);
|
||||||
|
const widgetIds = dashboard.widgets.map((w) => w.id);
|
||||||
|
|
||||||
|
const isActiveService = allServiceIds.includes(activeIdStr);
|
||||||
|
const isOverService = allServiceIds.includes(overIdStr);
|
||||||
|
const isActiveGroup = groupIds.includes(activeIdStr);
|
||||||
|
const isOverGroup = groupIds.includes(overIdStr);
|
||||||
|
const isActiveWidget = widgetIds.includes(activeIdStr);
|
||||||
|
const isOverWidget = widgetIds.includes(overIdStr);
|
||||||
|
|
||||||
|
// Service → Service (reorder / cross-group)
|
||||||
|
if (isActiveService && isOverService) {
|
||||||
|
const findServiceLocation = (sid: string): { groupId: string | null; index: number } => {
|
||||||
|
const ungroupedIdx = dashboard.ungroupedServices.findIndex((s) => s.id === sid);
|
||||||
|
if (ungroupedIdx !== -1) return { groupId: null, index: ungroupedIdx };
|
||||||
|
for (const g of dashboard.groups) {
|
||||||
|
const idx = g.services.findIndex((s) => s.id === sid);
|
||||||
|
if (idx !== -1) return { groupId: g.id, index: idx };
|
||||||
|
}
|
||||||
|
return { groupId: null, index: -1 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeLoc = findServiceLocation(activeIdStr);
|
||||||
|
const overLoc = findServiceLocation(overIdStr);
|
||||||
|
|
||||||
|
const groupServices: Record<string, string[]> = {};
|
||||||
|
for (const g of dashboard.groups) {
|
||||||
|
const ids = [...g.services.map((s) => s.id)];
|
||||||
|
if (activeLoc.groupId === g.id) ids.splice(activeLoc.index, 1);
|
||||||
|
if (overLoc.groupId === g.id) {
|
||||||
|
const insertIdx = activeLoc.groupId === g.id && activeLoc.index < overLoc.index ? overLoc.index : overLoc.index;
|
||||||
|
ids.splice(insertIdx, 0, activeIdStr);
|
||||||
|
}
|
||||||
|
groupServices[g.id] = ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ungroupedIds = [...dashboard.ungroupedServices.map((s) => s.id)];
|
||||||
|
if (activeLoc.groupId === null) ungroupedIds.splice(activeLoc.index, 1);
|
||||||
|
if (overLoc.groupId === null) {
|
||||||
|
const insertIdx = activeLoc.groupId === null && activeLoc.index < overLoc.index ? overLoc.index : overLoc.index;
|
||||||
|
ungroupedIds.splice(insertIdx, 0, activeIdStr);
|
||||||
|
}
|
||||||
|
if (activeLoc.groupId !== null && overLoc.groupId === null) {
|
||||||
|
ungroupedIds.splice(overLoc.index, 0, activeIdStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLayout.mutate({ groupIds, widgetIds, ungroupedServiceIds: ungroupedIds, groupServices });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service → Group header (move into group)
|
||||||
|
if (isActiveService && isOverGroup) {
|
||||||
|
const groupServices: Record<string, string[]> = {};
|
||||||
|
const ungroupedIds = [...dashboard.ungroupedServices.map((s) => s.id)];
|
||||||
|
|
||||||
|
for (const g of dashboard.groups) {
|
||||||
|
const ids = g.services.map((s) => s.id);
|
||||||
|
const idx = ids.indexOf(activeIdStr);
|
||||||
|
if (idx !== -1) ids.splice(idx, 1);
|
||||||
|
if (g.id === overIdStr) ids.push(activeIdStr);
|
||||||
|
groupServices[g.id] = ids;
|
||||||
|
}
|
||||||
|
const uIdx = ungroupedIds.indexOf(activeIdStr);
|
||||||
|
if (uIdx !== -1) ungroupedIds.splice(uIdx, 1);
|
||||||
|
|
||||||
|
updateLayout.mutate({ groupIds, widgetIds, ungroupedServiceIds: ungroupedIds, groupServices });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group reorder
|
||||||
|
if (isActiveGroup && isOverGroup) {
|
||||||
|
const newGroupIds = [...groupIds];
|
||||||
|
const fromIdx = newGroupIds.indexOf(activeIdStr);
|
||||||
|
const toIdx = newGroupIds.indexOf(overIdStr);
|
||||||
|
if (fromIdx !== -1 && toIdx !== -1) {
|
||||||
|
const [moved] = newGroupIds.splice(fromIdx, 1);
|
||||||
|
newGroupIds.splice(toIdx, 0, moved);
|
||||||
|
const groupServices: Record<string, string[]> = {};
|
||||||
|
for (const g of dashboard.groups) groupServices[g.id] = g.services.map((s) => s.id);
|
||||||
|
updateLayout.mutate({ groupIds: newGroupIds, widgetIds, ungroupedServiceIds: dashboard.ungroupedServices.map((s) => s.id), groupServices });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widget reorder
|
||||||
|
if (isActiveWidget && isOverWidget) {
|
||||||
|
const newWidgetIds = [...widgetIds];
|
||||||
|
const fromIdx = newWidgetIds.indexOf(activeIdStr);
|
||||||
|
const toIdx = newWidgetIds.indexOf(overIdStr);
|
||||||
|
if (fromIdx !== -1 && toIdx !== -1) {
|
||||||
|
const [moved] = newWidgetIds.splice(fromIdx, 1);
|
||||||
|
newWidgetIds.splice(toIdx, 0, moved);
|
||||||
|
const groupServices: Record<string, string[]> = {};
|
||||||
|
for (const g of dashboard.groups) groupServices[g.id] = g.services.map((s) => s.id);
|
||||||
|
updateLayout.mutate({ groupIds, widgetIds: newWidgetIds, ungroupedServiceIds: dashboard.ungroupedServices.map((s) => s.id), groupServices });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditService = (s: Service) => { setEditingService(s); setServiceFormOpen(true); };
|
||||||
|
const handleDeleteService = (id: string) => { if (confirm("Delete this app?")) deleteService.mutate(id); };
|
||||||
|
const handleEditGroup = (g: Group) => { setEditingGroup(g); setGroupFormOpen(true); };
|
||||||
|
const handleEditWidget = (w: WidgetInstance) => { setEditingWidget(w); setWidgetFormOpen(true); };
|
||||||
|
const handleDeleteWidget = (id: string) => { if (confirm("Delete this widget?")) deleteWidget.mutate(id); };
|
||||||
|
|
||||||
|
const openAddService = () => { setEditingService(null); setServiceFormOpen(true); };
|
||||||
|
const openAddGroup = () => { setEditingGroup(null); setGroupFormOpen(true); };
|
||||||
|
const openAddWidget = () => { setEditingWidget(null); setWidgetFormOpen(true); };
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-background">
|
||||||
|
<div className="h-14 border-b border-border/50" />
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-accent-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground font-medium">Loading dashboard...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-background">
|
||||||
|
<div className="h-14 border-b border-border/50" />
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-destructive/10">
|
||||||
|
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-semibold text-foreground">Failed to load dashboard</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = dashboard?.groups || [];
|
||||||
|
const ungrouped = dashboard?.ungroupedServices || [];
|
||||||
|
const widgets = dashboard?.widgets || [];
|
||||||
|
const isEmpty = groups.length === 0 && ungrouped.length === 0 && widgets.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<Header onAddService={openAddService} onAddWidget={openAddWidget} onAddGroup={openAddGroup} />
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6">
|
||||||
|
{isEmpty ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-6 py-32">
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-[24px] bg-gradient-to-br from-secondary to-accent border border-border/50 shadow-border-card">
|
||||||
|
<LayoutGrid className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground tracking-tight mb-2">Welcome to Dash</h2>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-xs">Your homelab dashboard is empty. Add apps and widgets to get started.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button onClick={openAddService} className="gap-2 rounded-xl">
|
||||||
|
<Plus className="h-4 w-4" /> Add App
|
||||||
|
</Button>
|
||||||
|
<Button onClick={openAddWidget} variant="outline" className="gap-2 rounded-xl">
|
||||||
|
<Plus className="h-4 w-4" /> Add Widget
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
{/* Widgets strip */}
|
||||||
|
<section className="mb-8">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-4 w-0.5 rounded-full bg-ring" />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Widgets</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={openAddWidget} className="gap-1.5 text-xs rounded-lg hover:bg-accent">
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">Add Widget</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{widgets.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<SortableContext items={widgets.map((w) => w.id)} strategy={rectSortingStrategy}>
|
||||||
|
{widgets.map((w) => (
|
||||||
|
<SortableWidget key={w.id} widget={w} onEdit={handleEditWidget} onDelete={handleDeleteWidget} />
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={openAddWidget}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-card p-6 text-sm text-muted-foreground transition-all hover:border-ring/40 hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> Add your first widget
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Apps section */}
|
||||||
|
<section className="mb-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-4 w-0.5 rounded-full bg-ring" />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Apps</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex items-center rounded-lg border border-border overflow-hidden mr-1 bg-card">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("grid")}
|
||||||
|
className={cn("px-2.5 py-1.5 text-xs transition-colors rounded-l-lg", viewMode === "grid" ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground")}
|
||||||
|
title="Grid view"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-3.5 bg-border/50" />
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
className={cn("px-2.5 py-1.5 text-xs transition-colors rounded-r-lg", viewMode === "list" ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground")}
|
||||||
|
title="List view"
|
||||||
|
>
|
||||||
|
<List className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={openAddGroup} className="gap-1.5 text-xs rounded-lg hover:bg-accent">
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">Group</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={openAddService} className="gap-1.5 text-xs rounded-lg hover:bg-accent">
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">App</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Groups */}
|
||||||
|
<SortableContext items={groups.map((g) => g.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<SortableGroup
|
||||||
|
key={g.id}
|
||||||
|
group={g}
|
||||||
|
onEditService={handleEditService}
|
||||||
|
onDeleteService={handleDeleteService}
|
||||||
|
onEditGroup={handleEditGroup}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
{/* Ungrouped services */}
|
||||||
|
{ungrouped.length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
{groups.length > 0 && (
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<div className="h-4 w-0.5 rounded-full bg-ring" />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Ungrouped</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">{ungrouped.length}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SortableContext items={ungrouped.map((s) => s.id)} strategy={rectSortingStrategy}>
|
||||||
|
{viewMode === "grid" ? (
|
||||||
|
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
|
||||||
|
{ungrouped.map((s) => (
|
||||||
|
<SortableService key={s.id} service={s} onEdit={handleEditService} onDelete={handleDeleteService} />
|
||||||
|
))}
|
||||||
|
<AddAppTile onClick={openAddService} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{ungrouped.map((s) => (
|
||||||
|
<ServiceListItem key={s.id} service={s} onEdit={handleEditService} onDelete={handleDeleteService} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* In-grid add tile when no ungrouped but groups exist */}
|
||||||
|
{ungrouped.length === 0 && groups.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<AddAppTile onClick={openAddService} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No apps at all - show empty state within apps section */}
|
||||||
|
{groups.length === 0 && ungrouped.length === 0 && (
|
||||||
|
<button
|
||||||
|
onClick={openAddService}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-card p-8 text-sm text-muted-foreground transition-all hover:border-ring/40 hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> Add your first app
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeId && dashboard ? (
|
||||||
|
<DashboardDragOverlay activeId={activeId} dashboard={dashboard} />
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<ServiceForm
|
||||||
|
service={editingService}
|
||||||
|
groups={groups.map((g) => ({ id: g.id, name: g.name }))}
|
||||||
|
open={serviceFormOpen}
|
||||||
|
onOpenChange={setServiceFormOpen}
|
||||||
|
/>
|
||||||
|
<GroupForm group={editingGroup} open={groupFormOpen} onOpenChange={setGroupFormOpen} />
|
||||||
|
<WidgetForm widget={editingWidget} open={widgetFormOpen} onOpenChange={setWidgetFormOpen} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { Group } from "@/lib/api/schema";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useCreateGroup, useUpdateGroup } from "@/lib/api/hooks";
|
||||||
|
|
||||||
|
interface GroupFormProps {
|
||||||
|
group?: Group | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupForm({ group, open, onOpenChange }: GroupFormProps) {
|
||||||
|
const isEdit = !!group;
|
||||||
|
const createMut = useCreateGroup();
|
||||||
|
const updateMut = useUpdateGroup();
|
||||||
|
const [name, setName] = useState(group?.name || "");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (isEdit && group) {
|
||||||
|
await updateMut.mutateAsync({ id: group.id, name: name.trim() });
|
||||||
|
} else {
|
||||||
|
await createMut.mutateAsync({ name: name.trim() });
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
setName("");
|
||||||
|
setError("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? "Rename Group" : "Create Group"}</DialogTitle>
|
||||||
|
<DialogDescription>{isEdit ? "Update group name" : "Add a new group for organizing apps"}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-2 py-2">
|
||||||
|
<Label htmlFor="group-name">Name</Label>
|
||||||
|
<Input id="group-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Infrastructure" />
|
||||||
|
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={createMut.isPending || updateMut.isPending}>
|
||||||
|
{isEdit ? "Save" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Group, Service } from "@/lib/api/schema";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ServiceCard } from "@/components/services/service-card";
|
||||||
|
import { ChevronDown, MoreVertical, Pencil, Trash2, GripVertical, FolderOpen } from "lucide-react";
|
||||||
|
import { useUpdateGroup, useDeleteGroup } from "@/lib/api/hooks";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTheme } from "@/components/providers";
|
||||||
|
|
||||||
|
interface GroupSectionProps {
|
||||||
|
group: Group;
|
||||||
|
onEditService: (s: Service) => void;
|
||||||
|
onDeleteService: (id: string) => void;
|
||||||
|
onEditGroup: (g: Group) => void;
|
||||||
|
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupSection({ group, onEditService, onDeleteService, onEditGroup, dragHandleProps }: GroupSectionProps) {
|
||||||
|
const updateGroup = useUpdateGroup();
|
||||||
|
const deleteGroup = useDeleteGroup();
|
||||||
|
const [open, setOpen] = useState(!group.collapsed);
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isCasaOS = theme === "casaos";
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
const next = !open;
|
||||||
|
setOpen(next);
|
||||||
|
updateGroup.mutate({ id: group.id, collapsed: !next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (group.services.length > 0) {
|
||||||
|
deleteGroup.mutate({ id: group.id, moveServices: true });
|
||||||
|
} else {
|
||||||
|
deleteGroup.mutate({ id: group.id });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<div className={cn("mb-5 rounded-2xl group/group", isCasaOS && "bg-card border border-border")}>
|
||||||
|
{/* Group header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||||
|
{dragHandleProps && (
|
||||||
|
<div
|
||||||
|
{...dragHandleProps}
|
||||||
|
className="cursor-grab rounded-md p-1 opacity-0 transition-opacity hover:bg-accent group-hover/group:opacity-60"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="flex flex-1 items-center gap-2.5 group/title min-w-0"
|
||||||
|
onClick={handleToggle}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"flex h-7 w-7 items-center justify-center rounded-lg transition-colors",
|
||||||
|
isCasaOS ? "bg-white/10" : "bg-accent"
|
||||||
|
)}>
|
||||||
|
<FolderOpen className={cn("h-3.5 w-3.5", isCasaOS ? "text-blue-300" : "text-accent-foreground")} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-sm font-semibold truncate">{group.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">{group.services.length}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={cn(
|
||||||
|
"ml-auto h-4 w-4 text-muted-foreground transition-transform duration-200 shrink-0",
|
||||||
|
!open && "-rotate-90"
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg shrink-0 hover:bg-accent">
|
||||||
|
<MoreVertical className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40 rounded-xl">
|
||||||
|
<DropdownMenuItem onClick={() => onEditGroup(group)} className="gap-2 text-xs">
|
||||||
|
<Pencil className="h-3.5 w-3.5" /> Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="gap-2 text-xs text-destructive" onClick={handleDelete}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className={cn("mx-3 h-px", isCasaOS ? "bg-white/5" : "bg-border/40")} />
|
||||||
|
|
||||||
|
{/* Services grid */}
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="p-3 pt-2">
|
||||||
|
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
|
||||||
|
{group.services.map((s) => (
|
||||||
|
<ServiceCard key={s.id} service={s} onEdit={onEditService} onDelete={onDeleteService} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { getQueryClient } from "@/lib/api/query-client";
|
||||||
|
import { Theme, getStoredTheme, setStoredTheme, applyTheme } from "@/lib/theme/themes";
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
const [theme, setTheme] = useState<Theme>("dark");
|
||||||
|
const [mswReady, setMswReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = getStoredTheme();
|
||||||
|
setTheme(stored);
|
||||||
|
applyTheme(stored);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV === "development" && process.env.NEXT_PUBLIC_API_BASE_URL === undefined) {
|
||||||
|
import("@/lib/mocks/browser").then(({ installMocks }) => {
|
||||||
|
installMocks();
|
||||||
|
setMswReady(true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setMswReady(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changeTheme = (t: Theme) => {
|
||||||
|
setTheme(t);
|
||||||
|
setStoredTheme(t);
|
||||||
|
applyTheme(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!mswReady) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||||
|
<span className="font-mono text-xs">[LOADING...]</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<ThemeContext.Provider value={{ theme, setTheme: changeTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
</TooltipProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
type ThemeContextType = { theme: Theme; setTheme: (t: Theme) => void };
|
||||||
|
export const ThemeContext = createContext<ThemeContextType>({ theme: "dark", setTheme: () => {} });
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { Service, ServiceUrl } from "@/lib/api/schema";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { MoreVertical, ExternalLink, Pencil, Trash2, GripVertical, Globe, Home, Settings } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTheme } from "@/components/providers";
|
||||||
|
|
||||||
|
function getInitials(name: string) {
|
||||||
|
const words = name.trim().split(/\s+/);
|
||||||
|
if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase();
|
||||||
|
return name.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHost(url: string) {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconUrl(service: Service) {
|
||||||
|
if (service.iconUrl) return service.iconUrl;
|
||||||
|
if (service.iconAssetId) return `/uploads/icons/${service.iconAssetId}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindIcon(kind: string) {
|
||||||
|
switch (kind) {
|
||||||
|
case "local": return <Home className="h-3 w-3" />;
|
||||||
|
case "external": return <Globe className="h-3 w-3" />;
|
||||||
|
default: return <Settings className="h-3 w-3" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindBadgeClass(kind: string) {
|
||||||
|
switch (kind) {
|
||||||
|
case "local": return "badge-local";
|
||||||
|
case "external": return "badge-external";
|
||||||
|
default: return "badge-custom";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useServicePing(url: string | undefined) {
|
||||||
|
const [status, setStatus] = useState<"up" | "down" | "unknown">("unknown");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
fetch(url, { method: "HEAD", mode: "no-cors", signal: controller.signal })
|
||||||
|
.then(() => { if (!cancelled) setStatus("up"); })
|
||||||
|
.catch(() => { if (!cancelled) setStatus("down"); })
|
||||||
|
.finally(() => clearTimeout(timer));
|
||||||
|
|
||||||
|
return () => { cancelled = true; controller.abort(); };
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusDot({ status }: { status: "up" | "down" | "unknown" }) {
|
||||||
|
if (status === "unknown") return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-card",
|
||||||
|
status === "up" && "bg-emerald-500",
|
||||||
|
status === "down" && "bg-red-500"
|
||||||
|
)}
|
||||||
|
title={status === "up" ? "Online" : "Offline"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UrlPickerDialog({
|
||||||
|
urls,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
urls: ServiceUrl[];
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Open App</DialogTitle>
|
||||||
|
<DialogDescription>Choose which URL to open</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{urls.map((u) => (
|
||||||
|
<a
|
||||||
|
key={u.id}
|
||||||
|
href={u.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-between rounded-xl border border-border bg-card px-4 py-3 text-sm transition-all hover:bg-accent hover:border-border"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Badge variant="secondary" className={cn("gap-1 text-[10px] px-2 py-0.5 font-medium uppercase", kindBadgeClass(u.kind))}>
|
||||||
|
{kindIcon(u.kind)}
|
||||||
|
{u.kind}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-medium truncate">{u.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="text-xs text-muted-foreground hidden sm:inline">{extractHost(u.url)}</span>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceCard({
|
||||||
|
service,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
isDragging = false,
|
||||||
|
dragHandleProps,
|
||||||
|
}: {
|
||||||
|
service: Service;
|
||||||
|
onEdit: (s: Service) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
isDragging?: boolean;
|
||||||
|
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
}) {
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isCasaOS = theme === "casaos";
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (service.urls.length === 1) {
|
||||||
|
window.open(service.urls[0].url, "_blank", "noopener,noreferrer");
|
||||||
|
} else {
|
||||||
|
setPickerOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconSrc = getIconUrl(service);
|
||||||
|
const primaryUrl = service.urls.find((u) => u.isPrimary) || service.urls[0];
|
||||||
|
const status = useServicePing(primaryUrl?.url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"service-card group relative cursor-pointer overflow-hidden",
|
||||||
|
isCasaOS
|
||||||
|
? "aspect-square rounded-[24px] border border-border bg-card shadow-[0_4px_16px_rgba(0,0,0,0.2)] hover:shadow-[0_8px_32px_rgba(0,0,0,0.3)] hover:bg-accent"
|
||||||
|
: "aspect-square rounded-2xl border border-border bg-card shadow-[0px_0px_0px_1px_var(--color-border)] hover:bg-accent hover:shadow-border-hover",
|
||||||
|
isDragging && "drag-overlay",
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{/* Gradient accent line at top */}
|
||||||
|
<div className={cn(
|
||||||
|
"absolute top-0 left-0 right-0 h-[2px] opacity-0 group-hover:opacity-100 transition-opacity",
|
||||||
|
isCasaOS ? "bg-gradient-to-r from-blue-400/60 via-purple-400/60 to-pink-400/60" : "bg-gradient-to-r from-ring/60 to-ring/20"
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2.5 p-4">
|
||||||
|
{dragHandleProps && (
|
||||||
|
<div
|
||||||
|
{...dragHandleProps}
|
||||||
|
className="absolute left-2 top-2 cursor-grab rounded-md p-1 opacity-0 transition-all group-hover:opacity-60 hover:opacity-100 hover:bg-accent"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<GripVertical className={cn("text-muted-foreground", isCasaOS ? "h-5 w-5" : "h-4 w-4")} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icon container */}
|
||||||
|
<div className={cn(
|
||||||
|
"relative flex items-center justify-center transition-transform duration-300 group-hover:scale-110",
|
||||||
|
isCasaOS ? "h-[52px] w-[52px]" : "h-12 w-12"
|
||||||
|
)}>
|
||||||
|
{iconSrc ? (
|
||||||
|
<img
|
||||||
|
src={iconSrc}
|
||||||
|
alt={service.name}
|
||||||
|
className={cn("h-full w-full object-contain drop-shadow-lg", isCasaOS ? "rounded-2xl" : "rounded-xl")}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
(e.target as HTMLImageElement).nextElementSibling?.classList.remove("hidden");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-xl font-mono font-bold text-secondary-foreground",
|
||||||
|
isCasaOS
|
||||||
|
? "bg-gradient-to-br from-blue-500/20 to-purple-500/20 text-lg border border-white/10"
|
||||||
|
: "bg-secondary text-sm",
|
||||||
|
iconSrc && "hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getInitials(service.name)}
|
||||||
|
</div>
|
||||||
|
<StatusDot status={status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* App name */}
|
||||||
|
<span className={cn(
|
||||||
|
"max-w-full truncate text-center font-semibold leading-tight",
|
||||||
|
isCasaOS ? "text-sm text-white/90" : "text-xs text-foreground"
|
||||||
|
)}>
|
||||||
|
{service.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* URL indicator */}
|
||||||
|
{primaryUrl && (
|
||||||
|
<span className="text-[10px] text-muted-foreground truncate max-w-full hidden sm:block">
|
||||||
|
{extractHost(primaryUrl.url)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* URL kind badges */}
|
||||||
|
{service.urls.length > 1 && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{service.urls.slice(0, 3).map((u) => (
|
||||||
|
<span
|
||||||
|
key={u.id}
|
||||||
|
className={cn(
|
||||||
|
"text-[9px] px-1.5 py-0.5 rounded-full font-medium uppercase tracking-wider",
|
||||||
|
kindBadgeClass(u.kind)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{u.kind}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div
|
||||||
|
className="absolute right-2 top-2 opacity-0 transition-all group-hover:opacity-100"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-7 rounded-lg",
|
||||||
|
isCasaOS ? "text-white/50 hover:text-white hover:bg-white/10" : "hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40 rounded-xl">
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(service)} className="gap-2 text-xs">
|
||||||
|
<Pencil className="h-3.5 w-3.5" /> Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="gap-2 text-xs text-destructive" onClick={() => onDelete(service.id)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
{service.urls.length > 1 && (
|
||||||
|
<UrlPickerDialog urls={service.urls} open={pickerOpen} onOpenChange={setPickerOpen} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import type { Service, ServiceUrlInput, ServiceRequest } from "@/lib/api/schema";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Plus, Trash2, Upload, Star } from "lucide-react";
|
||||||
|
import { useCreateService, useUpdateService, useUploadIcon } from "@/lib/api/hooks";
|
||||||
|
|
||||||
|
interface ServiceFormProps {
|
||||||
|
service?: Service | null;
|
||||||
|
groups: { id: string; name: string }[];
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_URL: ServiceUrlInput = { label: "", kind: "local", url: "", isPrimary: false };
|
||||||
|
|
||||||
|
export function ServiceForm({ service, groups, open, onOpenChange }: ServiceFormProps) {
|
||||||
|
const isEdit = !!service;
|
||||||
|
const createMut = useCreateService();
|
||||||
|
const updateMut = useUpdateService();
|
||||||
|
const uploadMut = useUploadIcon();
|
||||||
|
|
||||||
|
const [name, setName] = useState(service?.name || "");
|
||||||
|
const [groupId, setGroupId] = useState<string | null>(service?.groupId || null);
|
||||||
|
const [iconUrl, setIconUrl] = useState(service?.iconUrl || "");
|
||||||
|
const [iconAssetId, setIconAssetId] = useState<string | null>(service?.iconAssetId || null);
|
||||||
|
const [iconMode, setIconMode] = useState<"url" | "upload">("url");
|
||||||
|
const [urls, setUrls] = useState<ServiceUrlInput[]>(
|
||||||
|
service?.urls?.map((u) => ({ id: u.id, label: u.label, kind: u.kind, url: u.url, isPrimary: u.isPrimary })) || [{ ...EMPTY_URL, isPrimary: true }],
|
||||||
|
);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const addUrl = () => setUrls((prev) => [...prev, { ...EMPTY_URL }]);
|
||||||
|
const removeUrl = (idx: number) => setUrls((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
const updateUrl = (idx: number, field: keyof ServiceUrlInput, value: string | boolean) => {
|
||||||
|
setUrls((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = { ...next[idx], [field]: value };
|
||||||
|
if (field === "isPrimary" && value === true) {
|
||||||
|
next.forEach((u, i) => {
|
||||||
|
if (i !== idx) u.isPrimary = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const asset = await uploadMut.mutateAsync(file);
|
||||||
|
setIconAssetId(asset.id);
|
||||||
|
setIconUrl("");
|
||||||
|
} catch {
|
||||||
|
setErrors((prev) => ({ ...prev, icon: "Upload failed" }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = (): boolean => {
|
||||||
|
const e: Record<string, string> = {};
|
||||||
|
if (!name.trim()) e.name = "Name is required";
|
||||||
|
if (urls.length === 0) e.urls = "At least one URL is required";
|
||||||
|
urls.forEach((u, i) => {
|
||||||
|
if (!u.label.trim()) e[`url-label-${i}`] = "Label required";
|
||||||
|
if (!u.url.trim()) e[`url-${i}`] = "URL required";
|
||||||
|
else if (!/^https?:\/\//.test(u.url)) e[`url-${i}`] = "Must be http(s)";
|
||||||
|
});
|
||||||
|
setErrors(e);
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validate()) return;
|
||||||
|
const body: ServiceRequest = {
|
||||||
|
name: name.trim(),
|
||||||
|
groupId,
|
||||||
|
iconUrl: iconMode === "url" && iconUrl ? iconUrl : null,
|
||||||
|
iconAssetId: iconMode === "upload" && iconAssetId ? iconAssetId : null,
|
||||||
|
urls: urls.map((u) => ({ label: u.label.trim(), kind: u.kind, url: u.url.trim(), isPrimary: u.isPrimary })),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (isEdit && service) {
|
||||||
|
await updateMut.mutateAsync({ id: service.id, ...body });
|
||||||
|
} else {
|
||||||
|
await createMut.mutateAsync(body);
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
setErrors({ submit: err instanceof Error ? err.message : "Failed" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? "Edit App" : "Add App"}</DialogTitle>
|
||||||
|
<DialogDescription>{isEdit ? "Update app details" : "Add a new app to your dashboard"}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4 py-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Jellyfin" />
|
||||||
|
{errors.name && <span className="text-xs text-destructive">{errors.name}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Icon</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant={iconMode === "url" ? "secondary" : "ghost"} size="sm" onClick={() => setIconMode("url")}>
|
||||||
|
URL
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant={iconMode === "upload" ? "secondary" : "ghost"} size="sm" onClick={() => setIconMode("upload")}>
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{iconMode === "url" ? (
|
||||||
|
<Input value={iconUrl} onChange={(e) => setIconUrl(e.target.value)} placeholder="https://example.com/icon.png" />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFileUpload} />
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||||
|
<Upload className="h-3 w-3" /> Choose file
|
||||||
|
</Button>
|
||||||
|
{iconAssetId && <span className="text-xs text-muted-foreground">Uploaded</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errors.icon && <span className="text-xs text-destructive">{errors.icon}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Group</Label>
|
||||||
|
<Select value={groupId || "__none__"} onValueChange={(v: string) => setGroupId(v === "__none__" ? null : v)}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="No group" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">No group</SelectItem>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>URLs</Label>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={addUrl}>
|
||||||
|
<Plus className="h-3 w-3" /> Add URL
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{urls.map((u, i) => (
|
||||||
|
<div key={i} className="flex flex-col gap-1.5 rounded-md border border-border p-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input className="flex-1" value={u.label} onChange={(e) => updateUrl(i, "label", e.target.value)} placeholder="Label" />
|
||||||
|
<Select value={u.kind} onValueChange={(v: string) => updateUrl(i, "kind", v)}>
|
||||||
|
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="local">Local</SelectItem>
|
||||||
|
<SelectItem value="external">External</SelectItem>
|
||||||
|
<SelectItem value="custom">Custom</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeUrl(i)}>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input className="flex-1" value={u.url} onChange={(e) => updateUrl(i, "url", e.target.value)} placeholder="https://" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={u.isPrimary ? "secondary" : "ghost"}
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() => updateUrl(i, "isPrimary", !u.isPrimary)}
|
||||||
|
title="Primary URL"
|
||||||
|
>
|
||||||
|
<Star className={u.isPrimary ? "h-3 w-3 fill-current" : "h-3 w-3"} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{errors[`url-label-${i}`] && <span className="text-xs text-destructive">{errors[`url-label-${i}`]}</span>}
|
||||||
|
{errors[`url-${i}`] && <span className="text-xs text-destructive">{errors[`url-${i}`]}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errors.submit && <span className="text-xs text-destructive">{errors.submit}</span>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={createMut.isPending || updateMut.isPending}>
|
||||||
|
{isEdit ? "Save" : "Add App"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, LayoutGrid, AppWindow, Puzzle } from "lucide-react";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
onAddService,
|
||||||
|
onAddWidget,
|
||||||
|
onAddGroup,
|
||||||
|
}: {
|
||||||
|
onAddService: () => void;
|
||||||
|
onAddWidget: () => void;
|
||||||
|
onAddGroup: () => void;
|
||||||
|
}) {
|
||||||
|
const [now, setNow] = useState(new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(new Date()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const timeStr = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
const dateStr = now.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 w-full border-b border-border bg-background">
|
||||||
|
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-secondary">
|
||||||
|
<LayoutGrid className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold tracking-tight text-foreground">
|
||||||
|
Dash
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden h-4 w-px bg-border sm:block" />
|
||||||
|
<div className="hidden items-center gap-2 sm:flex">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{dateStr}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{timeStr}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onAddWidget} className="gap-1.5 text-xs text-muted-foreground hover:text-foreground">
|
||||||
|
<Puzzle className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">Widget</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onAddGroup} className="gap-1.5 text-xs text-muted-foreground hover:text-foreground">
|
||||||
|
<AppWindow className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">Group</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" size="sm" onClick={onAddService} className="gap-1.5 text-xs">
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">App</span>
|
||||||
|
</Button>
|
||||||
|
<div className="ml-1 h-4 w-px bg-border" />
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTheme } from "@/components/providers";
|
||||||
|
import { themeLabels, type Theme } from "@/lib/theme/themes";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { Sun, Moon, Sparkles, Check } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const themeIcons: Record<Theme, React.ReactNode> = {
|
||||||
|
light: <Sun className="h-4 w-4" />,
|
||||||
|
dark: <Moon className="h-4 w-4" />,
|
||||||
|
casaos: <Sparkles className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const themeDot: Record<Theme, string> = {
|
||||||
|
light: "bg-amber-400",
|
||||||
|
dark: "bg-indigo-400",
|
||||||
|
casaos: "bg-pink-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="rounded-lg hover:bg-accent relative" aria-label="Toggle theme">
|
||||||
|
<div className="relative">
|
||||||
|
{themeIcons[theme]}
|
||||||
|
<span className={cn("absolute -bottom-0.5 -right-0.5 h-1.5 w-1.5 rounded-full border-2 border-background", themeDot[theme])} />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40 rounded-xl">
|
||||||
|
{(["light", "dark", "casaos"] as Theme[]).map((t) => (
|
||||||
|
<DropdownMenuItem key={t} onClick={() => setTheme(t)} className={cn("gap-2.5 rounded-lg cursor-pointer", theme === t && "bg-accent")}>
|
||||||
|
<span className={cn("flex h-5 w-5 items-center justify-center rounded-md", theme === t ? "text-foreground" : "text-muted-foreground")}>
|
||||||
|
{themeIcons[t]}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">{themeLabels[t]}</span>
|
||||||
|
{theme === t && <Check className="ml-auto h-3.5 w-3.5 text-foreground" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-3 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "default" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||||
|
));
|
||||||
|
Alert.displayName = "Alert";
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
AlertTitle.displayName = "AlertTitle";
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
AlertDescription.displayName = "AlertDescription";
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent bg-primary text-primary-foreground",
|
||||||
|
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||||
|
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
||||||
|
outline: "text-foreground",
|
||||||
|
local: "border-transparent bg-blue-500/15 text-blue-500",
|
||||||
|
external: "border-transparent bg-emerald-500/15 text-emerald-500",
|
||||||
|
custom: "border-transparent bg-amber-500/15 text-amber-500",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "default" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "default", size: "default" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("rounded-lg bg-card text-card-foreground shadow-border-card", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex flex-col gap-1.5 p-4", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("text-sm font-medium leading-none", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("text-xs text-muted-foreground", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-4 pt-0", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex items-center p-4 pt-0", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||||
|
|
||||||
|
const Collapsible = CollapsiblePrimitive.Root;
|
||||||
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||||
|
const CollapsibleContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CollapsiblePrimitive.CollapsibleContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
|
||||||
|
>(({ ...props }, ref) => <CollapsiblePrimitive.CollapsibleContent ref={ref} {...props} />);
|
||||||
|
CollapsibleContent.displayName = "CollapsibleContent";
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Command.displayName = CommandPrimitive.displayName;
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b border-border px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List ref={ref} className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden p-1", className)} {...props} />
|
||||||
|
));
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group ref={ref} className={cn("overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", className)} {...props} />
|
||||||
|
));
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||||
|
|
||||||
|
export { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup };
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border bg-popover p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full p-1 opacity-70 transition-all hover:opacity-100 hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col gap-1.5 text-center sm:text-left", className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2", className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuGroup, DropdownMenuSub };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-xs font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root;
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
));
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent };
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
));
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
));
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-border bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn("p-1", position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem };
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & { orientation?: "horizontal" | "vertical" }>(
|
||||||
|
({ className, orientation = "horizontal", ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="separator"
|
||||||
|
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Separator.displayName = "Separator";
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Sheet = DialogPrimitive.Root;
|
||||||
|
const SheetTrigger = DialogPrimitive.Trigger;
|
||||||
|
const SheetClose = DialogPrimitive.Close;
|
||||||
|
const SheetPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
className={cn("fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
|
bottom: "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
|
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||||
|
right: "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { side: "right" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<React.ComponentRef<typeof DialogPrimitive.Content>, SheetContentProps>(
|
||||||
|
({ side = "right", className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
{children}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
SheetContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||||
|
);
|
||||||
|
SheetHeader.displayName = "SheetHeader";
|
||||||
|
|
||||||
|
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||||
|
);
|
||||||
|
SheetFooter.displayName = "SheetFooter";
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
|
||||||
|
));
|
||||||
|
SheetTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
));
|
||||||
|
SheetDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SwitchPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn("inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { WidgetInstance, WidgetData } from "@/lib/api/schema";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { MoreVertical, RefreshCw, Pencil, Trash2, GripVertical, Clock, Shield, ImageIcon, StickyNote, Camera, Activity } from "lucide-react";
|
||||||
|
import { useWidgetData, useRefreshWidget } from "@/lib/api/hooks";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTheme } from "@/components/providers";
|
||||||
|
|
||||||
|
const widgetTypeIcons: Record<string, React.ReactNode> = {
|
||||||
|
clock: <Clock className="h-3.5 w-3.5" />,
|
||||||
|
pihole: <Shield className="h-3.5 w-3.5" />,
|
||||||
|
image: <ImageIcon className="h-3.5 w-3.5" />,
|
||||||
|
memos: <StickyNote className="h-3.5 w-3.5" />,
|
||||||
|
immich: <Camera className="h-3.5 w-3.5" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const widgetTypeColors: Record<string, string> = {
|
||||||
|
clock: "from-blue-500/20 to-cyan-500/20",
|
||||||
|
pihole: "from-emerald-500/20 to-teal-500/20",
|
||||||
|
image: "from-purple-500/20 to-pink-500/20",
|
||||||
|
memos: "from-amber-500/20 to-orange-500/20",
|
||||||
|
immich: "from-rose-500/20 to-red-500/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WidgetCard({
|
||||||
|
widget,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
dragHandleProps,
|
||||||
|
}: {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
onEdit: (w: WidgetInstance) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
}) {
|
||||||
|
const { data, isLoading, error } = useWidgetData(widget.id);
|
||||||
|
const refreshMut = useRefreshWidget();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isCasaOS = theme === "casaos";
|
||||||
|
|
||||||
|
const handleRefresh = () => refreshMut.mutate(widget.id);
|
||||||
|
|
||||||
|
const statusLabel = data?.status === "stale" ? "stale" : data?.status === "error" ? "error" : "";
|
||||||
|
const typeIcon = widgetTypeIcons[widget.type] || <Activity className="h-3.5 w-3.5" />;
|
||||||
|
const typeGradient = widgetTypeColors[widget.type] || "from-muted to-muted";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn(
|
||||||
|
"group relative border-0 overflow-hidden",
|
||||||
|
isCasaOS
|
||||||
|
? "rounded-[20px] bg-card border border-border shadow-[0_4px_16px_rgba(0,0,0,0.15)] hover:shadow-[0_8px_32px_rgba(0,0,0,0.25)] hover:-translate-y-[2px] transition-all duration-300"
|
||||||
|
: "rounded-2xl shadow-[0px_0px_0px_1px_var(--color-border)] hover:shadow-border-hover transition-all duration-200"
|
||||||
|
)}>
|
||||||
|
<div className={cn(
|
||||||
|
"absolute top-0 left-0 right-0 h-1 opacity-60",
|
||||||
|
isCasaOS ? `bg-gradient-to-r ${typeGradient}` : "bg-gradient-to-r from-ring/40 to-transparent"
|
||||||
|
)} />
|
||||||
|
<CardHeader className={cn("flex flex-row items-center justify-between pt-4 pb-2", isCasaOS ? "px-5" : "px-4")}>
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
{dragHandleProps && (
|
||||||
|
<div {...dragHandleProps} className="cursor-grab opacity-0 group-hover:opacity-60 transition-opacity rounded-md p-0.5 hover:bg-accent">
|
||||||
|
<GripVertical className={cn("text-muted-foreground", isCasaOS ? "h-5 w-5" : "h-4 w-4")} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn("flex h-6 w-6 items-center justify-center rounded-md shrink-0", isCasaOS ? "bg-white/10" : "bg-accent")}>
|
||||||
|
{typeIcon}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<CardTitle className="text-xs font-semibold uppercase tracking-wide truncate">
|
||||||
|
{widget.title}
|
||||||
|
</CardTitle>
|
||||||
|
{statusLabel && (
|
||||||
|
<span className={cn(
|
||||||
|
"text-[9px] px-1.5 py-0.5 rounded-full font-medium uppercase shrink-0",
|
||||||
|
statusLabel === "stale" ? "bg-amber-500/15 text-amber-400" : "bg-destructive/15 text-destructive"
|
||||||
|
)}>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
|
<Button variant="ghost" size="icon" className={cn("relative z-10 pointer-events-auto rounded-lg h-7 w-7", isCasaOS ? "text-white/50 hover:text-white hover:bg-white/10" : "hover:bg-accent")} onClick={handleRefresh} disabled={refreshMut.isPending}>
|
||||||
|
<RefreshCw className={cn(refreshMut.isPending && "animate-spin", isCasaOS ? "h-4 w-4" : "h-3.5 w-3.5")} />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className={cn("rounded-lg h-7 w-7", isCasaOS ? "text-white/50 hover:text-white hover:bg-white/10" : "hover:bg-accent")}>
|
||||||
|
<MoreVertical className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40 rounded-xl">
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(widget)} className="gap-2 text-xs">
|
||||||
|
<Pencil className="h-3.5 w-3.5" /> Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="gap-2 text-xs text-destructive" onClick={() => onDelete(widget.id)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className={cn(isCasaOS ? "px-5 pb-5 pt-1" : "px-4 pb-4 pt-1")}>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">[LOADING...]</span>
|
||||||
|
) : error || data?.status === "error" ? (
|
||||||
|
<span className="font-mono text-xs text-destructive">[ERROR: {data?.error || "Failed to load"}]</span>
|
||||||
|
) : (
|
||||||
|
<WidgetContent widget={widget} data={data} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetContent({ widget, data }: { widget: WidgetInstance; data?: WidgetData }) {
|
||||||
|
switch (widget.type) {
|
||||||
|
case "clock":
|
||||||
|
return <ClockContent config={widget.config} data={data} />;
|
||||||
|
case "image":
|
||||||
|
return <ImageContent config={widget.config} />;
|
||||||
|
case "pihole":
|
||||||
|
return <PiHoleContent data={data} />;
|
||||||
|
case "memos":
|
||||||
|
return <MemosContent data={data} />;
|
||||||
|
case "immich":
|
||||||
|
return <ImmichContent data={data} />;
|
||||||
|
default:
|
||||||
|
return <span className="font-mono text-xs text-muted-foreground">Unknown widget type</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClockContent({ config }: { config: Record<string, unknown>; data?: WidgetData }) {
|
||||||
|
const timezones = (config.timezones as string[]) || [];
|
||||||
|
const now = new Date();
|
||||||
|
const localTime = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
const localDate = now.toLocaleDateString([], { weekday: "long", month: "long", day: "numeric" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="font-mono text-3xl tabular-nums tracking-tight text-foreground">{localTime}</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-medium">{localDate}</div>
|
||||||
|
{timezones.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-col gap-1.5 border-t border-border/30 pt-2">
|
||||||
|
{timezones.map((tz) => {
|
||||||
|
try {
|
||||||
|
const t = new Date().toLocaleTimeString([], { timeZone: tz, hour: "2-digit", minute: "2-digit" });
|
||||||
|
return (
|
||||||
|
<div key={tz} className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground text-[11px]">{tz.split("/").pop()?.replace("_", " ")}</span>
|
||||||
|
<span className="font-mono tabular-nums text-foreground">{t}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageContent({ config }: { config: Record<string, unknown> }) {
|
||||||
|
const imageUrl = config.imageUrl as string;
|
||||||
|
const linkUrl = config.linkUrl as string | null;
|
||||||
|
|
||||||
|
const img = (
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Widget image"
|
||||||
|
className="max-h-48 w-full rounded-xl object-cover border border-border/20 shadow-sm"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (linkUrl) {
|
||||||
|
return <a href={linkUrl} target="_blank" rel="noopener noreferrer" className="block rounded-xl overflow-hidden">{img}</a>;
|
||||||
|
}
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PiHoleContent({ data }: { data?: WidgetData }) {
|
||||||
|
const d = data?.data as Record<string, unknown> | undefined;
|
||||||
|
if (!d) return <span className="font-mono text-xs text-muted-foreground">No data</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg bg-emerald-500/10 p-2.5">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-emerald-400 font-medium mb-0.5">Status</div>
|
||||||
|
<div className={cn("text-sm font-semibold", d.status === "enabled" ? "text-emerald-400" : "text-destructive")}>
|
||||||
|
{String(d.status || "unknown")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-blue-500/10 p-2.5">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-blue-400 font-medium mb-0.5">Blocked</div>
|
||||||
|
<div className="font-mono text-sm font-semibold text-foreground">{String(d.ads_blocked_today || "0")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-purple-500/10 p-2.5">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-purple-400 font-medium mb-0.5">Queries</div>
|
||||||
|
<div className="font-mono text-sm font-semibold text-foreground">{String(d.dns_queries_today || "0")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-amber-500/10 p-2.5">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-amber-400 font-medium mb-0.5">% Blocked</div>
|
||||||
|
<div className="font-mono text-sm font-semibold text-foreground">{String(d.ads_percentage_today || "0")}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemosContent({ data }: { data?: WidgetData }) {
|
||||||
|
const d = data?.data as Record<string, unknown> | undefined;
|
||||||
|
const memos = (d?.memos as Array<Record<string, unknown>>) || [];
|
||||||
|
if (memos.length === 0) return <span className="font-mono text-xs text-muted-foreground">No memos</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto pr-1">
|
||||||
|
{memos.slice(0, 5).map((m, i) => (
|
||||||
|
<div key={i} className="rounded-lg bg-amber-500/10 p-2.5 border border-amber-500/10">
|
||||||
|
<div className="text-[11px] leading-relaxed line-clamp-2 text-foreground/90">
|
||||||
|
{String(m.content || m.snippet || "")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImmichContent({ data }: { data?: WidgetData }) {
|
||||||
|
const d = data?.data as Record<string, unknown> | undefined;
|
||||||
|
if (!d) return <span className="font-mono text-xs text-muted-foreground">No data</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg bg-blue-500/10 p-2.5">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-blue-400 font-medium mb-0.5">Photos</div>
|
||||||
|
<div className="font-mono text-sm font-semibold text-foreground">{String(d.photos || "0")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-rose-500/10 p-2.5">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-rose-400 font-medium mb-0.5">Videos</div>
|
||||||
|
<div className="font-mono text-sm font-semibold text-foreground">{String(d.videos || "0")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { WidgetInstance, WidgetRequest } from "@/lib/api/schema";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { useCreateWidget, useUpdateWidget } from "@/lib/api/hooks";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Check, ChevronsUpDown, X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const POPULAR_TIMEZONES = [
|
||||||
|
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
|
||||||
|
"America/Anchorage", "Pacific/Honolulu", "America/Sao_Paulo", "America/Argentina/Buenos_Aires",
|
||||||
|
"Europe/London", "Europe/Paris", "Europe/Berlin", "Europe/Prague", "Europe/Moscow",
|
||||||
|
"Asia/Dubai", "Asia/Kolkata", "Asia/Bangkok", "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul",
|
||||||
|
"Australia/Sydney", "Australia/Melbourne", "Pacific/Auckland", "UTC",
|
||||||
|
];
|
||||||
|
|
||||||
|
const WIDGET_TYPES = ["clock", "image", "pihole", "memos", "immich"] as const;
|
||||||
|
|
||||||
|
interface WidgetFormProps {
|
||||||
|
widget?: WidgetInstance | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetForm({ widget, open, onOpenChange }: WidgetFormProps) {
|
||||||
|
const isEdit = !!widget;
|
||||||
|
const createMut = useCreateWidget();
|
||||||
|
const updateMut = useUpdateWidget();
|
||||||
|
|
||||||
|
const [type, setType] = useState<string>(widget?.type || "clock");
|
||||||
|
const [title, setTitle] = useState(widget?.title || "");
|
||||||
|
const [enabled, setEnabled] = useState(widget?.enabled ?? true);
|
||||||
|
const [selectedTzs, setSelectedTzs] = useState<string[]>(
|
||||||
|
(widget?.config?.timezones as string[]) || [],
|
||||||
|
);
|
||||||
|
const [tzPopoverOpen, setTzPopoverOpen] = useState(false);
|
||||||
|
const [imageUrl, setImageUrl] = useState((widget?.config?.imageUrl as string) || "");
|
||||||
|
const [linkUrl, setLinkUrl] = useState((widget?.config?.linkUrl as string) || "");
|
||||||
|
const [piholeBaseUrl, setPiholeBaseUrl] = useState((widget?.config?.baseUrl as string) || "");
|
||||||
|
const [piholeApiToken, setPiholeApiToken] = useState((widget?.config?.apiToken as string) || "");
|
||||||
|
const [memosBaseUrl, setMemosBaseUrl] = useState((widget?.config?.baseUrl as string) || "");
|
||||||
|
const [memosApiToken, setMemosApiToken] = useState((widget?.config?.apiToken as string) || "");
|
||||||
|
const [memosPageSize, setMemosPageSize] = useState(String((widget?.config?.pageSize as number) || 5));
|
||||||
|
const [immichBaseUrl, setImmichBaseUrl] = useState((widget?.config?.baseUrl as string) || "");
|
||||||
|
const [immichApiKey, setImmichApiKey] = useState((widget?.config?.apiKey as string) || "");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const buildConfig = (): Record<string, unknown> => {
|
||||||
|
switch (type) {
|
||||||
|
case "clock":
|
||||||
|
return { timezones: selectedTzs };
|
||||||
|
case "image":
|
||||||
|
return { imageUrl, linkUrl: linkUrl || null };
|
||||||
|
case "pihole":
|
||||||
|
return { baseUrl: piholeBaseUrl, apiToken: piholeApiToken };
|
||||||
|
case "memos":
|
||||||
|
return { baseUrl: memosBaseUrl, apiToken: memosApiToken, pageSize: parseInt(memosPageSize) || 5 };
|
||||||
|
case "immich":
|
||||||
|
return { baseUrl: immichBaseUrl, apiKey: immichApiKey };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!title.trim()) { setError("Title is required"); return; }
|
||||||
|
if ((type === "pihole" || type === "memos") && !piholeBaseUrl && !memosBaseUrl) {
|
||||||
|
setError("Base URL is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === "immich" && !immichBaseUrl) {
|
||||||
|
setError("Base URL is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === "image" && !imageUrl) { setError("Image URL is required"); return; }
|
||||||
|
|
||||||
|
const body: WidgetRequest = {
|
||||||
|
type: type as WidgetRequest["type"],
|
||||||
|
title: title.trim(),
|
||||||
|
enabled,
|
||||||
|
config: buildConfig() as WidgetRequest["config"],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit && widget) {
|
||||||
|
await updateMut.mutateAsync({ id: widget.id, ...body });
|
||||||
|
} else {
|
||||||
|
await createMut.mutateAsync(body);
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
setError("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? "Edit Widget" : "Add Widget"}</DialogTitle>
|
||||||
|
<DialogDescription>{isEdit ? "Update widget settings" : "Add a new widget to your dashboard"}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4 py-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={type} onValueChange={setType} disabled={isEdit}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{WIDGET_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="widget-title">Title</Label>
|
||||||
|
<Input id="widget-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="My Widget" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
<Label>Enabled</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type === "clock" && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Timezones</Label>
|
||||||
|
<div className="flex flex-wrap gap-1 mb-1">
|
||||||
|
{selectedTzs.map((tz) => (
|
||||||
|
<Badge key={tz} variant="secondary" className="gap-1 text-xs">
|
||||||
|
{tz.split("/").pop()?.replace("_", " ")}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-0.5 rounded-full hover:bg-foreground/10"
|
||||||
|
onClick={() => setSelectedTzs((prev) => prev.filter((t) => t !== tz))}
|
||||||
|
>
|
||||||
|
<X className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Popover open={tzPopoverOpen} onOpenChange={setTzPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" type="button" className="justify-between text-xs font-normal">
|
||||||
|
Add timezone…
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-64 p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search timezone…" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No timezone found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{POPULAR_TIMEZONES.filter((tz) => !selectedTzs.includes(tz)).map((tz) => (
|
||||||
|
<CommandItem
|
||||||
|
key={tz}
|
||||||
|
value={tz}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedTzs((prev) => [...prev, tz]);
|
||||||
|
setTzPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", selectedTzs.includes(tz) ? "opacity-100" : "opacity-0")} />
|
||||||
|
{tz}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{type === "image" && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Image URL</Label>
|
||||||
|
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} placeholder="https://example.com/image.jpg" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Link URL (optional)</Label>
|
||||||
|
<Input value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} placeholder="https://example.com" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{type === "pihole" && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Pi-hole Base URL</Label>
|
||||||
|
<Input value={piholeBaseUrl} onChange={(e) => setPiholeBaseUrl(e.target.value)} placeholder="http://pihole.local" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>API Token</Label>
|
||||||
|
<Input type="password" value={piholeApiToken} onChange={(e) => setPiholeApiToken(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{type === "memos" && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Memos Base URL</Label>
|
||||||
|
<Input value={memosBaseUrl} onChange={(e) => setMemosBaseUrl(e.target.value)} placeholder="http://memos.local:5230" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>API Token</Label>
|
||||||
|
<Input type="password" value={memosApiToken} onChange={(e) => setMemosApiToken(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Page Size</Label>
|
||||||
|
<Input type="number" value={memosPageSize} onChange={(e) => setMemosPageSize(e.target.value)} min={1} max={20} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{type === "immich" && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Immich Base URL</Label>
|
||||||
|
<Input value={immichBaseUrl} onChange={(e) => setImmichBaseUrl(e.target.value)} placeholder="http://immich.local:2283" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>API Key</Label>
|
||||||
|
<Input type="password" value={immichApiKey} onChange={(e) => setImmichApiKey(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={createMut.isPending || updateMut.isPending}>
|
||||||
|
{isEdit ? "Save" : "Add Widget"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("smoke: page loads with header", async ({ page }) => {
|
||||||
|
await page.goto("http://localhost:3000");
|
||||||
|
await expect(page.locator("header")).toBeVisible();
|
||||||
|
await expect(page.getByText("Dash")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoke: theme toggle works", async ({ page }) => {
|
||||||
|
await page.goto("http://localhost:3000");
|
||||||
|
const toggle = page.getByLabel("Toggle theme");
|
||||||
|
await toggle.click();
|
||||||
|
await page.getByText("CasaOS").click();
|
||||||
|
const theme = await page.evaluate(() => document.documentElement.getAttribute("data-theme"));
|
||||||
|
expect(theme).toBe("casaos");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoke: empty state shows add button", async ({ page }) => {
|
||||||
|
await page.goto("http://localhost:3000");
|
||||||
|
// If no services exist, the empty state should be visible
|
||||||
|
const emptyState = page.getByText("No apps yet");
|
||||||
|
if (await emptyState.isVisible()) {
|
||||||
|
await expect(page.getByRole("button", { name: /add app/i })).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { useTheme, ThemeContext } from "@/components/providers";
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type {
|
||||||
|
Dashboard,
|
||||||
|
Group,
|
||||||
|
Service,
|
||||||
|
WidgetInstance,
|
||||||
|
WidgetData,
|
||||||
|
AssetFile,
|
||||||
|
CreateGroupRequest,
|
||||||
|
PatchGroupRequest,
|
||||||
|
ServiceRequest,
|
||||||
|
LayoutRequest,
|
||||||
|
WidgetRequest,
|
||||||
|
} from "./schema";
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080";
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: res.statusText }));
|
||||||
|
throw new ApiError(res.status, err.code || "unknown", err.message || "Request failed", err.details);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public code: string,
|
||||||
|
message: string,
|
||||||
|
public details?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard ──
|
||||||
|
export function getDashboard(): Promise<Dashboard> {
|
||||||
|
return request("/api/v1/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Groups ──
|
||||||
|
export function listGroups(): Promise<Group[]> {
|
||||||
|
return request("/api/v1/groups");
|
||||||
|
}
|
||||||
|
export function createGroup(body: CreateGroupRequest): Promise<Group> {
|
||||||
|
return request("/api/v1/groups", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
export function patchGroup(id: string, body: PatchGroupRequest): Promise<Group> {
|
||||||
|
return request(`/api/v1/groups/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
export function deleteGroup(id: string, moveServices = false): Promise<void> {
|
||||||
|
return request(`/api/v1/groups/${id}?moveServicesToUngrouped=${moveServices}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Services ──
|
||||||
|
export function listServices(): Promise<Service[]> {
|
||||||
|
return request("/api/v1/services");
|
||||||
|
}
|
||||||
|
export function createService(body: ServiceRequest): Promise<Service> {
|
||||||
|
return request("/api/v1/services", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
export function patchService(id: string, body: ServiceRequest): Promise<Service> {
|
||||||
|
return request(`/api/v1/services/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
export function deleteService(id: string): Promise<void> {
|
||||||
|
return request(`/api/v1/services/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout ──
|
||||||
|
export function putLayout(body: LayoutRequest): Promise<Dashboard> {
|
||||||
|
return request("/api/v1/layout", { method: "PUT", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Assets ──
|
||||||
|
export async function uploadIcon(file: File): Promise<AssetFile> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/assets/icons`, { method: "POST", body: form });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: res.statusText }));
|
||||||
|
throw new ApiError(res.status, err.code || "unknown", err.message || "Upload failed");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Widgets ──
|
||||||
|
export function listWidgets(): Promise<WidgetInstance[]> {
|
||||||
|
return request("/api/v1/widgets");
|
||||||
|
}
|
||||||
|
export function createWidget(body: WidgetRequest): Promise<WidgetInstance> {
|
||||||
|
return request("/api/v1/widgets", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
export function patchWidget(id: string, body: WidgetRequest): Promise<WidgetInstance> {
|
||||||
|
return request(`/api/v1/widgets/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
export function deleteWidget(id: string): Promise<void> {
|
||||||
|
return request(`/api/v1/widgets/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
export function getWidgetData(id: string): Promise<WidgetData> {
|
||||||
|
return request(`/api/v1/widgets/${id}/data`);
|
||||||
|
}
|
||||||
|
export function refreshWidget(id: string): Promise<WidgetData> {
|
||||||
|
return request(`/api/v1/widgets/${id}/refresh`, { method: "POST" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as api from "./client";
|
||||||
|
import type {
|
||||||
|
Dashboard,
|
||||||
|
CreateGroupRequest,
|
||||||
|
PatchGroupRequest,
|
||||||
|
ServiceRequest,
|
||||||
|
LayoutRequest,
|
||||||
|
WidgetRequest,
|
||||||
|
} from "./schema";
|
||||||
|
|
||||||
|
const DASHBOARD_KEY = ["dashboard"];
|
||||||
|
const WIDGETS_KEY = ["widgets"];
|
||||||
|
|
||||||
|
export function useDashboard() {
|
||||||
|
return useQuery({ queryKey: DASHBOARD_KEY, queryFn: api.getDashboard });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: CreateGroupRequest) => api.createGroup(body),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, ...body }: PatchGroupRequest & { id: string }) => api.patchGroup(id, body),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteGroup() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, moveServices }: { id: string; moveServices?: boolean }) =>
|
||||||
|
api.deleteGroup(id, moveServices),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateService() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: ServiceRequest) => api.createService(body),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateService() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, ...body }: ServiceRequest & { id: string }) => api.patchService(id, body),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteService() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.deleteService(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateLayout() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: LayoutRequest) => api.putLayout(body),
|
||||||
|
onMutate: async () => {
|
||||||
|
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
|
||||||
|
const prev = qc.getQueryData<Dashboard>(DASHBOARD_KEY);
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (_err, _vars, ctx) => {
|
||||||
|
if (ctx?.prev) qc.setQueryData(DASHBOARD_KEY, ctx.prev);
|
||||||
|
},
|
||||||
|
onSettled: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadIcon() {
|
||||||
|
return useMutation({ mutationFn: (file: File) => api.uploadIcon(file) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateWidget() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: WidgetRequest) => api.createWidget(body),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateWidget() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, ...body }: WidgetRequest & { id: string }) => api.patchWidget(id, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: DASHBOARD_KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: WIDGETS_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteWidget() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.deleteWidget(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWidgetData(widgetId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["widget-data", widgetId],
|
||||||
|
queryFn: () => api.getWidgetData(widgetId!),
|
||||||
|
enabled: !!widgetId,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRefreshWidget() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.refreshWidget(id),
|
||||||
|
onSuccess: (_data, id) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["widget-data", id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export function makeQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let browserQueryClient: QueryClient | undefined;
|
||||||
|
|
||||||
|
export function getQueryClient() {
|
||||||
|
if (typeof window === "undefined") return makeQueryClient();
|
||||||
|
if (!browserQueryClient) browserQueryClient = makeQueryClient();
|
||||||
|
return browserQueryClient;
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Auto-generated from ../openapi/openapi.yaml
|
||||||
|
// Run: npm run api:generate
|
||||||
|
|
||||||
|
export interface Dashboard {
|
||||||
|
groups: Group[];
|
||||||
|
ungroupedServices: Service[];
|
||||||
|
widgets: WidgetInstance[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sortOrder: number;
|
||||||
|
collapsed: boolean;
|
||||||
|
services: Service[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
id: string;
|
||||||
|
groupId: string | null;
|
||||||
|
name: string;
|
||||||
|
iconUrl: string | null;
|
||||||
|
iconAssetId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
urls: ServiceUrl[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceUrl {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: "local" | "external" | "custom";
|
||||||
|
url: string;
|
||||||
|
sortOrder: number;
|
||||||
|
isPrimary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetInstance {
|
||||||
|
id: string;
|
||||||
|
type: "clock" | "image" | "pihole" | "memos" | "immich";
|
||||||
|
title: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetData {
|
||||||
|
widgetId: string;
|
||||||
|
status: "fresh" | "stale" | "error";
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
error?: string | null;
|
||||||
|
fetchedAt?: string | null;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetFile {
|
||||||
|
id: string;
|
||||||
|
originalName: string;
|
||||||
|
storedName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
publicPath: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
code:
|
||||||
|
| "validation_error"
|
||||||
|
| "not_found"
|
||||||
|
| "conflict"
|
||||||
|
| "upload_too_large"
|
||||||
|
| "unsupported_media_type"
|
||||||
|
| "widget_fetch_failed"
|
||||||
|
| "internal_error";
|
||||||
|
message: string;
|
||||||
|
details: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGroupRequest {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchGroupRequest {
|
||||||
|
name?: string;
|
||||||
|
collapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceRequest {
|
||||||
|
groupId?: string | null;
|
||||||
|
name: string;
|
||||||
|
iconUrl?: string | null;
|
||||||
|
iconAssetId?: string | null;
|
||||||
|
urls: ServiceUrlInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceUrlInput {
|
||||||
|
id?: string;
|
||||||
|
label: string;
|
||||||
|
kind: "local" | "external" | "custom";
|
||||||
|
url: string;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutRequest {
|
||||||
|
groupIds: string[];
|
||||||
|
widgetIds: string[];
|
||||||
|
ungroupedServiceIds: string[];
|
||||||
|
groupServices: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetRequest {
|
||||||
|
type: "clock" | "image" | "pihole" | "memos" | "immich";
|
||||||
|
title: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
config: ClockWidgetConfig | ImageWidgetConfig | PiHoleWidgetConfig | MemosWidgetConfig | ImmichWidgetConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClockWidgetConfig {
|
||||||
|
timezones?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageWidgetConfig {
|
||||||
|
imageUrl: string;
|
||||||
|
linkUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PiHoleWidgetConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
apiToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemosWidgetConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
apiToken: string;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImmichWidgetConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { handlers } from "./handlers";
|
||||||
|
|
||||||
|
let installed = false;
|
||||||
|
|
||||||
|
export function installMocks() {
|
||||||
|
if (installed || typeof window === "undefined") return;
|
||||||
|
|
||||||
|
import("msw/browser").then(({ setupWorker }) => {
|
||||||
|
const worker = setupWorker(...handlers);
|
||||||
|
worker.start({ onUnhandledRequest: "bypass" });
|
||||||
|
installed = true;
|
||||||
|
console.log("[MSW] Mock Service Worker installed");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { Dashboard, Group, Service, WidgetInstance, WidgetData, AssetFile } from "@/lib/api/schema";
|
||||||
|
|
||||||
|
export const FIXTURE_ASSET: AssetFile = {
|
||||||
|
id: "a1b2c3d4-0000-0000-0000-000000000001",
|
||||||
|
originalName: "jellyfin.png",
|
||||||
|
storedName: "jellyfin-abc123.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
sizeBytes: 12345,
|
||||||
|
publicPath: "/uploads/icons/jellyfin-abc123.png",
|
||||||
|
createdAt: "2025-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FIXTURE_SERVICES: Service[] = [
|
||||||
|
{
|
||||||
|
id: "s1-0000-0000-0000-000000000001",
|
||||||
|
groupId: "g1-0000-0000-0000-000000000001",
|
||||||
|
name: "Jellyfin",
|
||||||
|
iconUrl: null,
|
||||||
|
iconAssetId: FIXTURE_ASSET.id,
|
||||||
|
sortOrder: 0,
|
||||||
|
urls: [
|
||||||
|
{ id: "u1", label: "Local", kind: "local", url: "http://jellyfin.local:8096", sortOrder: 0, isPrimary: true },
|
||||||
|
{ id: "u2", label: "External", kind: "external", url: "https://jellyfin.example.com", sortOrder: 1, isPrimary: false },
|
||||||
|
],
|
||||||
|
createdAt: "2025-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2025-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s2-0000-0000-0000-000000000002",
|
||||||
|
groupId: "g1-0000-0000-0000-000000000001",
|
||||||
|
name: "Pi-hole",
|
||||||
|
iconUrl: null,
|
||||||
|
iconAssetId: null,
|
||||||
|
sortOrder: 1,
|
||||||
|
urls: [
|
||||||
|
{ id: "u3", label: "Dashboard", kind: "local", url: "http://pihole.local/admin", sortOrder: 0, isPrimary: true },
|
||||||
|
],
|
||||||
|
createdAt: "2025-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2025-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s3-0000-0000-0000-000000000003",
|
||||||
|
groupId: null,
|
||||||
|
name: "Proxmox",
|
||||||
|
iconUrl: "https://proxmox.com/favicon.ico",
|
||||||
|
iconAssetId: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
urls: [
|
||||||
|
{ id: "u4", label: "Web UI", kind: "local", url: "https://proxmox.local:8006", sortOrder: 0, isPrimary: true },
|
||||||
|
],
|
||||||
|
createdAt: "2025-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2025-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FIXTURE_GROUPS: Group[] = [
|
||||||
|
{
|
||||||
|
id: "g1-0000-0000-0000-000000000001",
|
||||||
|
name: "Media",
|
||||||
|
sortOrder: 0,
|
||||||
|
collapsed: false,
|
||||||
|
services: FIXTURE_SERVICES.filter((s) => s.groupId === "g1-0000-0000-0000-000000000001"),
|
||||||
|
createdAt: "2025-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2025-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FIXTURE_WIDGETS: WidgetInstance[] = [
|
||||||
|
{
|
||||||
|
id: "w1-0000-0000-0000-000000000001",
|
||||||
|
type: "clock",
|
||||||
|
title: "Clock",
|
||||||
|
enabled: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
config: { timezones: ["Europe/Prague", "America/New_York"] },
|
||||||
|
createdAt: "2025-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2025-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "w2-0000-0000-0000-000000000002",
|
||||||
|
type: "pihole",
|
||||||
|
title: "Pi-hole Stats",
|
||||||
|
enabled: true,
|
||||||
|
sortOrder: 1,
|
||||||
|
config: { baseUrl: "http://pihole.local", apiToken: "••••••••" },
|
||||||
|
createdAt: "2025-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2025-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FIXTURE_WIDGET_DATA: Record<string, WidgetData> = {
|
||||||
|
"w1-0000-0000-0000-000000000001": {
|
||||||
|
widgetId: "w1-0000-0000-0000-000000000001",
|
||||||
|
status: "fresh",
|
||||||
|
data: {},
|
||||||
|
fetchedAt: "2025-01-01T12:00:00Z",
|
||||||
|
expiresAt: "2025-01-01T12:01:00Z",
|
||||||
|
},
|
||||||
|
"w2-0000-0000-0000-000000000002": {
|
||||||
|
widgetId: "w2-0000-0000-0000-000000000002",
|
||||||
|
status: "fresh",
|
||||||
|
data: {
|
||||||
|
status: "enabled",
|
||||||
|
ads_blocked_today: 45231,
|
||||||
|
dns_queries_today: 120000,
|
||||||
|
ads_percentage_today: 37.69,
|
||||||
|
},
|
||||||
|
fetchedAt: "2025-01-01T12:00:00Z",
|
||||||
|
expiresAt: "2025-01-01T12:01:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FIXTURE_DASHBOARD: Dashboard = {
|
||||||
|
groups: FIXTURE_GROUPS,
|
||||||
|
ungroupedServices: FIXTURE_SERVICES.filter((s) => s.groupId === null),
|
||||||
|
widgets: FIXTURE_WIDGETS,
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { FIXTURE_DASHBOARD, FIXTURE_WIDGET_DATA } from "./fixtures";
|
||||||
|
|
||||||
|
const BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080";
|
||||||
|
|
||||||
|
export const handlers = [
|
||||||
|
http.get(`${BASE}/api/v1/dashboard`, () => {
|
||||||
|
return HttpResponse.json(FIXTURE_DASHBOARD);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${BASE}/api/v1/groups`, () => {
|
||||||
|
return HttpResponse.json(FIXTURE_DASHBOARD.groups);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post(`${BASE}/api/v1/groups`, async ({ request }) => {
|
||||||
|
const body = await request.json();
|
||||||
|
const newGroup = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: (body as { name: string }).name,
|
||||||
|
sortOrder: FIXTURE_DASHBOARD.groups.length,
|
||||||
|
collapsed: false,
|
||||||
|
services: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return HttpResponse.json(newGroup, { status: 201 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${BASE}/api/v1/services`, () => {
|
||||||
|
const all = [...FIXTURE_DASHBOARD.ungroupedServices, ...FIXTURE_DASHBOARD.groups.flatMap((g) => g.services)];
|
||||||
|
return HttpResponse.json(all);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post(`${BASE}/api/v1/services`, async ({ request }) => {
|
||||||
|
const body = await request.json();
|
||||||
|
const newService = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
...(body as Record<string, unknown>),
|
||||||
|
sortOrder: 0,
|
||||||
|
urls: (body as { urls: Record<string, unknown>[] }).urls.map((u, i) => ({ ...u, id: crypto.randomUUID(), sortOrder: i })),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return HttpResponse.json(newService, { status: 201 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.put(`${BASE}/api/v1/layout`, async ({ request }) => {
|
||||||
|
const body = await request.json();
|
||||||
|
return HttpResponse.json({ ...FIXTURE_DASHBOARD, ...(body as Record<string, unknown>) });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post(`${BASE}/api/v1/assets/icons`, async () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
originalName: "icon.png",
|
||||||
|
storedName: "icon-mock.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
sizeBytes: 1024,
|
||||||
|
publicPath: "/uploads/icons/icon-mock.png",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${BASE}/api/v1/widgets`, () => {
|
||||||
|
return HttpResponse.json(FIXTURE_DASHBOARD.widgets);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post(`${BASE}/api/v1/widgets`, async ({ request }) => {
|
||||||
|
const body = await request.json();
|
||||||
|
const newWidget = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
...(body as Record<string, unknown>),
|
||||||
|
sortOrder: FIXTURE_DASHBOARD.widgets.length,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return HttpResponse.json(newWidget, { status: 201 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
Object.entries(FIXTURE_WIDGET_DATA).map(([widgetId, data]) =>
|
||||||
|
http.get(`${BASE}/api/v1/widgets/${widgetId}/data`, () => {
|
||||||
|
return HttpResponse.json(data);
|
||||||
|
})
|
||||||
|
),
|
||||||
|
|
||||||
|
http.post(`${BASE}/api/v1/widgets/:widgetId/refresh`, ({ params }) => {
|
||||||
|
const data = FIXTURE_WIDGET_DATA[params.widgetId as string];
|
||||||
|
return HttpResponse.json(data || { widgetId: params.widgetId, status: "fresh", data: {}, fetchedAt: new Date().toISOString() });
|
||||||
|
}),
|
||||||
|
].flat();
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type Theme = "light" | "dark" | "casaos";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "dash-theme";
|
||||||
|
|
||||||
|
export function getStoredTheme(): Theme {
|
||||||
|
if (typeof window === "undefined") return "dark";
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === "light" || stored === "dark" || stored === "casaos") return stored;
|
||||||
|
return "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredTheme(theme: Theme) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme: Theme) {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeLabels: Record<Theme, string> = {
|
||||||
|
light: "Light",
|
||||||
|
dark: "Dark",
|
||||||
|
casaos: "CasaOS",
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { cn } from "@/lib/utils";
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{ protocol: "https", hostname: "**" },
|
||||||
|
{ protocol: "http", hostname: "**" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async rewrites() {
|
||||||
|
const backend = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080";
|
||||||
|
return [
|
||||||
|
{ source: "/uploads/:path*", destination: `${backend}/uploads/:path*` },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
+3968
-701
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"name": "dash-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"api:generate": "openapi-typescript ../openapi/openapi.yaml -o lib/api/schema.ts",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@tanstack/react-query": "^5.80.7",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"lucide-react": "^0.511.0",
|
||||||
|
"next": "15.3.2",
|
||||||
|
"openapi-fetch": "^0.14.0",
|
||||||
|
"openapi-typescript": "^7.8.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"tailwind-merge": "^3.3.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
|
"@playwright/test": "^1.52.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
|
"@types/node": "^22.15.0",
|
||||||
|
"@types/react": "^19.1.0",
|
||||||
|
"@types/react-dom": "^19.1.0",
|
||||||
|
"eslint": "9.39.4",
|
||||||
|
"eslint-config-next": "^15.3.2",
|
||||||
|
"msw": "^2.7.0",
|
||||||
|
"tailwindcss": "^4.1.7",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^3.1.0"
|
||||||
|
},
|
||||||
|
"msw": {
|
||||||
|
"workerDirectory": [
|
||||||
|
"public"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
retries: 0,
|
||||||
|
reporter: "html",
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: "npm run dev",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Dash Frontend
|
||||||
|
|
||||||
|
See `../README.md` for full project documentation.
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Service Worker.
|
||||||
|
* @see https://github.com/mswjs/msw
|
||||||
|
* - Please do NOT modify this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PACKAGE_VERSION = '2.14.2'
|
||||||
|
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||||
|
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||||
|
const activeClientIds = new Set()
|
||||||
|
|
||||||
|
addEventListener('install', function () {
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('activate', function (event) {
|
||||||
|
event.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('message', async function (event) {
|
||||||
|
const clientId = Reflect.get(event.source || {}, 'id')
|
||||||
|
|
||||||
|
if (!clientId || !self.clients) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await self.clients.get(clientId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
switch (event.data) {
|
||||||
|
case 'KEEPALIVE_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'KEEPALIVE_RESPONSE',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'INTEGRITY_CHECK_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||||
|
payload: {
|
||||||
|
packageVersion: PACKAGE_VERSION,
|
||||||
|
checksum: INTEGRITY_CHECKSUM,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_ACTIVATE': {
|
||||||
|
activeClientIds.add(clientId)
|
||||||
|
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'MOCKING_ENABLED',
|
||||||
|
payload: {
|
||||||
|
client: {
|
||||||
|
id: client.id,
|
||||||
|
frameType: client.frameType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CLIENT_CLOSED': {
|
||||||
|
activeClientIds.delete(clientId)
|
||||||
|
|
||||||
|
const remainingClients = allClients.filter((client) => {
|
||||||
|
return client.id !== clientId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unregister itself when there are no more clients
|
||||||
|
if (remainingClients.length === 0) {
|
||||||
|
self.registration.unregister()
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('fetch', function (event) {
|
||||||
|
const requestInterceptedAt = Date.now()
|
||||||
|
|
||||||
|
// Bypass navigation requests.
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening the DevTools triggers the "only-if-cached" request
|
||||||
|
// that cannot be handled by the worker. Bypass such requests.
|
||||||
|
if (
|
||||||
|
event.request.cache === 'only-if-cached' &&
|
||||||
|
event.request.mode !== 'same-origin'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass all requests when there are no active clients.
|
||||||
|
// Prevents the self-unregistered worked from handling requests
|
||||||
|
// after it's been terminated (still remains active until the next reload).
|
||||||
|
if (activeClientIds.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = crypto.randomUUID()
|
||||||
|
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
*/
|
||||||
|
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||||
|
const client = await resolveMainClient(event)
|
||||||
|
const requestCloneForEvents = event.request.clone()
|
||||||
|
const response = await getResponse(
|
||||||
|
event,
|
||||||
|
client,
|
||||||
|
requestId,
|
||||||
|
requestInterceptedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send back the response clone for the "response:*" life-cycle events.
|
||||||
|
// Ensure MSW is active and ready to handle the message, otherwise
|
||||||
|
// this message will pend indefinitely.
|
||||||
|
if (client && activeClientIds.has(client.id)) {
|
||||||
|
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||||
|
|
||||||
|
// Clone the response so both the client and the library could consume it.
|
||||||
|
const responseClone = response.clone()
|
||||||
|
|
||||||
|
sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: 'RESPONSE',
|
||||||
|
payload: {
|
||||||
|
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||||
|
request: {
|
||||||
|
id: requestId,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
type: responseClone.type,
|
||||||
|
status: responseClone.status,
|
||||||
|
statusText: responseClone.statusText,
|
||||||
|
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||||
|
body: responseClone.body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the main client for the given event.
|
||||||
|
* Client that issues a request doesn't necessarily equal the client
|
||||||
|
* that registered the worker. It's with the latter the worker should
|
||||||
|
* communicate with during the response resolving phase.
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @returns {Promise<Client | undefined>}
|
||||||
|
*/
|
||||||
|
async function resolveMainClient(event) {
|
||||||
|
const client = await self.clients.get(event.clientId)
|
||||||
|
|
||||||
|
if (activeClientIds.has(event.clientId)) {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client?.frameType === 'top-level') {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
return allClients
|
||||||
|
.filter((client) => {
|
||||||
|
// Get only those clients that are currently visible.
|
||||||
|
return client.visibilityState === 'visible'
|
||||||
|
})
|
||||||
|
.find((client) => {
|
||||||
|
// Find the client ID that's recorded in the
|
||||||
|
// set of clients that have registered the worker.
|
||||||
|
return activeClientIds.has(client.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {Client | undefined} client
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||||
|
// Clone the request because it might've been already used
|
||||||
|
// (i.e. its body has been read and sent to the client).
|
||||||
|
const requestClone = event.request.clone()
|
||||||
|
|
||||||
|
function passthrough() {
|
||||||
|
// Cast the request headers to a new Headers instance
|
||||||
|
// so the headers can be manipulated with.
|
||||||
|
const headers = new Headers(requestClone.headers)
|
||||||
|
|
||||||
|
// Remove the "accept" header value that marked this request as passthrough.
|
||||||
|
// This prevents request alteration and also keeps it compliant with the
|
||||||
|
// user-defined CORS policies.
|
||||||
|
const acceptHeader = headers.get('accept')
|
||||||
|
if (acceptHeader) {
|
||||||
|
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||||
|
const filteredValues = values.filter(
|
||||||
|
(value) => value !== 'msw/passthrough',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filteredValues.length > 0) {
|
||||||
|
headers.set('accept', filteredValues.join(', '))
|
||||||
|
} else {
|
||||||
|
headers.delete('accept')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(requestClone, { headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass mocking when the client is not active.
|
||||||
|
if (!client) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass initial page load requests (i.e. static assets).
|
||||||
|
// The absence of the immediate/parent client in the map of the active clients
|
||||||
|
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||||
|
// and is not ready to handle requests.
|
||||||
|
if (!activeClientIds.has(client.id)) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the client that a request has been intercepted.
|
||||||
|
const serializedRequest = await serializeRequest(event.request)
|
||||||
|
const clientMessage = await sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: 'REQUEST',
|
||||||
|
payload: {
|
||||||
|
id: requestId,
|
||||||
|
interceptedAt: requestInterceptedAt,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[serializedRequest.body],
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (clientMessage.type) {
|
||||||
|
case 'MOCK_RESPONSE': {
|
||||||
|
return respondWithMock(clientMessage.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'PASSTHROUGH': {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Client} client
|
||||||
|
* @param {any} message
|
||||||
|
* @param {Array<Transferable>} transferrables
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
function sendToClient(client, message, transferrables = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
if (event.data && event.data.error) {
|
||||||
|
return reject(event.data.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.postMessage(message, [
|
||||||
|
channel.port2,
|
||||||
|
...transferrables.filter(Boolean),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Response} response
|
||||||
|
* @returns {Response}
|
||||||
|
*/
|
||||||
|
function respondWithMock(response) {
|
||||||
|
// Setting response status code to 0 is a no-op.
|
||||||
|
// However, when responding with a "Response.error()", the produced Response
|
||||||
|
// instance will have status code set to 0. Since it's not possible to create
|
||||||
|
// a Response instance with status code 0, handle that use-case separately.
|
||||||
|
if (response.status === 0) {
|
||||||
|
return Response.error()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockedResponse = new Response(response.body, response)
|
||||||
|
|
||||||
|
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||||
|
value: true,
|
||||||
|
enumerable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return mockedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Request} request
|
||||||
|
*/
|
||||||
|
async function serializeRequest(request) {
|
||||||
|
return {
|
||||||
|
url: request.url,
|
||||||
|
mode: request.mode,
|
||||||
|
method: request.method,
|
||||||
|
headers: Object.fromEntries(request.headers.entries()),
|
||||||
|
cache: request.cache,
|
||||||
|
credentials: request.credentials,
|
||||||
|
destination: request.destination,
|
||||||
|
integrity: request.integrity,
|
||||||
|
redirect: request.redirect,
|
||||||
|
referrer: request.referrer,
|
||||||
|
referrerPolicy: request.referrerPolicy,
|
||||||
|
body: await request.arrayBuffer(),
|
||||||
|
keepalive: request.keepalive,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-geist-sans)", "Arial", "sans-serif"],
|
||||||
|
mono: ["var(--font-geist-mono)", "ui-monospace", "monospace"],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
border: "var(--color-border)",
|
||||||
|
background: "var(--color-background)",
|
||||||
|
foreground: "var(--color-foreground)",
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "var(--color-muted)",
|
||||||
|
foreground: "var(--color-muted-foreground)",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "var(--color-accent)",
|
||||||
|
foreground: "var(--color-accent-foreground)",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "var(--color-destructive)",
|
||||||
|
foreground: "var(--color-destructive-foreground)",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "var(--color-card)",
|
||||||
|
foreground: "var(--color-card-foreground)",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "var(--color-popover)",
|
||||||
|
foreground: "var(--color-popover-foreground)",
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "var(--color-primary)",
|
||||||
|
foreground: "var(--color-primary-foreground)",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "var(--color-secondary)",
|
||||||
|
foreground: "var(--color-secondary-foreground)",
|
||||||
|
},
|
||||||
|
signal: "var(--color-signal)",
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "8px",
|
||||||
|
md: "6px",
|
||||||
|
sm: "4px",
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
"ring-border": "0px 0px 0px 1px var(--color-border)",
|
||||||
|
"card-stack":
|
||||||
|
"0px 0px 0px 1px var(--color-border), 0px 2px 4px rgba(0,0,0,0.04), 0px 8px 8px -8px rgba(0,0,0,0.04)",
|
||||||
|
"card-hover":
|
||||||
|
"0px 0px 0px 1px var(--color-border), 0px 4px 8px rgba(0,0,0,0.08), 0px 8px 16px -4px rgba(0,0,0,0.08)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
} satisfies Config;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": [
|
||||||
|
"4219922fea2e2bd3c691-2c97ef6de38543745b6a",
|
||||||
|
"4219922fea2e2bd3c691-afdb8990cae2360e1f04"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Instructions
|
||||||
|
|
||||||
|
- Following Playwright test failed.
|
||||||
|
- Explain why, be concise, respect Playwright best practices.
|
||||||
|
- Provide a snippet of code with the fix, if possible.
|
||||||
|
|
||||||
|
# Test info
|
||||||
|
|
||||||
|
- Name: smoke.spec.ts >> smoke: page loads with header
|
||||||
|
- Location: e2e/smoke.spec.ts:3:5
|
||||||
|
|
||||||
|
# Error details
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: expect(locator).toBeVisible() failed
|
||||||
|
|
||||||
|
Locator: locator('header')
|
||||||
|
Expected: visible
|
||||||
|
Timeout: 5000ms
|
||||||
|
Error: element(s) not found
|
||||||
|
|
||||||
|
Call log:
|
||||||
|
- Expect "toBeVisible" with timeout 5000ms
|
||||||
|
- waiting for locator('header')
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
# Page snapshot
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- alert [ref=e2]
|
||||||
|
- generic [ref=e7] [cursor=pointer]:
|
||||||
|
- button "Open Next.js Dev Tools" [ref=e8]:
|
||||||
|
- img [ref=e9]
|
||||||
|
- generic [ref=e12]:
|
||||||
|
- button "Open issues overlay" [ref=e13]:
|
||||||
|
- generic [ref=e14]:
|
||||||
|
- generic [ref=e15]: "0"
|
||||||
|
- generic [ref=e16]: "1"
|
||||||
|
- generic [ref=e17]: Issue
|
||||||
|
- button "Collapse issues badge" [ref=e18]:
|
||||||
|
- img [ref=e19]
|
||||||
|
- generic [ref=e21]:
|
||||||
|
- img [ref=e22]
|
||||||
|
- paragraph [ref=e24]: Failed to load dashboard
|
||||||
|
- paragraph [ref=e25]: Failed to fetch
|
||||||
|
```
|
||||||
|
|
||||||
|
# Test source
|
||||||
|
|
||||||
|
```ts
|
||||||
|
1 | import { test, expect } from "@playwright/test";
|
||||||
|
2 |
|
||||||
|
3 | test("smoke: page loads with header", async ({ page }) => {
|
||||||
|
4 | await page.goto("http://localhost:3000");
|
||||||
|
> 5 | await expect(page.locator("header")).toBeVisible();
|
||||||
|
| ^ Error: expect(locator).toBeVisible() failed
|
||||||
|
6 | await expect(page.getByText("Dash")).toBeVisible();
|
||||||
|
7 | });
|
||||||
|
8 |
|
||||||
|
9 | test("smoke: theme toggle works", async ({ page }) => {
|
||||||
|
10 | await page.goto("http://localhost:3000");
|
||||||
|
11 | const toggle = page.getByLabel("Toggle theme");
|
||||||
|
12 | await toggle.click();
|
||||||
|
13 | await page.getByText("CasaOS").click();
|
||||||
|
14 | const theme = await page.evaluate(() => document.documentElement.getAttribute("data-theme"));
|
||||||
|
15 | expect(theme).toBe("casaos");
|
||||||
|
16 | });
|
||||||
|
17 |
|
||||||
|
18 | test("smoke: empty state shows add button", async ({ page }) => {
|
||||||
|
19 | await page.goto("http://localhost:3000");
|
||||||
|
20 | // If no services exist, the empty state should be visible
|
||||||
|
21 | const emptyState = page.getByText("No apps yet");
|
||||||
|
22 | if (await emptyState.isVisible()) {
|
||||||
|
23 | await expect(page.getByRole("button", { name: /add app/i })).toBeVisible();
|
||||||
|
24 | }
|
||||||
|
25 | });
|
||||||
|
26 |
|
||||||
|
```
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Instructions
|
||||||
|
|
||||||
|
- Following Playwright test failed.
|
||||||
|
- Explain why, be concise, respect Playwright best practices.
|
||||||
|
- Provide a snippet of code with the fix, if possible.
|
||||||
|
|
||||||
|
# Test info
|
||||||
|
|
||||||
|
- Name: smoke.spec.ts >> smoke: theme toggle works
|
||||||
|
- Location: e2e/smoke.spec.ts:9:5
|
||||||
|
|
||||||
|
# Error details
|
||||||
|
|
||||||
|
```
|
||||||
|
Test timeout of 30000ms exceeded.
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: locator.click: Test timeout of 30000ms exceeded.
|
||||||
|
Call log:
|
||||||
|
- waiting for getByLabel('Toggle theme')
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
# Page snapshot
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- alert [ref=e2]
|
||||||
|
- generic [ref=e7] [cursor=pointer]:
|
||||||
|
- button "Open Next.js Dev Tools" [ref=e8]:
|
||||||
|
- img [ref=e9]
|
||||||
|
- generic [ref=e12]:
|
||||||
|
- button "Open issues overlay" [ref=e13]:
|
||||||
|
- generic [ref=e14]:
|
||||||
|
- generic [ref=e15]: "0"
|
||||||
|
- generic [ref=e16]: "1"
|
||||||
|
- generic [ref=e17]: Issue
|
||||||
|
- button "Collapse issues badge" [ref=e18]:
|
||||||
|
- img [ref=e19]
|
||||||
|
- generic [ref=e21]:
|
||||||
|
- img [ref=e22]
|
||||||
|
- paragraph [ref=e24]: Failed to load dashboard
|
||||||
|
- paragraph [ref=e25]: Failed to fetch
|
||||||
|
```
|
||||||
|
|
||||||
|
# Test source
|
||||||
|
|
||||||
|
```ts
|
||||||
|
1 | import { test, expect } from "@playwright/test";
|
||||||
|
2 |
|
||||||
|
3 | test("smoke: page loads with header", async ({ page }) => {
|
||||||
|
4 | await page.goto("http://localhost:3000");
|
||||||
|
5 | await expect(page.locator("header")).toBeVisible();
|
||||||
|
6 | await expect(page.getByText("Dash")).toBeVisible();
|
||||||
|
7 | });
|
||||||
|
8 |
|
||||||
|
9 | test("smoke: theme toggle works", async ({ page }) => {
|
||||||
|
10 | await page.goto("http://localhost:3000");
|
||||||
|
11 | const toggle = page.getByLabel("Toggle theme");
|
||||||
|
> 12 | await toggle.click();
|
||||||
|
| ^ Error: locator.click: Test timeout of 30000ms exceeded.
|
||||||
|
13 | await page.getByText("CasaOS").click();
|
||||||
|
14 | const theme = await page.evaluate(() => document.documentElement.getAttribute("data-theme"));
|
||||||
|
15 | expect(theme).toBe("casaos");
|
||||||
|
16 | });
|
||||||
|
17 |
|
||||||
|
18 | test("smoke: empty state shows add button", async ({ page }) => {
|
||||||
|
19 | await page.goto("http://localhost:3000");
|
||||||
|
20 | // If no services exist, the empty state should be visible
|
||||||
|
21 | const emptyState = page.getByText("No apps yet");
|
||||||
|
22 | if (await emptyState.isVisible()) {
|
||||||
|
23 | await expect(page.getByRole("button", { name: /add app/i })).toBeVisible();
|
||||||
|
24 | }
|
||||||
|
25 | });
|
||||||
|
26 |
|
||||||
|
```
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
describe("cn utility", () => {
|
||||||
|
it("merges class names", () => {
|
||||||
|
expect(cn("foo", "bar")).toBe("foo bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles conditional classes", () => {
|
||||||
|
expect(cn("foo", false && "bar", "baz")).toBe("foo baz");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates tailwind classes", () => {
|
||||||
|
expect(cn("px-2", "px-4")).toBe("px-4");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,24 +11,13 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [{ "name": "next" }],
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"next-env.d.ts",
|
"exclude": ["node_modules", "playwright.config.ts", "e2e", "tests", "vitest.config.ts"]
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts",
|
|
||||||
".next/dev/types/**/*.ts"
|
|
||||||
],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
exclude: ["e2e/**", "node_modules/**"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
dist/
|
|
||||||
node_modules/
|
|
||||||
.next/
|
|
||||||
.turbo/
|
|
||||||
coverage/
|
|
||||||
pnpm-lock.yaml
|
|
||||||
.pnpm-store/
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"endOfLine": "lf",
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": false,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"printWidth": 80,
|
|
||||||
"plugins": ["prettier-plugin-tailwindcss"],
|
|
||||||
"tailwindStylesheet": "app/globals.css",
|
|
||||||
"tailwindFunctions": ["cn", "cva"]
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Next.js template
|
|
||||||
|
|
||||||
This is a Next.js template with shadcn/ui.
|
|
||||||
|
|
||||||
## Adding components
|
|
||||||
|
|
||||||
To add components to your app, run the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx shadcn@latest add button
|
|
||||||
```
|
|
||||||
|
|
||||||
This will place the ui components in the `components` directory.
|
|
||||||
|
|
||||||
## Using components
|
|
||||||
|
|
||||||
To use the components in your app, import them as follows:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
```
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { Geist, Geist_Mono } from "next/font/google"
|
|
||||||
|
|
||||||
import "./globals.css"
|
|
||||||
import { ThemeProvider } from "@/components/theme-provider"
|
|
||||||
|
|
||||||
const fontSans = Geist({
|
|
||||||
subsets: ["latin"],
|
|
||||||
variable: "--font-sans",
|
|
||||||
})
|
|
||||||
|
|
||||||
const fontMono = Geist_Mono({
|
|
||||||
subsets: ["latin"],
|
|
||||||
variable: "--font-mono",
|
|
||||||
})
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html
|
|
||||||
lang="en"
|
|
||||||
suppressHydrationWarning
|
|
||||||
className={`${fontSans.variable} ${fontMono.variable} font-sans antialiased`}
|
|
||||||
>
|
|
||||||
<body>
|
|
||||||
<ThemeProvider>{children}</ThemeProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-svh p-6">
|
|
||||||
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
|
|
||||||
<div>
|
|
||||||
<h1 className="font-medium">Project ready!</h1>
|
|
||||||
<p>You may now add components and start building.</p>
|
|
||||||
<p>We've already added the button component for you.</p>
|
|
||||||
<Button className="mt-2">Button</Button>
|
|
||||||
</div>
|
|
||||||
<div className="font-mono text-xs text-muted-foreground">
|
|
||||||
(Press <kbd>d</kbd> to toggle dark mode)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"
|
|
||||||
|
|
||||||
function ThemeProvider({
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
|
||||||
return (
|
|
||||||
<NextThemesProvider
|
|
||||||
attribute="class"
|
|
||||||
defaultTheme="system"
|
|
||||||
enableSystem
|
|
||||||
disableTransitionOnChange
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ThemeHotkey />
|
|
||||||
{children}
|
|
||||||
</NextThemesProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTypingTarget(target: EventTarget | null) {
|
|
||||||
if (!(target instanceof HTMLElement)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
target.isContentEditable ||
|
|
||||||
target.tagName === "INPUT" ||
|
|
||||||
target.tagName === "TEXTAREA" ||
|
|
||||||
target.tagName === "SELECT"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThemeHotkey() {
|
|
||||||
const { resolvedTheme, setTheme } = useTheme()
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.defaultPrevented || event.repeat) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key.toLowerCase() !== "d") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTypingTarget(event.target)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", onKeyDown)
|
|
||||||
}
|
|
||||||
}, [resolvedTheme, setTheme])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ThemeProvider }
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
|
||||||
import nextTs from "eslint-config-next/typescript";
|
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
|
||||||
...nextVitals,
|
|
||||||
...nextTs,
|
|
||||||
// Override default ignores of eslint-config-next.
|
|
||||||
globalIgnores([
|
|
||||||
// Default ignores of eslint-config-next:
|
|
||||||
".next/**",
|
|
||||||
"out/**",
|
|
||||||
"build/**",
|
|
||||||
"next-env.d.ts",
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default eslintConfig;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {}
|
|
||||||
|
|
||||||
export default nextConfig
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "next-app",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev --turbopack",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "eslint",
|
|
||||||
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"next": "16.1.7",
|
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"react": "^19.2.4",
|
|
||||||
"react-dom": "^19.2.4"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/eslintrc": "^3",
|
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
|
||||||
"@types/node": "^25.5.0",
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"eslint": "^9.39.4",
|
|
||||||
"eslint-config-next": "16.1.7",
|
|
||||||
"prettier": "^3.8.1",
|
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
||||||
"postcss": "^8",
|
|
||||||
"tailwindcss": "^4.2.1",
|
|
||||||
"typescript": "^5.9.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
|
||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
"@tailwindcss/postcss": {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default config
|
|
||||||
@@ -521,7 +521,7 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
required: [type, title, config]
|
required: [type, title, config]
|
||||||
properties:
|
properties:
|
||||||
type: { type: string, enum: [clock, image, pihole, memos] }
|
type: { type: string, enum: [clock, image, pihole, memos, immich] }
|
||||||
title: { type: string, minLength: 1, maxLength: 80 }
|
title: { type: string, minLength: 1, maxLength: 80 }
|
||||||
enabled: { type: boolean, default: true }
|
enabled: { type: boolean, default: true }
|
||||||
config:
|
config:
|
||||||
@@ -530,6 +530,7 @@ components:
|
|||||||
- $ref: "#/components/schemas/ImageWidgetConfig"
|
- $ref: "#/components/schemas/ImageWidgetConfig"
|
||||||
- $ref: "#/components/schemas/PiHoleWidgetConfig"
|
- $ref: "#/components/schemas/PiHoleWidgetConfig"
|
||||||
- $ref: "#/components/schemas/MemosWidgetConfig"
|
- $ref: "#/components/schemas/MemosWidgetConfig"
|
||||||
|
- $ref: "#/components/schemas/ImmichWidgetConfig"
|
||||||
ClockWidgetConfig:
|
ClockWidgetConfig:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -555,6 +556,12 @@ components:
|
|||||||
baseUrl: { type: string, format: uri }
|
baseUrl: { type: string, format: uri }
|
||||||
apiToken: { type: string, writeOnly: true }
|
apiToken: { type: string, writeOnly: true }
|
||||||
pageSize: { type: integer, default: 5 }
|
pageSize: { type: integer, default: 5 }
|
||||||
|
ImmichWidgetConfig:
|
||||||
|
type: object
|
||||||
|
required: [baseUrl, apiKey]
|
||||||
|
properties:
|
||||||
|
baseUrl: { type: string, format: uri }
|
||||||
|
apiKey: { type: string, writeOnly: true }
|
||||||
ErrorResponse:
|
ErrorResponse:
|
||||||
type: object
|
type: object
|
||||||
required: [code, message, details]
|
required: [code, message, details]
|
||||||
|
|||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Dash",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user