improved a lot of things
This commit is contained in:
@@ -1,21 +1,83 @@
|
||||
// POST /api/ai/completion-zone
|
||||
// Fills a design completion zone given a component tree and user intent.
|
||||
//
|
||||
// Accepts two shapes:
|
||||
// Canvas UI payload: { artboard_id, bounds, prompt, componentTreeJson? }
|
||||
// Canonical payload: { componentTreeJson, intent, dlfJson?, screenshotBase64? }
|
||||
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { AIGateway, fillCompletionZone } from '@originmain/ai-layer';
|
||||
import type { CompletionZoneInput } from '@originmain/ai-layer';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import { getActiveDesignLanguageFile } from '@originmain/origin-graph';
|
||||
|
||||
// ── Canvas UI payload shape ───────────────────────────────────────────────────
|
||||
interface CanvasPayload {
|
||||
artboard_id?: string;
|
||||
bounds?: { x: number; y: number; width: number; height: number };
|
||||
prompt?: string;
|
||||
componentTreeJson?: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = (await req.json()) as CompletionZoneInput;
|
||||
const raw = (await req.json()) as CanvasPayload & Partial<CompletionZoneInput>;
|
||||
|
||||
// ── Adapt canvas UI payload → CompletionZoneInput ────────────────────────
|
||||
let input: CompletionZoneInput;
|
||||
|
||||
if (raw.intent !== undefined) {
|
||||
// Already canonical shape — pass through
|
||||
input = raw as CompletionZoneInput;
|
||||
} else {
|
||||
// Canvas UI shape: {artboard_id, bounds, prompt, componentTreeJson?}
|
||||
if (!raw.prompt?.trim()) {
|
||||
return NextResponse.json({ error: 'prompt is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build a context string from bounds + artboard metadata if available
|
||||
const boundsLabel = raw.bounds
|
||||
? `Canvas region: x=${raw.bounds.x}, y=${raw.bounds.y}, width=${raw.bounds.width}, height=${raw.bounds.height}`
|
||||
: '';
|
||||
|
||||
// Try to load the active DLF for this workspace (best-effort — skip on error)
|
||||
let dlfJson: string | undefined;
|
||||
if (raw.artboard_id) {
|
||||
try {
|
||||
const db = serverClient();
|
||||
// Resolve workspace_id from artboard
|
||||
const { data: ab } = await db
|
||||
.from('artboards')
|
||||
.select('workspace_id')
|
||||
.eq('id', raw.artboard_id)
|
||||
.single();
|
||||
if (ab) {
|
||||
const dlf = await getActiveDesignLanguageFile(db, (ab as { workspace_id: string }).workspace_id);
|
||||
if (dlf) dlfJson = JSON.stringify(dlf.schema_jsonb);
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
input = {
|
||||
componentTreeJson: raw.componentTreeJson ?? boundsLabel ?? '{}',
|
||||
intent: raw.prompt.trim(),
|
||||
...(dlfJson !== undefined ? { dlfJson } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const gateway = new AIGateway();
|
||||
const result = await fillCompletionZone(gateway, body);
|
||||
return NextResponse.json({ proposedTree: result.proposedTree, description: result.description });
|
||||
const result = await fillCompletionZone(gateway, input);
|
||||
return NextResponse.json({
|
||||
proposedTree: result.proposedTree,
|
||||
description: result.description,
|
||||
// Also include result/completion for backwards-compat with any old clients
|
||||
result: result.description,
|
||||
completion: result.description,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'AI error';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// POST /api/ai/diff-summary
|
||||
// Generates a one-sentence natural-language summary of a set of component changes.
|
||||
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { AIGateway, generateDiffSummary } from '@originmain/ai-layer';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = (await req.json()) as { changesJson?: string; componentName?: string };
|
||||
|
||||
if (!body.changesJson) {
|
||||
return NextResponse.json({ error: 'changesJson is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const gateway = new AIGateway();
|
||||
const result = await generateDiffSummary(gateway, {
|
||||
changesJson: body.changesJson,
|
||||
componentName: body.componentName ?? 'Component',
|
||||
});
|
||||
return NextResponse.json({ summary: result.summary });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'AI error';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,72 @@
|
||||
// POST /api/ai/query
|
||||
// Cross-artboard search: finds artboards matching a natural language query.
|
||||
//
|
||||
// Accepts two shapes:
|
||||
// Navigator UI payload: { workspace_id, question }
|
||||
// Canonical payload: { query, artboardsJson }
|
||||
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { AIGateway, queryCrossArtboard } from '@originmain/ai-layer';
|
||||
import type { ArtboardQueryInput } from '@originmain/ai-layer';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import { getArtboards } from '@originmain/origin-graph';
|
||||
|
||||
interface NavigatorPayload {
|
||||
workspace_id?: string;
|
||||
question?: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = (await req.json()) as ArtboardQueryInput;
|
||||
const raw = (await req.json()) as NavigatorPayload & Partial<ArtboardQueryInput>;
|
||||
|
||||
// ── Adapt navigator UI payload → ArtboardQueryInput ──────────────────────
|
||||
let input: ArtboardQueryInput;
|
||||
|
||||
if (raw.query !== undefined && raw.artboardsJson !== undefined) {
|
||||
// Already canonical
|
||||
input = raw as ArtboardQueryInput;
|
||||
} else {
|
||||
// Navigator UI shape: { workspace_id, question }
|
||||
const queryText = (raw.question ?? raw.query ?? '').trim();
|
||||
if (!queryText) return NextResponse.json({ error: 'question is required' }, { status: 400 });
|
||||
|
||||
const workspaceId = raw.workspace_id;
|
||||
if (!workspaceId) return NextResponse.json({ error: 'workspace_id is required' }, { status: 400 });
|
||||
|
||||
// Fetch artboard metadata to give the AI context
|
||||
let artboardsJson = '[]';
|
||||
try {
|
||||
const db = serverClient();
|
||||
const artboards = await getArtboards(db, workspaceId);
|
||||
artboardsJson = JSON.stringify(
|
||||
artboards.map(ab => ({
|
||||
id: ab.id,
|
||||
name: ab.name,
|
||||
metadata: ab.metadata_jsonb,
|
||||
created_at: ab.created_at,
|
||||
}))
|
||||
);
|
||||
} catch { /* non-fatal: AI will work with empty list */ }
|
||||
|
||||
input = { query: queryText, artboardsJson };
|
||||
}
|
||||
|
||||
try {
|
||||
const gateway = new AIGateway();
|
||||
const result = await queryCrossArtboard(gateway, body);
|
||||
return NextResponse.json(result);
|
||||
const result = await queryCrossArtboard(gateway, input);
|
||||
|
||||
// Return both the canonical shape AND a human-readable answer string
|
||||
// so the navigator UI's `data.answer ?? data.result` check gets something useful.
|
||||
const answerText = result.reasoning ||
|
||||
(result.results.length > 0
|
||||
? result.results.map(r => `${r.artboardId}: ${r.reason}`).join('\n')
|
||||
: 'No matching artboards found.');
|
||||
|
||||
return NextResponse.json({ ...result, answer: answerText, result: answerText });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'AI error';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// PATCH /api/workspace/:id → rename workspace (owner only)
|
||||
// GET /api/workspace/:id → fetch workspace (any member)
|
||||
// GET /api/workspace/:id → fetch workspace (any member)
|
||||
// PATCH /api/workspace/:id → rename workspace (owner only)
|
||||
// DELETE /api/workspace/:id → delete workspace and all its data (owner only)
|
||||
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import { deleteWorkspace } from '@originmain/origin-graph';
|
||||
import type { Workspace } from '@originmain/origin-graph';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
@@ -69,3 +71,22 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
||||
|
||||
return NextResponse.json(data as Workspace);
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: RouteContext) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const db = serverClient();
|
||||
|
||||
const isOwner = await assertOwner(db, id, userId);
|
||||
if (!isOwner) return NextResponse.json({ error: 'Only workspace owners can delete' }, { status: 403 });
|
||||
|
||||
try {
|
||||
await deleteWorkspace(db, id);
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Delete failed';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function GlobalError({
|
||||
margin: 0, minHeight: '100vh',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
background: '#FAFAFA',
|
||||
background: 'var(--page-bg)',
|
||||
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
||||
padding: 24,
|
||||
}}>
|
||||
|
||||
@@ -12,9 +12,28 @@ html, body {
|
||||
for the canvas route only. Setting it globally here would break
|
||||
scrolling on settings, workspace, and other non-canvas pages. */
|
||||
|
||||
/* ── Theme CSS variables ─────────────────────────────────────────────────────
|
||||
--page-bg : background of workspace / settings / plugin shell pages
|
||||
--page-text: primary text colour on those pages
|
||||
The canvas editor (AppChrome) uses its own inline dark styles and does not
|
||||
consume these variables. Toggling data-theme on <html> via Providers is
|
||||
enough for all shell pages to respond. */
|
||||
|
||||
:root {
|
||||
--page-bg: #FAFAFA;
|
||||
--page-text: #0A0A0A;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--page-bg: #0C0C10;
|
||||
--page-text: #FAFAFA;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
background: #f5f5f5;
|
||||
background: var(--page-bg);
|
||||
color: var(--page-text);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
/* ── Accessible focus indicators ────────────────────────────────────────────
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function OnboardingPage() {
|
||||
<main style={{
|
||||
minHeight: '100dvh',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
background: '#FAFAFA',
|
||||
background: 'var(--page-bg)',
|
||||
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
||||
padding: '24px',
|
||||
}}>
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useState, useEffect, type ReactNode } from 'react';
|
||||
import { FluentProvider } from '@fluentui/react-components';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { originmainLightTheme } from '@originmain/ui';
|
||||
import { originmainLightTheme, originmainDarkTheme } from '@originmain/ui';
|
||||
import { useTheme } from '@/store/theme';
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
// TanStack Query v5: create QueryClient inside useState so it's stable across
|
||||
// re-renders and each client-side navigation gets the same instance. Creating
|
||||
// it outside the component would cause it to be shared across requests on the
|
||||
// server (SSR memory leak / cross-request data pollution).
|
||||
// re-renders and each client-side navigation gets the same instance.
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000, // treat data as fresh for 30 s
|
||||
retry: 1, // one automatic retry on transient errors
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const mode = useTheme((s) => s.mode);
|
||||
const theme = mode === 'dark' ? originmainDarkTheme : originmainLightTheme;
|
||||
|
||||
// Sync data-theme on <html> so CSS variables and server-rendered page
|
||||
// backgrounds can respond to the user's preference without prop drilling.
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', mode);
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<FluentProvider theme={originmainLightTheme} style={{ height: '100%' }}>
|
||||
<FluentProvider theme={theme} style={{ height: '100%' }}>
|
||||
{children}
|
||||
</FluentProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function WorkspaceError({
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<div style={{ minHeight: '100vh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<AppHeader breadcrumbs={[{ label: 'Workspaces', href: '/workspaces' }]} />
|
||||
<main style={{ maxWidth: 480, margin: '80px auto', padding: '0 24px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2rem', marginBottom: 16 }}>⚠️</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
|
||||
const projects = (projectsData ?? []) as Project[];
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<AppHeader breadcrumbs={[
|
||||
{ label: 'Workspaces', href: '/workspaces' },
|
||||
{ label: workspace.name },
|
||||
@@ -67,6 +67,18 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<Link href={`/workspace/${wid}/plugins`} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
background: 'transparent', color: '#71717A',
|
||||
fontSize: '0.875rem', fontWeight: 500,
|
||||
padding: '9px 16px', borderRadius: 9,
|
||||
textDecoration: 'none', border: '1px solid rgba(0,0,0,0.1)',
|
||||
}}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2a2 2 0 0 1 2 2v1h3a1 1 0 0 1 1 1v3h1a2 2 0 0 1 0 4h-1v3a1 1 0 0 1-1 1h-3v1a2 2 0 0 1-4 0v-1H7a1 1 0 0 1-1-1v-3H5a2 2 0 0 1 0-4h1V6a1 1 0 0 1 1-1h3V4a2 2 0 0 1 2-2z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
Plugins
|
||||
</Link>
|
||||
<Link href={`/workspace/${wid}/settings`} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
background: 'transparent', color: '#71717A',
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import { AppHeader } from '@/components/shell/AppHeader';
|
||||
import type { Workspace } from '@originmain/origin-graph';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ wid: string }> }) {
|
||||
const { wid } = await params;
|
||||
const db = serverClient();
|
||||
const result = (await db.from('workspaces').select('name').eq('id', wid).single()) as unknown as { data: { name: string } | null };
|
||||
return { title: `Plugins — ${result.data?.name ?? 'Workspace'} — Originmain` };
|
||||
}
|
||||
|
||||
export default async function WorkspacePluginsPage({ params }: { params: Promise<{ wid: string }> }) {
|
||||
const { wid } = await params;
|
||||
const { userId } = await auth();
|
||||
if (!userId) redirect('/sign-in');
|
||||
|
||||
const db = serverClient();
|
||||
|
||||
// Verify membership
|
||||
const { data: member } = await db
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', wid)
|
||||
.eq('user_id', userId)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (!member) redirect('/workspaces');
|
||||
|
||||
const { data: ws } = await db.from('workspaces').select('*').eq('id', wid).single();
|
||||
const workspace = ws as Workspace | null;
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" }}>
|
||||
<AppHeader workspaceName={workspace?.name ?? 'Workspace'} workspaceId={wid} />
|
||||
|
||||
<div style={{ maxWidth: 760, margin: '0 auto', padding: '48px 24px' }}>
|
||||
{/* Breadcrumb */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 32, fontSize: '0.8125rem', color: '#71717A' }}>
|
||||
<Link href="/workspaces" style={{ color: '#71717A', textDecoration: 'none' }}>Workspaces</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/workspace/${wid}`} style={{ color: '#71717A', textDecoration: 'none' }}>{workspace?.name ?? 'Workspace'}</Link>
|
||||
<span>/</span>
|
||||
<span style={{ color: '#09090B' }}>Plugins</span>
|
||||
</div>
|
||||
|
||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, letterSpacing: '-0.025em', color: '#09090B', margin: '0 0 8px' }}>
|
||||
Plugins
|
||||
</h1>
|
||||
<p style={{ fontSize: '0.9375rem', color: '#52525B', margin: '0 0 40px', lineHeight: 1.6 }}>
|
||||
Extend Originmain with custom completion zones, ingestion connectors, and design tools.
|
||||
</p>
|
||||
|
||||
{/* Coming soon card */}
|
||||
<div style={{
|
||||
background: '#FFFFFF', border: '1px solid rgba(0,0,0,0.07)', borderRadius: 14,
|
||||
padding: '40px 36px', textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: 14,
|
||||
background: 'rgba(51,133,255,0.08)', border: '1px solid rgba(51,133,255,0.15)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
margin: '0 auto 20px',
|
||||
}}>
|
||||
{/* Puzzle icon */}
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2a2 2 0 0 1 2 2v1h3a1 1 0 0 1 1 1v3h1a2 2 0 0 1 0 4h-1v3a1 1 0 0 1-1 1h-3v1a2 2 0 0 1-4 0v-1H7a1 1 0 0 1-1-1v-3H5a2 2 0 0 1 0-4h1V6a1 1 0 0 1 1-1h3V4a2 2 0 0 1 2-2z" stroke="#3385FF" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: '#09090B', margin: '0 0 10px', letterSpacing: '-0.015em' }}>
|
||||
Plugin marketplace — coming in Phase 4
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.875rem', color: '#71717A', maxWidth: 480, margin: '0 auto 28px', lineHeight: 1.65 }}>
|
||||
The plugin API lets teams register custom AI completion zones, custom webhook ingesters, and design-language extensions — all sandboxed and permission-scoped.
|
||||
</p>
|
||||
|
||||
{/* Permissions preview */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center', marginBottom: 28 }}>
|
||||
{[
|
||||
'artboards:read', 'artboards:write', 'diffs:read', 'diffs:export',
|
||||
'completion-zones:register', 'ingesters:register', 'design-language:read',
|
||||
].map(scope => (
|
||||
<span key={scope} style={{
|
||||
fontSize: '0.6875rem', fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
background: 'rgba(51,133,255,0.07)', color: '#3385FF',
|
||||
border: '1px solid rgba(51,133,255,0.18)',
|
||||
borderRadius: 6, padding: '3px 8px',
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/workspace/${wid}/settings`}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
fontSize: '0.875rem', fontWeight: 500, color: '#3385FF',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Back to settings →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export default function NewProjectPage({ params }: { params: Promise<{ wid: stri
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<AppHeader breadcrumbs={[
|
||||
{ label: 'Workspaces', href: '/workspaces' },
|
||||
{ label: 'Workspace', href: `/workspace/${wid}` },
|
||||
|
||||
@@ -37,7 +37,7 @@ export default async function WorkspaceSettingsPage({ params }: { params: Promis
|
||||
const memberRole = (member as { role: string }).role;
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<AppHeader breadcrumbs={[
|
||||
{ label: 'Workspaces', href: '/workspaces' },
|
||||
{ label: workspace.name, href: `/workspace/${wid}` },
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function WorkspacesError({
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<div style={{ minHeight: '100vh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<AppHeader />
|
||||
<main style={{ maxWidth: 480, margin: '80px auto', padding: '0 24px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2rem', marginBottom: 16 }}>⚠️</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function NewWorkspacePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<AppHeader breadcrumbs={[{ label: 'Workspaces', href: '/workspaces' }, { label: 'New workspace' }]} />
|
||||
|
||||
<main style={{ maxWidth: 480, margin: '64px auto', padding: '0 24px' }}>
|
||||
|
||||
@@ -37,7 +37,7 @@ export default async function WorkspacesPage() {
|
||||
if (workspaces.length === 0) redirect('/onboarding');
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<AppHeader />
|
||||
|
||||
<main style={{ maxWidth: 960, margin: '0 auto', padding: '48px 24px' }}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useCallback, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
import { useViewport } from '@/store/viewport';
|
||||
import { useDiffs } from '@/hooks/useDiffs';
|
||||
import { LiveArtboard } from './LiveArtboard';
|
||||
import { SelectionOverlay } from './SelectionOverlay';
|
||||
import type { FiberNode } from '@originmain/renderer';
|
||||
@@ -18,11 +19,22 @@ interface ArtboardProps {
|
||||
renderUrl?: string;
|
||||
}
|
||||
|
||||
// Status color map matching the inspector
|
||||
const DIFF_STATUS_BADGE: Record<string, { color: string; bg: string; label: string }> = {
|
||||
DRAFT: { color: '#FFBA7B', bg: 'rgba(255,186,123,0.15)', label: 'draft' },
|
||||
REVIEWED: { color: '#7EB8FF', bg: 'rgba(126,184,255,0.15)', label: 'reviewed' },
|
||||
APPLIED: { color: '#10B981', bg: 'rgba(16,185,129,0.15)', label: 'applied' },
|
||||
REJECTED: { color: '#FF8080', bg: 'rgba(255,128,128,0.15)', label: 'blocked' },
|
||||
};
|
||||
|
||||
export function Artboard({ id, label, x, y, width, height, renderUrl }: ArtboardProps) {
|
||||
const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, setFiberRoot, selectComponent } = useCanvas();
|
||||
const selected = selectedArtboardId === id;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Diff status badges — fetch is cached by TanStack Query across all artboards
|
||||
const { diffs } = useDiffs(id);
|
||||
|
||||
// ── Fiber tree (from LiveArtboard) ─────────────────────────────────────────
|
||||
const [localFiberRoot, setLocalFiberRoot] = useState<FiberNode | undefined>(undefined);
|
||||
|
||||
@@ -246,6 +258,45 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Diff status badge strip — bottom-right overlay, only visible when diffs exist */}
|
||||
{diffs.length > 0 && (() => {
|
||||
// Group by status and show compact chips
|
||||
const counts: Record<string, number> = {};
|
||||
for (const d of diffs) counts[d.status] = (counts[d.status] ?? 0) + 1;
|
||||
const entries = Object.entries(counts).filter(([s]) => s in DIFF_STATUS_BADGE);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', bottom: 6, right: 6, zIndex: 10,
|
||||
display: 'flex', gap: 4, pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{entries.map(([status, count]) => {
|
||||
const b = DIFF_STATUS_BADGE[status]!;
|
||||
return (
|
||||
<span
|
||||
key={status}
|
||||
title={`${count} ${b.label} diff${count !== 1 ? 's' : ''}`}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 5px', borderRadius: 4,
|
||||
background: b.bg, border: `1px solid ${b.color}33`,
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
fontSize: '0.5rem', fontWeight: 600,
|
||||
color: b.color, letterSpacing: '0.03em',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 4, height: 4, borderRadius: '50%', background: b.color, display: 'inline-block' }} />
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Content */}
|
||||
{renderUrl ? (
|
||||
<>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Inspector } from '../inspector/Inspector';
|
||||
import { useHistory } from '@/store/history';
|
||||
import { useCanvas, type Tool } from '@/store/canvas';
|
||||
import { useViewport } from '@/store/viewport';
|
||||
import { useTheme } from '@/store/theme';
|
||||
|
||||
interface AppChromeProps {
|
||||
workspaceId?: string;
|
||||
@@ -22,6 +23,7 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
||||
const selectedArtboardId = useCanvas((s) => s.selectedArtboardId);
|
||||
const setContext = useCanvas((s) => s.setContext);
|
||||
const setActiveTool = useCanvas((s) => s.setActiveTool);
|
||||
const { mode: themeMode, toggle: toggleTheme } = useTheme();
|
||||
|
||||
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
|
||||
useEffect(() => {
|
||||
@@ -162,6 +164,34 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
title={`Switch to ${themeMode === 'dark' ? 'light' : 'dark'} mode`}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'rgba(255,255,255,0.35)', padding: '4px 6px',
|
||||
display: 'flex', alignItems: 'center',
|
||||
transition: 'color 0.12s',
|
||||
fontSize: 13,
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.75)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.35)')}
|
||||
>
|
||||
{themeMode === 'dark' ? (
|
||||
/* Sun icon */
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
|
||||
<circle cx="7" cy="7" r="2.5" stroke="currentColor" strokeWidth="1.2"/>
|
||||
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13M2.93 2.93l1.06 1.06M10.01 10.01l1.06 1.06M2.93 11.07l1.06-1.06M10.01 3.99l1.06-1.06" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
) : (
|
||||
/* Moon icon */
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M12 8.5A5.5 5.5 0 0 1 5.5 2a5.5 5.5 0 1 0 6.5 6.5z" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div style={{ transform: 'scale(0.8)', transformOrigin: 'right center' }}>
|
||||
<UserButton />
|
||||
</div>
|
||||
|
||||
@@ -542,19 +542,47 @@ function HSep() {
|
||||
function DiffTab({ artboardId }: { artboardId: string | null }) {
|
||||
const { stacks } = useHistory();
|
||||
const { diffs, createDiff, isLoading } = useDiffs(artboardId);
|
||||
const [summaryStatus, setSummaryStatus] = useState<'idle' | 'summarising' | 'exporting'>('idle');
|
||||
|
||||
const artboardHistory = artboardId ? (stacks[artboardId] ?? { past: [], future: [] }) : { past: [], future: [] };
|
||||
const pendingChanges: PropChange[] = artboardHistory.past.flatMap(e => e.changes);
|
||||
const hasChanges = pendingChanges.length > 0;
|
||||
|
||||
const exportDiff = useCallback(() => {
|
||||
const exportDiff = useCallback(async () => {
|
||||
if (!artboardId || !hasChanges) return;
|
||||
createDiff.mutate({
|
||||
artboard_id: artboardId,
|
||||
changes_jsonb: { propChanges: pendingChanges, styleChanges: [] },
|
||||
summary: '',
|
||||
status: 'DRAFT',
|
||||
});
|
||||
|
||||
// 1. Generate AI summary (best-effort — fall back to empty string on failure)
|
||||
let summary = '';
|
||||
const meaningfulChanges = pendingChanges.filter(c => c.changeType !== 'unchanged');
|
||||
if (meaningfulChanges.length > 0) {
|
||||
setSummaryStatus('summarising');
|
||||
try {
|
||||
const res = await fetch('/api/ai/diff-summary', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
changesJson: JSON.stringify(meaningfulChanges),
|
||||
componentName: meaningfulChanges[0]?.key ?? 'Component',
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { summary?: string };
|
||||
summary = data.summary ?? '';
|
||||
}
|
||||
} catch { /* non-fatal — proceed without summary */ }
|
||||
}
|
||||
|
||||
// 2. Export diff with AI-generated summary included
|
||||
setSummaryStatus('exporting');
|
||||
createDiff.mutate(
|
||||
{
|
||||
artboard_id: artboardId,
|
||||
changes_jsonb: { propChanges: pendingChanges, styleChanges: [] },
|
||||
summary,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
{ onSettled: () => setSummaryStatus('idle') },
|
||||
);
|
||||
}, [artboardId, pendingChanges, hasChanges, createDiff]);
|
||||
|
||||
if (!artboardId) {
|
||||
@@ -579,18 +607,22 @@ function DiffTab({ artboardId }: { artboardId: string | null }) {
|
||||
<DiffChangeRow key={i} change={change} />
|
||||
))}
|
||||
<button
|
||||
onClick={exportDiff}
|
||||
disabled={createDiff.isPending}
|
||||
onClick={() => void exportDiff()}
|
||||
disabled={summaryStatus !== 'idle' || createDiff.isPending}
|
||||
style={{
|
||||
marginTop: 10, width: '100%',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
|
||||
background: T.accent, border: 'none', borderRadius: 6,
|
||||
color: '#fff', padding: '7px 0', cursor: createDiff.isPending ? 'wait' : 'pointer',
|
||||
letterSpacing: '0.04em', opacity: createDiff.isPending ? 0.6 : 1,
|
||||
color: '#fff', padding: '7px 0',
|
||||
cursor: (summaryStatus !== 'idle' || createDiff.isPending) ? 'wait' : 'pointer',
|
||||
letterSpacing: '0.04em',
|
||||
opacity: (summaryStatus !== 'idle' || createDiff.isPending) ? 0.6 : 1,
|
||||
transition: 'opacity 0.15s',
|
||||
}}
|
||||
>
|
||||
{createDiff.isPending ? 'Exporting…' : 'Export diff →'}
|
||||
{summaryStatus === 'summarising' ? 'Summarising…' :
|
||||
summaryStatus === 'exporting' || createDiff.isPending ? 'Exporting…' :
|
||||
'Export diff →'}
|
||||
</button>
|
||||
{createDiff.isError && (
|
||||
<span style={{ fontSize: '0.5rem', color: '#FF8080', fontFamily: 'monospace', display: 'block', marginTop: 4 }}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useFileTree, FileTree } from '@pierre/trees/react';
|
||||
import { themeToTreeStyles } from '@pierre/trees';
|
||||
import { SquareRegular } from '@fluentui/react-icons';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
import { useArtboards, patchArtboard } from '@/hooks/useArtboards';
|
||||
import { useArtboards, patchArtboard, createArtboardMutation } from '@/hooks/useArtboards';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const T = {
|
||||
@@ -61,6 +61,30 @@ export function ArtboardNavigator() {
|
||||
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
||||
}, [workspaceId, projectId, queryClient]);
|
||||
|
||||
const forkArtboard = useCallback(async (id: string, label: string) => {
|
||||
if (!workspaceId) return;
|
||||
const source = rawArtboards.find(ab => ab.id === id);
|
||||
if (!source) return;
|
||||
const meta = { ...(source.metadata_jsonb as Record<string, unknown>) };
|
||||
// Offset fork to the right of the original so it doesn't overlap
|
||||
const srcWidth = typeof meta['width'] === 'number' ? (meta['width'] as number) : 360;
|
||||
meta['x'] = typeof meta['x'] === 'number' ? (meta['x'] as number) + srcWidth + 40 : 40;
|
||||
try {
|
||||
await createArtboardMutation({
|
||||
workspace_id: workspaceId,
|
||||
project_id: projectId ?? null,
|
||||
name: `Fork of ${label}`,
|
||||
origin_id: source.origin_id,
|
||||
parent_artboard_id: id,
|
||||
metadata_jsonb: meta,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
||||
} catch (err) {
|
||||
console.error('[Navigator] forkArtboard failed:', err);
|
||||
window.alert('Could not fork artboard — please try again.');
|
||||
}
|
||||
}, [workspaceId, projectId, rawArtboards, queryClient]);
|
||||
|
||||
// Real graph stats derived from live fiber trees
|
||||
const totalComponents = Object.values(artboardFiberRoots).reduce(
|
||||
(acc, root) => acc + countFiberNodes(root), 0,
|
||||
@@ -112,6 +136,7 @@ export function ArtboardNavigator() {
|
||||
}
|
||||
label={ab.label}
|
||||
onRename={() => void renameArtboard(ab.id, ab.label)}
|
||||
onFork={() => void forkArtboard(ab.id, ab.label)}
|
||||
onDelete={() => void deleteArtboard(ab.id, ab.label)}
|
||||
/>
|
||||
);
|
||||
@@ -160,6 +185,7 @@ function NavRow({
|
||||
icon,
|
||||
label,
|
||||
onRename,
|
||||
onFork,
|
||||
onDelete,
|
||||
}: {
|
||||
selected?: boolean;
|
||||
@@ -168,6 +194,7 @@ function NavRow({
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onRename?: () => void;
|
||||
onFork?: () => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const [hov, setHov] = useState(false);
|
||||
@@ -206,18 +233,31 @@ function NavRow({
|
||||
}} />
|
||||
)}
|
||||
|
||||
{/* Action buttons: rename + delete — shown on hover */}
|
||||
{/* Action buttons: rename + fork + delete — shown on hover */}
|
||||
{(hov || selected) && (
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
{onRename && (
|
||||
<IconBtn title="Rename" onClick={onRename}>
|
||||
{/* Pencil */}
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<path d="M1 7.5L7 1.5l1.5 1.5-6 6H1V7.5z" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</IconBtn>
|
||||
)}
|
||||
{onFork && (
|
||||
<IconBtn title="Fork" onClick={onFork}>
|
||||
{/* Branch / fork icon */}
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<circle cx="2" cy="2" r="1.2" stroke="currentColor" strokeWidth="1"/>
|
||||
<circle cx="8" cy="2" r="1.2" stroke="currentColor" strokeWidth="1"/>
|
||||
<circle cx="2" cy="8" r="1.2" stroke="currentColor" strokeWidth="1"/>
|
||||
<path d="M2 3.2v1.3C2 5.4 2.6 6 3.5 6H5M8 3.2V5a1 1 0 0 1-1 1H5m0 0v2" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</IconBtn>
|
||||
)}
|
||||
{onDelete && (
|
||||
<IconBtn title="Delete" onClick={onDelete} danger>
|
||||
{/* Trash */}
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<path d="M2 2.5h6M4 2.5V1.5h2V2.5M3 2.5v6h4v-6" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
|
||||
@@ -2,49 +2,69 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { UserButton } from '@clerk/nextjs';
|
||||
import { useTheme } from '@/store/theme';
|
||||
|
||||
interface Crumb { label: string; href?: string }
|
||||
|
||||
interface AppHeaderProps {
|
||||
breadcrumbs?: Crumb[];
|
||||
/** @deprecated Pass breadcrumbs instead */
|
||||
workspaceName?: string;
|
||||
/** @deprecated Pass breadcrumbs instead */
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
|
||||
export function AppHeader({ breadcrumbs = [], workspaceName, workspaceId }: AppHeaderProps) {
|
||||
const { mode, toggle } = useTheme();
|
||||
|
||||
// Back-compat: if old props are passed without breadcrumbs, synthesise them
|
||||
const crumbs: Crumb[] = breadcrumbs.length > 0
|
||||
? breadcrumbs
|
||||
: workspaceName
|
||||
? [
|
||||
{ label: 'Workspaces', href: '/workspaces' as string },
|
||||
...(workspaceId
|
||||
? [{ label: workspaceName, href: `/workspace/${workspaceId}` as string }]
|
||||
: [{ label: workspaceName }]),
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<header style={{
|
||||
position: 'sticky', top: 0, zIndex: 100,
|
||||
height: 56,
|
||||
background: 'rgba(255,255,255,0.92)',
|
||||
background: mode === 'dark' ? 'rgba(12,12,16,0.92)' : 'rgba(255,255,255,0.92)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.07)',
|
||||
borderBottom: mode === 'dark' ? '1px solid rgba(255,255,255,0.07)' : '1px solid rgba(0,0,0,0.07)',
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: '0 24px',
|
||||
gap: 0,
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<Link href="/workspaces" style={{ textDecoration: 'none', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: '1rem', fontWeight: 700, letterSpacing: '-0.02em', color: '#0A0A0A' }}>
|
||||
<span style={{ fontSize: '1rem', fontWeight: 700, letterSpacing: '-0.02em', color: mode === 'dark' ? '#FAFAFA' : '#0A0A0A' }}>
|
||||
Origin<span style={{ color: '#0066FF' }}>main</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
{crumbs.map((crumb, i) => (
|
||||
<span key={i} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ margin: '0 8px', color: '#D4D4D8', fontSize: '0.875rem' }}>/</span>
|
||||
<span style={{ margin: '0 8px', color: mode === 'dark' ? 'rgba(255,255,255,0.2)' : '#D4D4D8', fontSize: '0.875rem' }}>/</span>
|
||||
{crumb.href ? (
|
||||
<Link href={crumb.href} style={{
|
||||
fontSize: '0.875rem', fontWeight: 500,
|
||||
color: '#71717A', textDecoration: 'none',
|
||||
color: mode === 'dark' ? 'rgba(255,255,255,0.45)' : '#71717A',
|
||||
textDecoration: 'none',
|
||||
transition: 'color 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = '#0A0A0A')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = '#71717A')}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.85)' : '#0A0A0A')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.45)' : '#71717A')}
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#0A0A0A' }}>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: mode === 'dark' ? '#FAFAFA' : '#0A0A0A' }}>
|
||||
{crumb.label}
|
||||
</span>
|
||||
)}
|
||||
@@ -53,6 +73,40 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
title={`Switch to ${mode === 'dark' ? 'light' : 'dark'} mode`}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: mode === 'dark' ? 'rgba(255,255,255,0.4)' : '#A1A1AA',
|
||||
padding: '6px 8px', marginRight: 8,
|
||||
display: 'flex', alignItems: 'center', borderRadius: 6,
|
||||
transition: 'color 0.12s, background 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.85)' : '#0A0A0A';
|
||||
e.currentTarget.style.background = mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.4)' : '#A1A1AA';
|
||||
e.currentTarget.style.background = 'none';
|
||||
}}
|
||||
>
|
||||
{mode === 'dark' ? (
|
||||
/* Sun */
|
||||
<svg width="15" height="15" viewBox="0 0 14 14" fill="none">
|
||||
<circle cx="7" cy="7" r="2.5" stroke="currentColor" strokeWidth="1.2"/>
|
||||
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13M2.93 2.93l1.06 1.06M10.01 10.01l1.06 1.06M2.93 11.07l1.06-1.06M10.01 3.99l1.06-1.06" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
) : (
|
||||
/* Moon */
|
||||
<svg width="15" height="15" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M12 8.5A5.5 5.5 0 0 1 5.5 2a5.5 5.5 0 1 0 6.5 6.5z" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<UserButton />
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -377,10 +377,18 @@ export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }
|
||||
border: '1px solid rgba(239,68,68,0.35)', background: 'transparent',
|
||||
color: '#EF4444', cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete workspace "${workspaceName}"? This cannot be undone.`)) {
|
||||
// TODO: call DELETE /api/workspace/:id when implemented
|
||||
window.alert('Delete endpoint not yet implemented — coming soon.');
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`Delete workspace "${workspaceName}"? This cannot be undone. All projects and artboards will be permanently lost.`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/workspace/${workspaceId}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(body.error ?? `Server error ${res.status}`);
|
||||
}
|
||||
router.push('/workspaces');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
window.alert(`Could not delete workspace: ${msg}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import type { ComponentSnapshot } from '@originmain/diff-engine';
|
||||
|
||||
interface ArtboardSnapshots {
|
||||
before: ComponentSnapshot;
|
||||
after: ComponentSnapshot;
|
||||
}
|
||||
|
||||
const dashboardCard: ArtboardSnapshots = {
|
||||
before: {
|
||||
id: 'dashboard-card',
|
||||
name: 'DashboardCard',
|
||||
filePath: 'src/components/DashboardCard.tsx',
|
||||
props: {
|
||||
title: 'Revenue Overview',
|
||||
value: '$12,450',
|
||||
delta: 2.4,
|
||||
period: 'monthly',
|
||||
loading: false,
|
||||
},
|
||||
styles: {
|
||||
borderRadius: '8px',
|
||||
accentColor: '#2A6CD4',
|
||||
padding: '16',
|
||||
},
|
||||
},
|
||||
after: {
|
||||
id: 'dashboard-card',
|
||||
name: 'DashboardCard',
|
||||
filePath: 'src/components/DashboardCard.tsx',
|
||||
props: {
|
||||
title: 'Revenue Overview',
|
||||
value: '$12,450',
|
||||
delta: 2.4,
|
||||
period: 'monthly',
|
||||
loading: false,
|
||||
},
|
||||
styles: {
|
||||
borderRadius: '12px',
|
||||
accentColor: '#0066FF',
|
||||
padding: '20',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const userProfile: ArtboardSnapshots = {
|
||||
before: {
|
||||
id: 'user-profile',
|
||||
name: 'UserProfile',
|
||||
filePath: 'src/components/UserProfile.tsx',
|
||||
props: {
|
||||
name: 'Sarah Chen',
|
||||
role: 'Designer',
|
||||
plan: 'Pro',
|
||||
avatarSize: 40,
|
||||
},
|
||||
styles: {
|
||||
avatarGradient: 'linear-gradient(135deg, #6D28D9, #2563EB)',
|
||||
buttonVariant: 'outline',
|
||||
},
|
||||
},
|
||||
after: {
|
||||
id: 'user-profile',
|
||||
name: 'UserProfile',
|
||||
filePath: 'src/components/UserProfile.tsx',
|
||||
props: {
|
||||
name: 'Sarah Chen',
|
||||
role: 'Design Engineer',
|
||||
plan: 'Team',
|
||||
avatarSize: 52,
|
||||
},
|
||||
styles: {
|
||||
avatarGradient: 'linear-gradient(135deg, #7C3AED, #0066FF)',
|
||||
buttonVariant: 'ghost',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const navSidebar: ArtboardSnapshots = {
|
||||
before: {
|
||||
id: 'nav-sidebar',
|
||||
name: 'NavSidebar',
|
||||
filePath: 'src/components/NavSidebar.tsx',
|
||||
props: {
|
||||
brand: 'Origin',
|
||||
collapsed: false,
|
||||
activeItem: 'Dashboard',
|
||||
},
|
||||
styles: {
|
||||
background: '#F5F5F5',
|
||||
itemPadding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
},
|
||||
after: {
|
||||
id: 'nav-sidebar',
|
||||
name: 'NavSidebar',
|
||||
filePath: 'src/components/NavSidebar.tsx',
|
||||
props: {
|
||||
brand: 'Originmain',
|
||||
collapsed: false,
|
||||
activeItem: 'Dashboard',
|
||||
},
|
||||
styles: {
|
||||
background: '#FAFAFA',
|
||||
itemPadding: '7px 12px',
|
||||
borderRadius: '5px',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dataTable: ArtboardSnapshots = {
|
||||
before: {
|
||||
id: 'data-table',
|
||||
name: 'DataTable',
|
||||
filePath: 'src/components/DataTable.tsx',
|
||||
props: {
|
||||
title: 'Components',
|
||||
striped: false,
|
||||
pageSize: 10,
|
||||
},
|
||||
styles: {
|
||||
headerColor: '#71717A',
|
||||
rowHeight: '32px',
|
||||
},
|
||||
},
|
||||
after: {
|
||||
id: 'data-table',
|
||||
name: 'DataTable',
|
||||
filePath: 'src/components/DataTable.tsx',
|
||||
props: {
|
||||
title: 'Component Inventory',
|
||||
striped: true,
|
||||
pageSize: 10,
|
||||
},
|
||||
styles: {
|
||||
headerColor: '#A1A1AA',
|
||||
rowHeight: '36px',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ARTBOARD_SNAPSHOTS: Record<string, ArtboardSnapshots> = {
|
||||
'dashboard-card': dashboardCard,
|
||||
'user-profile': userProfile,
|
||||
'nav-sidebar': navSidebar,
|
||||
'data-table': dataTable,
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { diffComponents, type DiffResult } from '@originmain/diff-engine';
|
||||
import { ARTBOARD_SNAPSHOTS } from '@/data/artboard-snapshots';
|
||||
|
||||
export function useDiff(artboardId: string | null): DiffResult | null {
|
||||
return useMemo(() => {
|
||||
if (!artboardId) return null;
|
||||
const snapshots = ARTBOARD_SNAPSHOTS[artboardId];
|
||||
if (!snapshots) return null;
|
||||
return diffComponents(snapshots.before, snapshots.after);
|
||||
}, [artboardId]);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
type ThemeMode = 'light' | 'dark';
|
||||
|
||||
interface ThemeStore {
|
||||
mode: ThemeMode;
|
||||
toggle: () => void;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
export const useTheme = create<ThemeStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
mode: 'dark',
|
||||
toggle: () => set({ mode: get().mode === 'dark' ? 'light' : 'dark' }),
|
||||
setMode: (mode) => set({ mode }),
|
||||
}),
|
||||
{
|
||||
name: 'originmain:theme',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({ mode: state.mode }),
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user