nyk
feat(refactor): ready for manual QA after main sync (#274)
b6ecafa unverified
Raw
History Blame Contribute Delete
9.35 kB
'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>
)
}