Spaces:
Runtime error
Runtime error
| 'use client' | |
| import { useState, useRef } from 'react' | |
| import { useMissionControl } from '@/store' | |
| import { WIDGET_CATALOG, getDefaultLayout, getAvailableWidgets, getWidgetById } from '@/lib/dashboard-widgets' | |
| import { Button } from '@/components/ui/button' | |
| import type { DashboardData } from './widget-primitives' | |
| import { MetricCardsWidget } from './widgets/metric-cards-widget' | |
| import { RuntimeHealthWidget } from './widgets/runtime-health-widget' | |
| import { GatewayHealthWidget } from './widgets/gateway-health-widget' | |
| import { SessionWorkbenchWidget } from './widgets/session-workbench-widget' | |
| import { EventStreamWidget } from './widgets/event-stream-widget' | |
| import { TaskFlowWidget } from './widgets/task-flow-widget' | |
| import { GithubSignalWidget } from './widgets/github-signal-widget' | |
| import { SecurityAuditWidget } from './widgets/security-audit-widget' | |
| import { MaintenanceWidget } from './widgets/maintenance-widget' | |
| import { QuickActionsWidget } from './widgets/quick-actions-widget' | |
| const WIDGET_COMPONENTS: Record<string, React.ComponentType<{ data: DashboardData }>> = { | |
| 'metric-cards': MetricCardsWidget, | |
| 'runtime-health': RuntimeHealthWidget, | |
| 'gateway-health': GatewayHealthWidget, | |
| 'session-workbench': SessionWorkbenchWidget, | |
| 'event-stream': EventStreamWidget, | |
| 'task-flow': TaskFlowWidget, | |
| 'github-signal': GithubSignalWidget, | |
| 'security-audit': SecurityAuditWidget, | |
| 'maintenance': MaintenanceWidget, | |
| 'quick-actions': QuickActionsWidget, | |
| } | |
| // Map widget defaultSize to CSS grid column spans | |
| const SIZE_CLASSES: Record<string, string> = { | |
| sm: 'xl:col-span-6', | |
| md: 'xl:col-span-4', | |
| lg: 'xl:col-span-8', | |
| full: 'xl:col-span-12', | |
| } | |
| export function WidgetGrid({ data }: { data: DashboardData }) { | |
| const { dashboardLayout, setDashboardLayout, dashboardMode } = useMissionControl() | |
| const mode = dashboardMode === 'local' ? 'local' : 'full' | |
| const [customizing, setCustomizing] = useState(false) | |
| const [dragId, setDragId] = useState<string | null>(null) | |
| const [dragOverId, setDragOverId] = useState<string | null>(null) | |
| const dragCounter = useRef(0) | |
| const defaults = getDefaultLayout(mode) | |
| const activeLayout = dashboardLayout ?? defaults | |
| const available = getAvailableWidgets(mode) | |
| // Filter layout to only include widgets valid for current mode | |
| const validLayout = activeLayout.filter((id) => { | |
| const w = getWidgetById(id) | |
| return w && w.modes.includes(mode) | |
| }) | |
| // Widgets not in current layout but available for this mode | |
| const hiddenWidgets = available.filter((w) => !validLayout.includes(w.id)) | |
| const handleDragStart = (e: React.DragEvent, widgetId: string) => { | |
| setDragId(widgetId) | |
| e.dataTransfer.effectAllowed = 'move' | |
| e.dataTransfer.setData('text/plain', widgetId) | |
| } | |
| const handleDragOver = (e: React.DragEvent) => { | |
| e.preventDefault() | |
| e.dataTransfer.dropEffect = 'move' | |
| } | |
| const handleDragEnter = (_e: React.DragEvent, widgetId: string) => { | |
| dragCounter.current++ | |
| setDragOverId(widgetId) | |
| } | |
| const handleDragLeave = () => { | |
| dragCounter.current-- | |
| if (dragCounter.current <= 0) { | |
| setDragOverId(null) | |
| dragCounter.current = 0 | |
| } | |
| } | |
| const handleDrop = (e: React.DragEvent, targetId: string) => { | |
| e.preventDefault() | |
| dragCounter.current = 0 | |
| setDragOverId(null) | |
| setDragId(null) | |
| const sourceId = e.dataTransfer.getData('text/plain') | |
| if (!sourceId || sourceId === targetId) return | |
| setDashboardLayout((currentLayout) => { | |
| const activeLayout = currentLayout ?? defaults | |
| const nextLayout = activeLayout.filter((id) => { | |
| const widget = getWidgetById(id) | |
| return !!widget && widget.modes.includes(mode) | |
| }) | |
| const sourceIdx = nextLayout.indexOf(sourceId) | |
| const targetIdx = nextLayout.indexOf(targetId) | |
| if (sourceIdx === -1) { | |
| if (targetIdx === -1) return nextLayout | |
| nextLayout.splice(targetIdx + 1, 0, sourceId) | |
| return nextLayout | |
| } | |
| if (targetIdx === -1) return nextLayout | |
| nextLayout.splice(sourceIdx, 1) | |
| nextLayout.splice(targetIdx, 0, sourceId) | |
| return nextLayout | |
| }) | |
| } | |
| const handleDragEnd = () => { | |
| setDragId(null) | |
| setDragOverId(null) | |
| dragCounter.current = 0 | |
| } | |
| const addWidget = (widgetId: string) => { | |
| setDashboardLayout((currentLayout) => { | |
| const activeLayout = currentLayout ?? defaults | |
| const nextLayout = activeLayout.filter((id) => { | |
| const widget = getWidgetById(id) | |
| return !!widget && widget.modes.includes(mode) | |
| }) | |
| return nextLayout.includes(widgetId) ? nextLayout : [...nextLayout, widgetId] | |
| }) | |
| } | |
| const removeWidget = (widgetId: string) => { | |
| setDashboardLayout((currentLayout) => { | |
| const activeLayout = currentLayout ?? defaults | |
| return activeLayout.filter((id) => id !== widgetId) | |
| }) | |
| } | |
| const resetToDefaults = () => { | |
| setDashboardLayout(null) | |
| } | |
| // Group widgets into rows based on their sizes | |
| // full-width widgets get their own row; sm/md/lg widgets flow in a 12-col grid | |
| const renderWidgets = () => { | |
| const elements: React.ReactNode[] = [] | |
| let rowWidgets: { id: string; size: string }[] = [] | |
| let rowSpan = 0 | |
| const flushRow = () => { | |
| if (rowWidgets.length === 0) return | |
| elements.push( | |
| <section key={`row-${elements.length}`} className="grid xl:grid-cols-12 gap-4"> | |
| {rowWidgets.map(({ id, size }) => renderWidget(id, SIZE_CLASSES[size] || 'xl:col-span-4'))} | |
| </section> | |
| ) | |
| rowWidgets = [] | |
| rowSpan = 0 | |
| } | |
| for (const widgetId of validLayout) { | |
| const widget = getWidgetById(widgetId) | |
| if (!widget) continue | |
| const Component = WIDGET_COMPONENTS[widgetId] | |
| if (!Component) continue | |
| if (widget.defaultSize === 'full') { | |
| flushRow() | |
| elements.push(renderWidget(widgetId, '')) | |
| } else { | |
| const span = widget.defaultSize === 'sm' ? 6 : widget.defaultSize === 'md' ? 4 : 8 | |
| if (rowSpan + span > 12) flushRow() | |
| rowWidgets.push({ id: widgetId, size: widget.defaultSize }) | |
| rowSpan += span | |
| } | |
| } | |
| flushRow() | |
| return elements | |
| } | |
| const renderWidget = (widgetId: string, colClass: string) => { | |
| const Component = WIDGET_COMPONENTS[widgetId] | |
| const widget = getWidgetById(widgetId) | |
| if (!Component || !widget) return null | |
| const isDragging = dragId === widgetId | |
| const isDragOver = dragOverId === widgetId | |
| return ( | |
| <div | |
| key={widgetId} | |
| className={`${colClass} relative ${customizing ? 'cursor-grab' : ''} ${ | |
| isDragging ? 'opacity-40' : '' | |
| } ${isDragOver ? 'ring-2 ring-primary/50 rounded-lg' : ''}`} | |
| draggable={customizing} | |
| onDragStart={(e) => handleDragStart(e, widgetId)} | |
| onDragOver={handleDragOver} | |
| onDragEnter={(e) => handleDragEnter(e, widgetId)} | |
| onDragLeave={handleDragLeave} | |
| onDrop={(e) => handleDrop(e, widgetId)} | |
| onDragEnd={handleDragEnd} | |
| > | |
| {customizing && ( | |
| <div className="absolute top-1 right-1 z-10 flex items-center gap-1"> | |
| <span className="text-2xs text-muted-foreground bg-card/80 backdrop-blur-sm rounded px-1.5 py-0.5 border border-border/50 cursor-grab"> | |
| ::: | |
| </span> | |
| <button | |
| type="button" | |
| onClick={() => removeWidget(widgetId)} | |
| className="text-2xs text-red-400 hover:text-red-300 bg-card/80 backdrop-blur-sm rounded px-1.5 py-0.5 border border-border/50" | |
| > | |
| x | |
| </button> | |
| </div> | |
| )} | |
| <Component data={data} /> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <div className="space-y-4"> | |
| {renderWidgets()} | |
| {/* Customize mode: hidden widgets + controls */} | |
| {customizing && hiddenWidgets.length > 0 && ( | |
| <section className="space-y-2"> | |
| <h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Available Widgets</h4> | |
| <div className="grid grid-cols-2 lg:grid-cols-4 gap-2"> | |
| {hiddenWidgets.map((widget) => ( | |
| <button | |
| key={widget.id} | |
| type="button" | |
| onClick={() => addWidget(widget.id)} | |
| className="rounded-lg border border-dashed border-border/60 p-3 text-left hover:border-primary/40 hover:bg-primary/5 transition-smooth" | |
| > | |
| <div className="text-xs font-medium text-foreground/70">{widget.label}</div> | |
| <div className="text-2xs text-muted-foreground mt-0.5">{widget.description}</div> | |
| </button> | |
| ))} | |
| </div> | |
| </section> | |
| )} | |
| {/* Customize controls bar */} | |
| <div className="flex items-center justify-end gap-2"> | |
| {customizing && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={resetToDefaults} | |
| className="text-2xs h-7" | |
| > | |
| Reset to Defaults | |
| </Button> | |
| )} | |
| <Button | |
| variant={customizing ? 'default' : 'outline'} | |
| size="sm" | |
| onClick={() => setCustomizing(!customizing)} | |
| className="text-2xs h-7" | |
| > | |
| {customizing ? 'Done' : 'Customize'} | |
| </Button> | |
| </div> | |
| </div> | |
| ) | |
| } | |