"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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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,
rectSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
/* ---------- Sortable wrapper for widgets only ---------- */
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()}
);
}
/* ---------- Drag Overlay (widgets only) ---------- */
function DashboardDragOverlay({ activeId, dashboard }: { activeId: string; dashboard: Dashboard }) {
const widget = dashboard.widgets.find((w) => w.id === activeId);
if (widget) {
return (
{widget.title}
{widget.type}
);
}
return null;
}
/* ---------- 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 groupIds = dashboard.groups.map((g) => g.id);
const widgetIds = dashboard.widgets.map((w) => w.id);
const isActiveWidget = widgetIds.includes(activeIdStr);
const isOverWidget = widgetIds.includes(overIdStr);
// Widget reorder only
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 (
);
}
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 section */}
{widgets.length > 0 ? (
w.id)} strategy={rectSortingStrategy}>
{widgets.map((w) => (
))}
) : (
)}
{/* Apps section */}
{/* Groups */}
{groups.map((g) => (
))}
{/* Ungrouped services */}
{ungrouped.length > 0 && (
{groups.length > 0 && (
Ungrouped
{ungrouped.length}
)}
{viewMode === "grid" ? (
{ungrouped.map((s) => (
))}
) : (
{ungrouped.map((s) => (
))}
)}
)}
{/* 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}
/>
);
}