improved a lot of things

This commit is contained in:
SinachPat
2026-04-29 04:07:16 +01:00
parent 960646fee3
commit 050c4ce7fe
36 changed files with 856 additions and 226 deletions
@@ -4,6 +4,7 @@ import { useState, useCallback, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useCanvas } from '@/store/canvas';
import { useViewport } from '@/store/viewport';
import { useDiffs } from '@/hooks/useDiffs';
import { LiveArtboard } from './LiveArtboard';
import { SelectionOverlay } from './SelectionOverlay';
import type { FiberNode } from '@originmain/renderer';
@@ -18,11 +19,22 @@ interface ArtboardProps {
renderUrl?: string;
}
// Status color map matching the inspector
const DIFF_STATUS_BADGE: Record<string, { color: string; bg: string; label: string }> = {
DRAFT: { color: '#FFBA7B', bg: 'rgba(255,186,123,0.15)', label: 'draft' },
REVIEWED: { color: '#7EB8FF', bg: 'rgba(126,184,255,0.15)', label: 'reviewed' },
APPLIED: { color: '#10B981', bg: 'rgba(16,185,129,0.15)', label: 'applied' },
REJECTED: { color: '#FF8080', bg: 'rgba(255,128,128,0.15)', label: 'blocked' },
};
export function Artboard({ id, label, x, y, width, height, renderUrl }: ArtboardProps) {
const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, setFiberRoot, selectComponent } = useCanvas();
const selected = selectedArtboardId === id;
const queryClient = useQueryClient();
// Diff status badges — fetch is cached by TanStack Query across all artboards
const { diffs } = useDiffs(id);
// ── Fiber tree (from LiveArtboard) ─────────────────────────────────────────
const [localFiberRoot, setLocalFiberRoot] = useState<FiberNode | undefined>(undefined);
@@ -246,6 +258,45 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
</>
)}
{/* Diff status badge strip — bottom-right overlay, only visible when diffs exist */}
{diffs.length > 0 && (() => {
// Group by status and show compact chips
const counts: Record<string, number> = {};
for (const d of diffs) counts[d.status] = (counts[d.status] ?? 0) + 1;
const entries = Object.entries(counts).filter(([s]) => s in DIFF_STATUS_BADGE);
if (entries.length === 0) return null;
return (
<div
style={{
position: 'absolute', bottom: 6, right: 6, zIndex: 10,
display: 'flex', gap: 4, pointerEvents: 'none',
}}
>
{entries.map(([status, count]) => {
const b = DIFF_STATUS_BADGE[status]!;
return (
<span
key={status}
title={`${count} ${b.label} diff${count !== 1 ? 's' : ''}`}
style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 5px', borderRadius: 4,
background: b.bg, border: `1px solid ${b.color}33`,
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5rem', fontWeight: 600,
color: b.color, letterSpacing: '0.03em',
backdropFilter: 'blur(4px)',
}}
>
<span style={{ width: 4, height: 4, borderRadius: '50%', background: b.color, display: 'inline-block' }} />
{count}
</span>
);
})}
</div>
);
})()}
{/* Content */}
{renderUrl ? (
<>
@@ -10,6 +10,7 @@ import { Inspector } from '../inspector/Inspector';
import { useHistory } from '@/store/history';
import { useCanvas, type Tool } from '@/store/canvas';
import { useViewport } from '@/store/viewport';
import { useTheme } from '@/store/theme';
interface AppChromeProps {
workspaceId?: string;
@@ -22,6 +23,7 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
const selectedArtboardId = useCanvas((s) => s.selectedArtboardId);
const setContext = useCanvas((s) => s.setContext);
const setActiveTool = useCanvas((s) => s.setActiveTool);
const { mode: themeMode, toggle: toggleTheme } = useTheme();
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
useEffect(() => {
@@ -162,6 +164,34 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
<div style={{ flex: 1 }} />
{/* Theme toggle */}
<button
onClick={toggleTheme}
title={`Switch to ${themeMode === 'dark' ? 'light' : 'dark'} mode`}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'rgba(255,255,255,0.35)', padding: '4px 6px',
display: 'flex', alignItems: 'center',
transition: 'color 0.12s',
fontSize: 13,
}}
onMouseEnter={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.75)')}
onMouseLeave={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.35)')}
>
{themeMode === 'dark' ? (
/* Sun icon */
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="2.5" stroke="currentColor" strokeWidth="1.2"/>
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13M2.93 2.93l1.06 1.06M10.01 10.01l1.06 1.06M2.93 11.07l1.06-1.06M10.01 3.99l1.06-1.06" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
</svg>
) : (
/* Moon icon */
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
<path d="M12 8.5A5.5 5.5 0 0 1 5.5 2a5.5 5.5 0 1 0 6.5 6.5z" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</button>
<div style={{ transform: 'scale(0.8)', transformOrigin: 'right center' }}>
<UserButton />
</div>
@@ -542,19 +542,47 @@ function HSep() {
function DiffTab({ artboardId }: { artboardId: string | null }) {
const { stacks } = useHistory();
const { diffs, createDiff, isLoading } = useDiffs(artboardId);
const [summaryStatus, setSummaryStatus] = useState<'idle' | 'summarising' | 'exporting'>('idle');
const artboardHistory = artboardId ? (stacks[artboardId] ?? { past: [], future: [] }) : { past: [], future: [] };
const pendingChanges: PropChange[] = artboardHistory.past.flatMap(e => e.changes);
const hasChanges = pendingChanges.length > 0;
const exportDiff = useCallback(() => {
const exportDiff = useCallback(async () => {
if (!artboardId || !hasChanges) return;
createDiff.mutate({
artboard_id: artboardId,
changes_jsonb: { propChanges: pendingChanges, styleChanges: [] },
summary: '',
status: 'DRAFT',
});
// 1. Generate AI summary (best-effort — fall back to empty string on failure)
let summary = '';
const meaningfulChanges = pendingChanges.filter(c => c.changeType !== 'unchanged');
if (meaningfulChanges.length > 0) {
setSummaryStatus('summarising');
try {
const res = await fetch('/api/ai/diff-summary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
changesJson: JSON.stringify(meaningfulChanges),
componentName: meaningfulChanges[0]?.key ?? 'Component',
}),
});
if (res.ok) {
const data = await res.json() as { summary?: string };
summary = data.summary ?? '';
}
} catch { /* non-fatal — proceed without summary */ }
}
// 2. Export diff with AI-generated summary included
setSummaryStatus('exporting');
createDiff.mutate(
{
artboard_id: artboardId,
changes_jsonb: { propChanges: pendingChanges, styleChanges: [] },
summary,
status: 'DRAFT',
},
{ onSettled: () => setSummaryStatus('idle') },
);
}, [artboardId, pendingChanges, hasChanges, createDiff]);
if (!artboardId) {
@@ -579,18 +607,22 @@ function DiffTab({ artboardId }: { artboardId: string | null }) {
<DiffChangeRow key={i} change={change} />
))}
<button
onClick={exportDiff}
disabled={createDiff.isPending}
onClick={() => void exportDiff()}
disabled={summaryStatus !== 'idle' || createDiff.isPending}
style={{
marginTop: 10, width: '100%',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
background: T.accent, border: 'none', borderRadius: 6,
color: '#fff', padding: '7px 0', cursor: createDiff.isPending ? 'wait' : 'pointer',
letterSpacing: '0.04em', opacity: createDiff.isPending ? 0.6 : 1,
color: '#fff', padding: '7px 0',
cursor: (summaryStatus !== 'idle' || createDiff.isPending) ? 'wait' : 'pointer',
letterSpacing: '0.04em',
opacity: (summaryStatus !== 'idle' || createDiff.isPending) ? 0.6 : 1,
transition: 'opacity 0.15s',
}}
>
{createDiff.isPending ? 'Exporting' : 'Export diff →'}
{summaryStatus === 'summarising' ? 'Summarising…' :
summaryStatus === 'exporting' || createDiff.isPending ? 'Exporting…' :
'Export diff →'}
</button>
{createDiff.isError && (
<span style={{ fontSize: '0.5rem', color: '#FF8080', fontFamily: 'monospace', display: 'block', marginTop: 4 }}>
@@ -5,7 +5,7 @@ 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 } from '@/hooks/useArtboards';
import { useArtboards, patchArtboard, createArtboardMutation } from '@/hooks/useArtboards';
import { useQueryClient } from '@tanstack/react-query';
const T = {
@@ -61,6 +61,30 @@ export function ArtboardNavigator() {
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,
@@ -112,6 +136,7 @@ export function ArtboardNavigator() {
}
label={ab.label}
onRename={() => void renameArtboard(ab.id, ab.label)}
onFork={() => void forkArtboard(ab.id, ab.label)}
onDelete={() => void deleteArtboard(ab.id, ab.label)}
/>
);
@@ -160,6 +185,7 @@ function NavRow({
icon,
label,
onRename,
onFork,
onDelete,
}: {
selected?: boolean;
@@ -168,6 +194,7 @@ function NavRow({
icon: React.ReactNode;
label: string;
onRename?: () => void;
onFork?: () => void;
onDelete?: () => void;
}) {
const [hov, setHov] = useState(false);
@@ -206,18 +233,31 @@ function NavRow({
}} />
)}
{/* Action buttons: rename + delete — shown on hover */}
{/* 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>
+64 -10
View File
@@ -2,49 +2,69 @@
import Link from 'next/link';
import { UserButton } from '@clerk/nextjs';
import { useTheme } from '@/store/theme';
interface Crumb { label: string; href?: string }
interface AppHeaderProps {
breadcrumbs?: Crumb[];
/** @deprecated Pass breadcrumbs instead */
workspaceName?: string;
/** @deprecated Pass breadcrumbs instead */
workspaceId?: string;
}
export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
export function AppHeader({ breadcrumbs = [], workspaceName, workspaceId }: AppHeaderProps) {
const { mode, toggle } = useTheme();
// Back-compat: if old props are passed without breadcrumbs, synthesise them
const crumbs: Crumb[] = breadcrumbs.length > 0
? breadcrumbs
: workspaceName
? [
{ label: 'Workspaces', href: '/workspaces' as string },
...(workspaceId
? [{ label: workspaceName, href: `/workspace/${workspaceId}` as string }]
: [{ label: workspaceName }]),
]
: [];
return (
<header style={{
position: 'sticky', top: 0, zIndex: 100,
height: 56,
background: 'rgba(255,255,255,0.92)',
background: mode === 'dark' ? 'rgba(12,12,16,0.92)' : 'rgba(255,255,255,0.92)',
backdropFilter: 'blur(12px)',
borderBottom: '1px solid rgba(0,0,0,0.07)',
borderBottom: mode === 'dark' ? '1px solid rgba(255,255,255,0.07)' : '1px solid rgba(0,0,0,0.07)',
display: 'flex', alignItems: 'center',
padding: '0 24px',
gap: 0,
}}>
{/* Logo */}
<Link href="/workspaces" style={{ textDecoration: 'none', flexShrink: 0 }}>
<span style={{ fontSize: '1rem', fontWeight: 700, letterSpacing: '-0.02em', color: '#0A0A0A' }}>
<span style={{ fontSize: '1rem', fontWeight: 700, letterSpacing: '-0.02em', color: mode === 'dark' ? '#FAFAFA' : '#0A0A0A' }}>
Origin<span style={{ color: '#0066FF' }}>main</span>
</span>
</Link>
{/* Breadcrumbs */}
{breadcrumbs.map((crumb, i) => (
{crumbs.map((crumb, i) => (
<span key={i} style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ margin: '0 8px', color: '#D4D4D8', fontSize: '0.875rem' }}>/</span>
<span style={{ margin: '0 8px', color: mode === 'dark' ? 'rgba(255,255,255,0.2)' : '#D4D4D8', fontSize: '0.875rem' }}>/</span>
{crumb.href ? (
<Link href={crumb.href} style={{
fontSize: '0.875rem', fontWeight: 500,
color: '#71717A', textDecoration: 'none',
color: mode === 'dark' ? 'rgba(255,255,255,0.45)' : '#71717A',
textDecoration: 'none',
transition: 'color 0.1s',
}}
onMouseEnter={e => (e.currentTarget.style.color = '#0A0A0A')}
onMouseLeave={e => (e.currentTarget.style.color = '#71717A')}
onMouseEnter={e => (e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.85)' : '#0A0A0A')}
onMouseLeave={e => (e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.45)' : '#71717A')}
>
{crumb.label}
</Link>
) : (
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#0A0A0A' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: mode === 'dark' ? '#FAFAFA' : '#0A0A0A' }}>
{crumb.label}
</span>
)}
@@ -53,6 +73,40 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
<div style={{ flex: 1 }} />
{/* Theme toggle */}
<button
onClick={toggle}
title={`Switch to ${mode === 'dark' ? 'light' : 'dark'} mode`}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: mode === 'dark' ? 'rgba(255,255,255,0.4)' : '#A1A1AA',
padding: '6px 8px', marginRight: 8,
display: 'flex', alignItems: 'center', borderRadius: 6,
transition: 'color 0.12s, background 0.12s',
}}
onMouseEnter={e => {
e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.85)' : '#0A0A0A';
e.currentTarget.style.background = mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)';
}}
onMouseLeave={e => {
e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.4)' : '#A1A1AA';
e.currentTarget.style.background = 'none';
}}
>
{mode === 'dark' ? (
/* Sun */
<svg width="15" height="15" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="2.5" stroke="currentColor" strokeWidth="1.2"/>
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13M2.93 2.93l1.06 1.06M10.01 10.01l1.06 1.06M2.93 11.07l1.06-1.06M10.01 3.99l1.06-1.06" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
</svg>
) : (
/* Moon */
<svg width="15" height="15" viewBox="0 0 14 14" fill="none">
<path d="M12 8.5A5.5 5.5 0 0 1 5.5 2a5.5 5.5 0 1 0 6.5 6.5z" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</button>
<UserButton />
</header>
);
@@ -377,10 +377,18 @@ export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }
border: '1px solid rgba(239,68,68,0.35)', background: 'transparent',
color: '#EF4444', cursor: 'pointer',
}}
onClick={() => {
if (window.confirm(`Delete workspace "${workspaceName}"? This cannot be undone.`)) {
// TODO: call DELETE /api/workspace/:id when implemented
window.alert('Delete endpoint not yet implemented — coming soon.');
onClick={async () => {
if (!window.confirm(`Delete workspace "${workspaceName}"? This cannot be undone. All projects and artboards will be permanently lost.`)) return;
try {
const res = await fetch(`/api/workspace/${workspaceId}`, { method: 'DELETE' });
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body.error ?? `Server error ${res.status}`);
}
router.push('/workspaces');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
window.alert(`Could not delete workspace: ${msg}`);
}
}}
>