import { useState, useEffect, type ReactNode } from 'react'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { GripVertical, Eye, EyeOff, RotateCcw, Maximize2, Minimize2 } from 'lucide-react'; import { Button } from './Button'; export interface DashboardWidget { id: string; title: string; /** Render function that receives the current size for responsive content */ component: ReactNode | ((size: 1 | 2 | 4) => ReactNode); defaultVisible?: boolean; defaultSize?: 1 | 2 | 4; // 1 = quarter, 2 = half, 4 = full width (default) } interface DashboardProps { widgets: DashboardWidget[]; storageKey: string; columns?: number; stackBelow?: number; hideControls?: boolean; onResetLayout?: () => void; renderControls?: (controls: { hiddenCount: number; showHiddenPanel: boolean; setShowHiddenPanel: (show: boolean) => void; resetLayout: () => void; }) => ReactNode; } interface LayoutState { order: string[]; hidden: string[]; sizes: Record; } function SortableWidget({ id, title, component, isHidden, size, columnSpan, onToggleVisibility, onToggleSize, }: { id: string; title: string; component: ReactNode | ((size: 1 | 2 | 4) => ReactNode); isHidden: boolean; size: 1 | 2 | 4; columnSpan: number; onToggleVisibility: () => void; onToggleSize: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; if (isHidden) return null; return (
{/* Widget Header */}

{title}

{/* Widget Content */}
{typeof component === 'function' ? component(size) : component}
); } export function Dashboard({ widgets, storageKey, columns = 4, stackBelow, hideControls = false, onResetLayout, renderControls }: DashboardProps) { // Build default sizes from widget definitions const getDefaultSizes = () => { const sizes: Record = {}; widgets.forEach((w) => { sizes[w.id] = w.defaultSize || 4; }); return sizes; }; const [layout, setLayout] = useState(() => { // Load saved layout from localStorage const saved = localStorage.getItem(storageKey); if (saved) { try { const parsed = JSON.parse(saved); // Ensure sizes exist (for backwards compatibility) if (!parsed.sizes) { parsed.sizes = getDefaultSizes(); } else { // Merge in default sizes for any new widgets not in saved layout const defaults = getDefaultSizes(); for (const id in defaults) { if (!(id in parsed.sizes)) { parsed.sizes[id] = defaults[id]; } } } return parsed; } catch { // Invalid JSON, use default } } // Default layout: all widgets visible in original order return { order: widgets.map((w) => w.id), hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id), sizes: getDefaultSizes(), }; }); const [showHiddenPanel, setShowHiddenPanel] = useState(false); const [isStacked, setIsStacked] = useState(false); useEffect(() => { if (!stackBelow) return undefined; const mediaQuery = window.matchMedia(`(max-width: ${stackBelow}px)`); const handleChange = (event: MediaQueryListEvent | MediaQueryList) => { setIsStacked(event.matches); }; handleChange(mediaQuery); const onChange = (event: MediaQueryListEvent) => handleChange(event); if (mediaQuery.addEventListener) { mediaQuery.addEventListener('change', onChange); } else { mediaQuery.addListener(onChange); } return () => { if (mediaQuery.removeEventListener) { mediaQuery.removeEventListener('change', onChange); } else { mediaQuery.removeListener(onChange); } }; }, [stackBelow]); const effectiveColumns = stackBelow && isStacked ? 1 : columns; // Listen for toggle-hidden-panel event from parent useEffect(() => { const handleToggle = () => setShowHiddenPanel(prev => !prev); window.addEventListener('toggle-hidden-panel', handleToggle); return () => window.removeEventListener('toggle-hidden-panel', handleToggle); }, []); // Save layout to localStorage whenever it changes useEffect(() => { localStorage.setItem(storageKey, JSON.stringify(layout)); }, [layout, storageKey]); // Ensure all widget IDs are in the order array (for newly added widgets) useEffect(() => { const allIds = widgets.map((w) => w.id); const missingIds = allIds.filter((id) => !layout.order.includes(id)); if (missingIds.length > 0) { setLayout((prev) => ({ ...prev, order: [...prev.order, ...missingIds], })); } }, [widgets, layout.order]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { setLayout((prev) => { const oldIndex = prev.order.indexOf(active.id as string); const newIndex = prev.order.indexOf(over.id as string); return { ...prev, order: arrayMove(prev.order, oldIndex, newIndex), }; }); } }; const toggleVisibility = (id: string) => { setLayout((prev) => ({ ...prev, hidden: prev.hidden.includes(id) ? prev.hidden.filter((h) => h !== id) : [...prev.hidden, id], })); }; const toggleSize = (id: string) => { setLayout((prev) => { const currentSize = prev.sizes[id] || 4; // Cycle: 1 → 2 → 4 → 1 const nextSize = currentSize === 1 ? 2 : currentSize === 2 ? 4 : 1; return { ...prev, sizes: { ...prev.sizes, [id]: nextSize as 1 | 2 | 4, }, }; }); }; const resetLayout = () => { const defaultLayout = { order: widgets.map((w) => w.id), hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id), sizes: getDefaultSizes(), }; setLayout(defaultLayout); onResetLayout?.(); }; // Get ordered widgets const orderedWidgets = layout.order .map((id) => widgets.find((w) => w.id === id)) .filter(Boolean) as DashboardWidget[]; const visibleWidgets = orderedWidgets.filter((w) => !layout.hidden.includes(w.id)); const hiddenWidgets = orderedWidgets.filter((w) => layout.hidden.includes(w.id)); // Render external controls if provided const externalControls = renderControls?.({ hiddenCount: hiddenWidgets.length, showHiddenPanel, setShowHiddenPanel, resetLayout, }); return (
{/* External controls slot */} {externalControls} {/* Dashboard Controls */} {!hideControls && !renderControls && (
{hiddenWidgets.length > 0 && ( )}
)} {/* Hidden Widgets Panel */} {showHiddenPanel && hiddenWidgets.length > 0 && (

Hidden widgets (click to show):

{hiddenWidgets.map((widget) => ( ))}
)} {/* Draggable Widgets Grid */} w.id)} strategy={rectSortingStrategy}>
{visibleWidgets.map((widget) => { const size = layout.sizes[widget.id] || 2; const columnSpan = Math.min(size, effectiveColumns); return ( toggleVisibility(widget.id)} onToggleSize={() => toggleSize(widget.id)} /> ); })}
{visibleWidgets.length === 0 && (

All widgets are hidden.

)}
); }