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 committed 2026-05-04 12:31:34 +02:00
1 parent b17a06fbba
commit 17a579880f
85 files changed
+9441 -947

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*.local
# vercel
.vercel
# types
*.tsbuildinfo
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;
+9962
View File
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"
]
}
}
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html style='scrollbar-gutter: stable both-edges;'>
<head>
<meta charset='UTF-8'>
<meta name='color-scheme' content='dark light'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>Playwright Test Report</title>
<script type="module">(function(){const c=document.createElement("link").relList;if(c&&c.supports&&c.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))f(r);new MutationObserver(r=>{for(const o of r)if(o.type==="childList")for(const h of o.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&f(h)}).observe(document,{childList:!0,subtree:!0});function u(r){const o={};return r.integrity&&(o.integrity=r.integrity),r.referrerPolicy&&(o.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?o.credentials="include":r.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function f(r){if(r.ep)return;r.ep=!0;const o=u(r);fetch(r.href,o)}})();function vA(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var Of={exports:{}},vi={};/**
* @license React
* react-jsx-runtime.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var y1;function yA(){if(y1)return vi;y1=1;var i=Symbol.for("react.transitional.element"),c=Symbol.for("react.fragment");function u(f,r,o){var h=null;if(o!==void 0&&(h=""+o),r.key!==void 0&&(h=""+r.key),"key"in r){o={};for(var y in r)y!=="key"&&(o[y]=r[y])}else o=r;return r=o.ref,{$$typeof:i,type:f,key:h,ref:r!==void 0?r:null,props:o}}return vi.Fragment=c,vi.jsx=u,vi.jsxs=u,vi}var E1;function EA(){return E1||(E1=1,Of.exports=yA()),Of.exports}var m=EA();const pA=15,bt=0,En=1,xA=2,ye=-2,Ut=-3,p1=-4,pn=-5,Me=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535],R2=1440,bA=0,SA=4,TA=9,CA=5,OA=[96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,192,80,7,10,0,8,96,0,8,32,0,9,160,0,8,0,0,8,128,0,8,64,0,9,224,80,7,6,0,8,88,0,8,24,0,9,144,83,7,59,0,8,120,0,8,56,0,9,208,81,7,17,0,8,104,0,8,40,0,9,176,0,8,8,0,8,136,0,8,72,0,9,240,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,200,81,7,13,0,8,100,0,8,36,0,9,168,0,8,4,0,8,132,0,8,68,0,9,232,80,7,8,0,8,92,0,8,28,0,9,152,84,7,83,0,8,124,0,8,60,0,9,216,82,7,23,0,8,108,0,8,44,0,9,184,0,8,12,0,8,140,0,8,76,0,9,248,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,196,81,7,11,0,8,98,0,8,34,0,9,164,0,8,2,0,8,130,0,8,66,0,9,228,80,7,7,0,8,90,0,8,26,0,9,148,84,7,67,0,8,122,0,8,58,0,9,212,82,7,19,0,8,106,0,8,42,0,9,180,0,8,10,0,8,138,0,8,74,0,9,244,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,204,81,7,15,0,8,102,0,8,38,0,9,172,0,8,6,0,8,134,0,8,70,0,9,236,80,7,9,0,8,94,0,8,30,0,9,156,84,7,99,0,8,126,0,8,62,0,9,220,82,7,27,0,8,110,0,8,46,0,9,188,0,8,14,0,8,142,0,8,78,0,9,252,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,194,80,7,10,0,8,97,0,8,33,0,9,162,0,8,1,0,8,129,0,8,65,0,9,226,80,7,6,0,8,89,0,8,25,0,9,146,83,7,59,0,8,121,0,8,57,0,9,210,81,7,17,0,8,105,0,8,41,0,9,178,0,8,9,0,8,137,0,8,73,0,9,242,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,202,81,7,13,0,8,101,0,8,37,0,9,170,0,8,5,0,8,133,0,8,69,0,9,234,80,7,8,0,8,93,0,8,29,0,9,154,84,7,83,0,8,125,0,8,61,0,9,218,82,7,23,0,8,109,0,8,45,0,9,186,0,8,13,0,8,141,0,8,77,0,9,250,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,198,81,7,11,0,8,99,0,8,35,0,9,166,0,8,3,0,8,131,0,8,67,0,9,230,80,7,7,0,8,91,0,8,27,0,9,150,84,7,67,0,8,123,0,8,59,0,9,214,82,7,19,0,8,107,0,8,43,0,9,182,0,8,11,0,8,139,0,8,75,0,9,246,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,206,81,7,15,0,8,103,0,8,39,0,9,174,0,8,7,0,8,135,0,8,71,0,9,238,80,7,9,0,8,95,0,8,31,0,9,158,84,7,99,0,8,127,0,8,63,0,9,222,82,7,27,0,8,111,0,8,47,0,9,190,0,8,15,0,8,143,0,8,79,0,9,254,96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,193,80,7,10,0,8,96,0,8,32,0,9,161,0,8,0,0,8,128,0,8,64,0,9,225,80,7,6,0,8,88,0,8,24,0,9,145,83,7,59,0,8,120,0,8,56,0,9,209,81,7,17,0,8,104,0,8,40,0,9,177,0,8,8,0,8,136,0,8,72,0,9,241,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,201,81,7,13,0,8,100,0,8,36,0,9,169,0,8,4,0,8,132,0,8,68,0,9,233,80,7,8,0,8,92,0,8,28,0,9,153,84,7,83,0,8,124,0,8,60,0,9,217,82,7,23,0,8,108,0,8,44,0,9,185,0,8,12,0,8,140,0,8,76,0,9,249,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,197,81,7,11,0,8,98,0,8,34,0,9,165,0,8,2,0,8,130,0,8,66,0,9,229,80,7,7,0,8,90,0,8,26,0,9,149,84,7,67,0,8,122,0,8,58,0,9,213,82,7,19,0,8,106,0,8,42,0,9,181,0,8,10,0,8,138,0,8,74,0,9,245,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,205,81,7,15,0,8,102,0,8,38,0,9,173,0,8,6,0,8,134,0,8,70,0,9,237,80,7,9,0,8,94,0,8,30,0,9,157,84,7,99,0,8,126,0,8,62,0,9,221,82,7,27,0,8,110,0,8,46,0,9,189,0,8,14,0,8,142,0,8,78,0,9,253,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,195,80,7,10,0,8,97,0,8,33,0,9,163,0,8,1,0,8,129,0,8,65,0,9,227,80,7,6,0,8,89,0,8,25,0,9,147,83,7,59,0,8,121,0,8,57,0,9,211,81,7,17,0,8,105,0,8,41,0,9,179,0,8,9,0,8,137,0,8,73,0,9,243,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,203,81,7,13,0,8,101,0,8,37,0,9,171,0,8,5,0,8,133,0,8,69,0,9,235,80,7,8,0,8,93,0,8,29,0,9,155,84,7,83,0,8,125,0,8,61,0,9,219,82,7,23,0,8,109,0,8,45,0,9,187,0,8,13,0,8,141,0,8,77,0,9,251,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,199,81,7,11,0,8,99,0,8,35,0,9,167,0,8,3,0,8,131,0,8,67,0,9,231,80,7,7,0,8,91,0,8,27,0,9,151,84,7,67,0,8,123,0,8,59,0,9,215,82,7,19,0,8,107,0,8,43,0,9,183,0,8,11,0,8,139,0,8,75,0,9,247,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,207,81,7,15,0,8,103,0,8,39,0,9,175,0,8,7,0,8,135,0,8,71,0,9,239,80,7,9,0,8,95,0,8,31,0,9,159,84,7,99,0,8,127,0,8,63,0,9,223,82,7,27,0,8,111,0,8,47,0,9,191,0,8,15,0,8,143,0,8,79,0,9,255],DA=[80,5,1,87,5,257,83,5,17,91,5,4097,81,5,5,89,5,1025,85,5,65,93,5,16385,80,5,3,88,5,513,84,5,33,92,5,8193,82,5,9,90,5,2049,86,5,129,192,5,24577,80,5,2,87,5,385,83,5,25,91,5,6145,81,5,7,89,5,1537,85,5,97,93,5,24577,80,5,4,88,5,769,84,5,49,92,5,12289,82,5,13,90,5,3073,86,5,193,192,5,24577],RA=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0],wA=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,112,112],MA=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,Line truncated
* @license React
* react.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var o2;function $3(){if(o2)return dt;o2=1;var i=Symbol.for("react.transitional.element"),c=Symbol.for("react.portal"),u=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),r=Symbol.for("react.profiler"),o=Symbol.for("react.consumer"),h=Symbol.for("react.context"),y=Symbol.for("react.forward_ref"),v=Symbol.for("react.suspense"),A=Symbol.for("react.memo"),x=Symbol.for("react.lazy"),T=Symbol.for("react.activity"),D=Symbol.iterator;function X(C){return C===null||typeof C!="object"?null:(C=D&&C[D]||C["@@iterator"],typeof C=="function"?C:null)}var q={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},p=Object.assign,E={};function b(C,L,W){this.props=C,this.context=L,this.refs=E,this.updater=W||q}b.prototype.isReactComponent={},b.prototype.setState=function(C,L){if(typeof C!="object"&&typeof C!="function"&&C!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,C,L,"setState")},b.prototype.forceUpdate=function(C){this.updater.enqueueForceUpdate(this,C,"forceUpdate")};function R(){}R.prototype=b.prototype;function N(C,L,W){this.props=C,this.context=L,this.refs=E,this.updater=W||q}var V=N.prototype=new R;V.constructor=N,p(V,b.prototype),V.isPureReactComponent=!0;var F=Array.isArray;function H(){}var j={H:null,A:null,T:null,S:null},Y=Object.prototype.hasOwnProperty;function z(C,L,W){var et=W.ref;return{$$typeof:i,type:C,key:L,ref:et!==void 0?et:null,props:W}}function I(C,L){return z(C.type,L,C.props)}function k(C){return typeof C=="object"&&C!==null&&C.$$typeof===i}function nt(C){var L={"=":"=0",":":"=2"};return"$"+C.replace(/[=:]/g,function(W){return L[W]})}var P=/\/+/g;function st(C,L){return typeof C=="object"&&C!==null&&C.key!=null?nt(""+C.key):L.toString(36)}function ut(C){switch(C.status){case"fulfilled":return C.value;case"rejected":throw C.reason;default:switch(typeof C.status=="string"?C.then(H,H):(C.status="pending",C.then(function(L){C.status==="pending"&&(C.status="fulfilled",C.value=L)},function(L){C.status==="pending"&&(C.status="rejected",C.reason=L)})),C.status){case"fulfilled":return C.value;case"rejected":throw C.reason}}throw C}function M(C,L,W,et,rt){var ot=typeof C;(ot==="undefined"||ot==="boolean")&&(C=null);var gt=!1;if(C===null)gt=!0;else switch(ot){case"bigint":case"string":case"number":gt=!0;break;case"object":switch(C.$$typeof){case i:case c:gt=!0;break;case x:return gt=C._init,M(gt(C._payload),L,W,et,rt)}}if(gt)return rt=rt(C),gt=et===""?"."+st(C,0):et,F(rt)?(W="",gt!=null&&(W=gt.replace(P,"$&/")+"/"),M(rt,L,W,"",function(Sn){return Sn})):rt!=null&&(k(rt)&&(rt=I(rt,W+(rt.key==null||C&&C.key===rt.key?"":(""+rt.key).replace(P,"$&/")+"/")+gt)),L.push(rt)),1;gt=0;var Jt=et===""?".":et+":";if(F(C))for(var Qt=0;Qt<C.length;Qt++)et=C[Qt],ot=Jt+st(et,Qt),gt+=M(et,L,W,ot,rt);else if(Qt=X(C),typeof Qt=="function")for(C=Qt.call(C),Qt=0;!(et=C.next()).done;)et=et.value,ot=Jt+st(et,Qt++),gt+=M(et,L,W,ot,rt);else if(ot==="object"){if(typeof C.then=="function")return M(ut(C),L,W,et,rt);throw L=String(C),Error("Objects are not valid as a React child (found: "+(L==="[object Object]"?"object with keys {"+Object.keys(C).join(", ")+"}":L)+"). If you meant to render a collection of children, use an array instead.")}return gt}function _(C,L,W){if(C==null)return C;var et=[],rt=0;return M(C,et,"","",function(ot){return L.call(W,ot,rt++)}),et}function $(C){if(C._status===-1){var L=C._result;L=L(),L.then(function(W){(C._status===0||C._status===-1)&&(C._status=1,C._result=W)},function(W){(C._status===0||C._status===-1)&&(C._status=2,C._result=W)}),C._status===-1&&(C._status=0,C._result=L)}if(C._status===1)return C._result.default;throw C._result}var ht=typeof reportError=="function"?reportError:function(C){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var L=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof C=="object"&&C!==null&&typeof C.message=="string"?String(C.message):String(C),error:C});if(!window.dispatchEvent(L))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",C);return}console.error(C)},tt={map:_,forEach:function(C,L,W){_(C,function(){L.apply(this,arguments)},W)},count:function(C){var L=0;return _(C,function(){L++}),L},toArray:function(C){return _(C,function(L){return L})||[]},only:function(C){if(!k(C))throw Error("React.Children.only expected to receive a single React element child.");return C}};return dt.Activity=T,dt.Children=tt,dt.Component=b,dt.Fragment=u,dt.Profiler=r,dt.PureComponent=N,dt.StrictMode=f,dt.Suspense=v,dt.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=j,dt.__COMPILER_RUNTIME={__proto__:null,c:function(C){return j.H.useMemoCache(C)}},dt.cache=function(C){return function(){return C.apply(null,arguments)}},dt.cacheSignal=function(){return null},dt.cloneElement=function(C,L,W){if(C==nullLine truncated
* @license React
* scheduler.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var h2;function t5(){return h2||(h2=1,(function(i){function c(M,_){var $=M.length;M.push(_);t:for(;0<$;){var ht=$-1>>>1,tt=M[ht];if(0<r(tt,_))M[ht]=_,M[$]=tt,$=ht;else break t}}function u(M){return M.length===0?null:M[0]}function f(M){if(M.length===0)return null;var _=M[0],$=M.pop();if($!==_){M[0]=$;t:for(var ht=0,tt=M.length,C=tt>>>1;ht<C;){var L=2*(ht+1)-1,W=M[L],et=L+1,rt=M[et];if(0>r(W,$))et<tt&&0>r(rt,W)?(M[ht]=rt,M[et]=$,ht=et):(M[ht]=W,M[L]=$,ht=L);else if(et<tt&&0>r(rt,$))M[ht]=rt,M[et]=$,ht=et;else break t}}return _}function r(M,_){var $=M.sortIndex-_.sortIndex;return $!==0?$:M.id-_.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var o=performance;i.unstable_now=function(){return o.now()}}else{var h=Date,y=h.now();i.unstable_now=function(){return h.now()-y}}var v=[],A=[],x=1,T=null,D=3,X=!1,q=!1,p=!1,E=!1,b=typeof setTimeout=="function"?setTimeout:null,R=typeof clearTimeout=="function"?clearTimeout:null,N=typeof setImmediate<"u"?setImmediate:null;function V(M){for(var _=u(A);_!==null;){if(_.callback===null)f(A);else if(_.startTime<=M)f(A),_.sortIndex=_.expirationTime,c(v,_);else break;_=u(A)}}function F(M){if(p=!1,V(M),!q)if(u(v)!==null)q=!0,H||(H=!0,nt());else{var _=u(A);_!==null&&ut(F,_.startTime-M)}}var H=!1,j=-1,Y=5,z=-1;function I(){return E?!0:!(i.unstable_now()-z<Y)}function k(){if(E=!1,H){var M=i.unstable_now();z=M;var _=!0;try{t:{q=!1,p&&(p=!1,R(j),j=-1),X=!0;var $=D;try{e:{for(V(M),T=u(v);T!==null&&!(T.expirationTime>M&&I());){var ht=T.callback;if(typeof ht=="function"){T.callback=null,D=T.priorityLevel;var tt=ht(T.expirationTime<=M);if(M=i.unstable_now(),typeof tt=="function"){T.callback=tt,V(M),_=!0;break e}T===u(v)&&f(v),V(M)}else f(v);T=u(v)}if(T!==null)_=!0;else{var C=u(A);C!==null&&ut(F,C.startTime-M),_=!1}}break t}finally{T=null,D=$,X=!1}_=void 0}}finally{_?nt():H=!1}}}var nt;if(typeof N=="function")nt=function(){N(k)};else if(typeof MessageChannel<"u"){var P=new MessageChannel,st=P.port2;P.port1.onmessage=k,nt=function(){st.postMessage(null)}}else nt=function(){b(k,0)};function ut(M,_){j=b(function(){M(i.unstable_now())},_)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(M){M.callback=null},i.unstable_forceFrameRate=function(M){0>M||125<M?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):Y=0<M?Math.floor(1e3/M):5},i.unstable_getCurrentPriorityLevel=function(){return D},i.unstable_next=function(M){switch(D){case 1:case 2:case 3:var _=3;break;default:_=D}var $=D;D=_;try{return M()}finally{D=$}},i.unstable_requestPaint=function(){E=!0},i.unstable_runWithPriority=function(M,_){switch(M){case 1:case 2:case 3:case 4:case 5:break;default:M=3}var $=D;D=M;try{return _()}finally{D=$}},i.unstable_scheduleCallback=function(M,_,$){var ht=i.unstable_now();switch(typeof $=="object"&&$!==null?($=$.delay,$=typeof $=="number"&&0<$?ht+$:ht):$=ht,M){case 1:var tt=-1;break;case 2:tt=250;break;case 5:tt=1073741823;break;case 4:tt=1e4;break;default:tt=5e3}return tt=$+tt,M={id:x++,callback:_,priorityLevel:M,startTime:$,expirationTime:tt,sortIndex:-1},$>ht?(M.sortIndex=$,c(A,M),u(v)===null&&M===u(A)&&(p?(R(j),j=-1):p=!0,ut(F,$-ht))):(M.sortIndex=tt,c(v,M),q||X||(q=!0,H||(H=!0,nt()))),M},i.unstable_shouldYield=I,i.unstable_wrapCallback=function(M){var _=D;return function(){var $=D;D=_;try{return M.apply(this,arguments)}finally{D=$}}}})(Gf)),Gf}var m2;function e5(){return m2||(m2=1,Lf.exports=t5()),Lf.exports}var Xf={exports:{}},ce={};/**
* @license React
* react-dom.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var g2;function n5(){if(g2)return ce;g2=1;var i=rr();function c(v){var A="https://react.dev/errors/"+v;if(1<arguments.length){A+="?args[]="+encodeURIComponent(arguments[1]);for(var x=2;x<arguments.length;x++)A+="&args[]="+encodeURIComponent(arguments[x])}return"Minified React error #"+v+"; visit "+A+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function u(){}var f={d:{f:u,r:function(){throw Error(c(522))},D:u,C:u,L:u,m:u,X:u,S:u,M:u},p:0,findDOMNode:null},r=Symbol.for("react.portal");function o(v,A,x){var T=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:r,key:T==null?null:""+T,children:v,containerInfo:A,implementation:x}}var h=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function y(v,A){if(v==="font")return"";if(typeof A=="string")return A==="use-credentials"?A:""}return ce.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,ce.createPortal=function(v,A){var x=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!A||A.nodeType!==1&&A.nodeType!==9&&A.nodeType!==11)throw Error(c(299));return o(v,A,null,x)},ce.flushSync=function(v){var A=h.T,x=f.p;try{if(h.T=null,f.p=2,v)return v()}finally{h.T=A,f.p=x,f.d.f()}},ce.preconnect=function(v,A){typeof v=="string"&&(A?(A=A.crossOrigin,A=typeof A=="string"?A==="use-credentials"?A:"":void 0):A=null,f.d.C(v,A))},ce.prefetchDNS=function(v){typeof v=="string"&&f.d.D(v)},ce.preinit=function(v,A){if(typeof v=="string"&&A&&typeof A.as=="string"){var x=A.as,T=y(x,A.crossOrigin),D=typeof A.integrity=="string"?A.integrity:void 0,X=typeof A.fetchPriority=="string"?A.fetchPriority:void 0;x==="style"?f.d.S(v,typeof A.precedence=="string"?A.precedence:void 0,{crossOrigin:T,integrity:D,fetchPriority:X}):x==="script"&&f.d.X(v,{crossOrigin:T,integrity:D,fetchPriority:X,nonce:typeof A.nonce=="string"?A.nonce:void 0})}},ce.preinitModule=function(v,A){if(typeof v=="string")if(typeof A=="object"&&A!==null){if(A.as==null||A.as==="script"){var x=y(A.as,A.crossOrigin);f.d.M(v,{crossOrigin:x,integrity:typeof A.integrity=="string"?A.integrity:void 0,nonce:typeof A.nonce=="string"?A.nonce:void 0})}}else A==null&&f.d.M(v)},ce.preload=function(v,A){if(typeof v=="string"&&typeof A=="object"&&A!==null&&typeof A.as=="string"){var x=A.as,T=y(x,A.crossOrigin);f.d.L(v,x,{crossOrigin:T,integrity:typeof A.integrity=="string"?A.integrity:void 0,nonce:typeof A.nonce=="string"?A.nonce:void 0,type:typeof A.type=="string"?A.type:void 0,fetchPriority:typeof A.fetchPriority=="string"?A.fetchPriority:void 0,referrerPolicy:typeof A.referrerPolicy=="string"?A.referrerPolicy:void 0,imageSrcSet:typeof A.imageSrcSet=="string"?A.imageSrcSet:void 0,imageSizes:typeof A.imageSizes=="string"?A.imageSizes:void 0,media:typeof A.media=="string"?A.media:void 0})}},ce.preloadModule=function(v,A){if(typeof v=="string")if(A){var x=y(A.as,A.crossOrigin);f.d.m(v,{as:typeof A.as=="string"&&A.as!=="script"?A.as:void 0,crossOrigin:x,integrity:typeof A.integrity=="string"?A.integrity:void 0})}else f.d.m(v)},ce.requestFormReset=function(v){f.d.r(v)},ce.unstable_batchedUpdates=function(v,A){return v(A)},ce.useFormState=function(v,A,x){return h.H.useFormState(v,A,x)},ce.useFormStatus=function(){return h.H.useHostTransitionStatus()},ce.version="19.2.1",ce}var A2;function l5(){if(A2)return Xf.exports;A2=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(c){console.error(c)}}return i(),Xf.exports=n5(),Xf.exports}/**
* @license React
* react-dom-client.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var v2;function a5(){if(v2)return pi;v2=1;var i=e5(),c=rr(),u=l5();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var n=2;n<arguments.length;n++)e+="&args[]="+encodeURIComponent(arguments[n])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function r(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function o(t){var e=t,n=t;if(t.alternate)for(;e.return;)e=e.return;else{t=e;do e=t,(e.flags&4098)!==0&&(n=e.return),t=e.return;while(t)}return e.tag===3?n:null}function h(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function y(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function v(t){if(o(t)!==t)throw Error(f(188))}function A(t){var e=t.alternate;if(!e){if(e=o(t),e===null)throw Error(f(188));return e!==t?null:t}for(var n=t,l=e;;){var a=n.return;if(a===null)break;var s=a.alternate;if(s===null){if(l=a.return,l!==null){n=l;continue}break}if(a.child===s.child){for(s=a.child;s;){if(s===n)return v(a),t;if(s===l)return v(a),e;s=s.sibling}throw Error(f(188))}if(n.return!==l.return)n=a,l=s;else{for(var d=!1,g=a.child;g;){if(g===n){d=!0,n=a,l=s;break}if(g===l){d=!0,l=a,n=s;break}g=g.sibling}if(!d){for(g=s.child;g;){if(g===n){d=!0,n=s,l=a;break}if(g===l){d=!0,l=s,n=a;break}g=g.sibling}if(!d)throw Error(f(189))}}if(n.alternate!==l)throw Error(f(190))}if(n.tag!==3)throw Error(f(188));return n.stateNode.current===n?t:e}function x(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=x(t),e!==null)return e;t=t.sibling}return null}var T=Object.assign,D=Symbol.for("react.element"),X=Symbol.for("react.transitional.element"),q=Symbol.for("react.portal"),p=Symbol.for("react.fragment"),E=Symbol.for("react.strict_mode"),b=Symbol.for("react.profiler"),R=Symbol.for("react.consumer"),N=Symbol.for("react.context"),V=Symbol.for("react.forward_ref"),F=Symbol.for("react.suspense"),H=Symbol.for("react.suspense_list"),j=Symbol.for("react.memo"),Y=Symbol.for("react.lazy"),z=Symbol.for("react.activity"),I=Symbol.for("react.memo_cache_sentinel"),k=Symbol.iterator;function nt(t){return t===null||typeof t!="object"?null:(t=k&&t[k]||t["@@iterator"],typeof t=="function"?t:null)}var P=Symbol.for("react.client.reference");function st(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===P?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case p:return"Fragment";case b:return"Profiler";case E:return"StrictMode";case F:return"Suspense";case H:return"SuspenseList";case z:return"Activity"}if(typeof t=="object")switch(t.$$typeof){case q:return"Portal";case N:return t.displayName||"Context";case R:return(t._context.displayName||"Context")+".Consumer";case V:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case j:return e=t.displayName||null,e!==null?e:st(t.type)||"Memo";case Y:e=t._payload,t=t._init;try{return st(t(e))}catch{}}return null}var ut=Array.isArray,M=c.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,_=u.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,$={pending:!1,data:null,method:null,action:null},ht=[],tt=-1;function C(t){return{current:t}}function L(t){0>tt||(t.current=ht[tt],ht[tt]=null,tt--)}function W(t,e){tt++,ht[tt]=t.current,t.current=e}var et=C(null),rt=C(null),ot=C(null),gt=C(null);function Jt(t,e){switch(W(ot,e),W(rt,t),W(et,null),e.nodeType){case 9:case 11:t=(t=e.documentElement)&&(t=t.namespaceURI)?Xd(t):0;break;default:if(t=e.tagName,e=e.namespaceURI)e=Xd(e),t=Vd(e,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}L(et),W(et,t)}function Qt(){L(et),L(rt),L(ot)}function Sn(t){t.memoizedState!==null&&W(gt,t);var e=et.current,n=Vd(e,t.type);e!==n&&(W(rt,t),W(et,n))}function ul(t){rt.current===t&&(L(et),L(rt)),gt.current===t&&(L(gt),hi._currentValue=$)}var Tn,jl;function Ee(t){if(Tn===void 0)try{throw Error()}catch(n){var e=n.stack.trim().match(/\n( *(at )?)/);Tn=e&&e[1]||"",jl=-1<n.stack.indexOf(`
at`)?" (<anonymous>)":-1<n.stack.indexOf("@")?"@unknown:0:0":""}return`
`+Tn+t+jl}var Nl=!1;function Cn(t,e){if(!t||Nl)return"";Nl=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var l={DetermineComponentFrameRoot:function(){try{if(e){var J=function(){throw Error()};if(Object.defineProperty(J.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(J,[])}catch(G){var Q=G}Reflect.construct(t,[],J)}else{try{J.call()}catch(G){Q=G}t.call(J.prototype)}}else{try{throw Error()}catch(G){Q=G}(J=t())&&typeof J.catch=="function"&&J.catch(function(){})}}catch(G){if(G&&Q&&typeof G.stack=="string")return[G.stack,Q.stack]}return[null,null]}};l.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var a=Object.getOwnPropertyDescriptor(l.DetermineComponentFrameRoot,"name");a&&a.configurable&&Object.defineProperty(l.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var s=l.DetermineComponentFrameRoot(),d=s[0],g=s[1];if(d&&g){var S=d.split(`
`),U=g.split(`
`);for(a=l=0;l<S.length&&!S[l].includes("DetermineComponentFrameRoot");)l++;for(;a<U.length&&!U[a].includes("DetermineComponentFrameRoot");)a++;if(l===S.length||a===U.length)for(l=S.length-1,a=U.length-1;1<=l&&0<=a&&S[l]!==U[a];)a--;for(;1<=l&&0<=a;l--,a--)if(S[l]!==U[a]){if(l!==1||a!==1)do if(l--,a--,0>a||S[l]!==U[a]){var Z=`
`+S[l].replace(" at new "," at ");return t.displayName&&Z.includes("<anonymous>")&&(Z=Z.replace("<anonymous>",t.displayName)),Z}while(1<=l&&0<=a);break}}}finally{Nl=!1,Error.prepareStackTrace=n}return(n=t?t.displayName||t.name:"")?Ee(n):""}function Hl(t,e){switch(t.tag){case 26:case 27:case 5:return Ee(t.type);case 16:return Ee("Lazy");case 13:return t.child!==e&&e!==null?Ee("Suspense Fallback"):Ee("Suspense");case 19:return Ee("SuspenseList");case 0:case 15:return Cn(t.type,!1);case 11:return Cn(t.type.render,!1);case 1:return Cn(t.type,!0);case 31:return Ee("Activity");default:return""}}function ji(t){try{var e="",n=null;do e+=Hl(t,n),n=t,t=t.return;while(t);return e}catch(l){return`
Error generating stack: `+l.message+`
`+l.stack}}var rc=Object.prototype.hasOwnProperty,oc=i.unstable_scheduleCallback,dc=i.unstable_cancelCallback,Fh=i.unstable_shouldYield,Wh=i.unstable_requestPaint,pe=i.unstable_now,_h=i.unstable_getCurrentPriorityLevel,yr=i.unstable_ImmediatePriority,Er=i.unstable_UserBlockingPriority,Ni=i.unstable_NormalPriority,Ph=i.unstable_LowPriority,pr=i.unstable_IdlePriority,$h=i.log,tm=i.unstable_setDisableYieldValue,Ca=null,xe=null;function On(t){if(typeof $h=="function"&&tm(t),xe&&typeof xe.setStrictMode=="function")try{xe.setStrictMode(Ca,t)}catch{}}var be=Math.clz32?Math.clz32:lm,em=Math.log,nm=Math.LN2;function lm(t){return t>>>=0,t===0?32:31-(em(t)/nm|0)|0}var Hi=256,Bi=262144,Ui=4194304;function cl(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Qi(t,e,n){var l=t.pendingLanes;if(l===0)return 0;var a=0,s=t.suspendedLanes,d=t.pingedLanes;t=t.warmLanes;var g=l&134217727;return g!==0?(l=g&~s,l!==0?a=cl(l):(d&=g,d!==0?a=cl(d):n||(n=g&~t,n!==0&&(a=cl(n))))):(g=l&~s,g!==0?a=cl(g):d!==0?a=cl(d):n||(n=l&~t,n!==0&&(a=cl(n)))),a===0?0:e!==0&&e!==a&&(e&s)===0&&(s=a&-a,n=e&-e,s>=n||s===32&&(n&4194048)!==0)?e:a}function Oa(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function am(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function xr(){var t=Ui;return Ui<<=1,(Ui&62914560)===0&&(Ui=4194304),t}function hc(t){for(var e=[],n=0;31>n;n++)e.push(t);return e}function Da(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function im(t,e,n,l,a,s){var d=t.pendingLanes;t.pendingLanes=n,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=n,t.entangledLanes&=n,t.errorRecoveryDisabledLanes&=n,t.shellSuspendCounter=0;var g=t.entanglements,S=t.expirationTimes,U=t.hiddenUpdates;for(n=d&~n;0<n;){var Z=31-be(n),J=1<<Z;g[Z]=0,S[Z]=-1;var Q=U[Z];if(Q!==null)for(U[Z]=null,Z=0;Z<Q.length;Z++){var G=Q[Z];G!==null&&(G.lane&=-536870913)}n&=~J}l!==0&&br(t,l,0),s!==0&&a===0&&t.tag!==0&&(t.suspendedLanes|=s&~(d&~e))}function br(t,e,n){t.pendingLanes|=e,t.suspendedLanes&=~e;var l=31-be(e);t.entangledLanes|=e,t.entanglements[l]=t.entanglements[l]|1073741824|n&261930}function Sr(t,e){var n=t.entangledLanes|=e;for(t=t.entanglements;n;){var l=31-be(n),a=1<<l;a&e|t[l]&e&&(t[l]|=e),n&=~a}}function Tr(t,e){var n=e&-e;return n=(n&42)!==0?1:mc(n),(n&(t.suspendedLanes|e))!==0?0:n}function mc(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function gc(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function Cr(){var t=_.p;return t!==0?t:(t=window.event,t===void 0?32:o1(t.type))}function Or(t,e){var n=_.p;try{return _.p=t,e()}finally{_.p=n}}var Dn=Math.random().toString(36).slice(2),te="__reactFiber$"+Dn,oe="__reactProps$"+Dn,Bl="__reactContainer$"+Dn,Ac="__reactEvents$"+Dn,um="__reactListeners$"+Dn,cm="__reactHandles$"+Dn,Dr="__reactResources$"+Dn,Ra="__reactMarker$"+Dn;function vc(t){delete t[te],delete t[oe],delete t[Ac],delete t[um],delete t[cm]}function Ul(t){var e=t[te];if(e)return e;for(var n=t.parentNode;n;){if(e=n[Bl]||n[te]){if(n=e.alternate,e.child!==null||n!==null&&n.child!==null)for(t=Fd(t);t!==null;){if(n=t[te])return n;t=Fd(t)}return e}t=n,n=t.parentNode}return null}function Ql(t){if(t=t[te]||t[Bl]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function wa(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function zl(t){var e=t[Dr];return e||(e=t[Dr]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function Ft(t){t[Ra]=!0}var Rr=new Set,wr={};function sl(t,e){Yl(t,e),Yl(t+"Capture",e)}function Yl(t,e){for(wr[t]=e,t=0;t<e.length;t++)Rr.add(e[t])}var sm=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u21Line truncated
`).replace(Lg,"")}function Ld(t,e){return e=Yd(e),Yd(t)===e}function wt(t,e,n,l,a,s){switch(n){case"children":typeof l=="string"?e==="body"||e==="textarea"&&l===""||Gl(t,l):(typeof l=="number"||typeof l=="bigint")&&e!=="body"&&Gl(t,""+l);break;case"className":Yi(t,"class",l);break;case"tabIndex":Yi(t,"tabindex",l);break;case"dir":case"role":case"viewBox":case"width":case"height":Yi(t,n,l);break;case"style":Yr(t,l,s);break;case"data":if(e!=="object"){Yi(t,"data",l);break}case"src":case"href":if(l===""&&(e!=="a"||n!=="href")){t.removeAttribute(n);break}if(l==null||typeof l=="function"||typeof l=="symbol"||typeof l=="boolean"){t.removeAttribute(n);break}l=Gi(""+l),t.setAttribute(n,l);break;case"action":case"formAction":if(typeof l=="function"){t.setAttribute(n,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof s=="function"&&(n==="formAction"?(e!=="input"&&wt(t,e,"name",a.name,a,null),wt(t,e,"formEncType",a.formEncType,a,null),wt(t,e,"formMethod",a.formMethod,a,null),wt(t,e,"formTarget",a.formTarget,a,null)):(wt(t,e,"encType",a.encType,a,null),wt(t,e,"method",a.method,a,null),wt(t,e,"target",a.target,a,null)));if(l==null||typeof l=="symbol"||typeof l=="boolean"){t.removeAttribute(n);break}l=Gi(""+l),t.setAttribute(n,l);break;case"onClick":l!=null&&(t.onclick=tn);break;case"onScroll":l!=null&&yt("scroll",t);break;case"onScrollEnd":l!=null&&yt("scrollend",t);break;case"dangerouslySetInnerHTML":if(l!=null){if(typeof l!="object"||!("__html"in l))throw Error(f(61));if(n=l.__html,n!=null){if(a.children!=null)throw Error(f(60));t.innerHTML=n}}break;case"multiple":t.multiple=l&&typeof l!="function"&&typeof l!="symbol";break;case"muted":t.muted=l&&typeof l!="function"&&typeof l!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(l==null||typeof l=="function"||typeof l=="boolean"||typeof l=="symbol"){t.removeAttribute("xlink:href");break}n=Gi(""+l),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",n);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":l!=null&&typeof l!="function"&&typeof l!="symbol"?t.setAttribute(n,""+l):t.removeAttribute(n);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":l&&typeof l!="function"&&typeof l!="symbol"?t.setAttribute(n,""):t.removeAttribute(n);break;case"capture":case"download":l===!0?t.setAttribute(n,""):l!==!1&&l!=null&&typeof l!="function"&&typeof l!="symbol"?t.setAttribute(n,l):t.removeAttribute(n);break;case"cols":case"rows":case"size":case"span":l!=null&&typeof l!="function"&&typeof l!="symbol"&&!isNaN(l)&&1<=l?t.setAttribute(n,l):t.removeAttribute(n);break;case"rowSpan":case"start":l==null||typeof l=="function"||typeof l=="symbol"||isNaN(l)?t.removeAttribute(n):t.setAttribute(n,l);break;case"popover":yt("beforetoggle",t),yt("toggle",t),zi(t,"popover",l);break;case"xlinkActuate":$e(t,"http://www.w3.org/1999/xlink","xlink:actuate",l);break;case"xlinkArcrole":$e(t,"http://www.w3.org/1999/xlink","xlink:arcrole",l);break;case"xlinkRole":$e(t,"http://www.w3.org/1999/xlink","xlink:role",l);break;case"xlinkShow":$e(t,"http://www.w3.org/1999/xlink","xlink:show",l);break;case"xlinkTitle":$e(t,"http://www.w3.org/1999/xlink","xlink:title",l);break;case"xlinkType":$e(t,"http://www.w3.org/1999/xlink","xlink:type",l);break;case"xmlBase":$e(t,"http://www.w3.org/XML/1998/namespace","xml:base",l);break;case"xmlLang":$e(t,"http://www.w3.org/XML/1998/namespace","xml:lang",l);break;case"xmlSpace":$e(t,"http://www.w3.org/XML/1998/namespace","xml:space",l);break;case"is":zi(t,"is",l);break;case"innerText":case"textContent":break;default:(!(2<n.length)||n[0]!=="o"&&n[0]!=="O"||n[1]!=="n"&&n[1]!=="N")&&(n=hm.get(n)||n,zi(t,n,l))}}function sf(t,e,n,l,a,s){switch(n){case"style":Yr(t,l,s);break;case"dangerouslySetInnerHTML":if(l!=null){if(typeof l!="object"||!("__html"in l))throw Error(f(61));if(n=l.__html,n!=null){if(a.children!=null)throw Error(f(60));t.innerHTML=n}}break;case"children":typeof l=="string"?Gl(t,l):(typeof l=="number"||typeof l=="bigint")&&Gl(t,""+l);break;case"onScroll":l!=null&&yt("scroll",t);break;case"onScrollEnd":l!=null&&yt("scrollend",t);break;case"onClick":l!=null&&(t.onclick=tn);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inneLine truncated
linear-gradient(-45deg, #80808020 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #80808020 75%),
linear-gradient(-45deg, transparent 75%, #80808020 75%)`,backgroundSize:"20px 20px",backgroundPosition:"0 0, 0 10px, 10px -10px, -10px 0px",boxShadow:`rgb(0 0 0 / 10%) 0px 1.8px 1.9px,
rgb(0 0 0 / 15%) 0px 6.1px 6.3px,
rgb(0 0 0 / 10%) 0px -2px 4px,
rgb(0 0 0 / 15%) 0px -6.1px 12px,
rgb(0 0 0 / 25%) 0px 6px 12px`},Xh=({diff:i,noTargetBlank:c,hideDetails:u})=>{const[f,r]=it.useState(i.diff?"diff":"actual"),[o,h]=it.useState(!1),[y,v]=it.useState(null),[A,x]=it.useState("Expected"),[T,D]=it.useState(null),[X,q]=it.useState(null),[p,E]=Nh();it.useEffect(()=>{(async()=>{var z,I,k,nt;v(await Zf((z=i.expected)==null?void 0:z.attachment.path)),x(((I=i.expected)==null?void 0:I.title)||"Expected"),D(await Zf((k=i.actual)==null?void 0:k.attachment.path)),q(await Zf((nt=i.diff)==null?void 0:nt.attachment.path))})()},[i]);const b=y&&T&&X,R=b?Math.max(y.naturalWidth,T.naturalWidth,200):500,N=b?Math.max(y.naturalHeight,T.naturalHeight,200):500,V=Math.min(1,(p.width-30)/R),F=Math.min(1,(p.width-50)/R/2),H=R*V,j=N*V,Y={flex:"none",margin:"0 10px",cursor:"pointer",userSelect:"none"};return m.jsx("div",{"data-testid":"test-result-image-mismatch",style:{display:"flex",flexDirection:"column",alignItems:"center",flex:"auto"},ref:E,children:b&&m.jsxs(m.Fragment,{children:[m.jsxs("div",{"data-testid":"test-result-image-mismatch-tabs",style:{display:"flex",margin:"10px 0 20px"},children:[i.diff&&m.jsx("div",{style:{...Y,fontWeight:f==="diff"?600:"initial"},onClick:()=>r("diff"),children:"Diff"}),m.jsx("div",{style:{...Y,fontWeight:f==="actual"?600:"initial"},onClick:()=>r("actual"),children:"Actual"}),m.jsx("div",{style:{...Y,fontWeight:f==="expected"?600:"initial"},onClick:()=>r("expected"),children:A}),m.jsx("div",{style:{...Y,fontWeight:f==="sxs"?600:"initial"},onClick:()=>r("sxs"),children:"Side by side"}),m.jsx("div",{style:{...Y,fontWeight:f==="slider"?600:"initial"},onClick:()=>r("slider"),children:"Slider"})]}),m.jsxs("div",{style:{display:"flex",justifyContent:"center",flex:"auto",minHeight:j+60},children:[i.diff&&f==="diff"&&m.jsx(yn,{image:X,alt:"Diff",hideSize:u,canvasWidth:H,canvasHeight:j,scale:V}),i.diff&&f==="actual"&&m.jsx(yn,{image:T,alt:"Actual",hideSize:u,canvasWidth:H,canvasHeight:j,scale:V}),i.diff&&f==="expected"&&m.jsx(yn,{image:y,alt:A,hideSize:u,canvasWidth:H,canvasHeight:j,scale:V}),i.diff&&f==="slider"&&m.jsx(k5,{expectedImage:y,actualImage:T,hideSize:u,canvasWidth:H,canvasHeight:j,scale:V,expectedTitle:A}),i.diff&&f==="sxs"&&m.jsxs("div",{style:{display:"flex"},children:[m.jsx(yn,{image:y,title:A,hideSize:u,canvasWidth:F*R,canvasHeight:F*N,scale:F}),m.jsx(yn,{image:o?X:T,title:o?"Diff":"Actual",onClick:()=>h(!o),hideSize:u,canvasWidth:F*R,canvasHeight:F*N,scale:F})]}),!i.diff&&f==="actual"&&m.jsx(yn,{image:T,title:"Actual",hideSize:u,canvasWidth:H,canvasHeight:j,scale:V}),!i.diff&&f==="expected"&&m.jsx(yn,{image:y,title:A,hideSize:u,canvasWidth:H,canvasHeight:j,scale:V}),!i.diff&&f==="sxs"&&m.jsxs("div",{style:{display:"flex"},children:[m.jsx(yn,{image:y,title:A,canvasWidth:F*R,canvasHeight:F*N,scale:F}),m.jsx(yn,{image:T,title:"Actual",canvasWidth:F*R,canvasHeight:F*N,scale:F})]})]}),!u&&m.jsxs("div",{style:{alignSelf:"start",lineHeight:"18px",marginLeft:"15px"},children:[m.jsx("div",{children:i.diff&&m.jsx("a",{target:"_blank",href:i.diff.attachment.path,rel:"noreferrer",children:i.diff.attachment.name})}),m.jsx("div",{children:m.jsx("a",{target:c?"":"_blank",href:i.actual.attachment.path,rel:"noreferrer",children:i.actual.attachment.name})}),m.jsx("div",{children:m.jsx("a",{target:c?"":"_blank",href:i.expected.attachment.path,rel:"noreferrer",children:i.expected.attachment.name})})]})]})})},k5=({expectedImage:i,actualImage:c,canvasWidth:u,canvasHeight:f,scale:r,expectedTitle:o,hideSize:h})=>{const y={position:"absolute",top:0,left:0},[v,A]=it.useState(u/2),x=i.naturalWidth===c.naturalWidth&&i.naturalHeight===c.naturalHeight;return m.jsxs("div",{style:{flex:"none",display:"flex",alignItems:"center",flexDirection:"column",userSelect:"none"},children:[!h&&m.jsxs("div",{style:{margin:5},children:[!x&&m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"Expected "}),m.jsx("span",{children:i.naturalWidth}),m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"x"}),m.jsx("span",{children:i.naturalHeight}),!x&&m.jsx("span",{style:{flex:"none",margin:"0 5px 0 15px"},children:"Actual "}),!x&&m.jsx("span",{children:c.naturalWidth}),!x&&m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"x"}),!x&&m.jsx("span",{children:c.naturalHeight})]}),m.jsxs("div",{style:{position:"relative",width:u,height:f,margin:15,...nr},children:[m.jsx(K5,{orientation:"horizontal",offsets:[v],setOffsets:T=>A(T[0]),resizerColor:"#57606a80",resizerWidth:6}),m.jsx("img",{alt:o,style:{width:i.naturalWidth*r,height:i.naturalHeight*r},draggable:"false",src:i.src}),m.jsx("div",{style:{...y,bottom:0,overflow:"hidden",width:v,...nr},children:m.jsx("img",{alt:"Actual",style:{width:c.naturalWidth*r,height:c.naturalHeight*r},draggable:"false",src:c.src})})]})]})},yn=({image:i,title:c,alt:u,hideSize:f,canvasWidth:r,canvasHeight:o,scale:h,onClick:y})=>m.jsxs("div",{style:{flex:"none",display:"flex",alignItems:"center",flexDirection:"column"},children:[!f&&m.jsxs("div",{style:{margin:5},childrenLine truncated
# Stdout
\`\`\`
`+T2(R)+"\n```"),N&&(p+=`
# Stderr
\`\`\`
`+T2(N)+"\n```");const V=i.json().metadata;return V!=null&&V.gitDiff&&(p+=`
# Local changes
\`\`\`diff
`+V.gitDiff+"\n```"),p},[T,i,u],void 0);return m.jsxs("div",{className:"test-result",children:[!!v.length&&m.jsxs(ke,{header:"Errors",children:[q&&m.jsx("div",{style:{position:"absolute",right:"16px",padding:"10px",zIndex:1},children:m.jsx(_5,{prompt:q})}),v.map((p,E)=>{const b=iv(p,y);return m.jsxs(m.Fragment,{children:[m.jsx(Ar,{code:p},"test-result-error-message-"+E),b&&m.jsx(P5,{diff:b})]})})]}),!!u.steps.length&&m.jsxs(ke,{header:"Test Steps",children:[m.jsxs("form",{className:"subnav-search step-filter",onSubmit:p=>p.preventDefault(),children:[Ch(),m.jsx("input",{className:"form-control subnav-search-input input-contrast width-full",type:"search",spellCheck:!1,placeholder:"Filter steps","aria-label":"Filter steps",value:D,onChange:p=>X(p.target.value)})]}),u.steps.map((p,E)=>m.jsx(Kh,{step:p,result:u,test:c,depth:0,filterText:D},`step-${E}`))]}),y.map((p,E)=>m.jsx(xi,{id:p.anchors,children:m.jsx(ke,{dataTestId:"test-results-image-diff",header:`Image mismatch: ${p.name}`,revealOnAnchorId:p.anchors,children:m.jsx(Xh,{diff:p})})},`diff-${E}`)),!!f.length&&m.jsx(ke,{header:"Screenshots",revealOnAnchorId:x,children:f.map((p,E)=>m.jsxs(xi,{id:`attachment-${u.attachments.indexOf(p)}`,children:[m.jsx("a",{href:Ve(p.path),children:m.jsx("img",{className:"screenshot",src:Ve(p.path)})}),m.jsx($u,{attachment:p,result:u})]},`screenshot-${E}`))}),!!o.length&&m.jsx(xi,{id:"attachment-trace",children:m.jsx(ke,{header:"Traces",revealOnAnchorId:"attachment-trace",children:m.jsxs("div",{children:[m.jsx("a",{href:Ve(Yh(o)),children:m.jsx("img",{className:"screenshot",src:Z5,style:{width:192,height:117,marginLeft:20}})}),o.map((p,E)=>m.jsx($u,{attachment:p,result:u,linkName:o.length===1?"trace":`trace-${E+1}`},`trace-${E}`))]})})}),!!r.length&&m.jsx(xi,{id:"attachment-video",children:m.jsx(ke,{header:"Videos",revealOnAnchorId:"attachment-video",children:r.map(p=>m.jsxs("div",{children:[m.jsx("video",{controls:!0,children:m.jsx("source",{src:Ve(p.path),type:p.contentType})}),m.jsx($u,{attachment:p,result:u})]},p.path))})}),!!h.size&&m.jsx(ke,{header:"Attachments",revealOnAnchorId:A,dataTestId:"attachments",children:[...h].map((p,E)=>m.jsx(xi,{id:`attachment-${u.attachments.indexOf(p)}`,children:m.jsx($u,{attachment:p,result:u,openInNewTab:p.contentType.startsWith("text/html")})},`attachment-link-${E}`))}),m.jsx(ke,{header:`Executed in Worker #${u.workerIndex}`,dataTestId:"worker-test-list",initialExpanded:!1,noInsets:!0,body:()=>{const p=uv(i).get(u.workerIndex)||{tests:[],runs:[]};return m.jsx(Zh,{tests:p.tests,runs:p.runs,projectNames:i.json().projectNames,selectedTestId:c.testId})}})]})};function iv(i,c){const u=i.split(`
`)[0];if(!(!u.includes("toHaveScreenshot")&&!u.includes("toMatchSnapshot")))return c.find(f=>i.includes(f.name))}function Ih(i,c){return i.title.toLowerCase().includes(c.toLowerCase())}function qh(i,c){return i.steps.some(u=>Ih(u,c)||qh(u,c))}const Kh=({test:i,step:c,result:u,depth:f,filterText:r})=>{const o=se();let h=!1,y=m.jsx("span",{children:c.title});if(r){const v=!!r&&Ih(c,r),A=!!r&&qh(c,r);if(!v&&!A)return null;if(h=A,v){const x=c.title.toLowerCase().split(r.toLowerCase()),T=[];let D=0;for(let X=0;X<x.length;X++)X&&(T.push(m.jsx("span",{className:"step-title-highlight",children:c.title.substring(D,D+r.length)},X)),D+=r.length),T.push(x[X]),D+=x[X].length;y=T}}return m.jsx(V5,{title:m.jsxs("div",{"aria-label":c.title,className:"step-title-container",children:[fc(c.error||c.duration===-1?"failed":c.skipped?"skipped":"passed"),m.jsxs("span",{className:"step-title-text",children:[y,c.count>1&&m.jsxs(m.Fragment,{children:[" ✕ ",m.jsx("span",{className:"test-result-counter",children:c.count})]}),c.location&&m.jsxs("span",{className:"test-result-path",children:["— ",c.location.file,":",c.location.line]})]}),m.jsx("span",{className:"step-spacer"}),c.attachments.length>0&&m.jsx("a",{className:"step-attachment-link",title:"reveal attachment",href:Ve(il({test:i,result:u,anchor:`attachment-${c.attachments[0]}`},o)),onClick:v=>{v.stopPropagation()},children:Dh()}),m.jsx("span",{className:"step-duration",children:Ta(c.duration)})]}),loadChildren:c.steps.length||c.snippet?()=>{const v=c.snippet?[m.jsx(Ar,{testId:"test-snippet",code:c.snippet},"line")]:[],A=c.steps.map((x,T)=>m.jsx(Kh,{step:x,depth:f+1,result:u,test:i,filterText:r},T));return v.concat(A)}:void 0,depth:f,expandByDefault:h})},C2=Symbol("workerLists");function uv(i){let c=i[C2];if(!c){const u=new Map;for(const f of i.json().files)for(const r of f.tests)for(let o=0;o<r.results.length;o++){let h=u.get(r.results[o].workerIndex);h||(h=[],u.set(r.results[o].workerIndex,h)),h.push({test:r,time:new Date(r.results[o].startTime).valueOf(),run:o})}c=new Map;for(const[f,r]of u)r.sort((o,h)=>o.time-h.time),c.set(f,{tests:r.map(o=>o.test),runs:r.map(o=>o.run)});i[C2]=c}return c}const cv=({report:i,test:c,run:u,next:f,prev:r})=>{const[o,h]=it.useState(u),y=se(),v=c.annotations.filter(A=>!A.type.startsWith("_"))??[];return m.jsxs(m.Fragment,{children:[m.jsx(mr,{title:c.title,leftSuperHeader:m.jsx("div",{className:"test-case-path",children:c.path.join(" › ")}),rightSuperHeader:m.jsxs(m.Fragment,{children:[m.jsx("div",{className:Ze(!r&&"hidden"),children:m.jsx(bn,{href:il({test:r},y),children:"« previous"})}),m.jsx("div",{style:{width:10}}),m.jsx("div",{className:Ze(!f&&"hidden"),children:m.jsx(bn,{href:il({test:f},y),children:"next »"})})]})}),m.jsxs("div",{className:"hbox",style:{lineHeight:"24px"},children:[m.jsx("div",{className:"test-case-location",children:m.jsxs(or,{value:`${c.location.file}:${c.location.line}`,children:[c.location.file,":",c.location.line]})}),m.jsx("div",{style:{flex:"auto"}}),m.jsx(Qh,{test:c,trailingSeparator:!0}),m.jsx("div",{className:"test-case-duration",children:Ta(c.duration)})]}),m.jsx(Uh,{style:{marginLeft:"6px"},projectNames:i.json().projectNames,activeProjectName:c.projectName,otherLabels:c.tags}),c.results.length===0&&v.length!==0&&m.jsx(ke,{header:"Annotations",dataTestId:"test-case-annotations",children:v.map((A,x)=>m.jsx(O2,{annotation:A},x))}),m.jsx(X5,{tabs:c.results.map((A,x)=>({id:String(x),title:m.jsxs("div",{style:{display:"flex",alignItems:"center"},children:[fc(A.status)," ",sv(x),c.results.length>1&&m.jsx("span",{className:"test-case-run-duration",children:Ta(A.duration)})]}),render:()=>{const T=A.annotations.filter(D=>!D.type.startsWith("_"));return m.jsxs(m.Fragment,{children:[!!T.length&&m.jsx(ke,{header:"Annotations",dataTestId:"test-case-annotations",children:T.map((D,X)=>m.jsx(O2,{annotation:D},X))}),m.jsx(av,{test:c,result:A,report:i})]})}}))||[],selectedTab:String(o),setSelectedTab:A=>h(+A)})]})};function O2({annotation:{type:i,description:c}}){return m.jsxs("div",{className:"test-case-annotation",children:[m.jsx("span",{style:{fontWeight:"bold"},children:i}),c&&m.jsxs(or,{value:c,children:[": ",Di(c)]})]})}function sv(i){return i?`Retry #${i}`:"Run"}class fv extends it.Component{constructor(){super(...arguments),this.state={error:null,errorInfo:null}}componentDidCatch(c,u){this.setState({error:c,errorInfo:u})}render(){var c,u,f;return this.state.error||this.state.errorInfo?m.jsxs("div",{className:"metadata-view p-3",children:[m.jsx("p",{children:"An error was encountered when trying to render metadata."}),m.jsx("p",{children:m.jsxs("pre",{style:{overflow:"scroll"},children:[(c=this.state.error)==null?void 0:c.message,m.jsx("br",{}),(u=this.state.error)==null?void 0:u.stack,m.jsx("br",{}),(f=this.state.errorInfo)==null?void 0:f.componentStack]})})]}):this.props.children}}const rv=i=>m.jsx(fv,{children:m.jsx(ov,{metadata:i.metadata})}),ov=i=>{const c=i.metadata,u=se().has("show-metadata-other")?Object.eLine truncated
</script>
<style type='text/css'>:root{--color-canvas-default-transparent: rgba(255,255,255,0);--color-marketing-icon-primary: #218bff;--color-marketing-icon-secondary: #54aeff;--color-diff-blob-addition-num-text: #24292f;--color-diff-blob-addition-fg: #24292f;--color-diff-blob-addition-num-bg: #CCFFD8;--color-diff-blob-addition-line-bg: #E6FFEC;--color-diff-blob-addition-word-bg: #ABF2BC;--color-diff-blob-deletion-num-text: #24292f;--color-diff-blob-deletion-fg: #24292f;--color-diff-blob-deletion-num-bg: #FFD7D5;--color-diff-blob-deletion-line-bg: #FFEBE9;--color-diff-blob-deletion-word-bg: rgba(255,129,130,.4);--color-diff-blob-hunk-num-bg: rgba(84,174,255,.4);--color-diff-blob-expander-icon: #57606a;--color-diff-blob-selected-line-highlight-mix-blend-mode: multiply;--color-diffstat-deletion-border: rgba(27,31,36,.15);--color-diffstat-addition-border: rgba(27,31,36,.15);--color-diffstat-addition-bg: #2da44e;--color-search-keyword-hl: #fff8c5;--color-prettylights-syntax-comment: #6e7781;--color-prettylights-syntax-constant: #0550ae;--color-prettylights-syntax-entity: #8250df;--color-prettylights-syntax-storage-modifier-import: #24292f;--color-prettylights-syntax-entity-tag: #116329;--color-prettylights-syntax-keyword: #cf222e;--color-prettylights-syntax-string: #0a3069;--color-prettylights-syntax-variable: #953800;--color-prettylights-syntax-brackethighlighter-unmatched: #82071e;--color-prettylights-syntax-invalid-illegal-text: #f6f8fa;--color-prettylights-syntax-invalid-illegal-bg: #82071e;--color-prettylights-syntax-carriage-return-text: #f6f8fa;--color-prettylights-syntax-carriage-return-bg: #cf222e;--color-prettylights-syntax-string-regexp: #116329;--color-prettylights-syntax-markup-list: #3b2300;--color-prettylights-syntax-markup-heading: #0550ae;--color-prettylights-syntax-markup-italic: #24292f;--color-prettylights-syntax-markup-bold: #24292f;--color-prettylights-syntax-markup-deleted-text: #82071e;--color-prettylights-syntax-markup-deleted-bg: #FFEBE9;--color-prettylights-syntax-markup-inserted-text: #116329;--color-prettylights-syntax-markup-inserted-bg: #dafbe1;--color-prettylights-syntax-markup-changed-text: #953800;--color-prettylights-syntax-markup-changed-bg: #ffd8b5;--color-prettylights-syntax-markup-ignored-text: #eaeef2;--color-prettylights-syntax-markup-ignored-bg: #0550ae;--color-prettylights-syntax-meta-diff-range: #8250df;--color-prettylights-syntax-brackethighlighter-angle: #57606a;--color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;--color-prettylights-syntax-constant-other-reference-link: #0a3069;--color-codemirror-text: #24292f;--color-codemirror-bg: #ffffff;--color-codemirror-gutters-bg: #ffffff;--color-codemirror-guttermarker-text: #ffffff;--color-codemirror-guttermarker-subtle-text: #6e7781;--color-codemirror-linenumber-text: #57606a;--color-codemirror-cursor: #24292f;--color-codemirror-selection-bg: rgba(84,174,255,.4);--color-codemirror-activeline-bg: rgba(234,238,242,.5);--color-codemirror-matchingbracket-text: #24292f;--color-codemirror-lines-bg: #ffffff;--color-codemirror-syntax-comment: #24292f;--color-codemirror-syntax-constant: #0550ae;--color-codemirror-syntax-entity: #8250df;--color-codemirror-syntax-keyword: #cf222e;--color-codemirror-syntax-storage: #cf222e;--color-codemirror-syntax-string: #0a3069;--color-codemirror-syntax-support: #0550ae;--color-codemirror-syntax-variable: #953800;--color-checks-bg: #24292f;--color-checks-run-border-width: 0px;--color-checks-container-border-width: 0px;--color-checks-text-primary: #f6f8fa;--color-checks-text-secondary: #8c959f;--color-checks-text-link: #54aeff;--color-checks-btn-icon: #afb8c1;--color-checks-btn-hover-icon: #f6f8fa;--color-checks-btn-hover-bg: rgba(255,255,255,.125);--color-checks-input-text: #eaeef2;--color-checks-input-placeholder-text: #8c959f;--color-checks-input-focus-text: #8c959f;--color-checks-input-bg: #32383f;--color-checks-input-shadow: none;--color-checks-donut-error: #fa4549;--color-checks-donut-pending: #bf8700;--color-checks-donut-success: #2da44e;--color-checks-donut-neutral: #afb8c1;--color-checks-dropdown-text: #afb8c1;--color-checks-dropdown-bg: #32383f;--color-checks-dropdown-border: #424a53;--color-checks-dropdown-shadow: rgba(27,31,36,.3);--color-checks-dropdown-hover-text: #f6f8fa;--color-checks-dropdown-hover-bg: #424a53;--color-checks-dropdown-btn-hover-text: #f6f8fa;--color-checks-dropdown-btn-hover-bg: #32383f;--color-checks-scrollbar-thumb-bg: #57606a;--color-checks-header-label-text: #d0d7de;--color-checks-header-label-open-text: #f6f8fa;--color-checks-header-border: #32383f;--color-checks-header-icon: #8c959f;--color-checks-line-text: #d0d7de;--color-checks-line-num-text: rgba(140,149,159,.75);--color-checks-line-timestamp-text: #8c959f;--color-checks-line-hover-bg: #32383f;--color-checks-line-selected-bg: rgba(33,139,255,.15);--color-checks-line-selected-num-text: #54aeff;--color-checks-line-dt-fm-text: #24292f;--color-checks-line-dt-fm-bg: #9a6700;--color-checks-gate-bg: rgba(1Line truncated
</style>
</head>
<body>
<div id='root'></div>
</body>
</html>
<template id="playwrightReportBase64">data:application/zip;base64,UEsDBBQAAAgIADWYo1zKS/0sNwkAAOtAAAAZAAAANDIxOTkyMmZlYTJlMmJkM2M2OTEuanNvbu1bYW/bOBL9KwS/1MY5tkhKlqWixW27vdsCvXaxG9wBt+oBtETFukiiIVFNgtT/fUFJtihajmXHTZ1Fkw9RLHE488ghH+dZ9zCMYvY+gC40MXIcjENGMcPzgPhTB8FRef8jTRh0YZ7wazbOl8wfixyOoGC5yKH7x315tdPIBfYdm4XTgJGZZRLbtOZTKptHIt6YdcGSXjEQcxrk4CYSC7BgNGAZHMFlxv/PfFE74S8ynkRFAkcw5j4VEU+he1+62eFiHKUMumQEfR4XSQpdazWCQZHV7SwTzUaQpikX5Scyms8jKOhVfcUL4fOy3yJlt0vmCxZIl6hYVA9kLC/iGgXdbi5oJi6jsjk28PTCsC4Mcols10AuwmMDO/+F0oTI7qBryAZsWQNaY/OGhTxj4BfOr2U4ey0SLC02jmCCusz+I7oVRcaAB+cZv8lZ5sE+1k2rbR0ZZpf1D7RI/QWoTfcy7LQNO05j9/MIUiGov0hYKuoPfF6kAroytutouWQBdEMa52x10MOjLkB8ngp2K3oBgiykAdKJx9uMUcFAbbmXXaID/d3wkGnZD4ypNjuc2QNgSLO9jM40o9ZTIHEsbB/pl+hKhic48OCkF254ams5a+OHY+y97pnNuofM1e4QRjBP5f8CuhB4hWGg+R+OkQBAwNf6X+IkQK7yg/X/OPEeXrk92DRdX5FkpNhbX04Tmt+lvnJncF8ZXQ1B0/TVa+WJey9teWq2PAWq7RsaCeVeOaE3RsfNnSsueCu8hRBLdzKRcMcLnguXGIahhjVs7LxUgm17BlqerS/RGhNUPrL5+V/9McbJFnyGbtrqHXS1dw12xl5OKZ61w98axuGws7Hgb9i/ozyax2zQDQlUk+Rd6QvwoNLOg2Dtwouq3xfDHrljmdN27liGsWeB6J08VpM8prEaQZZlPJPuy7/uGmuc1NBujxtao7o1kDgZjpvnVRjW97fN4c1swzgBIY1iFnipl36o+nC38fPSdzVjccGX2n4qweSFcIFlGEaSe2kdDouZBGmQD0HKBQh5kZbm39I4BjG/cr104wgAF2DHIJb5L6o+1l00kbRNyCkapVcg5FmH80oreMTKdb7rwbNJ2ketY7t/DlrhpicD64qJN3eX7La9g/1M88XpV7ifQsGynqTdHluWTto7WeTBlExa1nnkkeTpkUxZemL2Ysoxz/sTZXts6ZyTnDM7/A/PrlkG3saMpsWyT3i2Rg2Rc6rTnDSuUWtkPxF45U5ahZCwPJcngR/b6tNvq+Xvet1VV91nw/rPapPv2BFfb023BhXp/7OlAm23e/10wNPNBFpdPZv9v+223XJ71afJTG2i0CEltwGgAkwWPGETEXzhGb2e/Mzya8GXk19/+/TPv/1y+a8PExnRJMzkFpoGE4bZpHW8cS3XNBrcS2odsDCriqsAAAS+gihZ8kyA+zL3RzW0YAXCjCfAg39fxvTuJouuFmIin/DgSy8FAGDwtfxLwNd61di3VoxAuQaomf/qdZnlJYOXEcthLm+Oq1TdlaDDl176umTXm1bKlBhvEmSTFsPhuDWsVQygbN+PyIL1Il/1U3ehmd3sKaBks53OKXOznpHdztngK1jV17MabGcLbLFgiaxAXV3FDNzw7Dp/EGhkHA40AAiVrXye5mLd1yvQRPOBzlk88OBldav0adMWKz1Wbcd+HPnXdZyIbHmkAPSW5vTT7x4cttuYqj8lAq9UC+wLjQsq2GBQRh5wv5Ab83h98a7aqGVPPwmRRfNCsIEHAyroxdr5uqdqitXjV96rBmvgQZ/mlOebOKfNeCG7Gi802xovlizFHciFrBvmC36TAxoEYF4IwdOHR845ZuRwNd6TCXgfgpSDnGVfIp/lgN1GMtvFgukuFXEA5qxhOwBgdfjLp38vH361PV4fOaDLZQ7umNi4UE2AKASDOhk2FsZRvpn0wypMTOqc3JE4v/FYYt8Adg9SmjAXTCSOdLmcRGDVmVC4mjSr8tpqBgtPq8HCMuEmEyhJqwSjyKELq3yGHbJRmwTfw7RaVEu6e9EcasqrVFzeLeVd+eEkodl1wG/SjbRUTrwJNQIznIVWaJGZbRDDn1vzeRDQwPTD0GTYIYbJKPLHSVD6eFMeMN6nAbuFriE/4dfqOeRBlY6GwXzmOIZPGSZTg6HQMLdVuu3F5SQKnbNLoSOGgaxvIdHVhvdLMdaJNTpkaWIX3nfeP0SjQ7Z+0CZ2l/WDNTpkO7rhY6sJTyvSEaQXQToBOVikI0g/nH8/PHqLdIRoiqWFHwCjp0hHCNGr8OdchjlGpCMzbbBNNWkfpTNIbfdxKp3T67zeRQuf8Kwuaeb5HNafSKGTLHlH0CV1UoetGhgFzF6IKFxbwaXNuvfB0apdv5WkGih2X6jGeqlyM31lxI5l7PlGR/90wUq6TLd1ufoUVh0OXHDJ5HmgrpzxEMgJYiSS7PqMBSwY767FqYW03XioFbUjhKrnMUO0WY17p3Llc6dj9ent9Al6YIqSE6xLnbWl9UG1E9yeQBwrK5mSn+hMcI940I9EdFk+drt/HLcrPdG/kdZNZw6RlaRZbP5lZSWzg69hcqIDSGlcww4/CS3eJSs1i8X+naCddVvS1PfcWXZqNedLq57LztaxnezTbb7zBngSZeW89z3N191C39ZUKk853TPpiAjVsrFiVD3nrCvIne218vIuFJWSswKkWnxWwOyFmC4ttsrVnW5UJWyl+3Uxe28KNcRKWeVOIlYh7KJpM5f/WmoVeLxa9UNQKlW/fYJSl6q3Ue8O39Z/KFQ/FKpzVqjk7A0+FeJJNCqEZzazQzYLEAltnzq2Qw1rRomPiEGobVnIZr5FOjUqdKBGZU0xNYnhOMQgwXzqz0zH39aoHpquJ1Gr5Mtd3XLVVNbTe4lVB0hV06pIv0efMciphSpDL+PhztP1kUIV0vWk7lPo4UIV1vUe9DzeJsOmXnU4zdtk2NTGkTyDt8mw/u1Uq/NFxsOEKjwzNBVnds6FnaOEKr38Ylp7ilf9Vz3nkUIVmvUSqh5mG1vnjm8oWTnnWlv5hpIV3lFQcozkEYTs0QVtKSvp74Ka05O8J4HHMxufxXsS0hO9NGs9uqAtzery9dN8V+HoCq7KZpc0z/t830rnlnjNLUVWsNXn1Z9QSwMEFAAACAgANZijXD08LqxQAgAAfAYAAAsAAAByZXBvcnQuanNvbtWUTW+bQBCG/wqa89rZD5avf5BLT5EqtfJh2B0CNbAIBjmRxX+vsN3UlZPWh1x6G2aXmXfeZ+AIHTF6ZITiCOh4xvZrGPc0TlCYRcDEOPJT0xEUKk3TTKdJkmbGCPDziNyEHgoT21hv4zgXUDUtTVB8P56iRw8FxFrludYVoSZdeuOSXMH55hdc68LUhT1tp4HclicQwDTxucgafVhko12eUpV4MpmNTRrbMsH19Ybbt7JFNOAzRW1AP0WHhuuoJvQ0goBhDD/I8UWEq8fQNXMHAtrgLpOdx3hHYtv0BIUR4EI7dz0Udrl2xMYqE4B9H/iUWafZCWB8vkRhZhdOfeeeXgZyTH6VhFxfLuyhqLCdSMBI09xeDEFmdHVH/eW5P4uncQzjxoWe6YVhVdUz9fz0Oqyna/Khw3Hvw6F/6wIr9QeUPq6yylbWZKk00pW2LL1HH7uqiknnRsaEym07D8vuj30ALXWykXYjzZNKC6kKpbdS599AwOG0Q4+9pxco5LJbxL9gYuXLLM+lQ9ImkaQqGd/C5Jo6ijg8P7cUrU2mTwGZfwTSSKns/0FS6SyltKLMK1OlDvM0R2kzNE4ZaTC1VqXkrLmXpLL2hqS6h6RNNMZG5rmRxpeJy+Lc3ZKkbuDXaGJkiqY6HKYIvY/KmTn0n8J0/QLfh5pYfSfSD4HyOP+V5z3+SnPjr152FzRrlSNwYGxP/5k3JYUS15tWaAFVi/tXKKSAad8Mw5qVV3u3rCWv/Fz1/Xb089uJ8xL/MnO4eHxcBHTo6qY/KdgtPwFQSwECPwMUAAAICAA1mKNcykv9LDcJAADrQAAAGQAAAAAAAAAAAAAAtIEAAAAANDIxOTkyMmZlYTJlMmJkM2M2OTEuanNvblBLAQI/AxQAAAgIADWYo1w9PC6sUAIAAHwGAAALAAAAAAAAAAAAAAC0gW4JAAByZXBvcnQuanNvblBLBQYAAAAAAgACAIAAAADnCwAAAAA=</template>
+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");
});
});
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "playwright.config.ts", "e2e", "tests", "vitest.config.ts"]
}
+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/**"],
},
});