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