Files
wursor/packages/app/src/components/navigator/ArtboardNavigator.tsx
T
2026-04-29 04:07:16 +01:00

435 lines
16 KiB
TypeScript

'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<string, unknown>) };
// 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 (
<div
className="dark-panel"
style={{
gridColumn: 1,
gridRow: 2,
background: T.bg,
borderRight: `1px solid ${T.border}`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
fontSize: 12,
}}
>
{/* ── Artboards ── */}
<SectionLabel>Artboards</SectionLabel>
<div style={{ padding: '2px 6px 0' }}>
{artboards.map((ab) => {
const sel = selectedArtboardId === ab.id;
const live = liveArtboardIds.has(ab.id);
return (
<NavRow
key={ab.id}
selected={sel}
live={live}
onClick={() => selectArtboard(ab.id)}
icon={
<SquareRegular
style={{ fontSize: 11, color: sel ? T.accent : 'rgba(255,255,255,0.2)', flexShrink: 0 }}
/>
}
label={ab.label}
onRename={() => void renameArtboard(ab.id, ab.label)}
onFork={() => void forkArtboard(ab.id, ab.label)}
onDelete={() => void deleteArtboard(ab.id, ab.label)}
/>
);
})}
</div>
<HSep />
{/* ── Files — @pierre/trees ── */}
<SectionLabel>Files</SectionLabel>
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<FileTree
model={model}
style={{
...treeThemeStyles,
height: '100%',
width: '100%',
// Fine-tune item sizing to match our panel density
'--trees-item-height': '26px',
'--trees-indent-width': '14px',
} as React.CSSProperties}
/>
</div>
<HSep />
{/* ── Graph stats ── */}
<SectionLabel>Graph</SectionLabel>
<div style={{ padding: '4px 14px 8px', display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<GraphStat label="artboards" value={String(rawArtboards.length)} color={T.accent} />
<GraphStat label="live" value={liveCount > 0 ? String(liveCount) : '—'} color={liveCount > 0 ? '#10B981' : 'rgba(255,255,255,0.25)'} />
<GraphStat label="components" value={totalComponents > 0 ? String(totalComponents) : '—'} color="rgba(255,255,255,0.45)" />
</div>
{/* ── Cross-artboard query ── */}
<CrossArtboardQuery workspaceId={workspaceId} />
</div>
);
}
/* ── 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 (
<div
onClick={onClick}
onMouseEnter={() => 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}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
{/* Live render indicator — pulsing green dot */}
{live && !hov && (
<span style={{
width: 5, height: 5, borderRadius: '50%',
background: '#10B981', flexShrink: 0, display: 'block',
boxShadow: '0 0 4px rgba(16,185,129,0.8)',
}} />
)}
{/* Action buttons: rename + fork + delete — shown on hover */}
{(hov || selected) && (
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
{onRename && (
<IconBtn title="Rename" onClick={onRename}>
{/* Pencil */}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M1 7.5L7 1.5l1.5 1.5-6 6H1V7.5z" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</IconBtn>
)}
{onFork && (
<IconBtn title="Fork" onClick={onFork}>
{/* Branch / fork icon */}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<circle cx="2" cy="2" r="1.2" stroke="currentColor" strokeWidth="1"/>
<circle cx="8" cy="2" r="1.2" stroke="currentColor" strokeWidth="1"/>
<circle cx="2" cy="8" r="1.2" stroke="currentColor" strokeWidth="1"/>
<path d="M2 3.2v1.3C2 5.4 2.6 6 3.5 6H5M8 3.2V5a1 1 0 0 1-1 1H5m0 0v2" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</IconBtn>
)}
{onDelete && (
<IconBtn title="Delete" onClick={onDelete} danger>
{/* Trash */}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M2 2.5h6M4 2.5V1.5h2V2.5M3 2.5v6h4v-6" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</IconBtn>
)}
</div>
)}
</div>
);
}
function IconBtn({ children, title, onClick, danger }: { children: React.ReactNode; title: string; onClick: () => void; danger?: boolean }) {
const [hov, setHov] = useState(false);
return (
<button
title={title}
onClick={onClick}
onMouseEnter={() => setHov(true)}
onMouseLeave={() => setHov(false)}
style={{
background: hov ? (danger ? 'rgba(255,80,80,0.15)' : 'rgba(255,255,255,0.08)') : 'none',
border: 'none', borderRadius: 3, padding: '2px 3px',
cursor: 'pointer',
color: hov ? (danger ? '#FF6060' : 'rgba(255,255,255,0.8)') : 'rgba(255,255,255,0.3)',
transition: 'background 0.1s, color 0.1s',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{children}
</button>
);
}
/* ── Helpers ──────────────────────────────────────────────── */
function SectionLabel({ children }: { children: string }) {
return (
<div style={{
padding: '12px 12px 5px',
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5625rem',
fontWeight: 500,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: T.dim,
userSelect: 'none',
flexShrink: 0,
}}>
{children}
</div>
);
}
function HSep() {
return <div style={{ height: 1, background: T.sep, margin: '6px 0', flexShrink: 0 }} />;
}
function ActiveDot() {
return (
<span style={{
marginLeft: 'auto',
width: 5,
height: 5,
borderRadius: '50%',
background: T.accent,
flexShrink: 0,
display: 'block',
}} />
);
}
function GraphStat({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.28)' }}>
{label}
</span>
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.625rem', color, fontWeight: 600 }}>
{value}
</span>
</div>
);
}
function countFiberNodes(node: { children?: unknown[] }): number {
return 1 + (node.children ?? []).reduce<number>(
(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 (
<div style={{ padding: '0 10px 14px', flexShrink: 0 }}>
<div style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5rem', fontWeight: 500, letterSpacing: '0.1em',
textTransform: 'uppercase', color: T.dim, padding: '8px 4px 6px',
}}>
Query
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input
value={query}
onChange={e => 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',
}}
/>
<button
onClick={() => void submit()}
disabled={status === 'loading' || !query.trim() || !workspaceId}
style={{
background: T.accent, border: 'none', borderRadius: 5,
padding: '5px 8px', cursor: 'pointer',
fontSize: '0.5875rem', color: '#fff', flexShrink: 0,
opacity: (status === 'loading' || !query.trim()) ? 0.5 : 1,
}}
>
{status === 'loading' ? '…' : '↵'}
</button>
</div>
{status === 'done' && answer && (
<div style={{
marginTop: 6, padding: '6px 8px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 5, fontSize: '0.5875rem',
color: 'rgba(255,255,255,0.6)', lineHeight: 1.55,
fontFamily: "'Inter', sans-serif",
maxHeight: 120, overflow: 'auto',
}}>
{answer}
</div>
)}
{status === 'error' && (
<div style={{ marginTop: 4, fontSize: '0.5rem', color: '#FF8080', fontFamily: 'monospace' }}>
Query failed try again
</div>
)}
</div>
);
}