refactor(frontend): restructure project layout and update API schema

Relocate frontend source code from `next-app/` to `frontend/` to align with the new project structure. This includes removing the old Next.js boilerplate files and establishing a cleaner workspace.

Additionally, updates the OpenAPI specification to include support for the `immich` widget type and its corresponding configuration schema.

- Move frontend files to `frontend/`
- Delete obsolete `next-app/` directory and its configuration
- Add `immich` widget type to `openapi.yaml`
- Update `FrontendPlan.md` with dashboard refactor and UX direction
This commit is contained in:
Tomas Dvorak
2026-05-04 12:31:34 +02:00
parent b17a06fbba
commit 17a579880f
85 changed files with 9441 additions and 947 deletions
@@ -0,0 +1,608 @@
"use client";
import { useState } from "react";
import type { Service, Group, WidgetInstance, Dashboard } from "@/lib/api/schema";
import { useDashboard, useDeleteService, useDeleteWidget, useUpdateLayout } from "@/lib/api/hooks";
import { Header } from "@/components/shell/header";
import { ServiceCard } from "@/components/services/service-card";
import { ServiceForm } from "@/components/services/service-form";
import { GroupSection } from "@/components/groups/group-section";
import { GroupForm } from "@/components/groups/group-form";
import { WidgetCard } from "@/components/widgets/widget-card";
import { WidgetForm } from "@/components/widgets/widget-form";
import { Button } from "@/components/ui/button";
import { Plus, Loader2, AlertCircle, LayoutGrid, List, Pencil, Trash2, GripVertical } from "lucide-react";
import {
DndContext,
closestCenter,
DragOverlay,
DragStartEvent,
DragEndEvent,
DragOverEvent,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
MeasuringStrategy,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
rectSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
/* ---------- Sortable wrappers ---------- */
function SortableGroup({
group,
onEditService,
onDeleteService,
onEditGroup,
}: {
group: Group;
onEditService: (s: Service) => void;
onDeleteService: (id: string) => void;
onEditGroup: (g: Group) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: group.id,
data: { type: "group" },
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<GroupSection
group={group}
onEditService={onEditService}
onDeleteService={onDeleteService}
onEditGroup={onEditGroup}
dragHandleProps={listeners}
/>
</div>
);
}
function SortableService({
service,
onEdit,
onDelete,
}: {
service: Service;
onEdit: (s: Service) => void;
onDelete: (id: string) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: service.id,
data: { type: "service", groupId: service.groupId },
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<ServiceCard service={service} onEdit={onEdit} onDelete={onDelete} dragHandleProps={listeners} isDragging={isDragging} />
</div>
);
}
function SortableWidget({
widget,
onEdit,
onDelete,
}: {
widget: WidgetInstance;
onEdit: (w: WidgetInstance) => void;
onDelete: (id: string) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: widget.id,
data: { type: "widget" },
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<WidgetCard widget={widget} onEdit={onEdit} onDelete={onDelete} dragHandleProps={listeners} />
</div>
);
}
/* ---------- Add-app tile ---------- */
function AddAppTile({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="service-card group flex aspect-square flex-col items-center justify-center gap-2.5 rounded-[24px] border border-dashed border-border bg-card p-4 transition-all duration-300 hover:-translate-y-1 hover:bg-accent hover:border-ring/40 hover:shadow-border-hover"
>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-secondary transition-colors group-hover:bg-accent">
<Plus className="h-5 w-5 text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
<span className="text-xs font-medium text-muted-foreground transition-colors group-hover:text-foreground">Add App</span>
</button>
);
}
/* ---------- Service List Item ---------- */
function ServiceListItem({
service,
onEdit,
onDelete,
}: {
service: Service;
onEdit: (s: Service) => void;
onDelete: (id: string) => void;
}) {
const primaryUrl = service.urls.find((u) => u.isPrimary) || service.urls[0];
return (
<div className="group flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 transition-all hover:bg-accent hover:border-border hover:shadow-border">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-secondary font-mono text-sm font-semibold text-secondary-foreground">
{service.name.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold truncate">{service.name}</div>
{primaryUrl && (
<a
href={primaryUrl.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground truncate block transition-colors"
>
{primaryUrl.url}
</a>
)}
</div>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg hover:bg-accent" onClick={() => onEdit(service)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg text-destructive hover:bg-destructive/10" onClick={() => onDelete(service.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
/* ---------- Drag Overlay ---------- */
function DashboardDragOverlay({ activeId, dashboard }: { activeId: string; dashboard: Dashboard }) {
const allServices = [
...dashboard.ungroupedServices,
...dashboard.groups.flatMap((g) => g.services),
];
const service = allServices.find((s) => s.id === activeId);
const group = dashboard.groups.find((g) => g.id === activeId);
const widget = dashboard.widgets.find((w) => w.id === activeId);
if (service) {
return (
<div className="drag-overlay flex aspect-square w-28 flex-col items-center justify-center gap-2 rounded-2xl bg-card border border-ring/50 p-3 shadow-2xl">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-secondary to-accent font-mono text-sm font-bold text-secondary-foreground">
{service.name.slice(0, 2).toUpperCase()}
</div>
<span className="text-xs font-semibold text-center truncate w-full">{service.name}</span>
</div>
);
}
if (group) {
return (
<div className="drag-overlay flex w-64 items-center gap-3 rounded-xl bg-card border border-ring/50 px-4 py-3 shadow-2xl">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent">
<GripVertical className="h-4 w-4 text-accent-foreground" />
</div>
<div>
<span className="text-sm font-semibold">{group.name}</span>
<span className="text-xs text-muted-foreground ml-2">{group.services.length} apps</span>
</div>
</div>
);
}
if (widget) {
return (
<div className="drag-overlay flex w-56 items-center gap-3 rounded-xl bg-card border border-ring/50 px-4 py-3 shadow-2xl">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent">
<GripVertical className="h-4 w-4 text-accent-foreground" />
</div>
<div>
<span className="text-sm font-semibold">{widget.title}</span>
<span className="text-xs text-muted-foreground ml-2 uppercase">{widget.type}</span>
</div>
</div>
);
}
return <div className="drag-overlay rounded-xl bg-card p-4 shadow-2xl border border-ring/50">Moving</div>;
}
/* ---------- Main Dashboard ---------- */
export default function DashboardPage() {
const { data: dashboard, isLoading, error } = useDashboard();
const deleteService = useDeleteService();
const deleteWidget = useDeleteWidget();
const updateLayout = useUpdateLayout();
const [serviceFormOpen, setServiceFormOpen] = useState(false);
const [editingService, setEditingService] = useState<Service | null>(null);
const [groupFormOpen, setGroupFormOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [widgetFormOpen, setWidgetFormOpen] = useState(false);
const [editingWidget, setEditingWidget] = useState<WidgetInstance | null>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor),
);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(String(event.active.id));
};
const handleDragOver = (_event: DragOverEvent) => {
void _event;
// Visual feedback placeholder
};
const handleDragEnd = (event: DragEndEvent) => {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id || !dashboard) return;
const activeIdStr = String(active.id);
const overIdStr = String(over.id);
const allServiceIds = [
...dashboard.ungroupedServices.map((s) => s.id),
...dashboard.groups.flatMap((g) => g.services.map((s) => s.id)),
];
const groupIds = dashboard.groups.map((g) => g.id);
const widgetIds = dashboard.widgets.map((w) => w.id);
const isActiveService = allServiceIds.includes(activeIdStr);
const isOverService = allServiceIds.includes(overIdStr);
const isActiveGroup = groupIds.includes(activeIdStr);
const isOverGroup = groupIds.includes(overIdStr);
const isActiveWidget = widgetIds.includes(activeIdStr);
const isOverWidget = widgetIds.includes(overIdStr);
// Service → Service (reorder / cross-group)
if (isActiveService && isOverService) {
const findServiceLocation = (sid: string): { groupId: string | null; index: number } => {
const ungroupedIdx = dashboard.ungroupedServices.findIndex((s) => s.id === sid);
if (ungroupedIdx !== -1) return { groupId: null, index: ungroupedIdx };
for (const g of dashboard.groups) {
const idx = g.services.findIndex((s) => s.id === sid);
if (idx !== -1) return { groupId: g.id, index: idx };
}
return { groupId: null, index: -1 };
};
const activeLoc = findServiceLocation(activeIdStr);
const overLoc = findServiceLocation(overIdStr);
const groupServices: Record<string, string[]> = {};
for (const g of dashboard.groups) {
const ids = [...g.services.map((s) => s.id)];
if (activeLoc.groupId === g.id) ids.splice(activeLoc.index, 1);
if (overLoc.groupId === g.id) {
const insertIdx = activeLoc.groupId === g.id && activeLoc.index < overLoc.index ? overLoc.index : overLoc.index;
ids.splice(insertIdx, 0, activeIdStr);
}
groupServices[g.id] = ids;
}
const ungroupedIds = [...dashboard.ungroupedServices.map((s) => s.id)];
if (activeLoc.groupId === null) ungroupedIds.splice(activeLoc.index, 1);
if (overLoc.groupId === null) {
const insertIdx = activeLoc.groupId === null && activeLoc.index < overLoc.index ? overLoc.index : overLoc.index;
ungroupedIds.splice(insertIdx, 0, activeIdStr);
}
if (activeLoc.groupId !== null && overLoc.groupId === null) {
ungroupedIds.splice(overLoc.index, 0, activeIdStr);
}
updateLayout.mutate({ groupIds, widgetIds, ungroupedServiceIds: ungroupedIds, groupServices });
return;
}
// Service → Group header (move into group)
if (isActiveService && isOverGroup) {
const groupServices: Record<string, string[]> = {};
const ungroupedIds = [...dashboard.ungroupedServices.map((s) => s.id)];
for (const g of dashboard.groups) {
const ids = g.services.map((s) => s.id);
const idx = ids.indexOf(activeIdStr);
if (idx !== -1) ids.splice(idx, 1);
if (g.id === overIdStr) ids.push(activeIdStr);
groupServices[g.id] = ids;
}
const uIdx = ungroupedIds.indexOf(activeIdStr);
if (uIdx !== -1) ungroupedIds.splice(uIdx, 1);
updateLayout.mutate({ groupIds, widgetIds, ungroupedServiceIds: ungroupedIds, groupServices });
return;
}
// Group reorder
if (isActiveGroup && isOverGroup) {
const newGroupIds = [...groupIds];
const fromIdx = newGroupIds.indexOf(activeIdStr);
const toIdx = newGroupIds.indexOf(overIdStr);
if (fromIdx !== -1 && toIdx !== -1) {
const [moved] = newGroupIds.splice(fromIdx, 1);
newGroupIds.splice(toIdx, 0, moved);
const groupServices: Record<string, string[]> = {};
for (const g of dashboard.groups) groupServices[g.id] = g.services.map((s) => s.id);
updateLayout.mutate({ groupIds: newGroupIds, widgetIds, ungroupedServiceIds: dashboard.ungroupedServices.map((s) => s.id), groupServices });
}
return;
}
// Widget reorder
if (isActiveWidget && isOverWidget) {
const newWidgetIds = [...widgetIds];
const fromIdx = newWidgetIds.indexOf(activeIdStr);
const toIdx = newWidgetIds.indexOf(overIdStr);
if (fromIdx !== -1 && toIdx !== -1) {
const [moved] = newWidgetIds.splice(fromIdx, 1);
newWidgetIds.splice(toIdx, 0, moved);
const groupServices: Record<string, string[]> = {};
for (const g of dashboard.groups) groupServices[g.id] = g.services.map((s) => s.id);
updateLayout.mutate({ groupIds, widgetIds: newWidgetIds, ungroupedServiceIds: dashboard.ungroupedServices.map((s) => s.id), groupServices });
}
}
};
const handleEditService = (s: Service) => { setEditingService(s); setServiceFormOpen(true); };
const handleDeleteService = (id: string) => { if (confirm("Delete this app?")) deleteService.mutate(id); };
const handleEditGroup = (g: Group) => { setEditingGroup(g); setGroupFormOpen(true); };
const handleEditWidget = (w: WidgetInstance) => { setEditingWidget(w); setWidgetFormOpen(true); };
const handleDeleteWidget = (id: string) => { if (confirm("Delete this widget?")) deleteWidget.mutate(id); };
const openAddService = () => { setEditingService(null); setServiceFormOpen(true); };
const openAddGroup = () => { setEditingGroup(null); setGroupFormOpen(true); };
const openAddWidget = () => { setEditingWidget(null); setWidgetFormOpen(true); };
if (isLoading) {
return (
<div className="flex h-screen flex-col bg-background">
<div className="h-14 border-b border-border/50" />
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent">
<Loader2 className="h-5 w-5 animate-spin text-accent-foreground" />
</div>
<span className="text-xs text-muted-foreground font-medium">Loading dashboard...</span>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-screen flex-col bg-background">
<div className="h-14 border-b border-border/50" />
<div className="flex flex-1 flex-col items-center justify-center gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-destructive/10">
<AlertCircle className="h-6 w-6 text-destructive" />
</div>
<div className="text-center">
<p className="text-sm font-semibold text-foreground">Failed to load dashboard</p>
<p className="text-xs text-muted-foreground mt-1">{error.message}</p>
</div>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
</div>
);
}
const groups = dashboard?.groups || [];
const ungrouped = dashboard?.ungroupedServices || [];
const widgets = dashboard?.widgets || [];
const isEmpty = groups.length === 0 && ungrouped.length === 0 && widgets.length === 0;
return (
<div className="flex min-h-screen flex-col">
<Header onAddService={openAddService} onAddWidget={openAddWidget} onAddGroup={openAddGroup} />
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6">
{isEmpty ? (
<div className="flex flex-col items-center justify-center gap-6 py-32">
<div className="flex h-20 w-20 items-center justify-center rounded-[24px] bg-gradient-to-br from-secondary to-accent border border-border/50 shadow-border-card">
<LayoutGrid className="h-8 w-8 text-muted-foreground" />
</div>
<div className="text-center">
<h2 className="text-xl font-semibold text-foreground tracking-tight mb-2">Welcome to Dash</h2>
<p className="text-sm text-muted-foreground max-w-xs">Your homelab dashboard is empty. Add apps and widgets to get started.</p>
</div>
<div className="flex gap-3">
<Button onClick={openAddService} className="gap-2 rounded-xl">
<Plus className="h-4 w-4" /> Add App
</Button>
<Button onClick={openAddWidget} variant="outline" className="gap-2 rounded-xl">
<Plus className="h-4 w-4" /> Add Widget
</Button>
</div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{/* Widgets strip */}
<section className="mb-8">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-4 w-0.5 rounded-full bg-ring" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Widgets</span>
</div>
<Button variant="ghost" size="sm" onClick={openAddWidget} className="gap-1.5 text-xs rounded-lg hover:bg-accent">
<Plus className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Add Widget</span>
</Button>
</div>
{widgets.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<SortableContext items={widgets.map((w) => w.id)} strategy={rectSortingStrategy}>
{widgets.map((w) => (
<SortableWidget key={w.id} widget={w} onEdit={handleEditWidget} onDelete={handleDeleteWidget} />
))}
</SortableContext>
</div>
) : (
<button
onClick={openAddWidget}
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-card p-6 text-sm text-muted-foreground transition-all hover:border-ring/40 hover:bg-accent hover:text-foreground"
>
<Plus className="h-4 w-4" /> Add your first widget
</button>
)}
</section>
{/* Apps section */}
<section className="mb-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-4 w-0.5 rounded-full bg-ring" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Apps</span>
</div>
<div className="flex items-center gap-1">
<div className="flex items-center rounded-lg border border-border overflow-hidden mr-1 bg-card">
<button
onClick={() => setViewMode("grid")}
className={cn("px-2.5 py-1.5 text-xs transition-colors rounded-l-lg", viewMode === "grid" ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground")}
title="Grid view"
>
<LayoutGrid className="h-3.5 w-3.5" />
</button>
<div className="w-px h-3.5 bg-border/50" />
<button
onClick={() => setViewMode("list")}
className={cn("px-2.5 py-1.5 text-xs transition-colors rounded-r-lg", viewMode === "list" ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground")}
title="List view"
>
<List className="h-3.5 w-3.5" />
</button>
</div>
<Button variant="ghost" size="sm" onClick={openAddGroup} className="gap-1.5 text-xs rounded-lg hover:bg-accent">
<Plus className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Group</span>
</Button>
<Button variant="ghost" size="sm" onClick={openAddService} className="gap-1.5 text-xs rounded-lg hover:bg-accent">
<Plus className="h-3.5 w-3.5" />
<span className="hidden sm:inline">App</span>
</Button>
</div>
</div>
{/* Groups */}
<SortableContext items={groups.map((g) => g.id)} strategy={verticalListSortingStrategy}>
{groups.map((g) => (
<SortableGroup
key={g.id}
group={g}
onEditService={handleEditService}
onDeleteService={handleDeleteService}
onEditGroup={handleEditGroup}
/>
))}
</SortableContext>
{/* Ungrouped services */}
{ungrouped.length > 0 && (
<div className="mb-2">
{groups.length > 0 && (
<div className="mb-4 flex items-center gap-2">
<div className="h-4 w-0.5 rounded-full bg-ring" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Ungrouped</span>
<span className="text-xs text-muted-foreground font-mono">{ungrouped.length}</span>
</div>
)}
<SortableContext items={ungrouped.map((s) => s.id)} strategy={rectSortingStrategy}>
{viewMode === "grid" ? (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
{ungrouped.map((s) => (
<SortableService key={s.id} service={s} onEdit={handleEditService} onDelete={handleDeleteService} />
))}
<AddAppTile onClick={openAddService} />
</div>
) : (
<div className="flex flex-col gap-2">
{ungrouped.map((s) => (
<ServiceListItem key={s.id} service={s} onEdit={handleEditService} onDelete={handleDeleteService} />
))}
</div>
)}
</SortableContext>
</div>
)}
{/* In-grid add tile when no ungrouped but groups exist */}
{ungrouped.length === 0 && groups.length > 0 && (
<div className="mt-2">
<AddAppTile onClick={openAddService} />
</div>
)}
{/* No apps at all - show empty state within apps section */}
{groups.length === 0 && ungrouped.length === 0 && (
<button
onClick={openAddService}
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-card p-8 text-sm text-muted-foreground transition-all hover:border-ring/40 hover:bg-accent hover:text-foreground"
>
<Plus className="h-4 w-4" /> Add your first app
</button>
)}
</section>
<DragOverlay>
{activeId && dashboard ? (
<DashboardDragOverlay activeId={activeId} dashboard={dashboard} />
) : null}
</DragOverlay>
</DndContext>
)}
</main>
{/* Modals */}
<ServiceForm
service={editingService}
groups={groups.map((g) => ({ id: g.id, name: g.name }))}
open={serviceFormOpen}
onOpenChange={setServiceFormOpen}
/>
<GroupForm group={editingGroup} open={groupFormOpen} onOpenChange={setGroupFormOpen} />
<WidgetForm widget={editingWidget} open={widgetFormOpen} onOpenChange={setWidgetFormOpen} />
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import type { Group } from "@/lib/api/schema";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCreateGroup, useUpdateGroup } from "@/lib/api/hooks";
interface GroupFormProps {
group?: Group | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function GroupForm({ group, open, onOpenChange }: GroupFormProps) {
const isEdit = !!group;
const createMut = useCreateGroup();
const updateMut = useUpdateGroup();
const [name, setName] = useState(group?.name || "");
const [error, setError] = useState("");
const handleSubmit = async () => {
if (!name.trim()) {
setError("Name is required");
return;
}
try {
if (isEdit && group) {
await updateMut.mutateAsync({ id: group.id, name: name.trim() });
} else {
await createMut.mutateAsync({ name: name.trim() });
}
onOpenChange(false);
setName("");
setError("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? "Rename Group" : "Create Group"}</DialogTitle>
<DialogDescription>{isEdit ? "Update group name" : "Add a new group for organizing apps"}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 py-2">
<Label htmlFor="group-name">Name</Label>
<Input id="group-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Infrastructure" />
{error && <span className="text-xs text-destructive">{error}</span>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={createMut.isPending || updateMut.isPending}>
{isEdit ? "Save" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,113 @@
"use client";
import type { Group, Service } from "@/lib/api/schema";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { ServiceCard } from "@/components/services/service-card";
import { ChevronDown, MoreVertical, Pencil, Trash2, GripVertical, FolderOpen } from "lucide-react";
import { useUpdateGroup, useDeleteGroup } from "@/lib/api/hooks";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { useTheme } from "@/components/providers";
interface GroupSectionProps {
group: Group;
onEditService: (s: Service) => void;
onDeleteService: (id: string) => void;
onEditGroup: (g: Group) => void;
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
}
export function GroupSection({ group, onEditService, onDeleteService, onEditGroup, dragHandleProps }: GroupSectionProps) {
const updateGroup = useUpdateGroup();
const deleteGroup = useDeleteGroup();
const [open, setOpen] = useState(!group.collapsed);
const { theme } = useTheme();
const isCasaOS = theme === "casaos";
const handleToggle = () => {
const next = !open;
setOpen(next);
updateGroup.mutate({ id: group.id, collapsed: !next });
};
const handleDelete = () => {
if (group.services.length > 0) {
deleteGroup.mutate({ id: group.id, moveServices: true });
} else {
deleteGroup.mutate({ id: group.id });
}
};
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className={cn("mb-5 rounded-2xl group/group", isCasaOS && "bg-card border border-border")}>
{/* Group header */}
<div className="flex items-center gap-2 px-3 py-2.5">
{dragHandleProps && (
<div
{...dragHandleProps}
className="cursor-grab rounded-md p-1 opacity-0 transition-opacity hover:bg-accent group-hover/group:opacity-60"
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
)}
<CollapsibleTrigger asChild>
<button
className="flex flex-1 items-center gap-2.5 group/title min-w-0"
onClick={handleToggle}
>
<div className={cn(
"flex h-7 w-7 items-center justify-center rounded-lg transition-colors",
isCasaOS ? "bg-white/10" : "bg-accent"
)}>
<FolderOpen className={cn("h-3.5 w-3.5", isCasaOS ? "text-blue-300" : "text-accent-foreground")} />
</div>
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold truncate">{group.name}</span>
<span className="text-xs text-muted-foreground font-mono">{group.services.length}</span>
</div>
<ChevronDown className={cn(
"ml-auto h-4 w-4 text-muted-foreground transition-transform duration-200 shrink-0",
!open && "-rotate-90"
)} />
</button>
</CollapsibleTrigger>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg shrink-0 hover:bg-accent">
<MoreVertical className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 rounded-xl">
<DropdownMenuItem onClick={() => onEditGroup(group)} className="gap-2 text-xs">
<Pencil className="h-3.5 w-3.5" /> Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 text-xs text-destructive" onClick={handleDelete}>
<Trash2 className="h-3.5 w-3.5" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Divider */}
<div className={cn("mx-3 h-px", isCasaOS ? "bg-white/5" : "bg-border/40")} />
{/* Services grid */}
<CollapsibleContent>
<div className="p-3 pt-2">
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
{group.services.map((s) => (
<ServiceCard key={s.id} service={s} onEdit={onEditService} onDelete={onDeleteService} />
))}
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useState, useEffect } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip";
import { getQueryClient } from "@/lib/api/query-client";
import { Theme, getStoredTheme, setStoredTheme, applyTheme } from "@/lib/theme/themes";
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [theme, setTheme] = useState<Theme>("dark");
const [mswReady, setMswReady] = useState(false);
useEffect(() => {
const stored = getStoredTheme();
setTheme(stored);
applyTheme(stored);
}, []);
useEffect(() => {
if (process.env.NODE_ENV === "development" && process.env.NEXT_PUBLIC_API_BASE_URL === undefined) {
import("@/lib/mocks/browser").then(({ installMocks }) => {
installMocks();
setMswReady(true);
});
} else {
setMswReady(true);
}
}, []);
const changeTheme = (t: Theme) => {
setTheme(t);
setStoredTheme(t);
applyTheme(t);
};
if (!mswReady) {
return (
<div className="flex h-screen items-center justify-center bg-background text-foreground">
<span className="font-mono text-xs">[LOADING...]</span>
</div>
);
}
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={300}>
<ThemeContext.Provider value={{ theme, setTheme: changeTheme }}>
{children}
</ThemeContext.Provider>
</TooltipProvider>
</QueryClientProvider>
);
}
import { createContext, useContext } from "react";
type ThemeContextType = { theme: Theme; setTheme: (t: Theme) => void };
export const ThemeContext = createContext<ThemeContextType>({ theme: "dark", setTheme: () => {} });
export function useTheme() {
return useContext(ThemeContext);
}
@@ -0,0 +1,285 @@
"use client";
import { useState, useEffect } from "react";
import type { Service, ServiceUrl } from "@/lib/api/schema";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { MoreVertical, ExternalLink, Pencil, Trash2, GripVertical, Globe, Home, Settings } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTheme } from "@/components/providers";
function getInitials(name: string) {
const words = name.trim().split(/\s+/);
if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
function extractHost(url: string) {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
function getIconUrl(service: Service) {
if (service.iconUrl) return service.iconUrl;
if (service.iconAssetId) return `/uploads/icons/${service.iconAssetId}`;
return null;
}
function kindIcon(kind: string) {
switch (kind) {
case "local": return <Home className="h-3 w-3" />;
case "external": return <Globe className="h-3 w-3" />;
default: return <Settings className="h-3 w-3" />;
}
}
function kindBadgeClass(kind: string) {
switch (kind) {
case "local": return "badge-local";
case "external": return "badge-external";
default: return "badge-custom";
}
}
function useServicePing(url: string | undefined) {
const [status, setStatus] = useState<"up" | "down" | "unknown">("unknown");
useEffect(() => {
if (!url) return;
let cancelled = false;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
fetch(url, { method: "HEAD", mode: "no-cors", signal: controller.signal })
.then(() => { if (!cancelled) setStatus("up"); })
.catch(() => { if (!cancelled) setStatus("down"); })
.finally(() => clearTimeout(timer));
return () => { cancelled = true; controller.abort(); };
}, [url]);
return status;
}
function StatusDot({ status }: { status: "up" | "down" | "unknown" }) {
if (status === "unknown") return null;
return (
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-card",
status === "up" && "bg-emerald-500",
status === "down" && "bg-red-500"
)}
title={status === "up" ? "Online" : "Offline"}
/>
);
}
function UrlPickerDialog({
urls,
open,
onOpenChange,
}: {
urls: ServiceUrl[];
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Open App</DialogTitle>
<DialogDescription>Choose which URL to open</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
{urls.map((u) => (
<a
key={u.id}
href={u.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between rounded-xl border border-border bg-card px-4 py-3 text-sm transition-all hover:bg-accent hover:border-border"
onClick={() => onOpenChange(false)}
>
<div className="flex items-center gap-3 min-w-0">
<Badge variant="secondary" className={cn("gap-1 text-[10px] px-2 py-0.5 font-medium uppercase", kindBadgeClass(u.kind))}>
{kindIcon(u.kind)}
{u.kind}
</Badge>
<span className="font-medium truncate">{u.label}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-muted-foreground hidden sm:inline">{extractHost(u.url)}</span>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</a>
))}
</div>
</DialogContent>
</Dialog>
);
}
export function ServiceCard({
service,
onEdit,
onDelete,
isDragging = false,
dragHandleProps,
}: {
service: Service;
onEdit: (s: Service) => void;
onDelete: (id: string) => void;
isDragging?: boolean;
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const { theme } = useTheme();
const isCasaOS = theme === "casaos";
const handleClick = () => {
if (service.urls.length === 1) {
window.open(service.urls[0].url, "_blank", "noopener,noreferrer");
} else {
setPickerOpen(true);
}
};
const iconSrc = getIconUrl(service);
const primaryUrl = service.urls.find((u) => u.isPrimary) || service.urls[0];
const status = useServicePing(primaryUrl?.url);
return (
<>
<Card
className={cn(
"service-card group relative cursor-pointer overflow-hidden",
isCasaOS
? "aspect-square rounded-[24px] border border-border bg-card shadow-[0_4px_16px_rgba(0,0,0,0.2)] hover:shadow-[0_8px_32px_rgba(0,0,0,0.3)] hover:bg-accent"
: "aspect-square rounded-2xl border border-border bg-card shadow-[0px_0px_0px_1px_var(--color-border)] hover:bg-accent hover:shadow-border-hover",
isDragging && "drag-overlay",
)}
onClick={handleClick}
>
{/* Gradient accent line at top */}
<div className={cn(
"absolute top-0 left-0 right-0 h-[2px] opacity-0 group-hover:opacity-100 transition-opacity",
isCasaOS ? "bg-gradient-to-r from-blue-400/60 via-purple-400/60 to-pink-400/60" : "bg-gradient-to-r from-ring/60 to-ring/20"
)} />
<div className="flex h-full flex-col items-center justify-center gap-2.5 p-4">
{dragHandleProps && (
<div
{...dragHandleProps}
className="absolute left-2 top-2 cursor-grab rounded-md p-1 opacity-0 transition-all group-hover:opacity-60 hover:opacity-100 hover:bg-accent"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className={cn("text-muted-foreground", isCasaOS ? "h-5 w-5" : "h-4 w-4")} />
</div>
)}
{/* Icon container */}
<div className={cn(
"relative flex items-center justify-center transition-transform duration-300 group-hover:scale-110",
isCasaOS ? "h-[52px] w-[52px]" : "h-12 w-12"
)}>
{iconSrc ? (
<img
src={iconSrc}
alt={service.name}
className={cn("h-full w-full object-contain drop-shadow-lg", isCasaOS ? "rounded-2xl" : "rounded-xl")}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
(e.target as HTMLImageElement).nextElementSibling?.classList.remove("hidden");
}}
/>
) : null}
<div
className={cn(
"flex h-full w-full items-center justify-center rounded-xl font-mono font-bold text-secondary-foreground",
isCasaOS
? "bg-gradient-to-br from-blue-500/20 to-purple-500/20 text-lg border border-white/10"
: "bg-secondary text-sm",
iconSrc && "hidden",
)}
>
{getInitials(service.name)}
</div>
<StatusDot status={status} />
</div>
{/* App name */}
<span className={cn(
"max-w-full truncate text-center font-semibold leading-tight",
isCasaOS ? "text-sm text-white/90" : "text-xs text-foreground"
)}>
{service.name}
</span>
{/* URL indicator */}
{primaryUrl && (
<span className="text-[10px] text-muted-foreground truncate max-w-full hidden sm:block">
{extractHost(primaryUrl.url)}
</span>
)}
{/* URL kind badges */}
{service.urls.length > 1 && (
<div className="flex gap-1">
{service.urls.slice(0, 3).map((u) => (
<span
key={u.id}
className={cn(
"text-[9px] px-1.5 py-0.5 rounded-full font-medium uppercase tracking-wider",
kindBadgeClass(u.kind)
)}
>
{u.kind}
</span>
))}
</div>
)}
</div>
{/* Actions */}
<div
className="absolute right-2 top-2 opacity-0 transition-all group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 rounded-lg",
isCasaOS ? "text-white/50 hover:text-white hover:bg-white/10" : "hover:bg-accent"
)}
>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 rounded-xl">
<DropdownMenuItem onClick={() => onEdit(service)} className="gap-2 text-xs">
<Pencil className="h-3.5 w-3.5" /> Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 text-xs text-destructive" onClick={() => onDelete(service.id)}>
<Trash2 className="h-3.5 w-3.5" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Card>
{service.urls.length > 1 && (
<UrlPickerDialog urls={service.urls} open={pickerOpen} onOpenChange={setPickerOpen} />
)}
</>
);
}
@@ -0,0 +1,204 @@
"use client";
import { useState, useRef } from "react";
import type { Service, ServiceUrlInput, ServiceRequest } from "@/lib/api/schema";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, Upload, Star } from "lucide-react";
import { useCreateService, useUpdateService, useUploadIcon } from "@/lib/api/hooks";
interface ServiceFormProps {
service?: Service | null;
groups: { id: string; name: string }[];
open: boolean;
onOpenChange: (open: boolean) => void;
}
const EMPTY_URL: ServiceUrlInput = { label: "", kind: "local", url: "", isPrimary: false };
export function ServiceForm({ service, groups, open, onOpenChange }: ServiceFormProps) {
const isEdit = !!service;
const createMut = useCreateService();
const updateMut = useUpdateService();
const uploadMut = useUploadIcon();
const [name, setName] = useState(service?.name || "");
const [groupId, setGroupId] = useState<string | null>(service?.groupId || null);
const [iconUrl, setIconUrl] = useState(service?.iconUrl || "");
const [iconAssetId, setIconAssetId] = useState<string | null>(service?.iconAssetId || null);
const [iconMode, setIconMode] = useState<"url" | "upload">("url");
const [urls, setUrls] = useState<ServiceUrlInput[]>(
service?.urls?.map((u) => ({ id: u.id, label: u.label, kind: u.kind, url: u.url, isPrimary: u.isPrimary })) || [{ ...EMPTY_URL, isPrimary: true }],
);
const [errors, setErrors] = useState<Record<string, string>>({});
const fileRef = useRef<HTMLInputElement>(null);
const addUrl = () => setUrls((prev) => [...prev, { ...EMPTY_URL }]);
const removeUrl = (idx: number) => setUrls((prev) => prev.filter((_, i) => i !== idx));
const updateUrl = (idx: number, field: keyof ServiceUrlInput, value: string | boolean) => {
setUrls((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: value };
if (field === "isPrimary" && value === true) {
next.forEach((u, i) => {
if (i !== idx) u.isPrimary = false;
});
}
return next;
});
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const asset = await uploadMut.mutateAsync(file);
setIconAssetId(asset.id);
setIconUrl("");
} catch {
setErrors((prev) => ({ ...prev, icon: "Upload failed" }));
}
};
const validate = (): boolean => {
const e: Record<string, string> = {};
if (!name.trim()) e.name = "Name is required";
if (urls.length === 0) e.urls = "At least one URL is required";
urls.forEach((u, i) => {
if (!u.label.trim()) e[`url-label-${i}`] = "Label required";
if (!u.url.trim()) e[`url-${i}`] = "URL required";
else if (!/^https?:\/\//.test(u.url)) e[`url-${i}`] = "Must be http(s)";
});
setErrors(e);
return Object.keys(e).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
const body: ServiceRequest = {
name: name.trim(),
groupId,
iconUrl: iconMode === "url" && iconUrl ? iconUrl : null,
iconAssetId: iconMode === "upload" && iconAssetId ? iconAssetId : null,
urls: urls.map((u) => ({ label: u.label.trim(), kind: u.kind, url: u.url.trim(), isPrimary: u.isPrimary })),
};
try {
if (isEdit && service) {
await updateMut.mutateAsync({ id: service.id, ...body });
} else {
await createMut.mutateAsync(body);
}
onOpenChange(false);
} catch (err) {
setErrors({ submit: err instanceof Error ? err.message : "Failed" });
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit App" : "Add App"}</DialogTitle>
<DialogDescription>{isEdit ? "Update app details" : "Add a new app to your dashboard"}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Jellyfin" />
{errors.name && <span className="text-xs text-destructive">{errors.name}</span>}
</div>
<div className="flex flex-col gap-1.5">
<Label>Icon</Label>
<div className="flex gap-2">
<Button type="button" variant={iconMode === "url" ? "secondary" : "ghost"} size="sm" onClick={() => setIconMode("url")}>
URL
</Button>
<Button type="button" variant={iconMode === "upload" ? "secondary" : "ghost"} size="sm" onClick={() => setIconMode("upload")}>
Upload
</Button>
</div>
{iconMode === "url" ? (
<Input value={iconUrl} onChange={(e) => setIconUrl(e.target.value)} placeholder="https://example.com/icon.png" />
) : (
<div className="flex items-center gap-2">
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFileUpload} />
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
<Upload className="h-3 w-3" /> Choose file
</Button>
{iconAssetId && <span className="text-xs text-muted-foreground">Uploaded</span>}
</div>
)}
{errors.icon && <span className="text-xs text-destructive">{errors.icon}</span>}
</div>
<div className="flex flex-col gap-1.5">
<Label>Group</Label>
<Select value={groupId || "__none__"} onValueChange={(v: string) => setGroupId(v === "__none__" ? null : v)}>
<SelectTrigger><SelectValue placeholder="No group" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No group</SelectItem>
{groups.map((g) => (
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label>URLs</Label>
<Button type="button" variant="ghost" size="sm" onClick={addUrl}>
<Plus className="h-3 w-3" /> Add URL
</Button>
</div>
{urls.map((u, i) => (
<div key={i} className="flex flex-col gap-1.5 rounded-md border border-border p-2">
<div className="flex items-center gap-2">
<Input className="flex-1" value={u.label} onChange={(e) => updateUrl(i, "label", e.target.value)} placeholder="Label" />
<Select value={u.kind} onValueChange={(v: string) => updateUrl(i, "kind", v)}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="external">External</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeUrl(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-2">
<Input className="flex-1" value={u.url} onChange={(e) => updateUrl(i, "url", e.target.value)} placeholder="https://" />
<Button
type="button"
variant={u.isPrimary ? "secondary" : "ghost"}
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => updateUrl(i, "isPrimary", !u.isPrimary)}
title="Primary URL"
>
<Star className={u.isPrimary ? "h-3 w-3 fill-current" : "h-3 w-3"} />
</Button>
</div>
{errors[`url-label-${i}`] && <span className="text-xs text-destructive">{errors[`url-label-${i}`]}</span>}
{errors[`url-${i}`] && <span className="text-xs text-destructive">{errors[`url-${i}`]}</span>}
</div>
))}
</div>
{errors.submit && <span className="text-xs text-destructive">{errors.submit}</span>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={createMut.isPending || updateMut.isPending}>
{isEdit ? "Save" : "Add App"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { ThemeToggle } from "./theme-toggle";
import { Button } from "@/components/ui/button";
import { Plus, LayoutGrid, AppWindow, Puzzle } from "lucide-react";
import { useState, useEffect } from "react";
export function Header({
onAddService,
onAddWidget,
onAddGroup,
}: {
onAddService: () => void;
onAddWidget: () => void;
onAddGroup: () => void;
}) {
const [now, setNow] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const timeStr = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const dateStr = now.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
return (
<header className="sticky top-0 z-40 w-full border-b border-border bg-background">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-secondary">
<LayoutGrid className="h-4 w-4 text-primary" />
</div>
<span className="text-sm font-semibold tracking-tight text-foreground">
Dash
</span>
</div>
<div className="hidden h-4 w-px bg-border sm:block" />
<div className="hidden items-center gap-2 sm:flex">
<span className="text-xs text-muted-foreground">
{dateStr}
</span>
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{timeStr}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={onAddWidget} className="gap-1.5 text-xs text-muted-foreground hover:text-foreground">
<Puzzle className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Widget</span>
</Button>
<Button variant="ghost" size="sm" onClick={onAddGroup} className="gap-1.5 text-xs text-muted-foreground hover:text-foreground">
<AppWindow className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Group</span>
</Button>
<Button variant="default" size="sm" onClick={onAddService} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" />
<span className="hidden sm:inline">App</span>
</Button>
<div className="ml-1 h-4 w-px bg-border" />
<ThemeToggle />
</div>
</div>
</header>
);
}
@@ -0,0 +1,48 @@
"use client";
import { useTheme } from "@/components/providers";
import { themeLabels, type Theme } from "@/lib/theme/themes";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Sun, Moon, Sparkles, Check } from "lucide-react";
import { cn } from "@/lib/utils";
const themeIcons: Record<Theme, React.ReactNode> = {
light: <Sun className="h-4 w-4" />,
dark: <Moon className="h-4 w-4" />,
casaos: <Sparkles className="h-4 w-4" />,
};
const themeDot: Record<Theme, string> = {
light: "bg-amber-400",
dark: "bg-indigo-400",
casaos: "bg-pink-400",
};
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-lg hover:bg-accent relative" aria-label="Toggle theme">
<div className="relative">
{themeIcons[theme]}
<span className={cn("absolute -bottom-0.5 -right-0.5 h-1.5 w-1.5 rounded-full border-2 border-background", themeDot[theme])} />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 rounded-xl">
{(["light", "dark", "casaos"] as Theme[]).map((t) => (
<DropdownMenuItem key={t} onClick={() => setTheme(t)} className={cn("gap-2.5 rounded-lg cursor-pointer", theme === t && "bg-accent")}>
<span className={cn("flex h-5 w-5 items-center justify-center rounded-md", theme === t ? "text-foreground" : "text-muted-foreground")}>
{themeIcons[t]}
</span>
<span className="text-sm">{themeLabels[t]}</span>
{theme === t && <Check className="ml-auto h-3.5 w-3.5 text-foreground" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+40
View File
@@ -0,0 +1,40 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-3 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: { variant: "default" },
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
),
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
),
);
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
local: "border-transparent bg-blue-500/15 text-blue-500",
external: "border-transparent bg-emerald-500/15 text-emerald-500",
custom: "border-transparent bg-amber-500/15 text-amber-500",
},
},
defaultVariants: { variant: "default" },
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg bg-card text-card-foreground shadow-border-card", className)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col gap-1.5 p-4", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm font-medium leading-none", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-xs text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-4 pt-0", className)} {...props} />
),
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-4 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+14
View File
@@ -0,0 +1,14 @@
"use client";
import * as React from "react";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = React.forwardRef<
React.ComponentRef<typeof CollapsiblePrimitive.CollapsibleContent>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
>(({ ...props }, ref) => <CollapsiblePrimitive.CollapsibleContent ref={ref} {...props} />);
CollapsibleContent.displayName = "CollapsibleContent";
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+69
View File
@@ -0,0 +1,69 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn("flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", className)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b border-border px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn("flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", className)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List ref={ref} className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden p-1", className)} {...props} />
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandItem = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn("relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", className)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandEmpty = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group ref={ref} className={cn("overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", className)} {...props} />
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
export { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup };
+78
View File
@@ -0,0 +1,78 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border bg-popover p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full p-1 opacity-70 transition-all hover:opacity-100 hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col gap-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };
+54
View File
@@ -0,0 +1,54 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuGroup, DropdownMenuSub };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
+19
View File
@@ -0,0 +1,19 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn("text-xs font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+29
View File
@@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };
+41
View File
@@ -0,0 +1,41 @@
"use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
+82
View File
@@ -0,0 +1,82 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-border bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}
>
{children}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectItem = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn("relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem };
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & { orientation?: "horizontal" | "vertical" }>(
({ className, orientation = "horizontal", ...props }, ref) => (
<div
ref={ref}
role="separator"
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
{...props}
/>
),
);
Separator.displayName = "Separator";
export { Separator };
+87
View File
@@ -0,0 +1,87 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Sheet = DialogPrimitive.Root;
const SheetTrigger = DialogPrimitive.Trigger;
const SheetClose = DialogPrimitive.Close;
const SheetPortal = DialogPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
className={cn("fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom: "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right: "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: { side: "right" },
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ComponentRef<typeof DialogPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{children}
</DialogPrimitive.Content>
</SheetPortal>
),
);
SheetContent.displayName = DialogPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
));
SheetTitle.displayName = DialogPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
SheetDescription.displayName = DialogPrimitive.Description.displayName;
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
+28
View File
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ComponentRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
));
Switch.displayName = SwitchPrimitive.Root.displayName;
export { Switch };
+48
View File
@@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn("inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground", className)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn("mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+27
View File
@@ -0,0 +1,27 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+251
View File
@@ -0,0 +1,251 @@
"use client";
import type { WidgetInstance, WidgetData } from "@/lib/api/schema";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { MoreVertical, RefreshCw, Pencil, Trash2, GripVertical, Clock, Shield, ImageIcon, StickyNote, Camera, Activity } from "lucide-react";
import { useWidgetData, useRefreshWidget } from "@/lib/api/hooks";
import { cn } from "@/lib/utils";
import { useTheme } from "@/components/providers";
const widgetTypeIcons: Record<string, React.ReactNode> = {
clock: <Clock className="h-3.5 w-3.5" />,
pihole: <Shield className="h-3.5 w-3.5" />,
image: <ImageIcon className="h-3.5 w-3.5" />,
memos: <StickyNote className="h-3.5 w-3.5" />,
immich: <Camera className="h-3.5 w-3.5" />,
};
const widgetTypeColors: Record<string, string> = {
clock: "from-blue-500/20 to-cyan-500/20",
pihole: "from-emerald-500/20 to-teal-500/20",
image: "from-purple-500/20 to-pink-500/20",
memos: "from-amber-500/20 to-orange-500/20",
immich: "from-rose-500/20 to-red-500/20",
};
export function WidgetCard({
widget,
onEdit,
onDelete,
dragHandleProps,
}: {
widget: WidgetInstance;
onEdit: (w: WidgetInstance) => void;
onDelete: (id: string) => void;
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
}) {
const { data, isLoading, error } = useWidgetData(widget.id);
const refreshMut = useRefreshWidget();
const { theme } = useTheme();
const isCasaOS = theme === "casaos";
const handleRefresh = () => refreshMut.mutate(widget.id);
const statusLabel = data?.status === "stale" ? "stale" : data?.status === "error" ? "error" : "";
const typeIcon = widgetTypeIcons[widget.type] || <Activity className="h-3.5 w-3.5" />;
const typeGradient = widgetTypeColors[widget.type] || "from-muted to-muted";
return (
<Card className={cn(
"group relative border-0 overflow-hidden",
isCasaOS
? "rounded-[20px] bg-card border border-border shadow-[0_4px_16px_rgba(0,0,0,0.15)] hover:shadow-[0_8px_32px_rgba(0,0,0,0.25)] hover:-translate-y-[2px] transition-all duration-300"
: "rounded-2xl shadow-[0px_0px_0px_1px_var(--color-border)] hover:shadow-border-hover transition-all duration-200"
)}>
<div className={cn(
"absolute top-0 left-0 right-0 h-1 opacity-60",
isCasaOS ? `bg-gradient-to-r ${typeGradient}` : "bg-gradient-to-r from-ring/40 to-transparent"
)} />
<CardHeader className={cn("flex flex-row items-center justify-between pt-4 pb-2", isCasaOS ? "px-5" : "px-4")}>
<div className="flex items-center gap-2.5 min-w-0">
{dragHandleProps && (
<div {...dragHandleProps} className="cursor-grab opacity-0 group-hover:opacity-60 transition-opacity rounded-md p-0.5 hover:bg-accent">
<GripVertical className={cn("text-muted-foreground", isCasaOS ? "h-5 w-5" : "h-4 w-4")} />
</div>
)}
<div className={cn("flex h-6 w-6 items-center justify-center rounded-md shrink-0", isCasaOS ? "bg-white/10" : "bg-accent")}>
{typeIcon}
</div>
<div className="flex items-center gap-1.5 min-w-0">
<CardTitle className="text-xs font-semibold uppercase tracking-wide truncate">
{widget.title}
</CardTitle>
{statusLabel && (
<span className={cn(
"text-[9px] px-1.5 py-0.5 rounded-full font-medium uppercase shrink-0",
statusLabel === "stale" ? "bg-amber-500/15 text-amber-400" : "bg-destructive/15 text-destructive"
)}>
{statusLabel}
</span>
)}
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button variant="ghost" size="icon" className={cn("relative z-10 pointer-events-auto rounded-lg h-7 w-7", isCasaOS ? "text-white/50 hover:text-white hover:bg-white/10" : "hover:bg-accent")} onClick={handleRefresh} disabled={refreshMut.isPending}>
<RefreshCw className={cn(refreshMut.isPending && "animate-spin", isCasaOS ? "h-4 w-4" : "h-3.5 w-3.5")} />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className={cn("rounded-lg h-7 w-7", isCasaOS ? "text-white/50 hover:text-white hover:bg-white/10" : "hover:bg-accent")}>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 rounded-xl">
<DropdownMenuItem onClick={() => onEdit(widget)} className="gap-2 text-xs">
<Pencil className="h-3.5 w-3.5" /> Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 text-xs text-destructive" onClick={() => onDelete(widget.id)}>
<Trash2 className="h-3.5 w-3.5" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className={cn(isCasaOS ? "px-5 pb-5 pt-1" : "px-4 pb-4 pt-1")}>
{isLoading ? (
<span className="font-mono text-xs text-muted-foreground">[LOADING...]</span>
) : error || data?.status === "error" ? (
<span className="font-mono text-xs text-destructive">[ERROR: {data?.error || "Failed to load"}]</span>
) : (
<WidgetContent widget={widget} data={data} />
)}
</CardContent>
</Card>
);
}
function WidgetContent({ widget, data }: { widget: WidgetInstance; data?: WidgetData }) {
switch (widget.type) {
case "clock":
return <ClockContent config={widget.config} data={data} />;
case "image":
return <ImageContent config={widget.config} />;
case "pihole":
return <PiHoleContent data={data} />;
case "memos":
return <MemosContent data={data} />;
case "immich":
return <ImmichContent data={data} />;
default:
return <span className="font-mono text-xs text-muted-foreground">Unknown widget type</span>;
}
}
function ClockContent({ config }: { config: Record<string, unknown>; data?: WidgetData }) {
const timezones = (config.timezones as string[]) || [];
const now = new Date();
const localTime = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const localDate = now.toLocaleDateString([], { weekday: "long", month: "long", day: "numeric" });
return (
<div className="flex flex-col gap-2">
<div className="font-mono text-3xl tabular-nums tracking-tight text-foreground">{localTime}</div>
<div className="text-xs text-muted-foreground font-medium">{localDate}</div>
{timezones.length > 0 && (
<div className="mt-2 flex flex-col gap-1.5 border-t border-border/30 pt-2">
{timezones.map((tz) => {
try {
const t = new Date().toLocaleTimeString([], { timeZone: tz, hour: "2-digit", minute: "2-digit" });
return (
<div key={tz} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground text-[11px]">{tz.split("/").pop()?.replace("_", " ")}</span>
<span className="font-mono tabular-nums text-foreground">{t}</span>
</div>
);
} catch {
return null;
}
})}
</div>
)}
</div>
);
}
function ImageContent({ config }: { config: Record<string, unknown> }) {
const imageUrl = config.imageUrl as string;
const linkUrl = config.linkUrl as string | null;
const img = (
<img
src={imageUrl}
alt="Widget image"
className="max-h-48 w-full rounded-xl object-cover border border-border/20 shadow-sm"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
);
if (linkUrl) {
return <a href={linkUrl} target="_blank" rel="noopener noreferrer" className="block rounded-xl overflow-hidden">{img}</a>;
}
return img;
}
function PiHoleContent({ data }: { data?: WidgetData }) {
const d = data?.data as Record<string, unknown> | undefined;
if (!d) return <span className="font-mono text-xs text-muted-foreground">No data</span>;
return (
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-emerald-500/10 p-2.5">
<div className="text-[10px] uppercase tracking-wider text-emerald-400 font-medium mb-0.5">Status</div>
<div className={cn("text-sm font-semibold", d.status === "enabled" ? "text-emerald-400" : "text-destructive")}>
{String(d.status || "unknown")}
</div>
</div>
<div className="rounded-lg bg-blue-500/10 p-2.5">
<div className="text-[10px] uppercase tracking-wider text-blue-400 font-medium mb-0.5">Blocked</div>
<div className="font-mono text-sm font-semibold text-foreground">{String(d.ads_blocked_today || "0")}</div>
</div>
<div className="rounded-lg bg-purple-500/10 p-2.5">
<div className="text-[10px] uppercase tracking-wider text-purple-400 font-medium mb-0.5">Queries</div>
<div className="font-mono text-sm font-semibold text-foreground">{String(d.dns_queries_today || "0")}</div>
</div>
<div className="rounded-lg bg-amber-500/10 p-2.5">
<div className="text-[10px] uppercase tracking-wider text-amber-400 font-medium mb-0.5">% Blocked</div>
<div className="font-mono text-sm font-semibold text-foreground">{String(d.ads_percentage_today || "0")}%</div>
</div>
</div>
);
}
function MemosContent({ data }: { data?: WidgetData }) {
const d = data?.data as Record<string, unknown> | undefined;
const memos = (d?.memos as Array<Record<string, unknown>>) || [];
if (memos.length === 0) return <span className="font-mono text-xs text-muted-foreground">No memos</span>;
return (
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto pr-1">
{memos.slice(0, 5).map((m, i) => (
<div key={i} className="rounded-lg bg-amber-500/10 p-2.5 border border-amber-500/10">
<div className="text-[11px] leading-relaxed line-clamp-2 text-foreground/90">
{String(m.content || m.snippet || "")}
</div>
</div>
))}
</div>
);
}
function ImmichContent({ data }: { data?: WidgetData }) {
const d = data?.data as Record<string, unknown> | undefined;
if (!d) return <span className="font-mono text-xs text-muted-foreground">No data</span>;
return (
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-blue-500/10 p-2.5">
<div className="text-[10px] uppercase tracking-wider text-blue-400 font-medium mb-0.5">Photos</div>
<div className="font-mono text-sm font-semibold text-foreground">{String(d.photos || "0")}</div>
</div>
<div className="rounded-lg bg-rose-500/10 p-2.5">
<div className="text-[10px] uppercase tracking-wider text-rose-400 font-medium mb-0.5">Videos</div>
<div className="font-mono text-sm font-semibold text-foreground">{String(d.videos || "0")}</div>
</div>
</div>
);
}
+247
View File
@@ -0,0 +1,247 @@
"use client";
import { useState } from "react";
import type { WidgetInstance, WidgetRequest } from "@/lib/api/schema";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useCreateWidget, useUpdateWidget } from "@/lib/api/hooks";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
const POPULAR_TIMEZONES = [
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
"America/Anchorage", "Pacific/Honolulu", "America/Sao_Paulo", "America/Argentina/Buenos_Aires",
"Europe/London", "Europe/Paris", "Europe/Berlin", "Europe/Prague", "Europe/Moscow",
"Asia/Dubai", "Asia/Kolkata", "Asia/Bangkok", "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul",
"Australia/Sydney", "Australia/Melbourne", "Pacific/Auckland", "UTC",
];
const WIDGET_TYPES = ["clock", "image", "pihole", "memos", "immich"] as const;
interface WidgetFormProps {
widget?: WidgetInstance | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function WidgetForm({ widget, open, onOpenChange }: WidgetFormProps) {
const isEdit = !!widget;
const createMut = useCreateWidget();
const updateMut = useUpdateWidget();
const [type, setType] = useState<string>(widget?.type || "clock");
const [title, setTitle] = useState(widget?.title || "");
const [enabled, setEnabled] = useState(widget?.enabled ?? true);
const [selectedTzs, setSelectedTzs] = useState<string[]>(
(widget?.config?.timezones as string[]) || [],
);
const [tzPopoverOpen, setTzPopoverOpen] = useState(false);
const [imageUrl, setImageUrl] = useState((widget?.config?.imageUrl as string) || "");
const [linkUrl, setLinkUrl] = useState((widget?.config?.linkUrl as string) || "");
const [piholeBaseUrl, setPiholeBaseUrl] = useState((widget?.config?.baseUrl as string) || "");
const [piholeApiToken, setPiholeApiToken] = useState((widget?.config?.apiToken as string) || "");
const [memosBaseUrl, setMemosBaseUrl] = useState((widget?.config?.baseUrl as string) || "");
const [memosApiToken, setMemosApiToken] = useState((widget?.config?.apiToken as string) || "");
const [memosPageSize, setMemosPageSize] = useState(String((widget?.config?.pageSize as number) || 5));
const [immichBaseUrl, setImmichBaseUrl] = useState((widget?.config?.baseUrl as string) || "");
const [immichApiKey, setImmichApiKey] = useState((widget?.config?.apiKey as string) || "");
const [error, setError] = useState("");
const buildConfig = (): Record<string, unknown> => {
switch (type) {
case "clock":
return { timezones: selectedTzs };
case "image":
return { imageUrl, linkUrl: linkUrl || null };
case "pihole":
return { baseUrl: piholeBaseUrl, apiToken: piholeApiToken };
case "memos":
return { baseUrl: memosBaseUrl, apiToken: memosApiToken, pageSize: parseInt(memosPageSize) || 5 };
case "immich":
return { baseUrl: immichBaseUrl, apiKey: immichApiKey };
default:
return {};
}
};
const handleSubmit = async () => {
if (!title.trim()) { setError("Title is required"); return; }
if ((type === "pihole" || type === "memos") && !piholeBaseUrl && !memosBaseUrl) {
setError("Base URL is required");
return;
}
if (type === "immich" && !immichBaseUrl) {
setError("Base URL is required");
return;
}
if (type === "image" && !imageUrl) { setError("Image URL is required"); return; }
const body: WidgetRequest = {
type: type as WidgetRequest["type"],
title: title.trim(),
enabled,
config: buildConfig() as WidgetRequest["config"],
};
try {
if (isEdit && widget) {
await updateMut.mutateAsync({ id: widget.id, ...body });
} else {
await createMut.mutateAsync(body);
}
onOpenChange(false);
setError("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit Widget" : "Add Widget"}</DialogTitle>
<DialogDescription>{isEdit ? "Update widget settings" : "Add a new widget to your dashboard"}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-1.5">
<Label>Type</Label>
<Select value={type} onValueChange={setType} disabled={isEdit}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{WIDGET_TYPES.map((t) => (
<SelectItem key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="widget-title">Title</Label>
<Input id="widget-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="My Widget" />
</div>
<div className="flex items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} />
<Label>Enabled</Label>
</div>
{type === "clock" && (
<div className="flex flex-col gap-1.5">
<Label>Timezones</Label>
<div className="flex flex-wrap gap-1 mb-1">
{selectedTzs.map((tz) => (
<Badge key={tz} variant="secondary" className="gap-1 text-xs">
{tz.split("/").pop()?.replace("_", " ")}
<button
type="button"
className="ml-0.5 rounded-full hover:bg-foreground/10"
onClick={() => setSelectedTzs((prev) => prev.filter((t) => t !== tz))}
>
<X className="h-2.5 w-2.5" />
</button>
</Badge>
))}
</div>
<Popover open={tzPopoverOpen} onOpenChange={setTzPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" type="button" className="justify-between text-xs font-normal">
Add timezone
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<Command>
<CommandInput placeholder="Search timezone…" />
<CommandList>
<CommandEmpty>No timezone found.</CommandEmpty>
<CommandGroup>
{POPULAR_TIMEZONES.filter((tz) => !selectedTzs.includes(tz)).map((tz) => (
<CommandItem
key={tz}
value={tz}
onSelect={() => {
setSelectedTzs((prev) => [...prev, tz]);
setTzPopoverOpen(false);
}}
>
<Check className={cn("mr-2 h-3 w-3", selectedTzs.includes(tz) ? "opacity-100" : "opacity-0")} />
{tz}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{type === "image" && (
<>
<div className="flex flex-col gap-1.5">
<Label>Image URL</Label>
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} placeholder="https://example.com/image.jpg" />
</div>
<div className="flex flex-col gap-1.5">
<Label>Link URL (optional)</Label>
<Input value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} placeholder="https://example.com" />
</div>
</>
)}
{type === "pihole" && (
<>
<div className="flex flex-col gap-1.5">
<Label>Pi-hole Base URL</Label>
<Input value={piholeBaseUrl} onChange={(e) => setPiholeBaseUrl(e.target.value)} placeholder="http://pihole.local" />
</div>
<div className="flex flex-col gap-1.5">
<Label>API Token</Label>
<Input type="password" value={piholeApiToken} onChange={(e) => setPiholeApiToken(e.target.value)} />
</div>
</>
)}
{type === "memos" && (
<>
<div className="flex flex-col gap-1.5">
<Label>Memos Base URL</Label>
<Input value={memosBaseUrl} onChange={(e) => setMemosBaseUrl(e.target.value)} placeholder="http://memos.local:5230" />
</div>
<div className="flex flex-col gap-1.5">
<Label>API Token</Label>
<Input type="password" value={memosApiToken} onChange={(e) => setMemosApiToken(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>Page Size</Label>
<Input type="number" value={memosPageSize} onChange={(e) => setMemosPageSize(e.target.value)} min={1} max={20} />
</div>
</>
)}
{type === "immich" && (
<>
<div className="flex flex-col gap-1.5">
<Label>Immich Base URL</Label>
<Input value={immichBaseUrl} onChange={(e) => setImmichBaseUrl(e.target.value)} placeholder="http://immich.local:2283" />
</div>
<div className="flex flex-col gap-1.5">
<Label>API Key</Label>
<Input type="password" value={immichApiKey} onChange={(e) => setImmichApiKey(e.target.value)} />
</div>
</>
)}
{error && <span className="text-xs text-destructive">{error}</span>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={createMut.isPending || updateMut.isPending}>
{isEdit ? "Save" : "Add Widget"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}