improved a lot of things
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
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 } from '@/hooks/useArtboards';
|
||||
import { useArtboards, patchArtboard } from '@/hooks/useArtboards';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const T = {
|
||||
bg: '#111115',
|
||||
@@ -35,8 +36,36 @@ const treeThemeStyles = themeToTreeStyles({
|
||||
});
|
||||
|
||||
export function ArtboardNavigator() {
|
||||
const { selectedArtboardId, selectArtboard, workspaceId, projectId } = useCanvas();
|
||||
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]);
|
||||
|
||||
// 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
|
||||
@@ -68,10 +97,12 @@ export function ArtboardNavigator() {
|
||||
<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
|
||||
@@ -79,7 +110,8 @@ export function ArtboardNavigator() {
|
||||
/>
|
||||
}
|
||||
label={ab.label}
|
||||
after={sel && <ActiveDot />}
|
||||
onRename={() => void renameArtboard(ab.id, ab.label)}
|
||||
onDelete={() => void deleteArtboard(ab.id, ab.label)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -107,11 +139,14 @@ export function ArtboardNavigator() {
|
||||
|
||||
{/* ── Graph stats ── */}
|
||||
<SectionLabel>Graph</SectionLabel>
|
||||
<div style={{ padding: '4px 14px 14px', display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
||||
<GraphStat label="artboards" value={String(rawArtboards.length || artboards.length)} color={T.accent} />
|
||||
<GraphStat label="components" value="—" color="rgba(255,255,255,0.45)" />
|
||||
<GraphStat label="tokens" value="—" color="rgba(255,255,255,0.45)" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -119,16 +154,20 @@ export function ArtboardNavigator() {
|
||||
/* ── Artboard row ─────────────────────────────────────────── */
|
||||
function NavRow({
|
||||
selected = false,
|
||||
live = false,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
after,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: {
|
||||
selected?: boolean;
|
||||
live?: boolean;
|
||||
onClick?: () => void;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
after?: React.ReactNode;
|
||||
onRename?: () => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const [hov, setHov] = useState(false);
|
||||
|
||||
@@ -141,7 +180,7 @@ function NavRow({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
padding: '6px 10px',
|
||||
padding: '4px 6px 4px 10px',
|
||||
borderRadius: 5,
|
||||
cursor: 'pointer',
|
||||
background: selected ? T.selBg : hov ? 'rgba(255,255,255,0.04)' : 'transparent',
|
||||
@@ -155,12 +194,62 @@ function NavRow({
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span style={{ flex: 1 }}>{label}</span>
|
||||
{after}
|
||||
<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 + delete — shown on hover */}
|
||||
{(hov || selected) && (
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
{onRename && (
|
||||
<IconBtn title="Rename" onClick={onRename}>
|
||||
<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>
|
||||
)}
|
||||
{onDelete && (
|
||||
<IconBtn title="Delete" onClick={onDelete} danger>
|
||||
<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 (
|
||||
@@ -210,3 +299,95 @@ function GraphStat({ label, value, color }: { label: string; value: string; colo
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user