'use client'; import { useState, useCallback, type ReactNode } from 'react'; import { useFileTree, FileTree } from '@pierre/trees/react'; import { themeToTreeStyles } from '@pierre/trees'; import { SquareRegular } from '@fluentui/react-icons'; import { useCanvas } from '@/store/canvas'; import { useArtboards, patchArtboard, createArtboardMutation } from '@/hooks/useArtboards'; import { useQueryClient } from '@tanstack/react-query'; const T = { bg: '#111115', border: 'rgba(255,255,255,0.055)', item: 'rgba(255,255,255,0.42)', itemHov: 'rgba(255,255,255,0.72)', selBg: 'rgba(51,133,255,0.12)', selFg: 'rgba(255,255,255,0.88)', accent: '#3385FF', dim: 'rgba(255,255,255,0.22)', sep: 'rgba(255,255,255,0.04)', }; // Map the panel's dark theme into Trees' CSS custom properties const treeThemeStyles = themeToTreeStyles({ type: 'dark', bg: '#111115', fg: 'rgba(255,255,255,0.42)', colors: { 'editor.selectionBackground': 'rgba(51,133,255,0.14)', 'list.activeSelectionBackground': 'rgba(51,133,255,0.14)', 'list.inactiveSelectionBackground': 'rgba(51,133,255,0.08)', 'list.hoverBackground': 'rgba(255,255,255,0.04)', 'list.activeSelectionForeground': 'rgba(255,255,255,0.88)', 'editorIndentGuide.background': 'rgba(255,255,255,0.04)', }, }); export function ArtboardNavigator() { const { selectedArtboardId, selectArtboard, workspaceId, projectId, liveArtboardIds, artboardFiberRoots } = useCanvas(); const { artboards, rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined); const queryClient = useQueryClient(); const deleteArtboard = useCallback(async (id: string, name: string) => { if (!window.confirm(`Delete "${name}"?`)) return; try { const res = await fetch(`/api/artboards/${id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(`Server returned ${res.status}`); } catch (err) { console.error('[Navigator] deleteArtboard failed:', err); window.alert(`Could not delete "${name}" — please try again.`); return; } if (selectedArtboardId === id) selectArtboard(null); queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); }, [selectedArtboardId, selectArtboard, workspaceId, projectId, queryClient]); const renameArtboard = useCallback(async (id: string, currentName: string) => { const newName = window.prompt('Rename artboard:', currentName); if (!newName || newName.trim() === currentName) return; await patchArtboard(id, { name: newName.trim() }).catch(console.error); queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); }, [workspaceId, projectId, queryClient]); const forkArtboard = useCallback(async (id: string, label: string) => { if (!workspaceId) return; const source = rawArtboards.find(ab => ab.id === id); if (!source) return; const meta = { ...(source.metadata_jsonb as Record) }; // Offset fork to the right of the original so it doesn't overlap const srcWidth = typeof meta['width'] === 'number' ? (meta['width'] as number) : 360; meta['x'] = typeof meta['x'] === 'number' ? (meta['x'] as number) + srcWidth + 40 : 40; try { await createArtboardMutation({ workspace_id: workspaceId, project_id: projectId ?? null, name: `Fork of ${label}`, origin_id: source.origin_id, parent_artboard_id: id, metadata_jsonb: meta, }); queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); } catch (err) { console.error('[Navigator] forkArtboard failed:', err); window.alert('Could not fork artboard — please try again.'); } }, [workspaceId, projectId, rawArtboards, queryClient]); // Real graph stats derived from live fiber trees const totalComponents = Object.values(artboardFiberRoots).reduce( (acc, root) => acc + countFiberNodes(root), 0, ); const liveCount = liveArtboardIds.size; // Build file tree paths from artboard names (strip .tsx suffix if present, else use name as path) const filePaths = rawArtboards.length > 0 ? rawArtboards.map((ab) => `artboards/${ab.name}`) : ['artboards/(no artboards yet)']; const { model } = useFileTree({ paths: filePaths, initialExpansion: 2, density: 'compact', icons: 'minimal', }); return (
{/* ── Artboards ── */} Artboards
{artboards.map((ab) => { const sel = selectedArtboardId === ab.id; const live = liveArtboardIds.has(ab.id); return ( selectArtboard(ab.id)} icon={ } label={ab.label} onRename={() => void renameArtboard(ab.id, ab.label)} onFork={() => void forkArtboard(ab.id, ab.label)} onDelete={() => void deleteArtboard(ab.id, ab.label)} /> ); })}
{/* ── Files — @pierre/trees ── */} Files
{/* ── Graph stats ── */} Graph
0 ? String(liveCount) : '—'} color={liveCount > 0 ? '#10B981' : 'rgba(255,255,255,0.25)'} /> 0 ? String(totalComponents) : '—'} color="rgba(255,255,255,0.45)" />
{/* ── Cross-artboard query ── */}
); } /* ── Artboard row ─────────────────────────────────────────── */ function NavRow({ selected = false, live = false, onClick, icon, label, onRename, onFork, onDelete, }: { selected?: boolean; live?: boolean; onClick?: () => void; icon: React.ReactNode; label: string; onRename?: () => void; onFork?: () => void; onDelete?: () => void; }) { const [hov, setHov] = useState(false); return (
setHov(true)} onMouseLeave={() => setHov(false)} style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '4px 6px 4px 10px', borderRadius: 5, cursor: 'pointer', background: selected ? T.selBg : hov ? 'rgba(255,255,255,0.04)' : 'transparent', color: selected ? T.selFg : hov ? T.itemHov : T.item, fontSize: '0.75rem', letterSpacing: '-0.01em', fontWeight: selected ? 500 : 400, userSelect: 'none', marginBottom: 1, transition: 'background 0.1s, color 0.1s', }} > {icon} {label} {/* Live render indicator — pulsing green dot */} {live && !hov && ( )} {/* Action buttons: rename + fork + delete — shown on hover */} {(hov || selected) && (
e.stopPropagation()}> {onRename && ( {/* Pencil */} )} {onFork && ( {/* Branch / fork icon */} )} {onDelete && ( {/* Trash */} )}
)}
); } function IconBtn({ children, title, onClick, danger }: { children: React.ReactNode; title: string; onClick: () => void; danger?: boolean }) { const [hov, setHov] = useState(false); return ( ); } /* ── Helpers ──────────────────────────────────────────────── */ function SectionLabel({ children }: { children: string }) { return (
{children}
); } function HSep() { return
; } function ActiveDot() { return ( ); } function GraphStat({ label, value, color }: { label: string; value: string; color: string }) { return (
{label} {value}
); } function countFiberNodes(node: { children?: unknown[] }): number { return 1 + (node.children ?? []).reduce( (acc, c) => acc + countFiberNodes(c as { children?: unknown[] }), 0, ); } /* ── Cross-artboard query ─────────────────────────────────── */ function CrossArtboardQuery({ workspaceId }: { workspaceId: string | null }) { const [query, setQuery] = useState(''); const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle'); const [answer, setAnswer] = useState(''); const submit = useCallback(async () => { if (!query.trim() || !workspaceId) return; setStatus('loading'); setAnswer(''); try { const res = await fetch('/api/ai/query', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workspace_id: workspaceId, question: query.trim() }), }); if (!res.ok) throw new Error(`${res.status}`); const data = await res.json() as { answer?: string; result?: string }; setAnswer(data.answer ?? data.result ?? '—'); setStatus('done'); } catch { setStatus('error'); } }, [query, workspaceId]); return (
Query
setQuery(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void submit(); e.stopPropagation(); }} placeholder="Ask across artboards…" style={{ flex: 1, background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 5, padding: '5px 8px', fontSize: '0.5875rem', fontFamily: 'inherit', color: 'rgba(255,255,255,0.75)', outline: 'none', }} />
{status === 'done' && answer && (
{answer}
)} {status === 'error' && (
Query failed — try again
)}
); }