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