"use client"; import { useMemo, useRef, useState, type ReactNode } from "react"; import { DndContext, DragOverlay, KeyboardSensor, PointerSensor, closestCorners, useDroppable, useSensor, useSensors, } from "@dnd-kit/core"; import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core"; import { SortableContext, arrayMove, rectSortingStrategy, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { toast } from "sonner"; import { CheckIcon, GripVerticalIcon, LayoutGridIcon, Maximize2Icon, Minimize2Icon, RotateCcwIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { saveDashboardLayout } from "@/app/(frontend)/actions"; import { DEFAULT_DASHBOARD_LAYOUT, WIDGET_LABELS, effectiveSpan, type DashboardLayout, type DashboardWidgetId, type DashboardZone, } from "@/lib/dashboard/layout"; import { cn } from "@/lib/utils"; export interface DashboardGridWidget { readonly id: DashboardWidgetId; readonly node: ReactNode; } interface DashboardGridProps { readonly layout: DashboardLayout; readonly widgets: readonly DashboardGridWidget[]; } const ZONE_MAIN = "zone:main"; const ZONE_SIDEBAR = "zone:sidebar"; function zoneFromContainerId(id: string): DashboardZone | null { if (id === ZONE_MAIN) return "main"; if (id === ZONE_SIDEBAR) return "sidebar"; return null; } function findZone(layout: DashboardLayout, id: string): DashboardZone | null { if (layout.main.includes(id as DashboardWidgetId)) return "main"; if (layout.sidebar.includes(id as DashboardWidgetId)) return "sidebar"; return null; } /** * Computes the next layout for a drop, or null when the drop changes nothing. * Pure and driven only by the layout as of drag end, so there is no state * mutation during the drag itself (mutating layout in onDragOver re-renders * the grid and re-measures droppables mid-drag, which ping-pongs collision * detection and crashes React with "Maximum update depth exceeded"). */ function placementFor( layout: DashboardLayout, activeId: string, overId: string, ): DashboardLayout | null { const from = findZone(layout, activeId); if (!from) return null; const toContainer = zoneFromContainerId(overId); const to = toContainer ?? findZone(layout, overId); if (!to) return null; if (to === from) { if (toContainer) return null; const items = layout[from]; const fromIndex = items.indexOf(activeId as DashboardWidgetId); const toIndex = items.indexOf(overId as DashboardWidgetId); if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null; return { ...layout, [from]: arrayMove(items, fromIndex, toIndex) }; } const source = layout[from].filter((id) => id !== (activeId as DashboardWidgetId)); const target = [...layout[to]]; if (toContainer) { target.push(activeId as DashboardWidgetId); } else { const overIndex = target.indexOf(overId as DashboardWidgetId); target.splice(overIndex >= 0 ? overIndex : target.length, 0, activeId as DashboardWidgetId); } return { ...layout, [from]: source, [to]: target }; } function SortableWidget({ id, span, animate, resizable, onToggleSpan, children, }: { id: string; span: 1 | 2; animate: boolean; resizable: boolean; onToggleSpan: () => void; children: ReactNode; }) { const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging, isOver, } = useSortable({ id }); return (
*]:flex-1 [&>*]:min-w-0", span === 2 && "lg:col-span-2", isDragging && "opacity-40", isOver && "outline-2 outline-dashed outline-primary/70", )} > {isOver && ( )} {children} {resizable && ( )}
); } function StaticWidget({ span, children }: { span: 1 | 2; children: ReactNode }) { return (
*]:flex-1 [&>*]:min-w-0", span === 2 && "lg:col-span-2")}> {children}
); } function MainZone({ layout, nodesById, editable, onToggleSpan, }: { layout: DashboardLayout; nodesById: Map; editable: boolean; onToggleSpan?: (id: DashboardWidgetId) => void; }) { const { setNodeRef, isOver } = useDroppable({ id: ZONE_MAIN }); const items = layout.main; const content = editable ? ( {items.map((id) => ( onToggleSpan?.(id)} > {nodesById.get(id) ?? null} ))} ) : ( items.map((id) => ( {nodesById.get(id) ?? null} )) ); return (
{content}
); } function SidebarZone({ layout, nodesById, editable, animate, }: { layout: DashboardLayout; nodesById: Map; editable: boolean; animate: boolean; }) { const { setNodeRef, isOver } = useDroppable({ id: ZONE_SIDEBAR }); const items = layout.sidebar; const content = editable ? ( {items.map((id) => ( undefined} > {nodesById.get(id) ?? null} ))} ) : ( items.map((id) => ( {nodesById.get(id) ?? null} )) ); return (
{content}
); } export function DashboardGrid({ layout: initialLayout, widgets }: DashboardGridProps) { const [layout, setLayout] = useState(initialLayout); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); const [activeDrag, setActiveDrag] = useState<{ id: string; width: number } | null>(null); const layoutRef = useRef(initialLayout); layoutRef.current = layout; const nodesById = useMemo( () => new Map(widgets.map((widget) => [widget.id, widget.node])), [widgets], ); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor), ); const sidebarAnimate = !activeDrag || findZone(layout, activeDrag.id) === "sidebar"; function commitLayout(next: DashboardLayout) { layoutRef.current = next; setLayout(next); setSaving(true); void saveDashboardLayout(next) .then((result) => { if (!result.success) { toast.error(result.error ?? "Could not save your dashboard layout."); } }) .catch((error: unknown) => { toast.error( error instanceof Error ? error.message : "Could not save your dashboard layout.", ); }) .finally(() => setSaving(false)); } function handleDragStart(event: DragStartEvent) { setActiveDrag({ id: String(event.active.id), width: event.active.rect.current.initial?.width ?? 288, }); } function handleDragEnd(event: DragEndEvent) { setActiveDrag(null); const { active, over } = event; if (!over || active.id === over.id) return; const next = placementFor(layoutRef.current, String(active.id), String(over.id)); if (!next) return; commitLayout(next); } function handleDragCancel() { setActiveDrag(null); } function toggleSpan(id: DashboardWidgetId) { const current = layoutRef.current; const spans = { ...(current.spans ?? {}) }; spans[id] = effectiveSpan(current, id) === 1 ? 2 : 1; commitLayout({ ...current, spans }); } function resetLayout() { commitLayout(DEFAULT_DASHBOARD_LAYOUT); } return (
{editing && ( <> {saving && ( Saving... )} )}
{editing ? (
{activeDrag ? (
{WIDGET_LABELS[activeDrag.id as DashboardWidgetId]}
) : null}
) : (
)} {editing && (

Drag cards by the handle to rearrange. Changes save automatically.

)}
); }