improved a lot of things
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
Reference in New Issue
Block a user