improved a lot of things

This commit is contained in:
SinachPat
2026-04-27 04:52:15 +01:00
parent 197313c0ef
commit 9d9d7d7a37
25 changed files with 1598 additions and 288 deletions
@@ -1,20 +1,85 @@
// POST /api/ai/drift-report
// Analyzes a screenshot against the active Design Language File for drift violations.
// Accepts { artboard_id } — fetches artboard metadata + workspace DLF from DB,
// then calls generateDriftReport. Screenshots are optional; text-only analysis runs
// when the artboard has no renderUrl or the screenshot is not provided by the client.
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { AIGateway, generateDriftReport } from '@originmain/ai-layer';
import type { DriftReportInput } from '@originmain/ai-layer';
import { serverClient } from '@/lib/supabase';
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = (await req.json()) as DriftReportInput;
const body = (await req.json().catch(() => ({}))) as {
artboard_id?: string;
/** Optional — client may pass a base64 screenshot captured from the live iframe */
screenshot_base64?: string;
};
if (!body.artboard_id) {
return NextResponse.json({ error: 'artboard_id is required' }, { status: 400 });
}
const db = serverClient();
// Fetch artboard + verify the caller is a workspace member
const { data: artboard } = await db
.from('artboards')
.select('id, name, workspace_id, project_id, metadata_jsonb')
.eq('id', body.artboard_id)
.single() as unknown as {
data: {
id: string;
name: string;
workspace_id: string;
project_id: string | null;
metadata_jsonb: Record<string, unknown>;
} | null;
};
if (!artboard) {
return NextResponse.json({ error: 'Artboard not found' }, { status: 404 });
}
// Auth gate: require workspace membership
const { data: member } = await db
.from('team_members')
.select('id')
.eq('workspace_id', artboard.workspace_id)
.eq('user_id', userId)
.limit(1)
.single();
if (!member) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Fetch the most recently updated Design Language File for this workspace (optional)
const { data: dlf } = await db
.from('design_language_files')
.select('schema_jsonb, name')
.eq('workspace_id', artboard.workspace_id)
.order('updated_at', { ascending: false })
.limit(1)
.single() as unknown as { data: { schema_jsonb: unknown; name: string } | null };
const meta = artboard.metadata_jsonb;
const artboardContext = [
`Artboard: ${artboard.name}`,
`Size: ${meta['width'] ?? '?'} × ${meta['height'] ?? '?'}`,
...(meta['renderUrl'] ? [`Render URL: ${String(meta['renderUrl'])}`] : []),
...(artboard.project_id ? [`Project ID: ${artboard.project_id}`] : []),
].join('\n');
try {
const gateway = new AIGateway();
const result = await generateDriftReport(gateway, body);
const result = await generateDriftReport(gateway, {
artboardContext,
...(dlf ? { dlfJson: JSON.stringify(dlf.schema_jsonb) } : {}),
...(body.screenshot_base64 ? { screenshotBase64: body.screenshot_base64 } : {}),
});
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'AI error';
@@ -98,6 +98,8 @@ export async function POST(
if (!workspaceId) {
return NextResponse.json({ error: 'workspace query param is required' }, { status: 400 });
}
// Optional: ?project=<uuid> scopes the created artboard to a specific project
const projectId = req.nextUrl.searchParams.get('project');
const rawBody = Buffer.from(await req.arrayBuffer());
@@ -147,7 +149,7 @@ export async function POST(
const artboard = await createArtboard(db, {
workspace_id: workspaceId,
project_id: null,
project_id: projectId,
name: result.artboardTitle,
origin_id: origin.id,
parent_artboard_id: null,
@@ -0,0 +1,103 @@
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { serverClient } from '@/lib/supabase';
import { ProjectSettingsForm } from '@/components/shell/ProjectSettingsForm';
export async function generateMetadata({ params }: { params: Promise<{ wid: string; pid: string }> }) {
const { pid } = await params;
const db = serverClient();
const { data } = await db.from('projects').select('name').eq('id', pid).single() as unknown as {
data: { name: string } | null;
};
return { title: `${data?.name ?? 'Project'} Settings — Originmain` };
}
export default async function ProjectSettingsPage({
params,
}: {
params: Promise<{ wid: string; pid: string }>;
}) {
const { wid, pid } = await params;
const { userId } = await auth();
if (!userId) redirect('/sign-in');
const db = serverClient();
// Verify membership and role
const { data: member } = await db
.from('team_members')
.select('role')
.eq('workspace_id', wid)
.eq('user_id', userId)
.limit(1)
.single() as unknown as { data: { role: string } | null };
if (!member) redirect('/workspaces');
// Fetch workspace name + project
type WsRow = { data: { name: string } | null };
type ProjRow = { data: { id: string; name: string; description: string | null; app_url: string | null; framework: string | null } | null };
const [wsResult, projResult] = await Promise.all([
db.from('workspaces').select('name').eq('id', wid).single() as unknown as Promise<WsRow>,
db.from('projects').select('id, name, description, app_url, framework').eq('id', pid).eq('workspace_id', wid).single() as unknown as Promise<ProjRow>,
]);
if (!projResult.data) redirect(`/workspace/${wid}`);
const project = projResult.data;
return (
<div style={{
minHeight: '100dvh',
background: '#F5F5F7',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
}}>
{/* Header */}
<div style={{
background: '#FFFFFF',
borderBottom: '1px solid rgba(0,0,0,0.07)',
padding: '0 32px',
display: 'flex',
alignItems: 'center',
height: 56,
gap: 0,
}}>
<Link href="/workspaces" style={{ textDecoration: 'none', fontSize: '0.875rem', fontWeight: 700, color: '#0A0A0A', letterSpacing: '-0.01em' }}>
Origin<span style={{ color: '#3385FF' }}>main</span>
</Link>
<span style={{ color: 'rgba(0,0,0,0.2)', margin: '0 8px', fontSize: '0.875rem' }}>/</span>
<Link href={`/workspace/${wid}`} style={{ textDecoration: 'none', fontSize: '0.875rem', color: '#52525B', fontWeight: 500 }}>
{wsResult.data?.name ?? 'Workspace'}
</Link>
<span style={{ color: 'rgba(0,0,0,0.2)', margin: '0 8px', fontSize: '0.875rem' }}>/</span>
<Link href={`/workspace/${wid}/project/${pid}`} style={{ textDecoration: 'none', fontSize: '0.875rem', color: '#52525B', fontWeight: 500 }}>
{project.name}
</Link>
<span style={{ color: 'rgba(0,0,0,0.2)', margin: '0 8px', fontSize: '0.875rem' }}>/</span>
<span style={{ fontSize: '0.875rem', color: '#0A0A0A', fontWeight: 600 }}>Settings</span>
</div>
{/* Content */}
<div style={{ maxWidth: 640, margin: '0 auto', padding: '40px 24px' }}>
<h1 style={{ fontSize: '1.375rem', fontWeight: 700, color: '#0A0A0A', margin: '0 0 6px', letterSpacing: '-0.02em' }}>
Project settings
</h1>
<p style={{ fontSize: '0.875rem', color: '#71717A', margin: '0 0 32px', lineHeight: 1.6 }}>
Configure your project name, URL, and framework.
</p>
<ProjectSettingsForm
workspaceId={wid}
projectId={pid}
initialName={project.name}
initialDescription={project.description ?? ''}
initialAppUrl={project.app_url ?? ''}
initialFramework={project.framework ?? ''}
memberRole={member.role}
/>
</div>
</div>
);
}
+55 -146
View File
@@ -19,24 +19,24 @@ interface ArtboardProps {
}
export function Artboard({ id, label, x, y, width, height, renderUrl }: ArtboardProps) {
const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, selectComponent } = useCanvas();
const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, setFiberRoot, selectComponent } = useCanvas();
const selected = selectedArtboardId === id;
const queryClient = useQueryClient();
// ── Fiber tree (from LiveArtboard) ─────────────────────────────────────────
const [fiberRoot, setFiberRoot] = useState<FiberNode | undefined>(undefined);
const [localFiberRoot, setLocalFiberRoot] = useState<FiberNode | undefined>(undefined);
const handleFiberUpdate = useCallback((root: FiberNode) => {
setFiberRoot(root);
setFiberRoot(id, root);
setArtboardLive(id, true);
}, [id, setArtboardLive]);
setLocalFiberRoot(root);
}, [id, setFiberRoot, setArtboardLive]);
const handleComponentSelected = useCallback((nodeId: string) => {
if (!fiberRoot) return;
// Walk fiber tree to find the selected node
const node = findFiberNode(fiberRoot, nodeId);
if (!localFiberRoot) return;
const node = findFiberNode(localFiberRoot, nodeId);
selectComponent(nodeId, node ?? null);
}, [fiberRoot, selectComponent]);
}, [localFiberRoot, selectComponent]);
// ── Drag to reposition ─────────────────────────────────────────────────────
const isDragging = useRef(false);
@@ -121,6 +121,17 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
}).catch(console.error);
}, [id, renameValue, label, workspaceId, projectId, queryClient]);
// ── Delete artboard ────────────────────────────────────────────────────────
const deleteArtboard = useCallback(() => {
if (!window.confirm(`Delete "${label}"? This cannot be undone.`)) return;
fetch(`/api/artboards/${id}`, { method: 'DELETE' })
.then(() => {
selectArtboard(null);
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
})
.catch(console.error);
}, [id, label, selectArtboard, workspaceId, projectId, queryClient]);
const effectiveX = x + (isDragging.current ? dragOffset.dx : 0);
const effectiveY = y + (isDragging.current ? dragOffset.dy : 0);
@@ -171,19 +182,41 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
}}
/>
) : (
<span
style={{
fontSize: 11,
fontFamily: "'JetBrains Mono', 'SF Mono', ui-monospace, monospace",
fontWeight: selected ? 500 : 400,
color: selected ? '#3385FF' : 'rgba(255,255,255,0.35)',
whiteSpace: 'nowrap',
letterSpacing: '-0.01em',
transition: 'color 0.15s',
}}
>
{label}
</span>
<>
<span
style={{
fontSize: 11,
fontFamily: "'JetBrains Mono', 'SF Mono', ui-monospace, monospace",
fontWeight: selected ? 500 : 400,
color: selected ? '#3385FF' : 'rgba(255,255,255,0.35)',
whiteSpace: 'nowrap',
letterSpacing: '-0.01em',
transition: 'color 0.15s',
}}
>
{label}
</span>
{/* Delete button — only visible when selected */}
{selected && (
<button
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); deleteArtboard(); }}
title="Delete artboard"
style={{
marginLeft: 6,
background: 'none', border: 'none',
padding: '1px 3px', borderRadius: 3,
cursor: 'pointer', color: 'rgba(255,80,80,0.6)',
fontSize: 11, lineHeight: 1,
transition: 'color 0.12s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.color = '#FF5050'; }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.color = 'rgba(255,80,80,0.6)'; }}
>
</button>
)}
</>
)}
</div>
@@ -226,7 +259,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
/>
<SelectionOverlay
artboardId={id}
{...(fiberRoot !== undefined ? { fiberRoot } : {})}
{...(localFiberRoot !== undefined ? { fiberRoot: localFiberRoot } : {})}
width={width}
height={height}
/>
@@ -350,18 +383,6 @@ function EmptyArtboardContent({
);
}
// ── Demo content (only for hardcoded demo IDs) ────────────────────────────────
function ArtboardContent({ id }: { id: string }) {
switch (id) {
case 'dashboard-card': return <DashboardCard />;
case 'user-profile': return <UserProfile />;
case 'nav-sidebar': return <NavSidebar />;
case 'data-table': return <DataTable />;
default: return null; // shouldn't reach here for real artboards
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function findFiberNode(root: FiberNode, nodeId: string): FiberNode | null {
@@ -384,115 +405,3 @@ function Handle({ pos }: { pos: React.CSSProperties }) {
);
}
// ── Demo card components ──────────────────────────────────────────────────────
function DashboardCard() {
return (
<div style={{ padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#111', letterSpacing: '-0.01em' }}>Revenue Overview</span>
<span style={{ fontSize: 9, background: '#ECFDF5', color: '#059669', padding: '2px 7px', borderRadius: 99, fontWeight: 600, fontFamily: 'monospace' }}>Live</span>
</div>
<div style={{ fontSize: 24, fontWeight: 800, color: '#0A0A0A', letterSpacing: '-0.045em', lineHeight: 1, marginBottom: 4 }}>$12,450</div>
<div style={{ fontSize: 10, color: '#059669', fontWeight: 500, marginBottom: 14, display: 'flex', alignItems: 'center', gap: 3 }}>
<span></span> +2.4% vs last month
</div>
<div style={{ height: 3, background: '#F0F0F0', borderRadius: 99, overflow: 'hidden', marginBottom: 14 }}>
<div style={{ height: '100%', width: '68%', background: 'linear-gradient(90deg, #0066FF, #3385FF)', borderRadius: 99 }} />
</div>
<div style={{ display: 'flex', gap: 4 }}>
{['Q4 2024', 'MRR', 'SaaS'].map(t => (
<span key={t} style={{ fontSize: 9, background: '#F4F4F5', color: '#71717A', padding: '3px 8px', borderRadius: 99, fontWeight: 500 }}>{t}</span>
))}
</div>
</div>
);
}
function UserProfile() {
return (
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%' }}>
<div style={{ width: 52, height: 52, borderRadius: '50%', background: 'linear-gradient(135deg, #7C3AED, #0066FF)', marginBottom: 12 }} />
<div style={{ fontSize: 13, fontWeight: 700, color: '#0A0A0A', letterSpacing: '-0.025em', marginBottom: 3 }}>Sarah Chen</div>
<div style={{ fontSize: 10, color: '#A1A1AA', marginBottom: 16 }}>Design Engineer</div>
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 7 }}>
{[['Team', 'Acme Inc'], ['Role', 'Admin'], ['Plan', 'Team']].map(([k, v]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10 }}>
<span style={{ color: '#A1A1AA' }}>{k}</span>
<span style={{ fontWeight: 500, color: '#0A0A0A' }}>{v}</span>
</div>
))}
</div>
</div>
);
}
function NavSidebar() {
const items = [
{ icon: '⊞', label: 'Dashboard', active: true },
{ icon: '◉', label: 'Origin Graph', active: false },
{ icon: '⬜', label: 'Artboards', active: false },
{ icon: '△', label: 'Diffs', active: false },
{ icon: '🔗', label: 'Integrations',active: false },
];
return (
<div style={{ height: '100%', background: '#FAFAFA', display: 'flex', flexDirection: 'column', padding: '12px 0' }}>
<div style={{ padding: '0 12px 12px', fontSize: 11, fontWeight: 800, letterSpacing: '-0.04em', color: '#0A0A0A' }}>
Origin<span style={{ color: '#0066FF' }}>main</span>
</div>
<div style={{ height: 1, background: '#EBEBEB', margin: '0 0 8px' }} />
{items.map(({ icon, label, active }) => (
<div key={label} style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 12px', margin: '1px 6px', borderRadius: 5,
background: active ? 'rgba(0,102,255,0.07)' : 'transparent',
fontSize: 10, fontWeight: active ? 600 : 400,
color: active ? '#0066FF' : '#52525B', cursor: 'default',
}}>
<span style={{ fontSize: 11 }}>{icon}</span>{label}
</div>
))}
</div>
);
}
function DataTable() {
const rows = [
{ name: 'DashboardCard', status: 'Live', nodes: 12, tokens: 8 },
{ name: 'UserProfile', status: 'Draft', nodes: 7, tokens: 3 },
{ name: 'NavSidebar', status: 'Live', nodes: 19, tokens: 11 },
{ name: 'DataTable', status: 'Review', nodes: 24, tokens: 14 },
];
return (
<div style={{ padding: '16px 0', height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '0 16px 12px', fontSize: 11, fontWeight: 700, color: '#0A0A0A', letterSpacing: '-0.02em' }}>
Component Inventory
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 60px 50px 50px', padding: '0 16px 6px', gap: 4 }}>
{['Name', 'Status', 'Nodes', 'Tokens'].map(h => (
<span key={h} style={{ fontFamily: 'monospace', fontSize: 8, color: '#A1A1AA', letterSpacing: '0.05em', textTransform: 'uppercase' }}>{h}</span>
))}
</div>
{rows.map((row, i) => (
<div key={row.name} style={{
display: 'grid', gridTemplateColumns: '1fr 60px 50px 50px',
padding: '8px 16px', gap: 4,
background: i % 2 === 0 ? 'transparent' : '#FAFAFA', alignItems: 'center',
}}>
<span style={{ fontSize: 10, fontWeight: 500, color: '#0A0A0A' }}>{row.name}</span>
<span style={{
fontSize: 8, fontWeight: 600, fontFamily: 'monospace',
color: row.status === 'Live' ? '#059669' : row.status === 'Draft' ? '#6B7280' : '#D97706',
background: row.status === 'Live' ? '#ECFDF5' : row.status === 'Draft' ? '#F9FAFB' : '#FFFBEB',
padding: '2px 6px', borderRadius: 99, width: 'fit-content',
}}>{row.status}</span>
<span style={{ fontSize: 10, color: '#52525B', fontFamily: 'monospace' }}>{row.nodes}</span>
<span style={{ fontSize: 10, color: '#52525B', fontFamily: 'monospace' }}>{row.tokens}</span>
</div>
))}
</div>
);
}
// Suppress unused warning — kept for demo IDs in ArtboardContent
void ArtboardContent;
+158 -4
View File
@@ -12,7 +12,7 @@ export function Canvas() {
const panX = useViewport((s) => s.panX);
const panY = useViewport((s) => s.panY);
const zoom = useViewport((s) => s.zoom);
const { activeTool, setActiveTool, selectArtboard, workspaceId, projectId } = useCanvas();
const { activeTool, setActiveTool, selectArtboard, selectedArtboardId, workspaceId, projectId } = useCanvas();
const { artboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
const queryClient = useQueryClient();
@@ -23,6 +23,7 @@ export function Canvas() {
// Zone tool: drag to draw a completion zone
const zoneStart = useRef<{ x: number; y: number } | null>(null);
const [zonePreview, setZonePreview] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const [zoneDone, setZoneDone] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
// Wheel: pan or pinch-zoom
useEffect(() => {
@@ -125,13 +126,15 @@ export function Canvas() {
const onMouseUp = useCallback(() => {
isPanning.current = false;
// Zone tool: finalise zone on mouse-up (clear preview; zone result is handled elsewhere)
if (activeTool === 'zone' && zoneStart.current) {
// Zone tool: finalise zone — keep the bounds and show prompt popup
if (activeTool === 'zone' && zoneStart.current && zonePreview) {
const bounds = { ...zonePreview };
zoneStart.current = null;
setZonePreview(null);
setActiveTool('select');
if (bounds.w > 8 && bounds.h > 8) setZoneDone(bounds);
}
}, [activeTool, setActiveTool]);
}, [activeTool, setActiveTool, zonePreview]);
// Dot grid that shifts with pan and scales with zoom
const gridSpacing = Math.max(6, 20 * zoom);
@@ -215,6 +218,16 @@ export function Canvas() {
)}
</div>
{/* Zone prompt overlay — shown after a zone drag completes */}
{zoneDone && (
<ZonePromptOverlay
bounds={zoneDone}
artboardId={selectedArtboardId}
panX={panX} panY={panY} zoom={zoom}
onClose={() => setZoneDone(null)}
/>
)}
{/* Empty canvas hint — shown only when workspace has no artboards yet */}
{artboards.length === 0 && (
<div style={{
@@ -245,3 +258,144 @@ export function Canvas() {
</div>
);
}
/* ── Zone prompt overlay ──────────────────────────────────── */
function ZonePromptOverlay({
bounds, artboardId, panX, panY, zoom, onClose,
}: {
bounds: { x: number; y: number; w: number; h: number };
artboardId: string | null;
panX: number; panY: number; zoom: number;
onClose: () => void;
}) {
const [prompt, setPrompt] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
const [result, setResult] = useState('');
// Convert canvas → screen coordinates (relative to canvas container)
const screenX = bounds.x * zoom + panX;
const screenY = (bounds.y + bounds.h) * zoom + panY + 10; // 10px below zone
const submit = useCallback(async () => {
if (!prompt.trim() || !artboardId) return;
setStatus('loading');
try {
const res = await fetch('/api/ai/completion-zone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
artboard_id: artboardId,
bounds: { x: bounds.x, y: bounds.y, width: bounds.w, height: bounds.h },
prompt: prompt.trim(),
}),
});
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json() as { completion?: string; result?: string };
setResult(data.completion ?? data.result ?? 'Done');
setStatus('done');
} catch (e) {
console.error('[ZonePrompt]', e);
setStatus('error');
}
}, [prompt, artboardId, bounds]);
return (
<div
style={{
position: 'absolute',
left: Math.max(8, screenX),
top: Math.max(8, screenY),
zIndex: 50,
width: 280,
background: '#1A1A20',
border: '1px solid rgba(51,133,255,0.35)',
borderRadius: 10,
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
padding: '12px 14px',
fontFamily: "'Inter', -apple-system, sans-serif",
}}
onMouseDown={e => e.stopPropagation()}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<span style={{ fontSize: '0.6875rem', fontWeight: 600, color: 'rgba(51,133,255,0.9)', letterSpacing: '-0.01em' }}>
Completion zone · {bounds.w}×{bounds.h}
</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'rgba(255,255,255,0.3)', cursor: 'pointer', fontSize: 13, padding: 0, lineHeight: 1 }}></button>
</div>
{status === 'done' ? (
<div style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.75)', lineHeight: 1.6, marginBottom: 10 }}>
{result}
</div>
) : (
<>
<textarea
autoFocus
value={prompt}
onChange={e => setPrompt(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) void submit();
if (e.key === 'Escape') onClose();
e.stopPropagation();
}}
placeholder="Describe what to generate in this zone…"
rows={3}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 6, padding: '8px 10px',
fontSize: '0.75rem', color: 'rgba(255,255,255,0.85)',
fontFamily: 'inherit', resize: 'none', outline: 'none',
marginBottom: 8,
}}
/>
{status === 'error' && (
<p style={{ fontSize: '0.625rem', color: '#FF8080', margin: '0 0 6px' }}>Request failed try again</p>
)}
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => void submit()}
disabled={status === 'loading' || !prompt.trim() || !artboardId}
style={{
flex: 1, padding: '7px 0', borderRadius: 6,
background: !prompt.trim() || !artboardId ? 'rgba(51,133,255,0.3)' : '#3385FF',
border: 'none', color: '#fff',
fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer',
fontFamily: 'inherit', opacity: status === 'loading' ? 0.7 : 1,
}}
>
{status === 'loading' ? 'Generating…' : 'Generate ⌘↵'}
</button>
<button
onClick={onClose}
style={{
padding: '7px 12px', borderRadius: 6,
background: 'transparent', border: '1px solid rgba(255,255,255,0.12)',
color: 'rgba(255,255,255,0.5)', fontSize: '0.75rem',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Cancel
</button>
</div>
</>
)}
{status === 'done' && (
<button
onClick={onClose}
style={{
width: '100%', padding: '7px 0', borderRadius: 6,
background: 'rgba(255,255,255,0.07)', border: 'none',
color: 'rgba(255,255,255,0.5)', fontSize: '0.75rem',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Close
</button>
)}
</div>
);
}
@@ -9,6 +9,7 @@ import { Canvas } from '../canvas/Canvas';
import { Inspector } from '../inspector/Inspector';
import { useHistory } from '@/store/history';
import { useCanvas, type Tool } from '@/store/canvas';
import { useViewport } from '@/store/viewport';
interface AppChromeProps {
workspaceId?: string;
@@ -27,6 +28,25 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
if (workspaceId && projectId) setContext(workspaceId, projectId);
}, [workspaceId, projectId, setContext]);
// Per-workspace viewport persistence: restore saved pan/zoom on mount,
// save current state back to localStorage on unmount.
useEffect(() => {
if (!workspaceId) return;
const key = `originmain:viewport:${workspaceId}`;
try {
const raw = localStorage.getItem(key);
if (raw) {
const saved = JSON.parse(raw) as { panX: number; panY: number; zoom: number };
useViewport.getState().restore(saved);
}
} catch { /* ignore parse errors */ }
return () => {
const { panX, panY, zoom } = useViewport.getState();
localStorage.setItem(key, JSON.stringify({ panX, panY, zoom }));
};
}, [workspaceId]);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
// Skip if user is typing in an input
@@ -116,6 +136,28 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
{projectName ?? 'Canvas'}
</span>
{/* Project settings link */}
{workspaceId && projectId && (
<Link
href={`/workspace/${workspaceId}/project/${projectId}/settings`}
title="Project settings"
style={{
marginLeft: 8,
display: 'flex', alignItems: 'center',
textDecoration: 'none',
color: 'rgba(255,255,255,0.25)',
transition: 'color 0.12s',
}}
onMouseEnter={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.65)')}
onMouseLeave={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.25)')}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M6 7.5A1.5 1.5 0 1 0 6 4.5 1.5 1.5 0 0 0 6 7.5Z" stroke="currentColor" strokeWidth="1" strokeLinecap="round"/>
<path d="M9.2 4.6l.4-1.4-1.2-.7-.9 1.1a3.5 3.5 0 0 0-3 0L3.6 2.5 2.4 3.2l.4 1.4A3.4 3.4 0 0 0 2 6c0 .5.1.9.3 1.4L1.9 8.7 3 9.5l1.1-1a3.5 3.5 0 0 0 3.8 0l1.1 1 1.2-.8-.4-1.3c.2-.5.3-1 .3-1.4a3.4 3.4 0 0 0-.9-2.4Z" stroke="currentColor" strokeWidth="1" strokeLinejoin="round"/>
</svg>
</Link>
)}
<div style={{ flex: 1 }} />
<div style={{ transform: 'scale(0.8)', transformOrigin: 'right center' }}>
@@ -7,6 +7,7 @@ import { useArtboards, patchArtboard } from '@/hooks/useArtboards';
import { useDiffs } from '@/hooks/useDiffs';
import { useQueryClient } from '@tanstack/react-query';
import type { PropChange } from '@originmain/diff-engine';
import type { FiberNode } from '@originmain/renderer';
import type { Artboard, IntentDiff } from '@originmain/origin-graph';
const TYPE_COLORS: Record<string, string> = {
@@ -31,7 +32,7 @@ const T = {
};
export function Inspector() {
const { selectedArtboardId, liveArtboardIds, workspaceId, projectId } = useCanvas();
const { selectedArtboardId, liveArtboardIds, artboardFiberRoots, selectedComponentId, selectedComponentData, workspaceId, projectId } = useCanvas();
const [tab, setTab] = useState<TabId>('props');
const { rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
const selectedArtboard = rawArtboards.find((ab) => ab.id === selectedArtboardId) ?? null;
@@ -104,24 +105,16 @@ export function Inspector() {
</span>
</div>
) : tab === 'props' ? (
<PropsTab artboard={selectedArtboard} workspaceId={workspaceId} projectId={projectId} />
<PropsTab
artboard={selectedArtboard}
selectedComponentData={selectedComponentId ? selectedComponentData : null}
workspaceId={workspaceId}
projectId={projectId}
/>
) : tab === 'diff' ? (
<DiffTab artboardId={selectedArtboardId} />
) : (
<Section label="Origin Graph">
<GraphNode label="DashboardCard" depth={0} isRoot />
<GraphNode label="StatsCard" depth={1} />
<GraphNode label="ProgressBar" depth={2} />
<GraphNode label="ValueDisplay" depth={2} />
<GraphNode label="CardBase" depth={1} />
<GraphNode label="Elevation" depth={2} />
<HSep />
<div style={{ padding: '4px 0 8px' }}>
<PropRow label="nodes" value="284" color="#7EB8FF" />
<PropRow label="depth" value="4" color="#7EB8FF" />
<PropRow label="tokens used" value="12" color="#FFBA7B" />
</div>
</Section>
<GraphTab fiberRoot={selectedArtboardId ? artboardFiberRoots[selectedArtboardId] : undefined} />
)}
</div>
@@ -160,10 +153,12 @@ export function Inspector() {
/* ── Props tab ────────────────────────────────────────────── */
function PropsTab({
artboard,
selectedComponentData,
workspaceId,
projectId,
}: {
artboard: Artboard | null;
selectedComponentData: FiberNode | null;
workspaceId: string | null;
projectId: string | null;
}) {
@@ -171,6 +166,29 @@ function PropsTab({
const [editingUrl, setEditingUrl] = useState(false);
const [urlDraft, setUrlDraft] = useState('');
// Drift report state
const [driftStatus, setDriftStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
const [driftReport, setDriftReport] = useState('');
const generateDriftReport = useCallback(async () => {
if (!artboard) return;
setDriftStatus('loading');
setDriftReport('');
try {
const res = await fetch('/api/ai/drift-report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artboard_id: artboard.id }),
});
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json() as { report?: string; result?: string };
setDriftReport(data.report ?? data.result ?? '— No report returned');
setDriftStatus('done');
} catch {
setDriftStatus('error');
}
}, [artboard]);
const saveRenderUrl = useCallback(async () => {
if (!artboard) return;
const { renderUrl: _removed, ...rest } = artboard.metadata_jsonb;
@@ -214,6 +232,26 @@ function PropsTab({
return (
<>
{/* Selected fiber component props — shown when a component is clicked in canvas */}
{selectedComponentData && (
<>
<Section label={`${selectedComponentData.name}`}>
{Object.entries(selectedComponentData.props ?? {}).map(([k, v]) => {
const t = typeof v;
const color = t === 'number' ? N : t === 'boolean' ? B : S;
const display = t === 'string' ? `"${v as string}"` : String(v);
return <PropRow key={k} label={k} value={display} color={color} />;
})}
{Object.keys(selectedComponentData.props ?? {}).length === 0 && (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No props
</span>
)}
</Section>
<HSep />
</>
)}
{extraProps.length > 0 && (
<>
<Section label="Component Props">
@@ -237,6 +275,7 @@ function PropsTab({
<PropRow label="id" value={artboard.id.slice(0, 8) + '…'} color="rgba(255,255,255,0.28)" />
{/* renderUrl — inline editable */}
<div style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: editingUrl ? 6 : 0 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.key }}>
@@ -299,6 +338,56 @@ function PropsTab({
)}
</div>
</Section>
<HSep />
{/* ── Drift Report ───────────────────────────────────── */}
<Section label="Drift Report">
<button
onClick={() => void generateDriftReport()}
disabled={driftStatus === 'loading'}
style={{
width: '100%',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: driftStatus === 'loading' ? 'rgba(255,255,255,0.06)' : 'rgba(51,133,255,0.12)',
border: `1px solid ${driftStatus === 'loading' ? 'rgba(255,255,255,0.08)' : 'rgba(51,133,255,0.25)'}`,
borderRadius: 6, padding: '6px 0',
color: driftStatus === 'loading' ? 'rgba(255,255,255,0.35)' : T.accent,
cursor: driftStatus === 'loading' ? 'wait' : 'pointer',
letterSpacing: '0.04em',
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
}}
>
{driftStatus === 'loading' ? 'Analysing…' : '↻ Generate drift report'}
</button>
{driftStatus === 'error' && (
<div style={{ marginTop: 6, fontSize: '0.5rem', color: '#FF8080', fontFamily: 'monospace' }}>
Report failed try again
</div>
)}
{driftStatus === 'done' && driftReport && (
<div style={{
marginTop: 8,
padding: '8px 10px',
background: 'rgba(0,0,0,0.35)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 6,
maxHeight: 220,
overflow: 'auto',
fontSize: '0.5875rem',
fontFamily: "'Inter', sans-serif",
color: 'rgba(255,255,255,0.62)',
lineHeight: 1.65,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{driftReport}
</div>
)}
</Section>
</>
);
}
@@ -367,6 +456,81 @@ function GraphNode({ label, depth, isRoot }: { label: string; depth: number; isR
);
}
/* ── Graph tab ────────────────────────────────────────────── */
function GraphTab({ fiberRoot }: { fiberRoot: FiberNode | undefined }) {
if (!fiberRoot) {
return (
<Section label="Origin Graph">
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No live render connect a URL in Props to see the fiber tree
</span>
</Section>
);
}
const nodeCount = countFiberNodes(fiberRoot);
const treeDepth = measureFiberDepth(fiberRoot);
return (
<Section label="Origin Graph">
<FiberTreeView node={fiberRoot} depth={0} />
<HSep />
<div style={{ padding: '4px 0 8px' }}>
<PropRow label="nodes" value={String(nodeCount)} color="#7EB8FF" />
<PropRow label="depth" value={String(treeDepth)} color="#7EB8FF" />
</div>
</Section>
);
}
function FiberTreeView({ node, depth }: { node: FiberNode; depth: number }) {
const [collapsed, setCollapsed] = useState(depth > 2);
const hasChildren = node.children && node.children.length > 0;
return (
<div>
<div
style={{
display: 'flex', alignItems: 'center', gap: 5,
paddingLeft: depth * 12, marginBottom: 4,
cursor: hasChildren ? 'pointer' : 'default',
}}
onClick={() => hasChildren && setCollapsed(c => !c)}
>
<div style={{
width: 5, height: 5, borderRadius: '50%', flexShrink: 0,
background: depth === 0 ? T.accent : 'rgba(255,255,255,0.2)',
}} />
{hasChildren && (
<span style={{ fontSize: '0.5rem', color: 'rgba(255,255,255,0.3)', marginRight: -2 }}>
{collapsed ? '▶' : '▼'}
</span>
)}
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.625rem',
color: depth === 0 ? T.accent : 'rgba(255,255,255,0.55)',
letterSpacing: '-0.01em',
}}>
{node.name}
</span>
</div>
{!collapsed && hasChildren && node.children!.map((child, i) => (
<FiberTreeView key={i} node={child} depth={depth + 1} />
))}
</div>
);
}
function countFiberNodes(node: FiberNode): number {
return 1 + (node.children ?? []).reduce((acc, c) => acc + countFiberNodes(c), 0);
}
function measureFiberDepth(node: FiberNode, d = 0): number {
if (!node.children?.length) return d;
return Math.max(...node.children.map(c => measureFiberDepth(c, d + 1)));
}
function HSep() {
return <div style={{ height: 1, background: 'rgba(255,255,255,0.04)', margin: '2px 0' }} />;
}
@@ -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>
);
}
@@ -0,0 +1,242 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
interface ProjectSettingsFormProps {
workspaceId: string;
projectId: string;
initialName: string;
initialDescription: string;
initialAppUrl: string;
initialFramework: string;
memberRole: string;
}
const SECTION: React.CSSProperties = {
background: '#FFFFFF',
border: '1px solid rgba(0,0,0,0.07)',
borderRadius: 14,
padding: '24px 28px',
marginBottom: 20,
};
const LABEL: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 600,
color: '#0A0A0A',
marginBottom: 6,
};
const INPUT: React.CSSProperties = {
width: '100%',
fontSize: '0.875rem',
padding: '9px 12px',
border: '1px solid rgba(0,0,0,0.12)',
borderRadius: 9,
outline: 'none',
fontFamily: "'Inter', -apple-system, sans-serif",
color: '#0A0A0A',
background: '#FAFAFA',
boxSizing: 'border-box',
};
const BTN_PRIMARY: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 6,
background: '#0A0A0A', color: '#FFFFFF',
fontSize: '0.875rem', fontWeight: 600,
padding: '9px 20px', borderRadius: 9,
border: 'none', cursor: 'pointer', letterSpacing: '-0.01em',
};
const FRAMEWORKS = ['', 'Next.js', 'Vite + React', 'Remix', 'SvelteKit', 'Nuxt', 'Other'] as const;
export function ProjectSettingsForm({
workspaceId,
projectId,
initialName,
initialDescription,
initialAppUrl,
initialFramework,
memberRole,
}: ProjectSettingsFormProps) {
const router = useRouter();
const isOwnerOrDev = memberRole === 'OWNER' || memberRole === 'DEVELOPER' || memberRole === 'DESIGNER';
/* ── Form state ── */
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [appUrl, setAppUrl] = useState(initialAppUrl);
const [framework, setFramework] = useState(initialFramework);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => { return () => { clearTimeout(saveTimer.current); }; }, []);
const isDirty =
name.trim() !== initialName ||
description !== initialDescription ||
appUrl !== initialAppUrl ||
framework !== initialFramework;
const save = useCallback(async () => {
if (!isDirty || !name.trim()) return;
clearTimeout(saveTimer.current);
setSaveStatus('saving');
try {
const res = await fetch(`/api/workspace/${workspaceId}/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
description: description || null,
app_url: appUrl || null,
framework: framework || null,
}),
});
if (!res.ok) throw new Error('Save failed');
setSaveStatus('saved');
router.refresh();
saveTimer.current = setTimeout(() => setSaveStatus('idle'), 2500);
} catch {
setSaveStatus('error');
saveTimer.current = setTimeout(() => setSaveStatus('idle'), 3000);
}
}, [isDirty, name, description, appUrl, framework, workspaceId, projectId, router]);
/* ── Delete ── */
const [deleteConfirm, setDeleteConfirm] = useState('');
const [deleting, setDeleting] = useState(false);
// Guard against deleting an unsaved name: always compare against the persisted name.
const deleteProject = useCallback(async () => {
if (deleteConfirm.trim() !== initialName.trim()) return;
setDeleting(true);
try {
const res = await fetch(`/api/workspace/${workspaceId}/projects/${projectId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
router.push(`/workspace/${workspaceId}`);
} catch {
setDeleting(false);
window.alert('Delete failed — please try again.');
}
}, [deleteConfirm, initialName, workspaceId, projectId, router]);
return (
<>
{/* General */}
<div style={SECTION}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: '#0A0A0A', margin: '0 0 20px' }}>General</h2>
<label style={LABEL} htmlFor="proj-name">Project name</label>
<input
id="proj-name"
style={{ ...INPUT, marginBottom: 16 }}
value={name}
onChange={e => setName(e.target.value)}
disabled={!isOwnerOrDev}
/>
<label style={LABEL} htmlFor="proj-desc">Description</label>
<textarea
id="proj-desc"
rows={3}
style={{ ...INPUT, marginBottom: 16, resize: 'vertical' } as React.CSSProperties}
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="What does this project do?"
disabled={!isOwnerOrDev}
/>
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<label style={LABEL} htmlFor="proj-url">App URL</label>
<input
id="proj-url"
style={INPUT}
value={appUrl}
onChange={e => setAppUrl(e.target.value)}
placeholder="https://your-app.vercel.app"
disabled={!isOwnerOrDev}
/>
</div>
<div style={{ flex: 1 }}>
<label style={LABEL} htmlFor="proj-framework">Framework</label>
<select
id="proj-framework"
style={{ ...INPUT }}
value={framework}
onChange={e => setFramework(e.target.value)}
disabled={!isOwnerOrDev}
>
{FRAMEWORKS.map(f => (
<option key={f} value={f}>{f || '— select —'}</option>
))}
</select>
</div>
</div>
{isOwnerOrDev && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
onClick={() => void save()}
disabled={!isDirty || saveStatus === 'saving' || !name.trim()}
style={{
...BTN_PRIMARY,
opacity: (!isDirty || !name.trim() || saveStatus === 'saving') ? 0.4 : 1,
}}
>
{saveStatus === 'saving' ? 'Saving…' : saveStatus === 'saved' ? '✓ Saved' : 'Save changes'}
</button>
{saveStatus === 'error' && (
<span style={{ fontSize: '0.8125rem', color: '#EF4444' }}>Save failed try again</span>
)}
</div>
)}
{!isOwnerOrDev && (
<span style={{ fontSize: '0.75rem', color: '#71717A' }}>Only Designers, Developers, and Owners can edit project settings.</span>
)}
</div>
{/* Danger zone — owners only */}
{memberRole === 'OWNER' && (
<div style={{ ...SECTION, borderColor: 'rgba(239,68,68,0.2)', background: 'rgba(239,68,68,0.02)' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: '#EF4444', margin: '0 0 8px' }}>
Danger zone
</h2>
<p style={{ fontSize: '0.8125rem', color: '#71717A', margin: '0 0 16px', lineHeight: 1.6 }}>
Deleting this project is permanent. All artboards, diffs, and origins will be removed. To confirm,
type the project name below.
</p>
<label style={{ ...LABEL, color: '#EF4444' }} htmlFor="proj-delete-confirm">
Type <strong>{initialName}</strong> to confirm
</label>
<div style={{ display: 'flex', gap: 10 }}>
<input
id="proj-delete-confirm"
style={INPUT}
value={deleteConfirm}
onChange={e => setDeleteConfirm(e.target.value)}
placeholder={initialName}
/>
<button
onClick={() => void deleteProject()}
disabled={deleteConfirm.trim() !== initialName.trim() || deleting}
style={{
fontSize: '0.875rem', fontWeight: 600, padding: '9px 20px', borderRadius: 9,
border: '1px solid rgba(239,68,68,0.35)', background: 'transparent',
color: '#EF4444', cursor: deleteConfirm.trim() === initialName.trim() ? 'pointer' : 'not-allowed',
flexShrink: 0,
opacity: deleteConfirm.trim() !== name.trim() || deleting ? 0.4 : 1,
}}
>
{deleting ? 'Deleting…' : 'Delete project'}
</button>
</div>
</div>
)}
</>
);
}
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
interface WorkspaceSettingsFormProps {
@@ -46,6 +46,110 @@ const BTN_PRIMARY: React.CSSProperties = {
border: 'none', cursor: 'pointer', letterSpacing: '-0.01em',
};
// Matches TeamRoleSchema in @originmain/origin-graph — keep in sync.
type TeamRole = 'OWNER' | 'DESIGNER' | 'ENGINEER' | 'PM' | 'VIEWER';
function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
const [userId, setUserId] = useState('');
const [role, setRole] = useState<TeamRole>('DESIGNER');
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'conflict' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState('');
const resetTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Clear any pending reset timers on unmount to avoid setState-after-unmount.
useEffect(() => { return () => { clearTimeout(resetTimer.current); }; }, []);
const submit = useCallback(async () => {
const trimmed = userId.trim();
if (!trimmed) return;
clearTimeout(resetTimer.current);
setStatus('loading');
setErrorMsg('');
try {
const res = await fetch(`/api/workspace/${workspaceId}/invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: trimmed, role }),
});
if (res.status === 409) { setStatus('conflict'); return; }
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(data.error ?? `HTTP ${res.status}`);
}
setStatus('done');
setUserId('');
resetTimer.current = setTimeout(() => setStatus('idle'), 3000);
} catch (e) {
setErrorMsg(e instanceof Error ? e.message : 'Invite failed');
setStatus('error');
resetTimer.current = setTimeout(() => setStatus('idle'), 4000);
}
}, [userId, role, workspaceId]);
const roles: TeamRole[] = ['DESIGNER', 'ENGINEER', 'PM', 'VIEWER', 'OWNER'];
return (
<div>
{/* Role picker */}
<label style={LABEL}>Role</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
{roles.map(r => (
<button
key={r}
onClick={() => setRole(r)}
style={{
fontSize: '0.75rem', fontWeight: 600, padding: '5px 13px', borderRadius: 8,
border: `1px solid ${role === r ? '#0066FF' : 'rgba(0,0,0,0.12)'}`,
background: role === r ? 'rgba(0,102,255,0.08)' : '#FFFFFF',
color: role === r ? '#0066FF' : '#52525B',
cursor: 'pointer',
}}
>
{r.charAt(0) + r.slice(1).toLowerCase()}
</button>
))}
</div>
{/* User ID input + submit */}
<label style={LABEL} htmlFor="invite-uid">Clerk user ID</label>
<div style={{ display: 'flex', gap: 10 }}>
<input
id="invite-uid"
style={INPUT}
placeholder="user_2abc…"
value={userId}
onChange={e => setUserId(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void submit(); }}
disabled={status === 'loading'}
/>
<button
onClick={() => void submit()}
disabled={status === 'loading' || !userId.trim()}
style={{
...BTN_PRIMARY,
flexShrink: 0,
opacity: (status === 'loading' || !userId.trim()) ? 0.5 : 1,
transition: 'opacity 0.15s',
}}
>
{status === 'loading' ? 'Inviting…' : 'Invite'}
</button>
</div>
{/* Feedback */}
{status === 'done' && (
<p style={{ fontSize: '0.8125rem', color: '#10B981', marginTop: 8 }}> Member added successfully</p>
)}
{status === 'conflict' && (
<p style={{ fontSize: '0.8125rem', color: '#F59E0B', marginTop: 8 }}>User is already a member of this workspace</p>
)}
{status === 'error' && (
<p style={{ fontSize: '0.8125rem', color: '#EF4444', marginTop: 8 }}>{errorMsg || 'Invite failed — try again'}</p>
)}
</div>
);
}
export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }: WorkspaceSettingsFormProps) {
const router = useRouter();
const isOwner = memberRole === 'OWNER';
@@ -53,9 +157,13 @@ export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }
/* ── Rename ── */
const [name, setName] = useState(workspaceName);
const [renameStatus, setRenameStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const renameTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => { return () => { clearTimeout(renameTimer.current); }; }, []);
async function saveName() {
if (!name.trim() || name.trim() === workspaceName) return;
clearTimeout(renameTimer.current);
setRenameStatus('saving');
try {
const res = await fetch(`/api/workspace/${workspaceId}`, {
@@ -66,10 +174,10 @@ export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }
if (!res.ok) throw new Error('Rename failed');
setRenameStatus('saved');
router.refresh();
setTimeout(() => setRenameStatus('idle'), 2000);
renameTimer.current = setTimeout(() => setRenameStatus('idle'), 2000);
} catch {
setRenameStatus('error');
setTimeout(() => setRenameStatus('idle'), 3000);
renameTimer.current = setTimeout(() => setRenameStatus('idle'), 3000);
}
}
@@ -241,6 +349,20 @@ export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }
)}
</div>
{/* Team */}
{isOwner && (
<div style={SECTION}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: '#0A0A0A', margin: '0 0 6px' }}>
Team
</h2>
<p style={{ fontSize: '0.8125rem', color: '#71717A', margin: '0 0 20px', lineHeight: 1.6 }}>
Add a team member using their Clerk user ID. You can find this in the Clerk dashboard under Users.
</p>
<TeamInviteForm workspaceId={workspaceId} />
</div>
)}
{/* Danger zone */}
{isOwner && (
<div style={{ ...SECTION, borderColor: 'rgba(239,68,68,0.2)', background: 'rgba(239,68,68,0.02)' }}>
+8
View File
@@ -22,6 +22,10 @@ interface CanvasStore {
liveArtboardIds: Set<string>;
setArtboardLive: (id: string, live: boolean) => void;
// ── Fiber tree cache (per artboard, updated on each FIBER_TREE_UPDATE) ─────
artboardFiberRoots: Record<string, FiberNode>;
setFiberRoot: (artboardId: string, root: FiberNode) => void;
// ── Component selection (from SelectionOverlay / fiber tree) ───────────────
selectedComponentId: string | null;
selectedComponentData: FiberNode | null;
@@ -48,6 +52,10 @@ export const useCanvas = create<CanvasStore>((set) => ({
return { liveArtboardIds: next };
}),
artboardFiberRoots: {},
setFiberRoot: (artboardId, root) =>
set((state) => ({ artboardFiberRoots: { ...state.artboardFiberRoots, [artboardId]: root } })),
selectedComponentId: null,
selectedComponentData: null,
selectComponent: (id, data) => set({ selectedComponentId: id, selectedComponentData: data }),
+3
View File
@@ -8,6 +8,8 @@ interface ViewportState {
setPan: (x: number, y: number) => void;
setZoom: (zoom: number, originX?: number, originY?: number) => void;
reset: () => void;
/** Restore from a saved snapshot (used for per-workspace persistence). */
restore: (data: { panX: number; panY: number; zoom: number }) => void;
}
export const useViewport = create<ViewportState>()(
@@ -31,6 +33,7 @@ export const useViewport = create<ViewportState>()(
},
reset: () => set({ panX: 0, panY: 0, zoom: 1 }),
restore: (data) => set(data),
}),
{
name: 'originmain:viewport',
File diff suppressed because one or more lines are too long