import { useCallback, useState, type CSSProperties, type DragEvent } from 'react'; import type { LayoutPersistence, TileSpan, TileSpec } from './types.js'; import { useWorkspaceLayout } from './useWorkspaceLayout.js'; export interface WorkspaceProps { /** Tile registry. */ tiles: TileSpec[]; /** Persistence key (route-scoped). */ storageKey: string; /** Optional persistence override (default localStorage). */ persistence?: LayoutPersistence; /** * Number of grid columns. Tiles' `span` is interpreted as a fraction * of this. Default 4. */ columns?: 4 | 6 | 12; /** Show the reset-layout CTA. Default true. */ showResetCta?: boolean; /** Bypass drag entirely (read-only). Default false. */ readOnly?: boolean; className?: string; style?: CSSProperties; } /** * `` — drag-reorderable tile grid with width control. * * Wave 13.F.1 + .2. HTML5 native drag (no `@dnd-kit` dependency). * Persists via the `LayoutPersistence` adapter (default localStorage — * hosts on `platform-service` pass a server-backed one). * * Keyboard shortcuts on a focused tile: * - ←/→ resize span down/up (clamped to 1..4) * - ↑/↓ move tile up/down by one slot */ export function Workspace({ tiles, storageKey, persistence, columns = 4, showResetCta = true, readOnly = false, className, style, }: WorkspaceProps) { const { layout, move, resize, reset, hydrated } = useWorkspaceLayout(tiles, { storageKey, persistence, }); const tileById = new Map(tiles.map((t) => [t.id, t])); const [dragIndex, setDragIndex] = useState(null); const [dropIndex, setDropIndex] = useState(null); const onDragStart = useCallback( (e: DragEvent, idx: number) => { if (readOnly) return; setDragIndex(idx); e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', String(idx)); } catch { /* ignore */ } }, [readOnly], ); const onDragOver = useCallback( (e: DragEvent, idx: number) => { if (readOnly) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDropIndex(idx); }, [readOnly], ); const onDrop = useCallback( (e: DragEvent, idx: number) => { if (readOnly) return; e.preventDefault(); const from = dragIndex; setDragIndex(null); setDropIndex(null); if (from === null) return; move(from, idx); }, [dragIndex, move, readOnly], ); return (
{showResetCta && !readOnly && (
{!hydrated && ( loading… )}
)}
{layout.entries.map((entry, idx) => { const tile = tileById.get(entry.id); if (!tile) return null; const span = Math.max(1, Math.min(columns, entry.span)) as TileSpan; return (
onDragStart(e, idx)} onDragOver={(e) => onDragOver(e, idx)} onDragLeave={() => setDropIndex((v) => (v === idx ? null : v))} onDrop={(e) => onDrop(e, idx)} onKeyDown={(e) => { if (readOnly) return; if (e.key === 'ArrowLeft') { e.preventDefault(); resize(entry.id, Math.max(1, span - 1) as TileSpan); } else if (e.key === 'ArrowRight') { e.preventDefault(); resize(entry.id, Math.min(columns, span + 1) as TileSpan); } else if (e.key === 'ArrowUp') { e.preventDefault(); move(idx, Math.max(0, idx - 1)); } else if (e.key === 'ArrowDown') { e.preventDefault(); move(idx, idx + 1); } }} style={{ gridColumn: `span ${span}`, background: 'var(--bl-surface-card, #fff)', border: '1px solid var(--bl-border, rgba(0,0,0,0.12))', borderRadius: 14, padding: 12, outline: dropIndex === idx ? '2px dashed var(--bl-accent, #6366f1)' : 'none', cursor: readOnly ? 'default' : 'grab', transition: 'outline-color 120ms ease', }} >

{tile.title}

{tile.subtitle && (

{tile.subtitle}

)}
{!readOnly && (
{([1, 2, 3, 4] as TileSpan[]).filter((n) => n <= columns).map((n) => ( ))}
)}
{tile.content}
); })}
); }