improved a lot of things

This commit is contained in:
SinachPat
2026-04-26 12:31:31 +01:00
parent f9528702c8
commit 7a4c1d3147
30 changed files with 1316 additions and 59 deletions
@@ -0,0 +1,102 @@
// POST /api/agent-bridge
// JSON-RPC 2.0 endpoint consumed by the Cursor / Claude Code MCP adapters.
// Authentication: Bearer <workspace_token> (HMAC-SHA256, issued by issueWorkspaceToken).
import { NextRequest, NextResponse } from 'next/server';
import { verifyWorkspaceToken, TOOL_MAP } from '@originmain/agent-bridge';
import type { JsonRpcRequest, ToolContext } from '@originmain/agent-bridge';
import {
getDiffsByStatus,
getDiff,
getArtboard,
getActiveDesignLanguageFile,
updateDiffStatus,
} from '@originmain/origin-graph';
import { AIGateway, answerAgentQuestion } from '@originmain/ai-layer';
import { serverClient } from '@/lib/supabase';
export async function POST(req: NextRequest) {
// ── Auth ────────────────────────────────────────────────────────────────────
const authHeader = req.headers.get('authorization') ?? '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (!token) {
return NextResponse.json(
{ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Missing Bearer token' } },
{ status: 401 },
);
}
const workspaceToken = verifyWorkspaceToken(token);
if (!workspaceToken) {
return NextResponse.json(
{ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Invalid or expired token' } },
{ status: 401 },
);
}
// ── Parse JSON-RPC body ─────────────────────────────────────────────────────
let body: JsonRpcRequest;
try {
body = (await req.json()) as JsonRpcRequest;
} catch {
return NextResponse.json(
{ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } },
{ status: 400 },
);
}
// tools/list — meta-endpoint; return available tools without executing one.
if (body.method === 'tools/list') {
const tools = [...TOOL_MAP.values()].map(t => ({
name: t.name,
description: t.description,
}));
return NextResponse.json({ jsonrpc: '2.0', id: body.id, result: { tools } });
}
// ── Dispatch ────────────────────────────────────────────────────────────────
const tool = TOOL_MAP.get(body.method);
if (!tool) {
return NextResponse.json({
jsonrpc: '2.0',
id: body.id,
error: { code: -32601, message: `Method not found: ${body.method}` },
});
}
const db = serverClient();
const { workspaceId } = workspaceToken;
const ctx: ToolContext = {
workspaceId,
db: {
getDiffsByStatus: (wsId, status) => getDiffsByStatus(db, wsId, status),
getDiff: (id) => getDiff(db, id),
getArtboard: (id) => getArtboard(db, id),
getDesignLanguageFile: (wsId) => getActiveDesignLanguageFile(db, wsId),
updateDiffStatus: (id, status, notes) =>
updateDiffStatus(db, id, status, notes).then(() => undefined),
},
ai: {
answerAgentQuestion: (diffId: string, question: string, artboardContext: unknown) =>
answerAgentQuestion(new AIGateway(), {
diffId,
question,
artboardContextJson: JSON.stringify(artboardContext),
}).then(r => r.answer),
},
};
try {
const result = await tool.execute(body.params ?? {}, ctx);
return NextResponse.json({ jsonrpc: '2.0', id: body.id, result });
} catch (err) {
const message = err instanceof Error ? err.message : 'Internal error';
return NextResponse.json({
jsonrpc: '2.0',
id: body.id,
error: { code: -32603, message },
});
}
}
@@ -0,0 +1,23 @@
// POST /api/ai/completion-zone
// Fills a design completion zone given a component tree and user intent.
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';
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;
try {
const gateway = new AIGateway();
const result = await fillCompletionZone(gateway, body);
return NextResponse.json({ proposedTree: result.proposedTree, description: result.description });
} catch (err) {
const message = err instanceof Error ? err.message : 'AI error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,23 @@
// POST /api/ai/drift-report
// Analyzes a screenshot against the active Design Language File for drift violations.
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { AIGateway, generateDriftReport } from '@originmain/ai-layer';
import type { DriftReportInput } from '@originmain/ai-layer';
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = (await req.json()) as DriftReportInput;
try {
const gateway = new AIGateway();
const result = await generateDriftReport(gateway, body);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'AI error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,23 @@
// POST /api/ai/query
// Cross-artboard search: finds artboards matching a natural language query.
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';
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;
try {
const gateway = new AIGateway();
const result = await queryCrossArtboard(gateway, body);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'AI error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,26 @@
// GET /api/artboards/:id → fetch single artboard
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { getArtboard } from '@originmain/origin-graph';
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
try {
const db = serverClient();
const artboard = await getArtboard(db, id);
return NextResponse.json(artboard);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
const status = message.includes('not found') || message.includes('0 rows') ? 404 : 500;
return NextResponse.json({ error: message }, { status });
}
}
@@ -0,0 +1,41 @@
// GET /api/artboards?workspaceId=<uuid> → list artboards for workspace
// POST /api/artboards → create artboard
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { getArtboards, createArtboard } from '@originmain/origin-graph';
import type { InsertArtboard } from '@originmain/origin-graph';
export async function GET(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const workspaceId = req.nextUrl.searchParams.get('workspaceId');
if (!workspaceId) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 });
try {
const db = serverClient();
const artboards = await getArtboards(db, workspaceId);
return NextResponse.json(artboards);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
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 InsertArtboard;
try {
const db = serverClient();
const artboard = await createArtboard(db, body);
return NextResponse.json(artboard, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,47 @@
// GET /api/design-language?workspaceId=<uuid> → active design language file
// POST /api/design-language → upsert (new version)
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { getActiveDesignLanguageFile } from '@originmain/origin-graph';
import type { InsertDesignLanguageFile } from '@originmain/origin-graph';
export async function GET(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const workspaceId = req.nextUrl.searchParams.get('workspaceId');
if (!workspaceId) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 });
try {
const db = serverClient();
const file = await getActiveDesignLanguageFile(db, workspaceId);
if (!file) return NextResponse.json(null, { status: 204 });
return NextResponse.json(file);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
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 InsertDesignLanguageFile;
const db = serverClient();
// Compute the next version: max(existing) + 1.
const existing = await getActiveDesignLanguageFile(db, body.workspace_id);
const version = existing ? existing.version + 1 : 1;
const { data, error } = await db
.from('design_language_files')
.insert({ ...body, version })
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json(data, { status: 201 });
}
@@ -0,0 +1,48 @@
// GET /api/diffs/:id → fetch single diff
// PATCH /api/diffs/:id → update status + notes
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { getDiff, updateDiffStatus } from '@originmain/origin-graph';
import type { DiffStatus } from '@originmain/origin-graph';
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
try {
const db = serverClient();
const diff = await getDiff(db, id);
return NextResponse.json(diff);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
const status = message.includes('0 rows') ? 404 : 500;
return NextResponse.json({ error: message }, { status });
}
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const body = (await req.json()) as { status: DiffStatus; notes?: string };
try {
const db = serverClient();
const updated = await updateDiffStatus(db, id, body.status, body.notes);
return NextResponse.json(updated);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
+42
View File
@@ -0,0 +1,42 @@
// GET /api/diffs?artboardId=<uuid> → list diffs for an artboard
// POST /api/diffs → create draft diff
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { getDiffs, createDiff } from '@originmain/origin-graph';
import type { InsertIntentDiff } from '@originmain/origin-graph';
export async function GET(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const artboardId = req.nextUrl.searchParams.get('artboardId');
if (!artboardId) return NextResponse.json({ error: 'artboardId is required' }, { status: 400 });
try {
const db = serverClient();
const diffs = await getDiffs(db, artboardId);
return NextResponse.json(diffs);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
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 Omit<InsertIntentDiff, 'author_id'>;
const insert: InsertIntentDiff = { ...body, author_id: userId };
try {
const db = serverClient();
const diff = await createDiff(db, insert);
return NextResponse.json(diff, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,161 @@
// POST /api/webhooks/:provider
// Ingests webhook events from Linear, Slack, GitHub, and Intercom.
// workspaceId must be passed as a query param: ?workspace=<uuid>
// Signature verification is skipped gracefully when the secret env var is absent
// (useful for local development), but enforced in production.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
import {
linearIngester,
slackIngester,
githubIngester,
intercomIngester,
} from '@originmain/integrations';
import type { OriginIngester } from '@originmain/integrations';
import { serverClient } from '@/lib/supabase';
import { createOrigin, createArtboard } from '@originmain/origin-graph';
// ── Signature helpers ─────────────────────────────────────────────────────────
function hmac(algo: 'sha256' | 'sha1', secret: string, body: Buffer): string {
return createHmac(algo, secret).update(body).digest('hex');
}
function safeEqual(a: string, b: string): boolean {
try {
const ab = Buffer.from(a.padEnd(b.length, '\0'));
const bb = Buffer.from(b.padEnd(a.length, '\0'));
return timingSafeEqual(ab, bb) && a.length === b.length;
} catch {
return false;
}
}
function verifyLinear(rawBody: Buffer, headers: Headers): boolean {
const secret = process.env.LINEAR_WEBHOOK_SECRET;
if (!secret) return true; // skip verification in dev
const sig = headers.get('x-linear-signature') ?? '';
return safeEqual(hmac('sha256', secret, rawBody), sig);
}
function verifySlack(rawBody: Buffer, headers: Headers): boolean {
const secret = process.env.SLACK_SIGNING_SECRET;
if (!secret) return true;
const ts = headers.get('x-slack-request-timestamp') ?? '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const baseString = `v0:${ts}:${rawBody.toString()}`;
const expected = `v0=${hmac('sha256', secret, Buffer.from(baseString))}`;
const provided = headers.get('x-slack-signature') ?? '';
return safeEqual(expected, provided);
}
function verifyGitHub(rawBody: Buffer, headers: Headers): boolean {
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) return true;
const expected = `sha256=${hmac('sha256', secret, rawBody)}`;
const provided = headers.get('x-hub-signature-256') ?? '';
return safeEqual(expected, provided);
}
function verifyIntercom(rawBody: Buffer, headers: Headers): boolean {
const secret = process.env.INTERCOM_WEBHOOK_SECRET;
if (!secret) return true;
const expected = `sha1=${hmac('sha1', secret, rawBody)}`;
const provided = headers.get('x-hub-signature') ?? '';
return safeEqual(expected, provided);
}
// ── Route config ──────────────────────────────────────────────────────────────
const INGESTER_MAP: Record<
string,
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ingester: OriginIngester<any>;
verify: (body: Buffer, headers: Headers) => boolean;
}
> = {
linear: { ingester: linearIngester, verify: verifyLinear },
slack: { ingester: slackIngester, verify: verifySlack },
github: { ingester: githubIngester, verify: verifyGitHub },
intercom: { ingester: intercomIngester, verify: verifyIntercom },
};
// ── Handler ───────────────────────────────────────────────────────────────────
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ provider: string }> },
) {
const { provider } = await params;
const entry = INGESTER_MAP[provider];
if (!entry) {
return NextResponse.json({ error: `Unknown provider: ${provider}` }, { status: 404 });
}
const workspaceId = req.nextUrl.searchParams.get('workspace');
if (!workspaceId) {
return NextResponse.json({ error: 'workspace query param is required' }, { status: 400 });
}
const rawBody = Buffer.from(await req.arrayBuffer());
if (!entry.verify(rawBody, req.headers)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
let parsed: unknown;
try {
parsed = JSON.parse(rawBody.toString('utf-8'));
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
// Slack sends a challenge request during webhook setup — respond immediately.
if (
provider === 'slack' &&
typeof parsed === 'object' &&
parsed !== null &&
'type' in parsed &&
(parsed as { type: string }).type === 'url_verification'
) {
return NextResponse.json({ challenge: (parsed as { challenge?: string }).challenge ?? '' });
}
let payload: unknown;
try {
payload = entry.ingester.parsePayload(parsed);
} catch (err) {
const message = err instanceof Error ? err.message : 'Payload validation failed';
return NextResponse.json({ error: message }, { status: 422 });
}
const result = entry.ingester.ingest(payload);
const db = serverClient();
try {
const origin = await createOrigin(db, result.origin);
const meta: Record<string, unknown> = {
x: 120,
y: 100,
width: 360,
height: 240,
};
if (result.renderUrl !== undefined) meta['renderUrl'] = result.renderUrl;
const artboard = await createArtboard(db, {
workspace_id: workspaceId,
name: result.artboardTitle,
origin_id: origin.id,
parent_artboard_id: null,
metadata_jsonb: meta,
});
return NextResponse.json({ originId: origin.id, artboardId: artboard.id }, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Database error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,66 @@
// GET /api/workspace
// Returns the authenticated user's workspace. Auto-creates one on first visit
// (FREE plan, name derived from Clerk user display name or email).
import { auth, currentUser } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import type { Workspace, InsertWorkspace } from '@originmain/origin-graph';
export async function GET() {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const db = serverClient();
// Look up an existing workspace owned by this Clerk user.
const { data: existing, error: findError } = await db
.from('workspaces')
.select('*')
.eq('owner_id', userId)
.limit(1)
.single();
if (findError && findError.code !== 'PGRST116') {
return NextResponse.json({ error: findError.message }, { status: 500 });
}
if (existing) {
return NextResponse.json(existing as Workspace);
}
// No workspace yet — auto-create one. Fetch the Clerk user for a friendly name.
const user = await currentUser();
const name =
user?.fullName ??
user?.emailAddresses[0]?.emailAddress ??
'My Workspace';
const insert: InsertWorkspace = {
owner_id: userId,
name,
plan: 'FREE',
settings_jsonb: {},
};
const { data: created, error: insertError } = await db
.from('workspaces')
.insert(insert)
.select()
.single();
if (insertError) {
return NextResponse.json({ error: insertError.message }, { status: 500 });
}
// Also add the owner as a team member.
await db.from('team_members').insert({
workspace_id: (created as Workspace).id,
user_id: userId,
role: 'OWNER',
});
return NextResponse.json(created as Workspace, { status: 201 });
}
+24 -4
View File
@@ -1,13 +1,33 @@
'use client';
import type { ReactNode } from 'react';
import { useState, type ReactNode } from 'react';
import { FluentProvider } from '@fluentui/react-components';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { originmainLightTheme } from '@originmain/ui';
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).
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
refetchOnWindowFocus: false,
},
},
}),
);
return (
<FluentProvider theme={originmainLightTheme} style={{ height: '100%' }}>
{children}
</FluentProvider>
<QueryClientProvider client={queryClient}>
<FluentProvider theme={originmainLightTheme} style={{ height: '100%' }}>
{children}
</FluentProvider>
</QueryClientProvider>
);
}
@@ -1,6 +1,10 @@
'use client';
import { useState, useCallback } from 'react';
import { useCanvas } from '@/store/canvas';
import { LiveArtboard } from './LiveArtboard';
import { SelectionOverlay } from './SelectionOverlay';
import type { FiberNode } from '@originmain/renderer';
interface ArtboardProps {
id: string;
@@ -9,11 +13,19 @@ interface ArtboardProps {
y: number;
width: number;
height: number;
renderUrl?: string;
}
export function Artboard({ id, label, x, y, width, height }: ArtboardProps) {
export function Artboard({ id, label, x, y, width, height, renderUrl }: ArtboardProps) {
const { selectedArtboardId, selectArtboard } = useCanvas();
const selected = selectedArtboardId === id;
const [fiberRoot, setFiberRoot] = useState<FiberNode | undefined>(undefined);
const handleFiberUpdate = useCallback((root: FiberNode) => setFiberRoot(root), []);
const handleComponentSelected = useCallback(
(nodeId: string) => { void nodeId; /* future: highlight in inspector */ },
[]
);
return (
<div
@@ -66,7 +78,26 @@ export function Artboard({ id, label, x, y, width, height }: ArtboardProps) {
)}
{/* Per-artboard content */}
<ArtboardContent id={id} />
{renderUrl ? (
<>
<LiveArtboard
id={id}
src={renderUrl}
width={width}
height={height}
onFiberTreeUpdate={handleFiberUpdate}
onComponentSelected={handleComponentSelected}
/>
<SelectionOverlay
artboardId={id}
{...(fiberRoot !== undefined ? { fiberRoot } : {})}
width={width}
height={height}
/>
</>
) : (
<ArtboardContent id={id} />
)}
</div>
</div>
);
@@ -3,15 +3,10 @@
import { useRef, useEffect, useCallback } from 'react';
import { useViewport } from '@/store/viewport';
import { useCanvas } from '@/store/canvas';
import { useWorkspace } from '@/hooks/useWorkspace';
import { useArtboards } from '@/hooks/useArtboards';
import { Artboard } from './Artboard';
const ARTBOARDS = [
{ id: 'dashboard-card', label: 'DashboardCard', x: 120, y: 100, width: 280, height: 200 },
{ id: 'user-profile', label: 'UserProfile', x: 460, y: 100, width: 200, height: 260 },
{ id: 'nav-sidebar', label: 'NavSidebar', x: 120, y: 360, width: 200, height: 340 },
{ id: 'data-table', label: 'DataTable', x: 380, y: 380, width: 420, height: 280 },
];
export function Canvas() {
const containerRef = useRef<HTMLDivElement>(null);
const panX = useViewport((s) => s.panX);
@@ -19,6 +14,9 @@ export function Canvas() {
const zoom = useViewport((s) => s.zoom);
const { activeTool, selectArtboard } = useCanvas();
const { data: workspace } = useWorkspace();
const { artboards } = useArtboards(workspace?.id);
const isPanning = useRef(false);
const lastPos = useRef({ x: 0, y: 0 });
const spaceDown = useRef(false);
@@ -124,7 +122,7 @@ export function Canvas() {
zIndex: 2,
}}
>
{ARTBOARDS.map((ab) => (
{artboards.map((ab) => (
<Artboard key={ab.id} {...ab} />
))}
</div>
@@ -1,11 +1,33 @@
'use client';
import { useEffect } from 'react';
import { Toolbar } from './Toolbar';
import { ArtboardNavigator } from '../navigator/ArtboardNavigator';
import { Canvas } from '../canvas/Canvas';
import { Inspector } from '../inspector/Inspector';
import { useHistory } from '@/store/history';
import { useCanvas } from '@/store/canvas';
export function AppChrome() {
const selectedArtboardId = useCanvas((s) => s.selectedArtboardId);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (!(e.metaKey || e.ctrlKey)) return;
if (!selectedArtboardId) return;
if (e.key === 'z' && !e.shiftKey) {
e.preventDefault();
useHistory.getState().undo(selectedArtboardId);
} else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
e.preventDefault();
useHistory.getState().redo(selectedArtboardId);
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [selectedArtboardId]);
return (
<div
style={{
@@ -1,17 +1,12 @@
'use client';
import { useState } from 'react';
import { useFileTree, FileTree, useFileTreeSelection } from '@pierre/trees/react';
import { useFileTree, FileTree } from '@pierre/trees/react';
import { themeToTreeStyles } from '@pierre/trees';
import { SquareRegular } from '@fluentui/react-icons';
import { useCanvas } from '@/store/canvas';
const ARTBOARDS = [
{ id: 'dashboard-card', label: 'DashboardCard' },
{ id: 'user-profile', label: 'UserProfile' },
{ id: 'nav-sidebar', label: 'NavSidebar' },
{ id: 'data-table', label: 'DataTable' },
];
import { useWorkspace } from '@/hooks/useWorkspace';
import { useArtboards } from '@/hooks/useArtboards';
const FILE_PATHS = [
'src/components/DashboardCard.tsx',
@@ -54,6 +49,8 @@ const treeThemeStyles = themeToTreeStyles({
export function ArtboardNavigator() {
const { selectedArtboardId, selectArtboard } = useCanvas();
const { data: workspace } = useWorkspace();
const { artboards } = useArtboards(workspace?.id);
const { model } = useFileTree({
paths: FILE_PATHS,
@@ -78,7 +75,7 @@ export function ArtboardNavigator() {
{/* ── Artboards ── */}
<SectionLabel>Artboards</SectionLabel>
<div style={{ padding: '2px 6px 0' }}>
{ARTBOARDS.map((ab) => {
{artboards.map((ab) => {
const sel = selectedArtboardId === ab.id;
return (
<NavRow
+53
View File
@@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query';
import type { Artboard } from '@originmain/origin-graph';
export interface CanvasArtboard {
id: string;
label: string;
x: number;
y: number;
width: number;
height: number;
renderUrl?: string;
}
const DEMO_ARTBOARDS: CanvasArtboard[] = [
{ id: 'dashboard-card', label: 'DashboardCard', x: 120, y: 100, width: 280, height: 200 },
{ id: 'user-profile', label: 'UserProfile', x: 460, y: 100, width: 200, height: 260 },
{ id: 'nav-sidebar', label: 'NavSidebar', x: 120, y: 360, width: 200, height: 340 },
{ id: 'data-table', label: 'DataTable', x: 380, y: 380, width: 420, height: 280 },
];
function toCanvasArtboard(ab: Artboard): CanvasArtboard | null {
const meta = ab.metadata_jsonb;
const x = typeof meta['x'] === 'number' ? meta['x'] : null;
const y = typeof meta['y'] === 'number' ? meta['y'] : null;
const width = typeof meta['width'] === 'number' ? meta['width'] : null;
const height = typeof meta['height'] === 'number' ? meta['height'] : null;
if (x === null || y === null || width === null || height === null) return null;
const base: CanvasArtboard = { id: ab.id, label: ab.name, x, y, width, height };
if (typeof meta['renderUrl'] === 'string') base.renderUrl = meta['renderUrl'];
return base;
}
async function fetchArtboards(workspaceId: string): Promise<CanvasArtboard[]> {
const res = await fetch(`/api/artboards?workspaceId=${encodeURIComponent(workspaceId)}`);
if (!res.ok) throw new Error(`Artboard fetch failed: ${res.status}`);
const rows = (await res.json()) as Artboard[];
return rows.map(toCanvasArtboard).filter((ab): ab is CanvasArtboard => ab !== null);
}
export function useArtboards(workspaceId: string | undefined) {
const query = useQuery({
queryKey: ['artboards', workspaceId],
queryFn: () => fetchArtboards(workspaceId!),
enabled: workspaceId !== undefined,
staleTime: 30_000,
});
// Show demo artboards while loading or when workspace has no artboards yet.
const artboards =
!query.data || query.data.length === 0 ? DEMO_ARTBOARDS : query.data;
return { artboards, isLoading: query.isLoading, error: query.error };
}
+17
View File
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import type { Workspace } from '@originmain/origin-graph';
async function fetchWorkspace(): Promise<Workspace> {
const res = await fetch('/api/workspace');
if (!res.ok) throw new Error(`Workspace fetch failed: ${res.status}`);
return res.json() as Promise<Workspace>;
}
export function useWorkspace() {
return useQuery({
queryKey: ['workspace'],
queryFn: fetchWorkspace,
staleTime: 5 * 60_000, // workspace data rarely changes
retry: 2,
});
}
+33
View File
@@ -0,0 +1,33 @@
// ── Supabase client helpers ───────────────────────────────────────────────────
// browserClient() — uses the anon key; safe to call on the client side.
// RLS policies apply (currently: deny all anon).
// serverClient() — uses the service-role key; ONLY call from Server
// Components or API route handlers. Never expose to the
// browser. Service role bypasses RLS entirely.
import { createClient } from '@supabase/supabase-js';
import type { DbClient } from '@originmain/origin-graph';
function requireEnv(name: string): string {
const val = process.env[name];
if (!val) throw new Error(`Missing required environment variable: ${name}`);
return val;
}
/** Browser-safe Supabase client (anon key, RLS enforced). */
export function browserClient(): DbClient {
return createClient(
requireEnv('NEXT_PUBLIC_SUPABASE_URL'),
requireEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY'),
) as unknown as DbClient;
}
/** Server-only Supabase client (service-role key, bypasses RLS). */
export function serverClient(): DbClient {
return createClient(
requireEnv('NEXT_PUBLIC_SUPABASE_URL'),
requireEnv('SUPABASE_SERVICE_ROLE_KEY'),
// Disable the auto-refresh token flow — this client never runs in a browser.
{ auth: { autoRefreshToken: false, persistSession: false } },
) as unknown as DbClient;
}
+12 -12
View File
@@ -62,12 +62,13 @@ export const useHistory = create<HistoryStore>((set, get) => ({
},
undo(artboardId) {
const stack = get().stacks[artboardId] ?? emptyStack();
const last = stack.past[stack.past.length - 1];
if (!last) return undefined;
// Read + write atomically inside set() to avoid TOCTOU between get() and set().
let undone: EditEntry | undefined;
set(state => {
const current = state.stacks[artboardId] ?? emptyStack();
const last = current.past[current.past.length - 1];
if (!last) return state; // nothing to undo — no-op
undone = last;
return {
stacks: {
...state.stacks,
@@ -78,17 +79,17 @@ export const useHistory = create<HistoryStore>((set, get) => ({
},
};
});
return last;
return undone;
},
redo(artboardId) {
const stack = get().stacks[artboardId] ?? emptyStack();
const next = stack.future[0];
if (!next) return undefined;
// Read + write atomically inside set() to avoid TOCTOU between get() and set().
let redone: EditEntry | undefined;
set(state => {
const current = state.stacks[artboardId] ?? emptyStack();
const next = current.future[0];
if (!next) return state; // nothing to redo — no-op
redone = next;
return {
stacks: {
...state.stacks,
@@ -99,8 +100,7 @@ export const useHistory = create<HistoryStore>((set, get) => ({
},
};
});
return next;
return redone;
},
canUndo(artboardId) {