"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 (
); } 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 (
); } 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 (
); } /* ---------- Add-app tile ---------- */ function AddAppTile({ onClick }: { onClick: () => void }) { return ( ); } /* ---------- 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 (
{service.name.slice(0, 2).toUpperCase()}
{service.name}
{primaryUrl && ( {primaryUrl.url} )}
); } /* ---------- 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 (
{service.name.slice(0, 2).toUpperCase()}
{service.name}
); } if (group) { return (
{group.name} {group.services.length} apps
); } if (widget) { return (
{widget.title} {widget.type}
); } return
Moving…
; } /* ---------- 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(null); const [groupFormOpen, setGroupFormOpen] = useState(false); const [editingGroup, setEditingGroup] = useState(null); const [widgetFormOpen, setWidgetFormOpen] = useState(false); const [editingWidget, setEditingWidget] = useState(null); const [activeId, setActiveId] = useState(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 = {}; 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 = {}; 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 = {}; 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 = {}; 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 (
Loading dashboard...
); } if (error) { return (

Failed to load dashboard

{error.message}

); } const groups = dashboard?.groups || []; const ungrouped = dashboard?.ungroupedServices || []; const widgets = dashboard?.widgets || []; const isEmpty = groups.length === 0 && ungrouped.length === 0 && widgets.length === 0; return (
{isEmpty ? (

Welcome to Dash

Your homelab dashboard is empty. Add apps and widgets to get started.

) : ( {/* Widgets strip */}
Widgets
{widgets.length > 0 ? (
w.id)} strategy={rectSortingStrategy}> {widgets.map((w) => ( ))}
) : ( )}
{/* Apps section */}
Apps
{/* Groups */} g.id)} strategy={verticalListSortingStrategy}> {groups.map((g) => ( ))} {/* Ungrouped services */} {ungrouped.length > 0 && (
{groups.length > 0 && (
Ungrouped {ungrouped.length}
)} s.id)} strategy={rectSortingStrategy}> {viewMode === "grid" ? (
{ungrouped.map((s) => ( ))}
) : (
{ungrouped.map((s) => ( ))}
)}
)} {/* In-grid add tile when no ungrouped but groups exist */} {ungrouped.length === 0 && groups.length > 0 && (
)} {/* No apps at all - show empty state within apps section */} {groups.length === 0 && ungrouped.length === 0 && ( )}
{activeId && dashboard ? ( ) : null}
)}
{/* Modals */} ({ id: g.id, name: g.name }))} open={serviceFormOpen} onOpenChange={setServiceFormOpen} />
); }