improved a lot of things

This commit is contained in:
SinachPat
2026-04-26 21:33:59 +01:00
parent 9c0af31751
commit 197313c0ef
20 changed files with 1726 additions and 295 deletions
+322 -118
View File
@@ -1,7 +1,9 @@
'use client';
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useCanvas } from '@/store/canvas';
import { useViewport } from '@/store/viewport';
import { LiveArtboard } from './LiveArtboard';
import { SelectionOverlay } from './SelectionOverlay';
import type { FiberNode } from '@originmain/renderer';
@@ -17,38 +19,172 @@ interface ArtboardProps {
}
export function Artboard({ id, label, x, y, width, height, renderUrl }: ArtboardProps) {
const { selectedArtboardId, selectArtboard } = useCanvas();
const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, selectComponent } = useCanvas();
const selected = selectedArtboardId === id;
const queryClient = useQueryClient();
// ── Fiber tree (from LiveArtboard) ─────────────────────────────────────────
const [fiberRoot, setFiberRoot] = useState<FiberNode | undefined>(undefined);
const handleFiberUpdate = useCallback((root: FiberNode) => setFiberRoot(root), []);
const handleComponentSelected = useCallback(
(nodeId: string) => { void nodeId; /* future: highlight in inspector */ },
[]
);
const handleFiberUpdate = useCallback((root: FiberNode) => {
setFiberRoot(root);
setArtboardLive(id, true);
}, [id, setArtboardLive]);
const handleComponentSelected = useCallback((nodeId: string) => {
if (!fiberRoot) return;
// Walk fiber tree to find the selected node
const node = findFiberNode(fiberRoot, nodeId);
selectComponent(nodeId, node ?? null);
}, [fiberRoot, selectComponent]);
// ── Drag to reposition ─────────────────────────────────────────────────────
const isDragging = useRef(false);
const dragStart = useRef({ mouseX: 0, mouseY: 0, artX: 0, artY: 0 });
const [dragOffset, setDragOffset] = useState({ dx: 0, dy: 0 });
const onLabelMouseDown = useCallback((e: React.MouseEvent) => {
// Only drag on left button; don't interfere with rename
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
isDragging.current = true;
const { zoom, panX, panY } = useViewport.getState();
dragStart.current = {
mouseX: (e.clientX - panX) / zoom,
mouseY: (e.clientY - panY) / zoom,
artX: x,
artY: y,
};
setDragOffset({ dx: 0, dy: 0 });
const onMove = (mv: MouseEvent) => {
if (!isDragging.current) return;
const { zoom: z, panX: px, panY: py } = useViewport.getState();
const curX = (mv.clientX - px) / z;
const curY = (mv.clientY - py) / z;
setDragOffset({
dx: curX - dragStart.current.mouseX,
dy: curY - dragStart.current.mouseY,
});
};
const onUp = () => {
if (!isDragging.current) return;
isDragging.current = false;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
const newX = Math.round(dragStart.current.artX + dragOffset.dx);
const newY = Math.round(dragStart.current.artY + dragOffset.dy);
// Persist position
fetch(`/api/artboards/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
metadata_jsonb: { x: newX, y: newY, width, height, ...(renderUrl ? { renderUrl } : {}) },
}),
}).then(() => {
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
setDragOffset({ dx: 0, dy: 0 });
}).catch(console.error);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}, [id, x, y, width, height, renderUrl, workspaceId, projectId, queryClient, dragOffset.dx, dragOffset.dy]);
// ── Inline rename ──────────────────────────────────────────────────────────
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(label);
const renameRef = useRef<HTMLInputElement>(null);
const startRename = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setRenameValue(label);
setRenaming(true);
setTimeout(() => renameRef.current?.select(), 0);
}, [label]);
const commitRename = useCallback(() => {
setRenaming(false);
const trimmed = renameValue.trim();
if (!trimmed || trimmed === label) return;
fetch(`/api/artboards/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }),
}).then(() => {
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
}).catch(console.error);
}, [id, renameValue, label, workspaceId, projectId, queryClient]);
const effectiveX = x + (isDragging.current ? dragOffset.dx : 0);
const effectiveY = y + (isDragging.current ? dragOffset.dy : 0);
return (
<div
style={{ position: 'absolute', top: y, left: x }}
style={{ position: 'absolute', top: effectiveY, left: effectiveX }}
onClick={(e) => { e.stopPropagation(); selectArtboard(id); }}
>
{/* Label */}
{/* Label / drag handle */}
<div
onMouseDown={onLabelMouseDown}
onDoubleClick={startRename}
style={{
position: 'absolute',
top: -24,
top: -26,
left: 0,
fontSize: 11,
fontFamily: "'JetBrains Mono', 'SF Mono', ui-monospace, monospace",
fontWeight: selected ? 500 : 400,
color: selected ? '#3385FF' : 'rgba(255,255,255,0.35)',
height: 22,
display: 'flex',
alignItems: 'center',
cursor: isDragging.current ? 'grabbing' : 'grab',
userSelect: 'none',
whiteSpace: 'nowrap',
letterSpacing: '-0.01em',
transition: 'color 0.15s',
minWidth: 80,
}}
>
{label}
{renaming ? (
<input
ref={renameRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') setRenaming(false);
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
style={{
fontSize: 11,
fontFamily: "'JetBrains Mono', 'SF Mono', ui-monospace, monospace",
fontWeight: 500,
color: '#3385FF',
background: 'rgba(51,133,255,0.12)',
border: '1px solid rgba(51,133,255,0.4)',
borderRadius: 3,
padding: '1px 6px',
outline: 'none',
width: Math.max(80, label.length * 7),
}}
/>
) : (
<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>
)}
</div>
{/* Frame */}
@@ -67,7 +203,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
transition: 'box-shadow 0.15s',
}}
>
{/* Selection handles */}
{/* Selection corner handles */}
{selected && (
<>
<Handle pos={{ top: -4, left: -4 }} />
@@ -77,7 +213,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
</>
)}
{/* Per-artboard content */}
{/* Content */}
{renderUrl ? (
<>
<LiveArtboard
@@ -96,24 +232,160 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
/>
</>
) : (
<ArtboardContent id={id} />
<EmptyArtboardContent id={id} label={label} width={width} height={height} workspaceId={workspaceId} projectId={projectId} queryClient={queryClient} />
)}
</div>
</div>
);
}
// ── Empty artboard (no renderUrl) ─────────────────────────────────────────────
function EmptyArtboardContent({
id, label, width, height, workspaceId, projectId, queryClient,
}: {
id: string; label: string; width: number; height: number;
workspaceId: string | null; projectId: string | null;
queryClient: ReturnType<typeof useQueryClient>;
}) {
const [editing, setEditing] = useState(false);
const [urlValue, setUrlValue] = useState('');
const [saving, setSaving] = useState(false);
const save = async () => {
const url = urlValue.trim();
if (!url) return;
setSaving(true);
await fetch(`/api/artboards/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
metadata_jsonb: { renderUrl: url, width, height, x: 0, y: 0 },
}),
}).catch(console.error);
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
setSaving(false);
setEditing(false);
};
return (
<div
style={{
width, height, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
background: '#F8F8FA', gap: 12, padding: 20,
}}
>
{/* Artboard name */}
<div style={{ fontSize: 13, fontWeight: 600, color: '#18181B', letterSpacing: '-0.02em', textAlign: 'center' }}>
{label}
</div>
{editing ? (
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 8 }}>
<input
autoFocus
type="url"
value={urlValue}
onChange={(e) => setUrlValue(e.target.value)}
placeholder="http://localhost:3000"
onKeyDown={(e) => { if (e.key === 'Enter') void save(); if (e.key === 'Escape') setEditing(false); e.stopPropagation(); }}
style={{
padding: '7px 10px', borderRadius: 6, border: '1.5px solid #0066FF',
fontSize: 11, fontFamily: 'inherit', outline: 'none', width: '100%',
boxSizing: 'border-box' as const,
}}
/>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => void save()} disabled={saving}
style={{
flex: 1, padding: '6px 0', borderRadius: 5, border: 'none',
background: '#0066FF', color: '#fff', fontSize: 11, fontWeight: 600,
cursor: saving ? 'default' : 'pointer', fontFamily: 'inherit',
}}
>
{saving ? 'Saving…' : 'Connect'}
</button>
<button
onClick={() => setEditing(false)}
style={{
padding: '6px 10px', borderRadius: 5, border: '1px solid #E4E4E7',
background: '#fff', fontSize: 11, cursor: 'pointer', fontFamily: 'inherit',
}}
>
Cancel
</button>
</div>
</div>
) : (
<>
{/* Icon */}
<div style={{
width: 40, height: 40, borderRadius: 10,
background: 'rgba(0,102,255,0.07)', border: '1px solid rgba(0,102,255,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect x="1" y="3" width="16" height="12" rx="2" stroke="#0066FF" strokeWidth="1.3"/>
<path d="M6 8l2.5 2.5L12 6" stroke="#0066FF" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<p style={{ margin: 0, fontSize: 11, color: '#71717A', textAlign: 'center', lineHeight: 1.5, maxWidth: 180 }}>
Connect a running app URL to enable live rendering
</p>
<button
onClick={() => setEditing(true)}
style={{
padding: '7px 14px', borderRadius: 6, border: '1px solid rgba(0,0,0,0.12)',
background: '#fff', fontSize: 11, fontWeight: 600, color: '#0A0A0A',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Connect app
</button>
</>
)}
</div>
);
}
// ── 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 <Placeholder label={id} />;
default: return null; // shouldn't reach here for real artboards
}
}
// ── DashboardCard ──────────────────────────────────────────
// ── Helpers ───────────────────────────────────────────────────────────────────
function findFiberNode(root: FiberNode, nodeId: string): FiberNode | null {
if (root.id === nodeId) return root;
if (!root.children) return null;
for (const child of root.children) {
const found = findFiberNode(child, nodeId);
if (found) return found;
}
return null;
}
function Handle({ pos }: { pos: React.CSSProperties }) {
return (
<div style={{
position: 'absolute', width: 8, height: 8,
background: '#fff', border: '2px solid #3385FF',
borderRadius: 2, zIndex: 10, ...pos,
}} />
);
}
// ── Demo card components ──────────────────────────────────────────────────────
function DashboardCard() {
return (
<div style={{ padding: 20 }}>
@@ -129,15 +401,14 @@ function DashboardCard() {
<div style={{ height: '100%', width: '68%', background: 'linear-gradient(90deg, #0066FF, #3385FF)', borderRadius: 99 }} />
</div>
<div style={{ display: 'flex', gap: 4 }}>
<Tag>Q4 2024</Tag>
<Tag>MRR</Tag>
<Tag>SaaS</Tag>
{['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>
);
}
// ── UserProfile ────────────────────────────────────────────
function UserProfile() {
return (
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%' }}>
@@ -152,17 +423,13 @@ function UserProfile() {
</div>
))}
</div>
<button style={{ marginTop: 16, width: '100%', padding: '7px 0', borderRadius: 6, border: '1px solid #E4E4E7', background: '#FAFAFA', fontSize: 10, fontWeight: 600, color: '#3F3F46', cursor: 'default' }}>
Edit profile
</button>
</div>
);
}
// ── NavSidebar ─────────────────────────────────────────────
function NavSidebar() {
const items = [
{ icon: '⊞', label: 'Dashboard', active: true },
{ icon: '⊞', label: 'Dashboard', active: true },
{ icon: '◉', label: 'Origin Graph', active: false },
{ icon: '⬜', label: 'Artboards', active: false },
{ icon: '△', label: 'Diffs', active: false },
@@ -175,46 +442,26 @@ function NavSidebar() {
</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 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 style={{ marginTop: 'auto', padding: '8px 12px 0', borderTop: '1px solid #EBEBEB' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 24, height: 24, borderRadius: '50%', background: 'linear-gradient(135deg, #7C3AED, #0066FF)', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: '#0A0A0A' }}>Sarah Chen</div>
<div style={{ fontSize: 8, color: '#A1A1AA' }}>Admin</div>
</div>
</div>
</div>
</div>
);
}
// ── DataTable ──────────────────────────────────────────────
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 },
{ 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' }}>
@@ -226,32 +473,19 @@ function DataTable() {
<span key={h} style={{ fontFamily: 'monospace', fontSize: 8, color: '#A1A1AA', letterSpacing: '0.05em', textTransform: 'uppercase' }}>{h}</span>
))}
</div>
<div style={{ height: 1, background: '#F0F0F0' }} />
{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', letterSpacing: '-0.01em' }}>{row.name}</span>
<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,
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',
fontFamily: 'monospace',
}}>
{row.status}
</span>
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>
@@ -260,35 +494,5 @@ function DataTable() {
);
}
function Placeholder({ label }: { label: string }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#A1A1AA', fontSize: 11 }}>
{label}
</div>
);
}
function Handle({ pos }: { pos: React.CSSProperties }) {
return (
<div
style={{
position: 'absolute',
width: 8,
height: 8,
background: '#fff',
border: '2px solid #3385FF',
borderRadius: 2,
zIndex: 10,
...pos,
}}
/>
);
}
function Tag({ children }: { children: string }) {
return (
<span style={{ fontSize: 9, background: '#F4F4F5', color: '#71717A', padding: '3px 8px', borderRadius: 99, fontWeight: 500 }}>
{children}
</span>
);
}
// Suppress unused warning — kept for demo IDs in ArtboardContent
void ArtboardContent;
+104 -19
View File
@@ -1,6 +1,6 @@
'use client';
import { useRef, useEffect, useCallback } from 'react';
import { useRef, useEffect, useCallback, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useViewport } from '@/store/viewport';
import { useCanvas } from '@/store/canvas';
@@ -16,9 +16,13 @@ export function Canvas() {
const { artboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
const queryClient = useQueryClient();
const isPanning = useRef(false);
const lastPos = useRef({ x: 0, y: 0 });
const spaceDown = useRef(false);
const isPanning = useRef(false);
const lastPos = useRef({ x: 0, y: 0 });
const spaceDown = useRef(false);
// 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);
// Wheel: pan or pinch-zoom
useEffect(() => {
@@ -57,14 +61,20 @@ export function Canvas() {
return;
}
const rect = containerRef.current!.getBoundingClientRect();
const { panX, panY, zoom } = useViewport.getState();
const canvasX = Math.round((e.clientX - rect.left - panX) / zoom);
const canvasY = Math.round((e.clientY - rect.top - panY) / zoom);
// Zone tool: start drag to define completion zone bounds
if (activeTool === 'zone') {
zoneStart.current = { x: canvasX, y: canvasY };
setZonePreview({ x: canvasX, y: canvasY, w: 0, h: 0 });
return;
}
// Artboard creation tool: click on canvas to place a new artboard
if (activeTool === 'artboard' && e.target === e.currentTarget && workspaceId) {
const rect = containerRef.current!.getBoundingClientRect();
// Convert screen → canvas space (invert matrix(zoom,0,0,zoom,panX,panY))
const { panX, panY, zoom } = useViewport.getState();
const canvasX = Math.round((e.clientX - rect.left - panX) / zoom);
const canvasY = Math.round((e.clientY - rect.top - panY) / zoom);
const label = `Artboard ${Date.now().toString(36).slice(-4).toUpperCase()}`;
createArtboardMutation({
workspace_id: workspaceId,
@@ -88,21 +98,46 @@ export function Canvas() {
}, [activeTool, setActiveTool, selectArtboard, workspaceId, projectId, queryClient]);
const onMouseMove = useCallback((e: React.MouseEvent) => {
if (!isPanning.current) return;
const dx = e.clientX - lastPos.current.x;
const dy = e.clientY - lastPos.current.y;
lastPos.current = { x: e.clientX, y: e.clientY };
const { panX, panY, setPan } = useViewport.getState();
setPan(panX + dx, panY + dy);
}, []);
if (isPanning.current) {
const dx = e.clientX - lastPos.current.x;
const dy = e.clientY - lastPos.current.y;
lastPos.current = { x: e.clientX, y: e.clientY };
const { panX, panY, setPan } = useViewport.getState();
setPan(panX + dx, panY + dy);
return;
}
const onMouseUp = useCallback(() => { isPanning.current = false; }, []);
// Zone tool: update preview rectangle while dragging
if (activeTool === 'zone' && zoneStart.current) {
const rect = containerRef.current!.getBoundingClientRect();
const { panX, panY, zoom } = useViewport.getState();
const cx = Math.round((e.clientX - rect.left - panX) / zoom);
const cy = Math.round((e.clientY - rect.top - panY) / zoom);
setZonePreview({
x: Math.min(zoneStart.current.x, cx),
y: Math.min(zoneStart.current.y, cy),
w: Math.abs(cx - zoneStart.current.x),
h: Math.abs(cy - zoneStart.current.y),
});
}
}, [activeTool]);
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) {
zoneStart.current = null;
setZonePreview(null);
setActiveTool('select');
}
}, [activeTool, setActiveTool]);
// Dot grid that shifts with pan and scales with zoom
const gridSpacing = Math.max(6, 20 * zoom);
const cursor =
activeTool === 'pan' || isPanning.current ? 'grab' :
activeTool === 'artboard' ? 'crosshair' :
activeTool === 'artboard' || activeTool === 'zone' ? 'crosshair' :
'default';
return (
@@ -156,7 +191,57 @@ export function Canvas() {
{artboards.map((ab) => (
<Artboard key={ab.id} {...ab} />
))}
{/* Zone tool: live drag preview rectangle */}
{zonePreview && zonePreview.w > 4 && zonePreview.h > 4 && (
<div style={{
position: 'absolute',
left: zonePreview.x, top: zonePreview.y,
width: zonePreview.w, height: zonePreview.h,
border: '1.5px dashed rgba(51,133,255,0.8)',
background: 'rgba(51,133,255,0.06)',
borderRadius: 4,
pointerEvents: 'none',
}}>
<span style={{
position: 'absolute', top: -20, left: 0,
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem', color: 'rgba(51,133,255,0.9)',
letterSpacing: '-0.01em', whiteSpace: 'nowrap',
}}>
{zonePreview.w} × {zonePreview.h}
</span>
</div>
)}
</div>
{/* Empty canvas hint — shown only when workspace has no artboards yet */}
{artboards.length === 0 && (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
pointerEvents: 'none', zIndex: 3,
}}>
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
opacity: 0.4,
}}>
<svg width="36" height="36" viewBox="0 0 36 36" fill="none">
<rect x="4" y="4" width="12" height="12" rx="2" stroke="rgba(255,255,255,0.5)" strokeWidth="1.2" strokeDasharray="3 2"/>
<rect x="20" y="4" width="12" height="12" rx="2" stroke="rgba(255,255,255,0.5)" strokeWidth="1.2" strokeDasharray="3 2"/>
<rect x="4" y="20" width="12" height="12" rx="2" stroke="rgba(255,255,255,0.5)" strokeWidth="1.2" strokeDasharray="3 2"/>
<rect x="20" y="20" width="12" height="12" rx="2" stroke="rgba(255,255,255,0.5)" strokeWidth="1.2" strokeDasharray="3 2"/>
</svg>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem', color: 'rgba(255,255,255,0.45)',
letterSpacing: '0.08em', textTransform: 'uppercase',
}}>
Press A to create an artboard
</span>
</div>
</div>
)}
</div>
);
}
@@ -1,12 +1,13 @@
'use client';
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { useCanvas } from '@/store/canvas';
import { useArtboards } from '@/hooks/useArtboards';
import { useDiff } from '@/hooks/useDiff';
import { ARTBOARD_SNAPSHOTS } from '@/data/artboard-snapshots';
import type { DiffResult, PropChange } from '@originmain/diff-engine';
import type { Artboard } from '@originmain/origin-graph';
import { useHistory } from '@/store/history';
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 { Artboard, IntentDiff } from '@originmain/origin-graph';
const TYPE_COLORS: Record<string, string> = {
s: '#7DD3A8',
@@ -30,11 +31,11 @@ const T = {
};
export function Inspector() {
const { selectedArtboardId, workspaceId, projectId } = useCanvas();
const { selectedArtboardId, liveArtboardIds, workspaceId, projectId } = useCanvas();
const [tab, setTab] = useState<TabId>('props');
const diffResult = useDiff(selectedArtboardId);
const { rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
const selectedArtboard = rawArtboards.find((ab) => ab.id === selectedArtboardId) ?? null;
const isLive = selectedArtboardId ? liveArtboardIds.has(selectedArtboardId) : false;
return (
<div
@@ -50,13 +51,7 @@ export function Inspector() {
}}
>
{/* Tab bar */}
<div
style={{
display: 'flex',
borderBottom: `1px solid ${T.border}`,
flexShrink: 0,
}}
>
<div style={{ display: 'flex', borderBottom: `1px solid ${T.border}`, flexShrink: 0 }}>
{(['props', 'diff', 'graph'] as TabId[]).map((t) => (
<button
key={t}
@@ -109,10 +104,9 @@ export function Inspector() {
</span>
</div>
) : tab === 'props' ? (
<PropsTab artboard={selectedArtboard} />
<PropsTab artboard={selectedArtboard} workspaceId={workspaceId} projectId={projectId} />
) : tab === 'diff' ? (
<DiffTab artboardId={selectedArtboardId} diffResult={diffResult} />
<DiffTab artboardId={selectedArtboardId} />
) : (
<Section label="Origin Graph">
<GraphNode label="DashboardCard" depth={0} isRoot />
@@ -144,9 +138,14 @@ export function Inspector() {
flexShrink: 0,
}}
>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#10B981', flexShrink: 0 }} />
<div style={{
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
background: isLive ? '#10B981' : 'rgba(255,255,255,0.15)',
boxShadow: isLive ? '0 0 6px rgba(16,185,129,0.6)' : 'none',
transition: 'background 0.3s, box-shadow 0.3s',
}} />
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.45)' }}>
Live render connected
{isLive ? 'Live render connected' : selectedArtboardId ? 'No render — set URL in Props' : 'No artboard selected'}
</span>
<span style={{ marginLeft: 'auto', fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.22)' }}>
{selectedArtboard
@@ -159,24 +158,48 @@ export function Inspector() {
}
/* ── Props tab ────────────────────────────────────────────── */
function PropsTab({ artboard }: { artboard: Artboard | null }) {
function PropsTab({
artboard,
workspaceId,
projectId,
}: {
artboard: Artboard | null;
workspaceId: string | null;
projectId: string | null;
}) {
const queryClient = useQueryClient();
const [editingUrl, setEditingUrl] = useState(false);
const [urlDraft, setUrlDraft] = useState('');
const saveRenderUrl = useCallback(async () => {
if (!artboard) return;
const { renderUrl: _removed, ...rest } = artboard.metadata_jsonb;
const meta: Record<string, unknown> = urlDraft.trim()
? { ...rest, renderUrl: urlDraft.trim() }
: { ...rest };
try {
await patchArtboard(artboard.id, { metadata_jsonb: meta });
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
} catch (e) {
console.error('[Inspector] patch renderUrl failed', e);
}
setEditingUrl(false);
}, [artboard, urlDraft, workspaceId, projectId, queryClient]);
if (!artboard) return null;
const meta = artboard.metadata_jsonb;
const N = TYPE_COLORS['n']!;
const B = TYPE_COLORS['b']!;
const S = TYPE_COLORS['s']!;
// Extract canvas geometry
const canvasProps: Array<{ key: string; val: string; color: string }> = [
{ key: 'x', val: String(meta['x'] ?? 0), color: N },
{ key: 'y', val: String(meta['y'] ?? 0), color: N },
{ key: 'width', val: String(meta['width'] ?? 0), color: N },
{ key: 'height', val: String(meta['height'] ?? 0), color: N },
{ key: 'x', val: String(meta['x'] ?? 0), color: N },
{ key: 'y', val: String(meta['y'] ?? 0), color: N },
{ key: 'width', val: String(meta['width'] ?? 0), color: N },
{ key: 'height', val: String(meta['height'] ?? 0), color: N },
];
// Any extra metadata keys beyond the canvas geometry
const reservedKeys = new Set(['x', 'y', 'width', 'height', 'renderUrl']);
const extraProps = Object.entries(meta)
.filter(([k]) => !reservedKeys.has(k))
@@ -187,7 +210,7 @@ function PropsTab({ artboard }: { artboard: Artboard | null }) {
return { key: k, val, color };
});
const renderUrl = typeof meta['renderUrl'] === 'string' ? meta['renderUrl'] as string : null;
const renderUrl = typeof meta['renderUrl'] === 'string' ? meta['renderUrl'] as string : '';
return (
<>
@@ -201,17 +224,80 @@ function PropsTab({ artboard }: { artboard: Artboard | null }) {
<HSep />
</>
)}
<Section label="Canvas">
{canvasProps.map(({ key, val, color }) => (
<PropRow key={key} label={key} value={val} color={color} />
))}
</Section>
<HSep />
<Section label="Render Target">
<PropRow label="name" value={artboard.name} color="#7EB8FF" />
<PropRow label="status" value={renderUrl ? 'connected' : 'none'} color={renderUrl ? '#7DD3A8' : 'rgba(255,255,255,0.28)'} />
{renderUrl && <PropRow label="url" value={renderUrl} color="#7DD3A8" />}
<PropRow label="id" value={artboard.id.slice(0, 8) + '…'} color="rgba(255,255,255,0.28)" />
<PropRow label="name" value={artboard.name} color="#7EB8FF" />
<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 }}>
url
</span>
<button
onClick={() => { setUrlDraft(renderUrl); setEditingUrl(true); }}
style={{
fontSize: '0.5rem', fontFamily: "'JetBrains Mono', monospace",
background: 'none', border: 'none', color: T.accent,
cursor: 'pointer', padding: 0, letterSpacing: '0.06em',
display: editingUrl ? 'none' : 'block',
}}
>
{renderUrl ? 'edit' : '+ set'}
</button>
</div>
{editingUrl ? (
<div style={{ display: 'flex', gap: 4 }}>
<input
autoFocus
value={urlDraft}
onChange={e => setUrlDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') void saveRenderUrl();
if (e.key === 'Escape') setEditingUrl(false);
}}
placeholder="http://localhost:3000"
style={{
flex: 1, fontSize: '0.5875rem', fontFamily: "'JetBrains Mono', monospace",
background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 5, padding: '4px 8px', color: 'rgba(255,255,255,0.85)',
outline: 'none',
}}
/>
<button
onClick={() => void saveRenderUrl()}
style={{
fontSize: '0.5625rem', fontFamily: "'JetBrains Mono', monospace",
background: T.accent, border: 'none', borderRadius: 5,
color: '#fff', padding: '4px 8px', cursor: 'pointer', flexShrink: 0,
}}
>
</button>
</div>
) : renderUrl ? (
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem',
color: '#7DD3A8', overflow: 'hidden', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', display: 'block', maxWidth: '100%',
}}>
{renderUrl}
</span>
) : (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.18)' }}>
not connected
</span>
)}
</div>
</Section>
</>
);
@@ -272,32 +358,9 @@ function PropRow({ label, value, color }: { label: string; value: string; color:
function GraphNode({ label, depth, isRoot }: { label: string; depth: number; isRoot?: boolean }) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
paddingLeft: depth * 14,
marginBottom: 6,
}}
>
<div
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: isRoot ? T.accent : 'rgba(255,255,255,0.18)',
flexShrink: 0,
}}
/>
<span
style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.625rem',
color: isRoot ? T.accent : 'rgba(255,255,255,0.5)',
letterSpacing: '-0.01em',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, paddingLeft: depth * 14, marginBottom: 6 }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: isRoot ? T.accent : 'rgba(255,255,255,0.18)', flexShrink: 0 }} />
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.625rem', color: isRoot ? T.accent : 'rgba(255,255,255,0.5)', letterSpacing: '-0.01em' }}>
{label}
</span>
</div>
@@ -309,61 +372,128 @@ function HSep() {
}
/* ── Diff tab ─────────────────────────────────────────────── */
function DiffTab({
artboardId,
diffResult,
}: {
artboardId: string | null;
diffResult: DiffResult | null;
}) {
if (!artboardId || !diffResult) {
function DiffTab({ artboardId }: { artboardId: string | null }) {
const { stacks } = useHistory();
const { diffs, createDiff, isLoading } = useDiffs(artboardId);
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(() => {
if (!artboardId || !hasChanges) return;
createDiff.mutate({
artboard_id: artboardId,
changes_jsonb: { propChanges: pendingChanges, styleChanges: [] },
summary: '',
status: 'DRAFT',
});
}, [artboardId, pendingChanges, hasChanges, createDiff]);
if (!artboardId) {
return (
<Section label="Intent Diff">
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No diff available
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No artboard selected
</span>
</Section>
);
}
const { diff } = diffResult;
const snapshots = ARTBOARD_SNAPSHOTS[artboardId];
const filename = snapshots?.after.filePath ?? `${diff.name}.tsx`;
const allChanges = [...diff.propChanges, ...diff.styleChanges];
const hunkCount = allChanges.filter(c => c.changeType !== 'unchanged').length;
return (
<Section label="Intent Diff">
<div
style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5875rem',
color: 'rgba(255,255,255,0.28)',
marginBottom: 10,
letterSpacing: '-0.01em',
}}
>
{filename} · {hunkCount} change{hunkCount !== 1 ? 's' : ''}
<>
{/* Pending local changes */}
<Section label={`Pending · ${hasChanges ? pendingChanges.filter(c => c.changeType !== 'unchanged').length : 0} changes`}>
{hasChanges ? (
<>
{pendingChanges
.filter(c => c.changeType !== 'unchanged')
.map((change, i) => (
<DiffChangeRow key={i} change={change} />
))}
<button
onClick={exportDiff}
disabled={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,
transition: 'opacity 0.15s',
}}
>
{createDiff.isPending ? 'Exporting…' : 'Export diff →'}
</button>
{createDiff.isError && (
<span style={{ fontSize: '0.5rem', color: '#FF8080', fontFamily: 'monospace', display: 'block', marginTop: 4 }}>
Export failed try again
</span>
)}
</>
) : (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No pending changes
</span>
)}
</Section>
<HSep />
{/* Saved diffs from DB */}
<Section label="Exported">
{isLoading ? (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
Loading
</span>
) : diffs.length === 0 ? (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No exported diffs yet
</span>
) : (
diffs.map(d => <SavedDiffRow key={d.id} diff={d} />)
)}
</Section>
</>
);
}
const STATUS_COLOR: Record<string, string> = {
DRAFT: '#FFBA7B',
REVIEWED: '#7EB8FF',
APPLIED: '#7DD3A8',
REJECTED: '#FF8080',
};
function SavedDiffRow({ diff }: { diff: IntentDiff }) {
const changes = diff.changes_jsonb as { propChanges?: PropChange[]; styleChanges?: PropChange[] } | null;
const count = (changes?.propChanges?.length ?? 0) + (changes?.styleChanges?.length ?? 0);
const color = STATUS_COLOR[diff.status] ?? T.dim;
return (
<div style={{ marginBottom: 8, padding: '6px 8px', background: 'rgba(255,255,255,0.025)', borderRadius: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color }}>
{diff.status}
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: 'rgba(255,255,255,0.22)' }}>
{count} change{count !== 1 ? 's' : ''}
</span>
</div>
{allChanges.map((change, i) => (
<DiffChangeRow key={i} change={change} />
))}
{allChanges.length === 0 && (
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No changes detected
{diff.summary && (
<span style={{ fontFamily: 'sans-serif', fontSize: '0.625rem', color: 'rgba(255,255,255,0.45)', lineHeight: 1.4, display: 'block' }}>
{diff.summary}
</span>
)}
</Section>
</div>
);
}
function DiffChangeRow({ change }: { change: PropChange }) {
const isRemoved = change.changeType === 'removed';
const isAdded = change.changeType === 'added';
const isRemoved = change.changeType === 'removed';
const isAdded = change.changeType === 'added';
const isModified = change.changeType === 'modified';
const rows: Array<{ op: 'del' | 'add'; text: string }> = [];
if (isModified) {
rows.push({ op: 'del', text: ` ${change.key}: ${change.before}` });
rows.push({ op: 'add', text: `+ ${change.key}: ${change.after}` });
@@ -0,0 +1,272 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
interface WorkspaceSettingsFormProps {
workspaceId: string;
workspaceName: 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',
};
export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }: WorkspaceSettingsFormProps) {
const router = useRouter();
const isOwner = memberRole === 'OWNER';
/* ── Rename ── */
const [name, setName] = useState(workspaceName);
const [renameStatus, setRenameStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
async function saveName() {
if (!name.trim() || name.trim() === workspaceName) return;
setRenameStatus('saving');
try {
const res = await fetch(`/api/workspace/${workspaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() }),
});
if (!res.ok) throw new Error('Rename failed');
setRenameStatus('saved');
router.refresh();
setTimeout(() => setRenameStatus('idle'), 2000);
} catch {
setRenameStatus('error');
setTimeout(() => setRenameStatus('idle'), 3000);
}
}
/* ── IDE Token ── */
const [agentType, setAgentType] = useState<'CURSOR' | 'CLAUDE_CODE' | 'GENERIC'>('CLAUDE_CODE');
const [tokenResult, setTokenResult] = useState<{
token: string;
cursorConfig: { cursorrules: string; settings: unknown };
claudeCodeConfig: { claudeMd: string; settings: unknown };
} | null>(null);
const [tokenLoading, setTokenLoading] = useState(false);
const [tokenError, setTokenError] = useState('');
const [copiedToken, setCopiedToken] = useState(false);
async function issueToken() {
setTokenLoading(true);
setTokenError('');
setTokenResult(null);
try {
const res = await fetch(`/api/workspace/${workspaceId}/tokens`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentType, workspaceName }),
});
if (!res.ok) throw new Error('Token issuance failed');
const data = await res.json() as typeof tokenResult;
setTokenResult(data);
} catch (e) {
setTokenError(e instanceof Error ? e.message : 'Failed to issue token');
} finally {
setTokenLoading(false);
}
}
function copyToken() {
if (!tokenResult) return;
void navigator.clipboard.writeText(tokenResult.token);
setCopiedToken(true);
setTimeout(() => setCopiedToken(false), 2000);
}
const configText = tokenResult
? agentType === 'CURSOR'
? tokenResult.cursorConfig.cursorrules
: tokenResult.claudeCodeConfig.claudeMd
: '';
return (
<>
{/* General */}
<div style={SECTION}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: '#0A0A0A', margin: '0 0 20px' }}>
General
</h2>
<label style={LABEL} htmlFor="ws-name">Workspace name</label>
<div style={{ display: 'flex', gap: 10, marginBottom: 4 }}>
<input
id="ws-name"
style={INPUT}
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void saveName(); }}
disabled={!isOwner}
/>
<button
onClick={() => void saveName()}
disabled={!isOwner || renameStatus === 'saving' || name.trim() === workspaceName}
style={{
...BTN_PRIMARY,
opacity: (!isOwner || name.trim() === workspaceName) ? 0.4 : 1,
flexShrink: 0,
}}
>
{renameStatus === 'saving' ? 'Saving…' : renameStatus === 'saved' ? '✓ Saved' : 'Rename'}
</button>
</div>
{renameStatus === 'error' && (
<span style={{ fontSize: '0.75rem', color: '#EF4444' }}>Rename failed try again</span>
)}
{!isOwner && (
<span style={{ fontSize: '0.75rem', color: '#71717A' }}>Only workspace owners can rename.</span>
)}
</div>
{/* IDE Integration */}
<div style={SECTION}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: '#0A0A0A', margin: '0 0 6px' }}>
IDE Integration
</h2>
<p style={{ fontSize: '0.8125rem', color: '#71717A', margin: '0 0 20px', lineHeight: 1.6 }}>
Issue a signed workspace token and copy the config snippet into your editor. Tokens expire after 30 days.
</p>
<label style={LABEL}>Agent type</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
{(['CLAUDE_CODE', 'CURSOR', 'GENERIC'] as const).map(t => (
<button
key={t}
onClick={() => setAgentType(t)}
style={{
fontSize: '0.75rem', fontWeight: 600, padding: '6px 14px', borderRadius: 8,
border: `1px solid ${agentType === t ? '#0066FF' : 'rgba(0,0,0,0.12)'}`,
background: agentType === t ? 'rgba(0,102,255,0.08)' : '#FFFFFF',
color: agentType === t ? '#0066FF' : '#52525B',
cursor: 'pointer',
}}
>
{t === 'CLAUDE_CODE' ? 'Claude Code' : t === 'CURSOR' ? 'Cursor' : 'Generic'}
</button>
))}
</div>
<button
onClick={() => void issueToken()}
disabled={tokenLoading}
style={{ ...BTN_PRIMARY, opacity: tokenLoading ? 0.6 : 1, marginBottom: tokenResult ? 16 : 0 }}
>
{tokenLoading ? 'Generating…' : 'Generate token'}
</button>
{tokenError && (
<p style={{ fontSize: '0.75rem', color: '#EF4444', marginTop: 8 }}>{tokenError}</p>
)}
{tokenResult && (
<div style={{ marginTop: 16 }}>
{/* Token display */}
<label style={{ ...LABEL, marginBottom: 6 }}>Workspace token</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input
readOnly
value={tokenResult.token}
style={{ ...INPUT, fontFamily: 'monospace', fontSize: '0.75rem', color: '#0066FF' }}
/>
<button
onClick={copyToken}
style={{
...BTN_PRIMARY, background: copiedToken ? '#10B981' : '#0066FF', flexShrink: 0,
transition: 'background 0.2s',
}}
>
{copiedToken ? '✓ Copied' : 'Copy'}
</button>
</div>
{/* Config snippet */}
<label style={{ ...LABEL, marginBottom: 6 }}>
{agentType === 'CURSOR' ? '.cursorrules snippet' : 'CLAUDE.md snippet'}
</label>
<pre style={{
background: '#0A0A0A', color: 'rgba(255,255,255,0.75)',
borderRadius: 10, padding: '14px 16px', fontSize: '0.625rem',
fontFamily: 'monospace', overflowX: 'auto', lineHeight: 1.7,
margin: 0, maxHeight: 260, overflow: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{configText}
</pre>
<p style={{ fontSize: '0.75rem', color: '#71717A', marginTop: 8, lineHeight: 1.5 }}>
Paste this into your{' '}
{agentType === 'CURSOR' ? (
<code style={{ fontFamily: 'monospace', background: 'rgba(0,0,0,0.06)', padding: '1px 5px', borderRadius: 4 }}>.cursorrules</code>
) : (
<code style={{ fontFamily: 'monospace', background: 'rgba(0,0,0,0.06)', padding: '1px 5px', borderRadius: 4 }}>CLAUDE.md</code>
)}{' '}
file to enable the Originmain MCP server in your editor.
</p>
</div>
)}
</div>
{/* Danger zone */}
{isOwner && (
<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 workspace is permanent and cannot be undone. All projects and artboards will be lost.
</p>
<button
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: '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.');
}
}}
>
Delete workspace
</button>
</div>
)}
</>
);
}