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:
Tomas Dvorak
2026-05-04 12:31:34 +02:00
parent b17a06fbba
commit 17a579880f
85 changed files with 9441 additions and 947 deletions
+821
View File
@@ -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.
+1
View File
@@ -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
+5 -2
View File
@@ -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
+38
View File
@@ -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
+23
View File
@@ -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)
+24
View File
@@ -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"]
+225
View File
@@ -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;
}
+1
View File
@@ -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

+32
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import DashboardPage from "@/components/dashboard/dashboard-page";
export default function Home() {
return <DashboardPage />;
}
+21
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+62
View File
@@ -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>
);
}
+68
View File
@@ -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>
);
}
+40
View File
@@ -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 };
+29
View File
@@ -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 };
+43
View File
@@ -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 };
+50
View File
@@ -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 };
+14
View File
@@ -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 };
+69
View File
@@ -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 };
+78
View File
@@ -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 };
+54
View File
@@ -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 };
+21
View File
@@ -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 };
+19
View File
@@ -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 };
+29
View File
@@ -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 };
+41
View File
@@ -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 };
+82
View File
@@ -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 };
+16
View File
@@ -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 };
+87
View File
@@ -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 };
+28
View File
@@ -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 };
+48
View File
@@ -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 };
+27
View File
@@ -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 };
+251
View File
@@ -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>
);
}
+247
View File
@@ -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>
);
}
+25
View File
@@ -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();
}
});
+16
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export { useTheme, ThemeContext } from "@/components/providers";
+110
View File
@@ -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" });
}
+134
View File
@@ -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] });
},
});
}
+23
View File
@@ -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;
}
+146
View File
@@ -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;
}
+16
View File
@@ -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");
});
}
+117
View File
@@ -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,
};
+93
View File
@@ -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();
+26
View File
@@ -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",
};
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+1
View File
@@ -0,0 +1 @@
export { cn } from "@/lib/utils";
+19
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+64
View File
@@ -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
+20
View File
@@ -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,
},
});
+6
View File
@@ -0,0 +1,6 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+3
View File
@@ -0,0 +1,3 @@
# Dash Frontend
See `../README.md` for full project documentation.
+349
View File
@@ -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,
}
}
+74
View File
@@ -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;
+7
View File
@@ -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 |
```
+16
View File
@@ -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"]
} }
+13
View File
@@ -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/**"],
},
});
-7
View File
@@ -1,7 +0,0 @@
dist/
node_modules/
.next/
.turbo/
coverage/
pnpm-lock.yaml
.pnpm-store/
-11
View File
@@ -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"]
}
-21
View File
@@ -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
View File
@@ -1 +0,0 @@
@import "tailwindcss";
-32
View File
@@ -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>
)
}
-19
View File
@@ -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&apos;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>
)
}
View File
-71
View File
@@ -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 }
-18
View File
@@ -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;
View File
View File
-4
View File
@@ -1,4 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
export default nextConfig
-34
View File
@@ -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"
}
}
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
}
export default config
View File
+8 -1
View File
@@ -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]
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Dash",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}