made tiny updates
This commit is contained in:
@@ -27,6 +27,7 @@
|
|||||||
"@pierre/trees": "1.0.0-beta.3",
|
"@pierre/trees": "1.0.0-beta.3",
|
||||||
"@supabase/supabase-js": "^2.0.0",
|
"@supabase/supabase-js": "^2.0.0",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"@trpc/client": "^11.17.0",
|
"@trpc/client": "^11.17.0",
|
||||||
"@trpc/react-query": "^11.17.0",
|
"@trpc/react-query": "^11.17.0",
|
||||||
"@trpc/server": "^11.17.0",
|
"@trpc/server": "^11.17.0",
|
||||||
|
|||||||
@@ -3,14 +3,17 @@
|
|||||||
// then calls generateDriftReport. Screenshots are optional; text-only analysis runs
|
// then calls generateDriftReport. Screenshots are optional; text-only analysis runs
|
||||||
// when the artboard has no renderUrl or the screenshot is not provided by the client.
|
// when the artboard has no renderUrl or the screenshot is not provided by the client.
|
||||||
|
|
||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { AIGateway, generateDriftReport } from '@originmain/ai-layer';
|
import { AIGateway, generateDriftReport } from '@originmain/ai-layer';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const callerEmail = user.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!callerEmail) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
const body = (await req.json().catch(() => ({}))) as {
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
artboard_id?: string;
|
artboard_id?: string;
|
||||||
@@ -48,7 +51,7 @@ export async function POST(req: NextRequest) {
|
|||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('workspace_id', artboard.workspace_id)
|
.eq('workspace_id', artboard.workspace_id)
|
||||||
.eq('user_id', userId)
|
.eq('email', callerEmail)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@@ -56,14 +59,15 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the most recently updated Design Language File for this workspace (optional)
|
// Fetch the active Design Language for this workspace (Phase 6 table).
|
||||||
const { data: dlf } = await db
|
// raw_json holds the original uploaded token file; pass it to generateDriftReport
|
||||||
.from('design_language_files')
|
// for context. Absence of a design language is non-fatal — analysis runs without it.
|
||||||
.select('schema_jsonb, name')
|
const { data: dl } = await db
|
||||||
|
.from('design_languages')
|
||||||
|
.select('raw_json, name')
|
||||||
.eq('workspace_id', artboard.workspace_id)
|
.eq('workspace_id', artboard.workspace_id)
|
||||||
.order('updated_at', { ascending: false })
|
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single() as unknown as { data: { schema_jsonb: unknown; name: string } | null };
|
.single() as unknown as { data: { raw_json: unknown; name: string } | null };
|
||||||
|
|
||||||
const meta = artboard.metadata_jsonb;
|
const meta = artboard.metadata_jsonb;
|
||||||
const artboardContext = [
|
const artboardContext = [
|
||||||
@@ -77,7 +81,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const gateway = new AIGateway();
|
const gateway = new AIGateway();
|
||||||
const result = await generateDriftReport(gateway, {
|
const result = await generateDriftReport(gateway, {
|
||||||
artboardContext,
|
artboardContext,
|
||||||
...(dlf ? { dlfJson: JSON.stringify(dlf.schema_jsonb) } : {}),
|
...(dl ? { dlfJson: JSON.stringify(dl.raw_json) } : {}),
|
||||||
...(body.screenshot_base64 ? { screenshotBase64: body.screenshot_base64 } : {}),
|
...(body.screenshot_base64 ? { screenshotBase64: body.screenshot_base64 } : {}),
|
||||||
});
|
});
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
|||||||
const patch: Partial<InsertArtboard> = {};
|
const patch: Partial<InsertArtboard> = {};
|
||||||
if (typeof body.name === 'string' && body.name.trim()) patch.name = body.name.trim();
|
if (typeof body.name === 'string' && body.name.trim()) patch.name = body.name.trim();
|
||||||
if (body.metadata_jsonb !== undefined) patch.metadata_jsonb = body.metadata_jsonb;
|
if (body.metadata_jsonb !== undefined) patch.metadata_jsonb = body.metadata_jsonb;
|
||||||
|
// isolation_props are stored as a direct DB column and patched independently
|
||||||
|
// of metadata_jsonb so IsolationFrame can receive live prop updates.
|
||||||
|
if (body.isolation_props !== undefined) patch.isolation_props = body.isolation_props;
|
||||||
|
|
||||||
if (Object.keys(patch).length === 0)
|
if (Object.keys(patch).length === 0)
|
||||||
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
|
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
//
|
//
|
||||||
// spec: SOURCE-AWARE-CANVAS.md Phase 5 §8.6
|
// spec: SOURCE-AWARE-CANVAS.md Phase 5 §8.6
|
||||||
|
|
||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
import { issueWorkspaceToken } from '@originmain/agent-bridge';
|
import { issueWorkspaceToken } from '@originmain/agent-bridge';
|
||||||
@@ -59,38 +59,45 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Require authentication ────────────────────────────────────────────────
|
// ── Require authentication ────────────────────────────────────────────────
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) {
|
if (!user) {
|
||||||
// Redirect to sign-in, then back here after login
|
|
||||||
const signInUrl = new URL('/sign-in', APP_URL);
|
const signInUrl = new URL('/sign-in', APP_URL);
|
||||||
signInUrl.searchParams.set('redirect_url', req.nextUrl.toString());
|
signInUrl.searchParams.set('redirect_url', req.nextUrl.toString());
|
||||||
return NextResponse.redirect(signInUrl.toString());
|
return NextResponse.redirect(signInUrl.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!email) {
|
||||||
|
return errorRedirect(callbackUrl, 'No verified email address on this account.');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Resolve workspace ─────────────────────────────────────────────────────
|
// ── Resolve workspace ─────────────────────────────────────────────────────
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
let workspaceId = workspaceIdParam;
|
let workspaceId = workspaceIdParam;
|
||||||
|
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
// Use the user's first workspace membership
|
// Use the user's first workspace membership.
|
||||||
const { data: member } = await db
|
const { data: member } = await db
|
||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('workspace_id')
|
.select('workspace_id')
|
||||||
.eq('user_id', userId)
|
.eq('email', email)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single() as unknown as { data: { workspace_id: string } | null; error: unknown };
|
.single() as unknown as { data: { workspace_id: string } | null; error: unknown };
|
||||||
|
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return errorRedirect(callbackUrl, 'No workspace found for this account. Create a workspace at ' + APP_URL);
|
return errorRedirect(
|
||||||
|
callbackUrl,
|
||||||
|
'No workspace found for this account. Create a workspace at ' + APP_URL,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
workspaceId = member.workspace_id;
|
workspaceId = member.workspace_id;
|
||||||
} else {
|
} else {
|
||||||
// Verify the user is a member of the requested workspace
|
// Verify the user is a member of the requested workspace.
|
||||||
const { data: member } = await db
|
const { data: member } = await db
|
||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('workspace_id', workspaceId)
|
.eq('workspace_id', workspaceId)
|
||||||
.eq('user_id', userId)
|
.eq('email', email)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|||||||
@@ -1,125 +1,129 @@
|
|||||||
// GET /api/design-language?workspaceId=<uuid> → active design language file
|
// GET /api/design-language?workspaceId=<uuid> → active DesignLanguage row
|
||||||
// GET /api/design-language?workspaceId=<uuid>&all=1 → all versions (history)
|
// GET /api/design-language?workspaceId=<uuid>&all=1 → version history (newest first)
|
||||||
// POST /api/design-language → upload new version (deactivates prior)
|
// POST /api/design-language → upload / replace token file
|
||||||
|
|
||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
import { getActiveDesignLanguageFile } from '@originmain/origin-graph';
|
import {
|
||||||
import type { InsertDesignLanguageFile, DesignLanguageFile } from '@originmain/origin-graph';
|
getDesignLanguage,
|
||||||
|
upsertDesignLanguage,
|
||||||
|
getDesignLanguageVersions,
|
||||||
|
} from '@originmain/origin-graph';
|
||||||
|
import type { InsertDesignLanguage } from '@originmain/origin-graph';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
// ── Auth + workspace-member guard (shared) ────────────────────────────────────
|
||||||
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 });
|
|
||||||
|
|
||||||
|
async function assertMember(email: string, workspaceId: string) {
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
const { data } = await db
|
||||||
// Guard: caller must be a member of the target workspace.
|
|
||||||
const { data: member } = await db
|
|
||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('workspace_id', workspaceId)
|
.eq('workspace_id', workspaceId)
|
||||||
.eq('user_id', userId)
|
.eq('email', email)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
if (!member) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
return Boolean(data);
|
||||||
|
}
|
||||||
|
|
||||||
// ?all=1 returns the full version history, newest first.
|
// ── GET ───────────────────────────────────────────────────────────────────────
|
||||||
if (req.nextUrl.searchParams.get('all') === '1') {
|
|
||||||
const { data, error } = await (db
|
export async function GET(req: NextRequest) {
|
||||||
.from('design_language_files')
|
const user = await currentUser();
|
||||||
.select('*')
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
.eq('workspace_id', workspaceId)
|
|
||||||
.order('version', { ascending: false }) as unknown as Promise<{
|
const email = user.primaryEmailAddress?.emailAddress;
|
||||||
data: DesignLanguageFile[];
|
if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
error: { message: string } | null;
|
|
||||||
}>);
|
const workspaceId = req.nextUrl.searchParams.get('workspaceId');
|
||||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
if (!workspaceId) {
|
||||||
return NextResponse.json(data ?? []);
|
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: return the single active file.
|
if (!(await assertMember(email, workspaceId))) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = serverClient();
|
||||||
|
|
||||||
|
// ?all=1 — full version history for the workspace's design language.
|
||||||
|
if (req.nextUrl.searchParams.get('all') === '1') {
|
||||||
|
const dl = await getDesignLanguage(db, workspaceId).catch(() => null);
|
||||||
|
if (!dl) return NextResponse.json([]);
|
||||||
|
const versions = await getDesignLanguageVersions(db, dl.id).catch(() => []);
|
||||||
|
return NextResponse.json(versions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default — active token file (one row per workspace).
|
||||||
|
const dl = await getDesignLanguage(db, workspaceId).catch((err: Error) => {
|
||||||
|
throw new Response(JSON.stringify({ error: err.message }), { status: 500 });
|
||||||
|
});
|
||||||
|
return NextResponse.json(dl ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await currentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
|
let body: Partial<InsertDesignLanguage>;
|
||||||
try {
|
try {
|
||||||
const file = await getActiveDesignLanguageFile(db, workspaceId);
|
body = (await req.json()) as Partial<InsertDesignLanguage>;
|
||||||
if (!file) return NextResponse.json(null);
|
} catch {
|
||||||
return NextResponse.json(file);
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspace_id, name, raw_json, normalized, source_format, token_count } = body;
|
||||||
|
|
||||||
|
if (!workspace_id || !raw_json || !normalized || !source_format) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required fields: workspace_id, raw_json, normalized, source_format' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await assertMember(email, workspace_id))) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = serverClient();
|
||||||
|
|
||||||
|
// Determine next version number: current + 1, or 1 for first upload.
|
||||||
|
const existing = await getDesignLanguage(db, workspace_id).catch(() => null);
|
||||||
|
const version = existing ? existing.version + 1 : 1;
|
||||||
|
|
||||||
|
const row: InsertDesignLanguage = {
|
||||||
|
workspace_id,
|
||||||
|
name: name ?? 'Design Language',
|
||||||
|
raw_json,
|
||||||
|
normalized,
|
||||||
|
source_format,
|
||||||
|
token_count: token_count ?? (Array.isArray(normalized) ? normalized.length : 0),
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dl = await upsertDesignLanguage(db, row);
|
||||||
|
|
||||||
|
// Insert a version snapshot — the prune trigger fires after this insert
|
||||||
|
// and deletes any rows beyond the 10-most-recent.
|
||||||
|
await db
|
||||||
|
.from('design_language_versions')
|
||||||
|
.insert({
|
||||||
|
design_language_id: dl.id,
|
||||||
|
version: dl.version,
|
||||||
|
raw_json: dl.raw_json,
|
||||||
|
normalized: dl.normalized,
|
||||||
|
source_format: dl.source_format,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(dl, { status: 201 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
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 });
|
|
||||||
|
|
||||||
let body: InsertDesignLanguageFile & { is_active?: boolean };
|
|
||||||
try {
|
|
||||||
body = (await req.json()) as InsertDesignLanguageFile & { is_active?: boolean };
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!body.workspace_id || !body.name || !body.schema_jsonb) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Missing required fields: workspace_id, name, schema_jsonb' },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = serverClient();
|
|
||||||
|
|
||||||
// Guard: caller must be a member of the target workspace.
|
|
||||||
const { data: postMember } = await db
|
|
||||||
.from('team_members')
|
|
||||||
.select('id')
|
|
||||||
.eq('workspace_id', body.workspace_id)
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.limit(1)
|
|
||||||
.single();
|
|
||||||
if (!postMember) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
||||||
|
|
||||||
// ── Compute next version number ────────────────────────────────────────────
|
|
||||||
const existing = await getActiveDesignLanguageFile(db, body.workspace_id);
|
|
||||||
const version = existing ? existing.version + 1 : 1;
|
|
||||||
|
|
||||||
// ── Deactivate all prior versions for this workspace ───────────────────────
|
|
||||||
// This must happen before the insert so the partial unique index
|
|
||||||
// (only one is_active = true per workspace) is not violated.
|
|
||||||
if (existing) {
|
|
||||||
const { error: deactivateError } = await (db
|
|
||||||
.from('design_language_files')
|
|
||||||
.update({ is_active: false })
|
|
||||||
.eq('workspace_id', body.workspace_id) as unknown as Promise<{
|
|
||||||
data: unknown;
|
|
||||||
error: { message: string } | null;
|
|
||||||
}>);
|
|
||||||
if (deactivateError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Failed to deactivate previous version: ${deactivateError.message}` },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Insert new version as the active one ───────────────────────────────────
|
|
||||||
const { data, error } = await (db
|
|
||||||
.from('design_language_files')
|
|
||||||
.insert({
|
|
||||||
...body,
|
|
||||||
version,
|
|
||||||
is_active: true,
|
|
||||||
created_by: userId,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single() as unknown as Promise<{
|
|
||||||
data: DesignLanguageFile;
|
|
||||||
error: { message: string } | null;
|
|
||||||
}>);
|
|
||||||
|
|
||||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
|
||||||
return NextResponse.json(data, { status: 201 });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
// GET /api/diffs?artboardId=<uuid> → list diffs for an artboard
|
// GET /api/diffs?artboardId=<uuid> → list diffs for an artboard
|
||||||
// POST /api/diffs → create draft diff
|
// POST /api/diffs → create draft diff
|
||||||
|
|
||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
import { getDiffs, createDiff } from '@originmain/origin-graph';
|
import { getDiffs, createDiff } from '@originmain/origin-graph';
|
||||||
import type { InsertIntentDiff } from '@originmain/origin-graph';
|
import type { InsertIntentDiff } from '@originmain/origin-graph';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const artboardId = req.nextUrl.searchParams.get('artboardId');
|
const artboardId = req.nextUrl.searchParams.get('artboardId');
|
||||||
if (!artboardId) return NextResponse.json({ error: 'artboardId is required' }, { status: 400 });
|
if (!artboardId) return NextResponse.json({ error: 'artboardId is required' }, { status: 400 });
|
||||||
@@ -25,11 +25,14 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const body = (await req.json()) as Omit<InsertIntentDiff, 'author_id'>;
|
const authorEmail = user.primaryEmailAddress?.emailAddress;
|
||||||
const insert: InsertIntentDiff = { ...body, author_id: userId };
|
if (!authorEmail) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
|
const body = (await req.json()) as Omit<InsertIntentDiff, 'author_email'>;
|
||||||
|
const insert: InsertIntentDiff = { ...body, author_email: authorEmail };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* (or receives an INTENT_RECEIVED WebSocket push in Phase 5+).
|
* (or receives an INTENT_RECEIVED WebSocket push in Phase 5+).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { storePendingIntent } from '@originmain/agent-bridge';
|
import { storePendingIntent } from '@originmain/agent-bridge';
|
||||||
import { createDiff } from '@originmain/origin-graph';
|
import { createDiff } from '@originmain/origin-graph';
|
||||||
@@ -37,10 +37,11 @@ interface IntentPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) {
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
const authorEmail = user.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!authorEmail) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
let body: IntentPayload;
|
let body: IntentPayload;
|
||||||
try {
|
try {
|
||||||
@@ -87,7 +88,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const row = await createDiff(db, {
|
const row = await createDiff(db, {
|
||||||
artboard_id: artboardId,
|
artboard_id: artboardId,
|
||||||
author_id: userId,
|
author_email: authorEmail,
|
||||||
changes: changesRecord,
|
changes: changesRecord,
|
||||||
aggregate_summary: summary ?? `${componentName} style changes`,
|
aggregate_summary: summary ?? `${componentName} style changes`,
|
||||||
status: 'EXPORTED',
|
status: 'EXPORTED',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// POST /api/workspace/:id/invite → add a member by Clerk userId
|
// POST /api/workspace/:id/invite → add a member by email address
|
||||||
// DELETE /api/workspace/:id/invite → remove a member by Clerk userId (owners only)
|
// DELETE /api/workspace/:id/invite → remove a member by email address (owners only)
|
||||||
|
|
||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
import { addTeamMember, removeTeamMember } from '@originmain/origin-graph';
|
import { addTeamMember, removeTeamMember } from '@originmain/origin-graph';
|
||||||
@@ -9,33 +9,43 @@ import type { TeamRole } from '@originmain/origin-graph';
|
|||||||
|
|
||||||
type Ctx = { params: Promise<{ id: string }> };
|
type Ctx = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: Ctx) {
|
export async function POST(req: NextRequest, { params }: Ctx) {
|
||||||
const { userId } = await auth();
|
const caller = await currentUser();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const callerEmail = caller.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!callerEmail) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
const { id: workspaceId } = await params;
|
const { id: workspaceId } = await params;
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
// Only workspace owners can invite
|
// Only workspace owners can invite.
|
||||||
const { data: owner } = await db
|
const { data: ownerRow } = await db
|
||||||
.from('workspaces')
|
.from('workspaces')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('id', workspaceId)
|
.eq('id', workspaceId)
|
||||||
.eq('owner_id', userId)
|
.eq('owner_email', callerEmail)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (!owner) return NextResponse.json({ error: 'Forbidden — only owners can invite members' }, { status: 403 });
|
if (!ownerRow)
|
||||||
|
return NextResponse.json({ error: 'Forbidden — only owners can invite members' }, { status: 403 });
|
||||||
|
|
||||||
const body = (await req.json().catch(() => ({}))) as { userId?: string; role?: TeamRole };
|
const body = (await req.json().catch(() => ({}))) as { email?: string; role?: TeamRole };
|
||||||
if (!body.userId) return NextResponse.json({ error: 'userId is required' }, { status: 400 });
|
|
||||||
|
if (!body.email || !EMAIL_RE.test(body.email))
|
||||||
|
return NextResponse.json({ error: 'A valid email address is required' }, { status: 400 });
|
||||||
|
|
||||||
|
const inviteEmail = body.email.toLowerCase().trim();
|
||||||
const role: TeamRole = body.role ?? 'DESIGNER';
|
const role: TeamRole = body.role ?? 'DESIGNER';
|
||||||
|
|
||||||
// Check if already a member
|
// Check if already a member.
|
||||||
const { data: existing } = await db
|
const { data: existing } = await db
|
||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('workspace_id', workspaceId)
|
.eq('workspace_id', workspaceId)
|
||||||
.eq('user_id', body.userId)
|
.eq('email', inviteEmail)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@@ -44,7 +54,7 @@ export async function POST(req: NextRequest, { params }: Ctx) {
|
|||||||
try {
|
try {
|
||||||
const member = await addTeamMember(db, {
|
const member = await addTeamMember(db, {
|
||||||
workspace_id: workspaceId,
|
workspace_id: workspaceId,
|
||||||
user_id: body.userId,
|
email: inviteEmail,
|
||||||
role,
|
role,
|
||||||
});
|
});
|
||||||
return NextResponse.json(member, { status: 201 });
|
return NextResponse.json(member, { status: 201 });
|
||||||
@@ -55,28 +65,33 @@ export async function POST(req: NextRequest, { params }: Ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest, { params }: Ctx) {
|
export async function DELETE(req: NextRequest, { params }: Ctx) {
|
||||||
const { userId } = await auth();
|
const caller = await currentUser();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const callerEmail = caller.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!callerEmail) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
const { id: workspaceId } = await params;
|
const { id: workspaceId } = await params;
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
const body = (await req.json().catch(() => ({}))) as { userId?: string };
|
const body = (await req.json().catch(() => ({}))) as { email?: string };
|
||||||
if (!body.userId) return NextResponse.json({ error: 'userId is required' }, { status: 400 });
|
if (!body.email) return NextResponse.json({ error: 'email is required' }, { status: 400 });
|
||||||
|
|
||||||
// Only owners can remove; a member can remove themselves
|
const targetEmail = body.email.toLowerCase().trim();
|
||||||
const { data: owner } = await db
|
|
||||||
|
// Owners can remove anyone; members can remove themselves.
|
||||||
|
const { data: ownerRow } = await db
|
||||||
.from('workspaces')
|
.from('workspaces')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('id', workspaceId)
|
.eq('id', workspaceId)
|
||||||
.eq('owner_id', userId)
|
.eq('owner_email', callerEmail)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (!owner && body.userId !== userId)
|
if (!ownerRow && targetEmail !== callerEmail)
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await removeTeamMember(db, workspaceId, body.userId);
|
await removeTeamMember(db, workspaceId, targetEmail);
|
||||||
return new NextResponse(null, { status: 204 });
|
return new NextResponse(null, { status: 204 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// PATCH /api/workspace/:id/projects/:pid → update name / app_url / framework / description
|
// PATCH /api/workspace/:id/projects/:pid → update name / app_url / framework / description
|
||||||
// DELETE /api/workspace/:id/projects/:pid → permanently delete project
|
// DELETE /api/workspace/:id/projects/:pid → permanently delete project
|
||||||
|
|
||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
import { updateProject, deleteProject } from '@originmain/origin-graph';
|
import { updateProject, deleteProject } from '@originmain/origin-graph';
|
||||||
@@ -10,23 +10,27 @@ import type { InsertProject, Project } from '@originmain/origin-graph';
|
|||||||
|
|
||||||
type Ctx = { params: Promise<{ id: string; pid: string }> };
|
type Ctx = { params: Promise<{ id: string; pid: string }> };
|
||||||
|
|
||||||
async function assertMember(workspaceId: string, userId: string): Promise<boolean> {
|
async function assertMember(workspaceId: string, email: string): Promise<boolean> {
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
const { data } = await db
|
const { data } = await db
|
||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('workspace_id', workspaceId)
|
.eq('workspace_id', workspaceId)
|
||||||
.eq('user_id', userId)
|
.eq('email', email)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
return !!data;
|
return !!data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(_req: NextRequest, { params }: Ctx) {
|
export async function GET(_req: NextRequest, { params }: Ctx) {
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
const { id: workspaceId, pid } = await params;
|
const { id: workspaceId, pid } = await params;
|
||||||
if (!(await assertMember(workspaceId, userId)))
|
if (!(await assertMember(workspaceId, email)))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
|
||||||
const { data, error } = await serverClient()
|
const { data, error } = await serverClient()
|
||||||
@@ -42,10 +46,14 @@ export async function GET(_req: NextRequest, { params }: Ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest, { params }: Ctx) {
|
export async function PATCH(req: NextRequest, { params }: Ctx) {
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
const { id: workspaceId, pid } = await params;
|
const { id: workspaceId, pid } = await params;
|
||||||
if (!(await assertMember(workspaceId, userId)))
|
if (!(await assertMember(workspaceId, email)))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
|
||||||
const body = (await req.json().catch(() => ({}))) as Partial<InsertProject>;
|
const body = (await req.json().catch(() => ({}))) as Partial<InsertProject>;
|
||||||
@@ -68,10 +76,14 @@ export async function PATCH(req: NextRequest, { params }: Ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_req: NextRequest, { params }: Ctx) {
|
export async function DELETE(_req: NextRequest, { params }: Ctx) {
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
const { id: workspaceId, pid } = await params;
|
const { id: workspaceId, pid } = await params;
|
||||||
if (!(await assertMember(workspaceId, userId)))
|
if (!(await assertMember(workspaceId, email)))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
// GET /api/workspace/[id]/projects — list projects in a workspace
|
// GET /api/workspace/[id]/projects — list projects in a workspace
|
||||||
// POST /api/workspace/[id]/projects — create a new project
|
// POST /api/workspace/[id]/projects — create a new project
|
||||||
|
|
||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
import type { Project, InsertProject } from '@originmain/origin-graph';
|
import type { Project, InsertProject } from '@originmain/origin-graph';
|
||||||
|
|
||||||
async function assertMember(db: ReturnType<typeof serverClient>, workspaceId: string, userId: string) {
|
async function assertMember(
|
||||||
|
db: ReturnType<typeof serverClient>,
|
||||||
|
workspaceId: string,
|
||||||
|
email: string,
|
||||||
|
) {
|
||||||
const { data } = await db
|
const { data } = await db
|
||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('workspace_id', workspaceId)
|
.eq('workspace_id', workspaceId)
|
||||||
.eq('user_id', userId)
|
.eq('email', email)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
return !!data;
|
return !!data;
|
||||||
@@ -21,13 +25,16 @@ export async function GET(
|
|||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
const { id: workspaceId } = await params;
|
const { id: workspaceId } = await params;
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
if (!(await assertMember(db, workspaceId, userId)))
|
if (!(await assertMember(db, workspaceId, email)))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
|
||||||
const { data, error } = await db
|
const { data, error } = await db
|
||||||
@@ -45,13 +52,16 @@ export async function POST(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
const { id: workspaceId } = await params;
|
const { id: workspaceId } = await params;
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
if (!(await assertMember(db, workspaceId, userId)))
|
if (!(await assertMember(db, workspaceId, email)))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// PATCH /api/workspace/:id → rename workspace (owner only)
|
// PATCH /api/workspace/:id → rename workspace (owner only)
|
||||||
// DELETE /api/workspace/:id → delete workspace and all its data (owner only)
|
// DELETE /api/workspace/:id → delete workspace and all its data (owner only)
|
||||||
|
|
||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
import { deleteWorkspace } from '@originmain/origin-graph';
|
import { deleteWorkspace } from '@originmain/origin-graph';
|
||||||
@@ -10,20 +10,29 @@ import type { Workspace } from '@originmain/origin-graph';
|
|||||||
|
|
||||||
type RouteContext = { params: Promise<{ id: string }> };
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
async function assertOwner(db: ReturnType<typeof import('@/lib/supabase').serverClient>, workspaceId: string, userId: string) {
|
async function getCallerEmail(): Promise<string | null> {
|
||||||
|
const user = await currentUser();
|
||||||
|
return user?.primaryEmailAddress?.emailAddress ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertOwner(
|
||||||
|
db: ReturnType<typeof serverClient>,
|
||||||
|
workspaceId: string,
|
||||||
|
email: string,
|
||||||
|
) {
|
||||||
const { data } = await db
|
const { data } = await db
|
||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('role')
|
.select('role')
|
||||||
.eq('workspace_id', workspaceId)
|
.eq('workspace_id', workspaceId)
|
||||||
.eq('user_id', userId)
|
.eq('email', email)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
return (data as { role: string } | null)?.role === 'OWNER';
|
return (data as { role: string } | null)?.role === 'OWNER';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(_req: NextRequest, { params }: RouteContext) {
|
export async function GET(_req: NextRequest, { params }: RouteContext) {
|
||||||
const { userId } = await auth();
|
const email = await getCallerEmail();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
@@ -33,7 +42,7 @@ export async function GET(_req: NextRequest, { params }: RouteContext) {
|
|||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('workspace_id', id)
|
.eq('workspace_id', id)
|
||||||
.eq('user_id', userId)
|
.eq('email', email)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@@ -46,13 +55,13 @@ export async function GET(_req: NextRequest, { params }: RouteContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
||||||
const { userId } = await auth();
|
const email = await getCallerEmail();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
const isOwner = await assertOwner(db, id, userId);
|
const isOwner = await assertOwner(db, id, email);
|
||||||
if (!isOwner) return NextResponse.json({ error: 'Only workspace owners can rename' }, { status: 403 });
|
if (!isOwner) return NextResponse.json({ error: 'Only workspace owners can rename' }, { status: 403 });
|
||||||
|
|
||||||
const body = (await req.json().catch(() => ({}))) as { name?: string };
|
const body = (await req.json().catch(() => ({}))) as { name?: string };
|
||||||
@@ -73,13 +82,13 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_req: NextRequest, { params }: RouteContext) {
|
export async function DELETE(_req: NextRequest, { params }: RouteContext) {
|
||||||
const { userId } = await auth();
|
const email = await getCallerEmail();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
const isOwner = await assertOwner(db, id, userId);
|
const isOwner = await assertOwner(db, id, email);
|
||||||
if (!isOwner) return NextResponse.json({ error: 'Only workspace owners can delete' }, { status: 403 });
|
if (!isOwner) return NextResponse.json({ error: 'Only workspace owners can delete' }, { status: 403 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Issues a signed HMAC workspace token for IDE integrations (Cursor, Claude Code).
|
// Issues a signed HMAC workspace token for IDE integrations (Cursor, Claude Code).
|
||||||
// Returns the token plus ready-to-paste config snippets for each IDE.
|
// Returns the token plus ready-to-paste config snippets for each IDE.
|
||||||
|
|
||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
import {
|
import {
|
||||||
@@ -16,18 +16,21 @@ export async function POST(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
const { id: workspaceId } = await params;
|
const { id: workspaceId } = await params;
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
// Verify membership
|
// Verify membership.
|
||||||
const { data: member } = await db
|
const { data: member } = await db
|
||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('workspace_id', workspaceId)
|
.eq('workspace_id', workspaceId)
|
||||||
.eq('user_id', userId)
|
.eq('email', email)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|||||||
@@ -2,44 +2,42 @@
|
|||||||
// Returns the authenticated user's workspace. Auto-creates one on first visit
|
// Returns the authenticated user's workspace. Auto-creates one on first visit
|
||||||
// (FREE plan, name derived from Clerk user display name or email).
|
// (FREE plan, name derived from Clerk user display name or email).
|
||||||
|
|
||||||
import { auth, currentUser } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
import type { Workspace, InsertWorkspace } from '@originmain/origin-graph';
|
import type { Workspace, InsertWorkspace } from '@originmain/origin-graph';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) {
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
const email = user.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
// Look up an existing workspace owned by this Clerk user.
|
// Look up an existing workspace where this user is a member.
|
||||||
const { data: existing, error: findError } = await db
|
const { data: existing } = await db
|
||||||
.from('workspaces')
|
.from('team_members')
|
||||||
.select('*')
|
.select('workspace_id')
|
||||||
.eq('owner_id', userId)
|
.eq('email', email)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single() as unknown as { data: { workspace_id: string } | null };
|
||||||
|
|
||||||
if (findError && findError.code !== 'PGRST116') {
|
|
||||||
return NextResponse.json({ error: findError.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return NextResponse.json(existing as Workspace);
|
const { data: ws, error } = await db
|
||||||
|
.from('workspaces')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', existing.workspace_id)
|
||||||
|
.single();
|
||||||
|
if (!error && ws) return NextResponse.json(ws as Workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No workspace yet — auto-create one. Fetch the Clerk user for a friendly name.
|
// No workspace yet — auto-create one with a friendly name.
|
||||||
const user = await currentUser();
|
const name = user.fullName ?? email;
|
||||||
const name =
|
|
||||||
user?.fullName ??
|
|
||||||
user?.emailAddresses[0]?.emailAddress ??
|
|
||||||
'My Workspace';
|
|
||||||
|
|
||||||
const insert: InsertWorkspace = {
|
const insert: InsertWorkspace = {
|
||||||
owner_id: userId,
|
owner_email: email,
|
||||||
name,
|
name,
|
||||||
plan: 'FREE',
|
plan: 'FREE',
|
||||||
settings_jsonb: {},
|
settings_jsonb: {},
|
||||||
@@ -55,10 +53,10 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: insertError.message }, { status: 500 });
|
return NextResponse.json({ error: insertError.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also add the owner as a team member.
|
// Add the owner as a team member so GET /api/workspaces can find it.
|
||||||
await db.from('team_members').insert({
|
await db.from('team_members').insert({
|
||||||
workspace_id: (created as Workspace).id,
|
workspace_id: (created as Workspace).id,
|
||||||
user_id: userId,
|
email,
|
||||||
role: 'OWNER',
|
role: 'OWNER',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
// GET /api/workspaces — list every workspace the signed-in user belongs to
|
// GET /api/workspaces — list every workspace the signed-in user belongs to
|
||||||
// POST /api/workspaces — create a new workspace + add creator as OWNER
|
// POST /api/workspaces — create a new workspace + add creator as OWNER
|
||||||
|
|
||||||
import { auth, currentUser } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
import type { Workspace, InsertWorkspace } from '@originmain/origin-graph';
|
import type { Workspace, InsertWorkspace } from '@originmain/origin-graph';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
@@ -16,7 +19,7 @@ export async function GET() {
|
|||||||
const { data: memberships, error: mErr } = await db
|
const { data: memberships, error: mErr } = await db
|
||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('workspace_id')
|
.select('workspace_id')
|
||||||
.eq('user_id', userId);
|
.eq('email', email);
|
||||||
|
|
||||||
if (mErr) return NextResponse.json({ error: mErr.message }, { status: 500 });
|
if (mErr) return NextResponse.json({ error: mErr.message }, { status: 500 });
|
||||||
|
|
||||||
@@ -35,8 +38,11 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress;
|
||||||
|
if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 });
|
||||||
|
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const name = typeof body.name === 'string' && body.name.trim()
|
const name = typeof body.name === 'string' && body.name.trim()
|
||||||
@@ -48,7 +54,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
const insert: InsertWorkspace = {
|
const insert: InsertWorkspace = {
|
||||||
owner_id: userId,
|
owner_email: email,
|
||||||
name,
|
name,
|
||||||
plan: 'FREE',
|
plan: 'FREE',
|
||||||
settings_jsonb: {},
|
settings_jsonb: {},
|
||||||
@@ -67,7 +73,7 @@ export async function POST(req: NextRequest) {
|
|||||||
// Add creator as OWNER team member so GET /api/workspaces can find it.
|
// Add creator as OWNER team member so GET /api/workspaces can find it.
|
||||||
await db.from('team_members').insert({
|
await db.from('team_members').insert({
|
||||||
workspace_id: workspace.id,
|
workspace_id: workspace.id,
|
||||||
user_id: userId,
|
email,
|
||||||
role: 'OWNER',
|
role: 'OWNER',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
@@ -18,9 +18,10 @@ export async function generateMetadata({ params }: { params: Promise<{ wid: stri
|
|||||||
|
|
||||||
export default async function WorkspacePage({ params }: { params: Promise<{ wid: string }> }) {
|
export default async function WorkspacePage({ params }: { params: Promise<{ wid: string }> }) {
|
||||||
const { wid } = await params;
|
const { wid } = await params;
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) redirect('/sign-in');
|
if (!user) redirect('/sign-in');
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress ?? '';
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
// Verify membership
|
// Verify membership
|
||||||
@@ -28,7 +29,7 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
|
|||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('workspace_id', wid)
|
.eq('workspace_id', wid)
|
||||||
.eq('user_id', userId)
|
.eq('email', email)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
@@ -14,9 +14,10 @@ export async function generateMetadata({ params }: { params: Promise<{ wid: stri
|
|||||||
|
|
||||||
export default async function WorkspacePluginsPage({ params }: { params: Promise<{ wid: string }> }) {
|
export default async function WorkspacePluginsPage({ params }: { params: Promise<{ wid: string }> }) {
|
||||||
const { wid } = await params;
|
const { wid } = await params;
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) redirect('/sign-in');
|
if (!user) redirect('/sign-in');
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress ?? '';
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
// Verify membership
|
// Verify membership
|
||||||
@@ -24,7 +25,7 @@ export default async function WorkspacePluginsPage({ params }: { params: Promise
|
|||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('workspace_id', wid)
|
.eq('workspace_id', wid)
|
||||||
.eq('user_id', userId)
|
.eq('email', email)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
import { AppChrome } from '@/components/chrome/AppChrome';
|
import { AppChrome } from '@/components/chrome/AppChrome';
|
||||||
@@ -16,9 +16,10 @@ export default async function ProjectCanvasPage({
|
|||||||
params: Promise<{ wid: string; pid: string }>;
|
params: Promise<{ wid: string; pid: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { wid, pid } = await params;
|
const { wid, pid } = await params;
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) redirect('/sign-in');
|
if (!user) redirect('/sign-in');
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress ?? '';
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
// Verify membership
|
// Verify membership
|
||||||
@@ -26,7 +27,7 @@ export default async function ProjectCanvasPage({
|
|||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('workspace_id', wid)
|
.eq('workspace_id', wid)
|
||||||
.eq('user_id', userId)
|
.eq('email', email)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
import { AppHeader } from '@/components/shell/AppHeader';
|
import { AppHeader } from '@/components/shell/AppHeader';
|
||||||
@@ -19,9 +19,10 @@ export default async function ProjectSettingsPage({
|
|||||||
params: Promise<{ wid: string; pid: string }>;
|
params: Promise<{ wid: string; pid: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { wid, pid } = await params;
|
const { wid, pid } = await params;
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) redirect('/sign-in');
|
if (!user) redirect('/sign-in');
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress ?? '';
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
// Verify membership and role
|
// Verify membership and role
|
||||||
@@ -29,7 +30,7 @@ export default async function ProjectSettingsPage({
|
|||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('role')
|
.select('role')
|
||||||
.eq('workspace_id', wid)
|
.eq('workspace_id', wid)
|
||||||
.eq('user_id', userId)
|
.eq('email', email)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single() as unknown as { data: { role: string } | null };
|
.single() as unknown as { data: { role: string } | null };
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
import { AppHeader } from '@/components/shell/AppHeader';
|
import { AppHeader } from '@/components/shell/AppHeader';
|
||||||
@@ -14,9 +14,10 @@ export async function generateMetadata({ params }: { params: Promise<{ wid: stri
|
|||||||
|
|
||||||
export default async function WorkspaceSettingsPage({ params }: { params: Promise<{ wid: string }> }) {
|
export default async function WorkspaceSettingsPage({ params }: { params: Promise<{ wid: string }> }) {
|
||||||
const { wid } = await params;
|
const { wid } = await params;
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) redirect('/sign-in');
|
if (!user) redirect('/sign-in');
|
||||||
|
|
||||||
|
const email = user.primaryEmailAddress?.emailAddress ?? '';
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
|
|
||||||
// Verify membership
|
// Verify membership
|
||||||
@@ -24,7 +25,7 @@ export default async function WorkspaceSettingsPage({ params }: { params: Promis
|
|||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('id, role')
|
.select('id, role')
|
||||||
.eq('workspace_id', wid)
|
.eq('workspace_id', wid)
|
||||||
.eq('user_id', userId)
|
.eq('email', email)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { auth } from '@clerk/nextjs/server';
|
import { currentUser } from '@clerk/nextjs/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
@@ -8,12 +8,12 @@ import type { Workspace } from '@originmain/origin-graph';
|
|||||||
|
|
||||||
export const metadata = { title: 'Workspaces — Originmain' };
|
export const metadata = { title: 'Workspaces — Originmain' };
|
||||||
|
|
||||||
async function getWorkspaces(userId: string): Promise<Workspace[]> {
|
async function getWorkspaces(email: string): Promise<Workspace[]> {
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
const { data: memberships } = await db
|
const { data: memberships } = await db
|
||||||
.from('team_members')
|
.from('team_members')
|
||||||
.select('workspace_id')
|
.select('workspace_id')
|
||||||
.eq('user_id', userId);
|
.eq('email', email);
|
||||||
|
|
||||||
const ids = (memberships ?? []).map((m) => (m as { workspace_id: string }).workspace_id);
|
const ids = (memberships ?? []).map((m) => (m as { workspace_id: string }).workspace_id);
|
||||||
if (ids.length === 0) return [];
|
if (ids.length === 0) return [];
|
||||||
@@ -28,10 +28,11 @@ async function getWorkspaces(userId: string): Promise<Workspace[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function WorkspacesPage() {
|
export default async function WorkspacesPage() {
|
||||||
const { userId } = await auth();
|
const user = await currentUser();
|
||||||
if (!userId) redirect('/sign-in');
|
if (!user) redirect('/sign-in');
|
||||||
|
|
||||||
const workspaces = await getWorkspaces(userId);
|
const email = user.primaryEmailAddress?.emailAddress ?? '';
|
||||||
|
const workspaces = await getWorkspaces(email);
|
||||||
|
|
||||||
// New user with no workspaces yet → send to onboarding
|
// New user with no workspaces yet → send to onboarding
|
||||||
if (workspaces.length === 0) redirect('/onboarding');
|
if (workspaces.length === 0) redirect('/onboarding');
|
||||||
|
|||||||
@@ -45,32 +45,25 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
|||||||
|
|
||||||
const db = browserClient() as unknown as SupabaseClient;
|
const db = browserClient() as unknown as SupabaseClient;
|
||||||
const channel = db
|
const channel = db
|
||||||
.channel(`dlf:${workspaceId}`)
|
.channel(`dl:${workspaceId}`)
|
||||||
.on(
|
.on(
|
||||||
'postgres_changes',
|
'postgres_changes',
|
||||||
{
|
{
|
||||||
event: '*',
|
event: '*',
|
||||||
schema: 'public',
|
schema: 'public',
|
||||||
table: 'design_language_files',
|
table: 'design_languages',
|
||||||
filter: `workspace_id=eq.${workspaceId}`,
|
filter: `workspace_id=eq.${workspaceId}`,
|
||||||
},
|
},
|
||||||
(payload) => {
|
(payload) => {
|
||||||
// Only react to rows that are marked as the active version.
|
// One row per workspace — any INSERT/UPDATE means new active tokens.
|
||||||
const row = (payload.new ?? payload.old) as Record<string, unknown> | undefined;
|
const row = (payload.new ?? payload.old) as Record<string, unknown> | undefined;
|
||||||
if (!row || !row['is_active']) return;
|
if (!row) return;
|
||||||
|
|
||||||
// Re-parse tokens from the updated schema_jsonb.
|
// `normalized` is already a DesignToken[] stored as JSONB — use directly.
|
||||||
const schemaJsonb = row['schema_jsonb'];
|
const normalized = row['normalized'];
|
||||||
if (!schemaJsonb || typeof schemaJsonb !== 'object') return;
|
if (!Array.isArray(normalized)) return;
|
||||||
|
|
||||||
import('@originmain/design-language').then(({ parseTokenFile }) => {
|
setDesignLanguageTokens(normalized as Parameters<typeof setDesignLanguageTokens>[0]);
|
||||||
try {
|
|
||||||
const tokens = parseTokenFile(schemaJsonb);
|
|
||||||
setDesignLanguageTokens(tokens as Parameters<typeof setDesignLanguageTokens>[0]);
|
|
||||||
} catch {
|
|
||||||
// Malformed token file in DB — don't crash the session
|
|
||||||
}
|
|
||||||
}).catch(() => { /* design-language package unavailable */ });
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|||||||
@@ -0,0 +1,645 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// ── Code Tab (Phase 4) ────────────────────────────────────────────────────────
|
||||||
|
// Shows a live source diff for the selected component based on pending style
|
||||||
|
// edits, with hunk-level accept/reject, Send-to-Agent, and Realtime status.
|
||||||
|
// Extracted from Inspector.tsx as per spec SOURCE-AWARE-CANVAS.md Phase 2.
|
||||||
|
//
|
||||||
|
// Phase 4 additions:
|
||||||
|
// • diffIndicators: 'bars' — gutter bar change indicators
|
||||||
|
// • lineAnnotations — token match badges on addition lines
|
||||||
|
// • renderAnnotation — renders the token key pill
|
||||||
|
// • onTokenEnter/Leave — hover tooltip showing matched design token
|
||||||
|
// • rejectedHunks Set — tracks explicitly rejected hunks
|
||||||
|
// • allRejected guard — disables Send-to-Agent when all hunks rejected
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { useCanvas } from '@/store/canvas';
|
||||||
|
import { useHistory } from '@/store/history';
|
||||||
|
import { useDiffs } from '@/hooks/useDiffs';
|
||||||
|
import { useIndexer } from '@/hooks/useIndexer';
|
||||||
|
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||||
|
import { generatePatch } from '@originmain/diff-engine';
|
||||||
|
import type { PropChange } from '@originmain/diff-engine';
|
||||||
|
import type { FiberNode } from '@originmain/renderer';
|
||||||
|
import type { IntentDiff } from '@originmain/origin-graph';
|
||||||
|
import { FileDiff as PierreDiff } from '@pierre/diffs/react';
|
||||||
|
import type { DiffLineAnnotation } from '@pierre/diffs/react';
|
||||||
|
import { processFile, diffAcceptRejectHunk } from '@pierre/diffs';
|
||||||
|
import type { FileDiffMetadata } from '@pierre/diffs';
|
||||||
|
import { resolveValueToToken } from '@originmain/design-language';
|
||||||
|
import { browserClient } from '@/lib/supabase';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort application of PropChange values to a source file string.
|
||||||
|
* Searches for `propKey: oldValue` patterns and replaces with new values.
|
||||||
|
*/
|
||||||
|
function applyChangesToSource(source: string, changes: PropChange[]): string {
|
||||||
|
let result = source;
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.before === undefined || change.before === change.after) continue;
|
||||||
|
const escapedBefore = String(change.before).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const re = new RegExp(`(${change.key}\\s*:\\s*)${escapedBefore}`, 'g');
|
||||||
|
result = result.replace(re, `$1${String(change.after)}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sub-components ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
DRAFT: '#FFBA7B',
|
||||||
|
REVIEWED: '#7EB8FF',
|
||||||
|
APPLIED: '#7DD3A8',
|
||||||
|
REJECTED: '#FF8080',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SavedDiffRow({ diff }: { diff: IntentDiff }) {
|
||||||
|
const T = useCanvasTheme();
|
||||||
|
const changes = diff.changes as { propChanges?: PropChange[]; styleChanges?: PropChange[] } | null;
|
||||||
|
const count = (changes?.propChanges?.length ?? 0) + (changes?.styleChanges?.length ?? 0);
|
||||||
|
const color = STATUS_COLOR[diff.status] ?? T.dim;
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 8, padding: '6px 8px', background: T.bgDeep, borderRadius: 6 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
|
||||||
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color }}>
|
||||||
|
{diff.status}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: T.dim }}>
|
||||||
|
{count} change{count !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{diff.aggregate_summary && (
|
||||||
|
<span style={{ fontFamily: 'sans-serif', fontSize: '0.625rem', color: T.fgMuted, lineHeight: 1.4, display: 'block' }}>
|
||||||
|
{diff.aggregate_summary}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffChangeRow({ change }: { change: PropChange }) {
|
||||||
|
const isRemoved = change.changeType === 'removed';
|
||||||
|
const isAdded = change.changeType === 'added';
|
||||||
|
const isModified = change.changeType === 'modified';
|
||||||
|
|
||||||
|
const rows: Array<{ op: 'del' | 'add'; text: string }> = [];
|
||||||
|
if (isModified) {
|
||||||
|
rows.push({ op: 'del', text: `− ${change.key}: ${change.before}` });
|
||||||
|
rows.push({ op: 'add', text: `+ ${change.key}: ${change.after}` });
|
||||||
|
} else if (isRemoved) {
|
||||||
|
rows.push({ op: 'del', text: `− ${change.key}: ${change.before}` });
|
||||||
|
} else if (isAdded) {
|
||||||
|
rows.push({ op: 'add', text: `+ ${change.key}: ${change.after}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||||
|
fontSize: '0.625rem', padding: '4px 8px',
|
||||||
|
borderRadius: 4, marginBottom: 3, lineHeight: 1.55,
|
||||||
|
background: r.op === 'del' ? 'rgba(255,70,70,0.08)' : 'rgba(70,220,120,0.08)',
|
||||||
|
color: r.op === 'del' ? '#FF8080' : '#7DDBA0',
|
||||||
|
borderLeft: `2px solid ${r.op === 'del' ? 'rgba(255,80,80,0.3)' : 'rgba(70,220,120,0.3)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HunkList — virtualised list of hunk accept/reject controls ────────────────
|
||||||
|
// Uses @tanstack/react-virtual so even a 1 000-hunk diff won't freeze the panel.
|
||||||
|
// Each row is ~26px high; overscan=3 keeps the list feeling instant on scroll.
|
||||||
|
|
||||||
|
interface HunkListProps {
|
||||||
|
hunks: FileDiffMetadata['hunks'];
|
||||||
|
allRejected: boolean;
|
||||||
|
fileDiff: FileDiffMetadata;
|
||||||
|
handleRejectHunk: (renderedIdx: number) => void;
|
||||||
|
setFileDiff: (d: FileDiffMetadata) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HunkList({ hunks, allRejected, fileDiff, handleRejectHunk, setFileDiff }: HunkListProps) {
|
||||||
|
const T = useCanvasTheme();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: hunks.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 26,
|
||||||
|
overscan: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px', borderTop: `1px solid ${T.border}`,
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
|
||||||
|
color: T.dim, letterSpacing: '0.07em', textTransform: 'uppercase', marginBottom: 2,
|
||||||
|
}}>
|
||||||
|
{hunks.length} hunk{hunks.length !== 1 ? 's' : ''}
|
||||||
|
{allRejected && <span style={{ color: '#FF8080', marginLeft: 6 }}>— all rejected</span>}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Scrollable virtual container — capped at 160px so it doesn't crowd the diff */}
|
||||||
|
<div ref={scrollRef} style={{ maxHeight: 160, overflowY: 'auto' }}>
|
||||||
|
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||||
|
{virtualizer.getVirtualItems().map((vItem) => (
|
||||||
|
<div
|
||||||
|
key={vItem.index}
|
||||||
|
data-index={vItem.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, width: '100%',
|
||||||
|
transform: `translateY(${vItem.start}px)`,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, paddingBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
|
||||||
|
color: T.fgMuted, flex: 1,
|
||||||
|
}}>
|
||||||
|
Hunk {vItem.index + 1}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setFileDiff(diffAcceptRejectHunk(fileDiff, vItem.index, 'accept'))}
|
||||||
|
style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
|
||||||
|
color: '#7DD3A8', background: 'rgba(125,211,168,0.08)',
|
||||||
|
border: '1px solid rgba(125,211,168,0.28)', borderRadius: 3,
|
||||||
|
padding: '2px 7px', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRejectHunk(vItem.index)}
|
||||||
|
style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
|
||||||
|
color: '#FF6B6B', background: 'rgba(255,107,107,0.08)',
|
||||||
|
border: '1px solid rgba(255,107,107,0.28)', borderRadius: 3,
|
||||||
|
padding: '2px 7px', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main CodeTab component ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CodeTabProps {
|
||||||
|
componentId: string | null;
|
||||||
|
componentData: FiberNode | null;
|
||||||
|
artboardId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Token annotation metadata shape ───────────────────────────────────────────
|
||||||
|
interface TokenAnnotationMeta {
|
||||||
|
tokenKey: string;
|
||||||
|
tokenName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeTab({ componentId, componentData, artboardId }: CodeTabProps) {
|
||||||
|
const T = useCanvasTheme();
|
||||||
|
const {
|
||||||
|
indexerStatus, undoStyleEdit, patchStyleEdit,
|
||||||
|
designLanguageTokens, artboardRootFontSize,
|
||||||
|
} = useCanvas();
|
||||||
|
const { stacks } = useHistory();
|
||||||
|
const { fetchFile } = useIndexer();
|
||||||
|
const { createDiff } = useDiffs(artboardId);
|
||||||
|
|
||||||
|
const [diffStyle, setDiffStyle] = useState<'split' | 'unified'>('split');
|
||||||
|
const [fileDiff, setFileDiff] = useState<FileDiffMetadata | null>(null);
|
||||||
|
const [patchStr, setPatchStr] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [diffError, setDiffError] = useState<string | null>(null);
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [exportedId, setExportedId] = useState<string | null>(null);
|
||||||
|
const [intentRtStatus, setIntentRtStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ── Phase 4: rejection tracking ───────────────────────────────────────────
|
||||||
|
// rejectedHunks tracks each explicit hunk rejection; size compared against
|
||||||
|
// initialHunkCount to determine when all hunks have been dismissed.
|
||||||
|
const [rejectedHunks, setRejectedHunks] = useState<Set<number>>(new Set());
|
||||||
|
const [initialHunkCount, setInitialHunkCount] = useState(0);
|
||||||
|
// Monotonically incrementing ID so each rejection inserts a unique entry.
|
||||||
|
const rejectionIdRef = useRef(0);
|
||||||
|
|
||||||
|
// ── Phase 4: token hover tooltip ──────────────────────────────────────────
|
||||||
|
const [tokenTooltip, setTokenTooltip] = useState<{
|
||||||
|
tokenKey: string; tokenName: string; x: number; y: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const tokens = designLanguageTokens ?? [];
|
||||||
|
const rootFontSizePx = artboardId ? (artboardRootFontSize[artboardId] ?? 16) : 16;
|
||||||
|
|
||||||
|
const artboardHistory = artboardId
|
||||||
|
? (stacks[artboardId] ?? { past: [], future: [] })
|
||||||
|
: { past: [], future: [] };
|
||||||
|
const pendingChanges = artboardHistory.past
|
||||||
|
.flatMap(e => e.changes)
|
||||||
|
.filter(c => c.changeType !== 'unchanged');
|
||||||
|
|
||||||
|
// ── Generate diff ──────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!componentData?.callSite || indexerStatus !== 'ready' || pendingChanges.length === 0) {
|
||||||
|
setFileDiff(null);
|
||||||
|
setPatchStr('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setIsLoading(true);
|
||||||
|
setDiffError(null);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const filePath = componentData.callSite!.fileName.replace(/\\/g, '/');
|
||||||
|
const sourceContent = await fetchFile(filePath).catch(() => null);
|
||||||
|
|
||||||
|
let patch: string;
|
||||||
|
if (sourceContent) {
|
||||||
|
const afterContent = applyChangesToSource(sourceContent, pendingChanges);
|
||||||
|
patch = generatePatch(sourceContent, afterContent, { filename: filePath });
|
||||||
|
} else {
|
||||||
|
const beforeText = pendingChanges.map(c => ` ${c.key}: ${String(c.before)},`).join('\n');
|
||||||
|
const afterText = pendingChanges.map(c => ` ${c.key}: ${String(c.after)},`).join('\n');
|
||||||
|
patch = generatePatch(beforeText, afterText, { filename: filePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled || !patch) return;
|
||||||
|
const parsed = processFile(patch);
|
||||||
|
if (!cancelled) {
|
||||||
|
setFileDiff(parsed ?? null);
|
||||||
|
setPatchStr(patch);
|
||||||
|
// Reset rejection tracking when a fresh diff arrives.
|
||||||
|
setRejectedHunks(new Set());
|
||||||
|
rejectionIdRef.current = 0;
|
||||||
|
setInitialHunkCount(parsed?.hunks?.length ?? 0);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setDiffError(err instanceof Error ? err.message : 'Diff generation failed');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setIsLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [componentData?.callSite?.fileName, pendingChanges.length, indexerStatus, fetchFile]);
|
||||||
|
|
||||||
|
// ── Realtime — watch intent_diffs for agent status ─────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!exportedId) return;
|
||||||
|
const db = browserClient() as unknown as SupabaseClient;
|
||||||
|
const channel = db
|
||||||
|
.channel(`code_tab_intent_${exportedId}`)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: 'UPDATE', schema: 'public', table: 'intent_diffs', filter: `id=eq.${exportedId}` },
|
||||||
|
(payload: { new: Record<string, unknown> }) => {
|
||||||
|
const status = payload.new['status'];
|
||||||
|
if (typeof status === 'string') setIntentRtStatus(status);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
return () => { void db.removeChannel(channel); };
|
||||||
|
}, [exportedId]);
|
||||||
|
|
||||||
|
// ── Cmd+Z undo ─────────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
|
||||||
|
const undone = undoStyleEdit();
|
||||||
|
if (undone) {
|
||||||
|
e.preventDefault();
|
||||||
|
patchStyleEdit(undone.artboardId, undone.nodeId, undone.property, undone.previousValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [undoStyleEdit, patchStyleEdit]);
|
||||||
|
|
||||||
|
// ── Empty state ────────────────────────────────────────────────────────────
|
||||||
|
if (!componentId) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '32px 20px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, textAlign: 'center' }}>
|
||||||
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" style={{ opacity: 0.22 }}>
|
||||||
|
<polyline points="7,9 2,14 7,19" stroke="white" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||||
|
<polyline points="21,9 26,14 21,19" stroke="white" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||||
|
<line x1="16" y1="6" x2="12" y2="22" stroke="white" strokeWidth="1.4" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em', lineHeight: 1.6 }}>
|
||||||
|
Select a component to<br/>view its source
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = componentData?.callSite?.fileName?.replace(/\\/g, '/') ?? null;
|
||||||
|
const shortPath = filePath ? filePath.split('/').slice(-2).join('/') : null;
|
||||||
|
const hunkCount = fileDiff?.hunks?.length ?? 0;
|
||||||
|
|
||||||
|
// ── Phase 4: allRejected guard ────────────────────────────────────────────
|
||||||
|
// True when the user has explicitly dismissed every hunk via "reject".
|
||||||
|
// Prevents sending an empty diff to the agent.
|
||||||
|
const allRejected = initialHunkCount > 0 && rejectedHunks.size >= initialHunkCount;
|
||||||
|
|
||||||
|
// ── Phase 4: per-hunk reject handler ─────────────────────────────────────
|
||||||
|
const handleRejectHunk = useCallback((renderedIdx: number) => {
|
||||||
|
const id = rejectionIdRef.current++;
|
||||||
|
setRejectedHunks(prev => new Set([...prev, id]));
|
||||||
|
setFileDiff(prev => prev ? diffAcceptRejectHunk(prev, renderedIdx, 'reject') : null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Phase 4: line annotations (token match badges on addition lines) ──────
|
||||||
|
// For each hunk that is not yet rejected, find the first pending change
|
||||||
|
// whose new value resolves to a known design token and annotate the first
|
||||||
|
// addition line of that hunk with the token key/name.
|
||||||
|
const lineAnnotations = useMemo<DiffLineAnnotation<TokenAnnotationMeta>[]>(() => {
|
||||||
|
if (!fileDiff || !tokens.length) return [];
|
||||||
|
|
||||||
|
const annotations: DiffLineAnnotation<TokenAnnotationMeta>[] = [];
|
||||||
|
|
||||||
|
fileDiff.hunks.forEach((hunk) => {
|
||||||
|
for (const change of pendingChanges) {
|
||||||
|
const cssValue = String(change.after ?? '').trim();
|
||||||
|
if (!cssValue) continue;
|
||||||
|
const match = resolveValueToToken(cssValue, tokens, rootFontSizePx);
|
||||||
|
if (match) {
|
||||||
|
annotations.push({
|
||||||
|
side: 'additions',
|
||||||
|
lineNumber: hunk.additionStart,
|
||||||
|
metadata: { tokenKey: match.token.key, tokenName: match.token.name },
|
||||||
|
});
|
||||||
|
break; // one annotation per hunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return annotations;
|
||||||
|
}, [fileDiff, tokens, pendingChanges, rootFontSizePx]);
|
||||||
|
|
||||||
|
// ── Phase 4: annotation renderer ─────────────────────────────────────────
|
||||||
|
// Renders the token key as a small pill badge in the diff gutter annotation slot.
|
||||||
|
const renderAnnotation = useCallback(
|
||||||
|
(annotation: DiffLineAnnotation<TokenAnnotationMeta>): React.ReactNode => {
|
||||||
|
if (!annotation.metadata) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={annotation.metadata.tokenName}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center',
|
||||||
|
padding: '1px 5px', borderRadius: 3, flexShrink: 0, whiteSpace: 'nowrap',
|
||||||
|
background: 'rgba(125,211,168,0.10)',
|
||||||
|
border: '1px solid rgba(125,211,168,0.28)',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: '0.45rem', color: '#7DD3A8', letterSpacing: '0.04em', lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{annotation.metadata.tokenKey}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSendToAgent() {
|
||||||
|
if (!artboardId || !fileDiff || pendingChanges.length === 0 || isSending) return;
|
||||||
|
setIsSending(true);
|
||||||
|
try {
|
||||||
|
const result = await createDiff.mutateAsync({
|
||||||
|
artboard_id: artboardId,
|
||||||
|
changes: { propChanges: pendingChanges, styleChanges: [] },
|
||||||
|
aggregate_summary: `Code diff — ${componentData?.name ?? componentId} (${pendingChanges.length} change${pendingChanges.length !== 1 ? 's' : ''})`,
|
||||||
|
status: 'EXPORTED',
|
||||||
|
session_id: '',
|
||||||
|
exported_code: patchStr || null,
|
||||||
|
});
|
||||||
|
setExportedId(result.id);
|
||||||
|
setIntentRtStatus('EXPORTED');
|
||||||
|
} catch { /* mutation error shown via createDiff.isError */ }
|
||||||
|
finally { setIsSending(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rtColour =
|
||||||
|
intentRtStatus === 'IMPLEMENTED' ? '#7DD3A8' :
|
||||||
|
intentRtStatus === 'BLOCKED' ? '#FF6B6B' : '#FFBA7B';
|
||||||
|
const rtBg =
|
||||||
|
intentRtStatus === 'IMPLEMENTED' ? 'rgba(125,211,168,0.10)' :
|
||||||
|
intentRtStatus === 'BLOCKED' ? 'rgba(255,107,107,0.10)' : 'rgba(255,186,123,0.10)';
|
||||||
|
const rtBorder =
|
||||||
|
intentRtStatus === 'IMPLEMENTED' ? 'rgba(125,211,168,0.30)' :
|
||||||
|
intentRtStatus === 'BLOCKED' ? 'rgba(255,107,107,0.30)' : 'rgba(255,186,123,0.30)';
|
||||||
|
const rtLabel =
|
||||||
|
intentRtStatus === 'IMPLEMENTED' ? '✓ Implemented by agent' :
|
||||||
|
intentRtStatus === 'BLOCKED' ? '✗ Blocked — check agent output' :
|
||||||
|
intentRtStatus ?? '';
|
||||||
|
|
||||||
|
const canSend = !!fileDiff && !isSending && pendingChanges.length > 0 && !allRejected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* ── Header ────────────────────────────────────────────────────────── */}
|
||||||
|
<div style={{
|
||||||
|
padding: '9px 12px', borderBottom: `1px solid ${T.border}`,
|
||||||
|
flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 7,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.fgMuted, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{shortPath ?? '—'}
|
||||||
|
{componentData?.callSite?.lineNumber != null && (
|
||||||
|
<span style={{ color: T.dim }}>:{componentData.callSite.lineNumber}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
title={indexerStatus === 'ready' ? 'CLI indexer ready' : indexerStatus === 'indexing' ? 'Indexing…' : 'CLI indexer offline'}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block', width: 5, height: 5, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: indexerStatus === 'ready' ? '#7DD3A8' : indexerStatus === 'indexing' ? '#FFBA7B' : T.dim,
|
||||||
|
boxShadow: indexerStatus === 'ready' ? '0 0 5px rgba(125,211,168,0.7)' : 'none',
|
||||||
|
transition: 'background 0.25s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem',
|
||||||
|
color: T.accent, background: T.accentBg,
|
||||||
|
border: `1px solid ${T.accent}33`, borderRadius: 4, padding: '1px 6px',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 100,
|
||||||
|
}}>
|
||||||
|
{componentData?.name ?? componentId}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
{(['split', 'unified'] as const).map(s => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setDiffStyle(s)}
|
||||||
|
style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
|
||||||
|
letterSpacing: '0.04em', textTransform: 'uppercase',
|
||||||
|
color: diffStyle === s ? T.accent : T.dim,
|
||||||
|
background: diffStyle === s ? T.accentBg : 'transparent',
|
||||||
|
border: `1px solid ${diffStyle === s ? T.accent + '44' : 'transparent'}`,
|
||||||
|
borderRadius: 3, padding: '2px 6px', cursor: 'pointer',
|
||||||
|
transition: 'color 0.15s, background 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Diff viewer ───────────────────────────────────────────────────── */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||||
|
{indexerStatus !== 'ready' ? (
|
||||||
|
<div style={{ padding: '20px 14px' }}>
|
||||||
|
<div style={{ padding: '10px 12px', background: 'rgba(255,186,123,0.06)', border: '1px solid rgba(255,186,123,0.2)', borderRadius: 7 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||||
|
<div style={{ width: 5, height: 5, borderRadius: '50%', background: '#FFBA7B', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#FFBA7B', letterSpacing: '0.04em' }}>CLI indexer offline</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.5875rem', color: T.fgDim, lineHeight: 1.6, margin: 0 }}>
|
||||||
|
Source diffs require the CLI indexer. Run{' '}
|
||||||
|
<code style={{ fontFamily: "'JetBrains Mono', monospace", color: '#FFBA7B', fontSize: '0.5rem' }}>
|
||||||
|
npx @originmain/cli dev
|
||||||
|
</code>{' '}to enable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : pendingChanges.length === 0 ? (
|
||||||
|
<div style={{ padding: '36px 14px', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em' }}>
|
||||||
|
No pending changes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div style={{ padding: '36px 14px', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim }}>Generating diff…</span>
|
||||||
|
</div>
|
||||||
|
) : diffError ? (
|
||||||
|
<div style={{ padding: '14px', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#FF6B6B' }}>
|
||||||
|
{diffError}
|
||||||
|
</div>
|
||||||
|
) : fileDiff ? (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{/* Token hover tooltip (Phase 4) */}
|
||||||
|
{tokenTooltip && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: tokenTooltip.x, top: tokenTooltip.y + 4,
|
||||||
|
zIndex: 9999,
|
||||||
|
padding: '4px 8px', borderRadius: 5,
|
||||||
|
background: '#1A1A22', border: '1px solid rgba(125,211,168,0.3)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: '#7DD3A8' }}>
|
||||||
|
{tokenTooltip.tokenKey}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.45rem', color: 'rgba(255,255,255,0.4)', marginTop: 1 }}>
|
||||||
|
{tokenTooltip.tokenName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PierreDiff<TokenAnnotationMeta>
|
||||||
|
fileDiff={fileDiff}
|
||||||
|
lineAnnotations={lineAnnotations}
|
||||||
|
renderAnnotation={renderAnnotation}
|
||||||
|
options={{
|
||||||
|
diffStyle,
|
||||||
|
lineDiffType: 'char',
|
||||||
|
diffIndicators: 'bars',
|
||||||
|
onTokenEnter: (props, event) => {
|
||||||
|
void event;
|
||||||
|
const match = resolveValueToToken(props.tokenText, tokens, rootFontSizePx);
|
||||||
|
if (match) {
|
||||||
|
const rect = props.tokenElement.getBoundingClientRect();
|
||||||
|
setTokenTooltip({
|
||||||
|
tokenKey: match.token.key,
|
||||||
|
tokenName: match.token.name,
|
||||||
|
x: rect.left,
|
||||||
|
y: rect.bottom,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTokenLeave: () => setTokenTooltip(null),
|
||||||
|
onTokenClick: (props, event) => {
|
||||||
|
void event;
|
||||||
|
// Copy token key to clipboard on click.
|
||||||
|
const match = resolveValueToToken(props.tokenText, tokens, rootFontSizePx);
|
||||||
|
if (match) {
|
||||||
|
void navigator.clipboard.writeText(match.token.key).catch(() => { /* non-fatal */ });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
style={{ fontSize: '0.5625rem' }}
|
||||||
|
/>
|
||||||
|
{hunkCount > 0 && (
|
||||||
|
<HunkList
|
||||||
|
hunks={fileDiff.hunks}
|
||||||
|
allRejected={allRejected}
|
||||||
|
fileDiff={fileDiff}
|
||||||
|
handleRejectHunk={handleRejectHunk}
|
||||||
|
setFileDiff={setFileDiff}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Footer: Realtime status + Send to Agent ───────────────────────── */}
|
||||||
|
<div style={{ padding: '9px 12px', borderTop: `1px solid ${T.border}`, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
|
{intentRtStatus && (
|
||||||
|
<div style={{ padding: '4px 9px', background: rtBg, border: `1px solid ${rtBorder}`, borderRadius: 5 }}>
|
||||||
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: rtColour, letterSpacing: '0.02em' }}>
|
||||||
|
{rtLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => void handleSendToAgent()}
|
||||||
|
disabled={!canSend}
|
||||||
|
style={{
|
||||||
|
width: '100%', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
|
||||||
|
background: canSend ? T.accent : T.bgDeep,
|
||||||
|
color: canSend ? '#fff' : T.dim,
|
||||||
|
border: 'none', borderRadius: 6, padding: '7px 0',
|
||||||
|
cursor: canSend ? 'pointer' : 'not-allowed',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSending ? 'Sending…' : 'Send to Agent'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// ── Design Tab (Phase 2) ──────────────────────────────────────────────────────
|
||||||
|
// The main inspector Design panel: component identity header, DLF violation
|
||||||
|
// banner, and all design-property section components. Extracted from
|
||||||
|
// Inspector.tsx as per spec SOURCE-AWARE-CANVAS.md Phase 2.
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
|
import { Badge } from '@fluentui/react-components';
|
||||||
|
import { useCanvas } from '@/store/canvas';
|
||||||
|
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||||
|
import { useDlf } from '@/hooks/useDlf';
|
||||||
|
import { checkComponentConstraints } from '@originmain/design-language';
|
||||||
|
import type { Violation } from '@originmain/design-language';
|
||||||
|
import type { FiberNode } from '@originmain/renderer';
|
||||||
|
import { FrameSection } from './sections/FrameSection';
|
||||||
|
import { LayoutSection } from './sections/LayoutSection';
|
||||||
|
import { FillSection } from './sections/FillSection';
|
||||||
|
import { StrokeSection } from './sections/StrokeSection';
|
||||||
|
import { EffectsSection } from './sections/EffectsSection';
|
||||||
|
import { TypographySection } from './sections/TypographySection';
|
||||||
|
import { BoxModelSection } from './sections/BoxModelSection';
|
||||||
|
import { ConstraintsSection } from './sections/ConstraintsSection';
|
||||||
|
|
||||||
|
// ── DLF violation banner ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DlfViolationBanner({ violations }: { violations: Violation[] }) {
|
||||||
|
const T = useCanvasTheme();
|
||||||
|
const hasError = violations.some(v => v.severity === 'error');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: '4px 10px 2px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: hasError ? 'rgba(255,80,80,0.07)' : 'rgba(255,186,123,0.07)',
|
||||||
|
border: `1px solid ${hasError ? 'rgba(255,80,80,0.4)' : 'rgba(255,186,123,0.4)'}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', fontWeight: 600,
|
||||||
|
letterSpacing: '0.08em', textTransform: 'uppercase',
|
||||||
|
color: hasError ? '#FF8080' : '#FFBA7B', marginBottom: 6,
|
||||||
|
}}>
|
||||||
|
{hasError ? 'Design system violations' : 'Design system warnings'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{violations.map((v, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 5 }}>
|
||||||
|
<Badge
|
||||||
|
appearance="filled"
|
||||||
|
color={v.severity === 'error' ? 'danger' : 'warning'}
|
||||||
|
size="small"
|
||||||
|
style={{ flexShrink: 0, marginTop: 1 }}
|
||||||
|
>
|
||||||
|
{v.severity}
|
||||||
|
</Badge>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem',
|
||||||
|
color: T.fgMuted, lineHeight: 1.5,
|
||||||
|
}}>
|
||||||
|
{v.prop && <strong style={{ color: T.fg }}>{v.prop}: </strong>}
|
||||||
|
{v.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main DesignTab component ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DesignTabProps {
|
||||||
|
artboardId: string | null;
|
||||||
|
componentId: string | null;
|
||||||
|
componentData: FiberNode | null;
|
||||||
|
styles: Record<string, string> | null;
|
||||||
|
workspaceId: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesignTab({
|
||||||
|
artboardId,
|
||||||
|
componentId,
|
||||||
|
componentData,
|
||||||
|
styles,
|
||||||
|
workspaceId,
|
||||||
|
}: DesignTabProps) {
|
||||||
|
const T = useCanvasTheme();
|
||||||
|
const {
|
||||||
|
patchStyleEdit,
|
||||||
|
patchChildrenStyleEdit,
|
||||||
|
indexerStatus,
|
||||||
|
selectedComponentHasDirectText,
|
||||||
|
selectedComponentHasParagraphChildren,
|
||||||
|
setActiveViolations,
|
||||||
|
} = useCanvas();
|
||||||
|
const { dlf } = useDlf(workspaceId);
|
||||||
|
|
||||||
|
// Re-run constraint checks whenever selected component or active DLF changes.
|
||||||
|
const dlfViolations = useMemo<Violation[]>(() => {
|
||||||
|
if (!dlf || !componentData?.name) return [];
|
||||||
|
return checkComponentConstraints({
|
||||||
|
componentName: componentData.name,
|
||||||
|
props: (componentData.props ?? {}) as Record<string, unknown>,
|
||||||
|
dlf,
|
||||||
|
});
|
||||||
|
}, [dlf, componentData?.name, componentData?.props]);
|
||||||
|
|
||||||
|
// Sync violations to canvas store so SelectionOverlay can render inline badges.
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveViolations(dlfViolations);
|
||||||
|
return () => { setActiveViolations([]); };
|
||||||
|
}, [dlfViolations, setActiveViolations]);
|
||||||
|
|
||||||
|
if (!artboardId) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '32px 16px', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
|
||||||
|
Select an artboard
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!componentId) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '32px 20px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, textAlign: 'center' }}>
|
||||||
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" style={{ opacity: 0.22 }}>
|
||||||
|
<rect x="2" y="2" width="24" height="24" rx="4" stroke="white" strokeWidth="1.4" strokeDasharray="4 2"/>
|
||||||
|
<circle cx="14" cy="14" r="4" stroke="white" strokeWidth="1.4"/>
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em', lineHeight: 1.6 }}>
|
||||||
|
Click a component in the<br/>artboard to inspect & edit
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!styles) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
|
||||||
|
Fetching styles…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = (prop: string, val: string) => {
|
||||||
|
if (!artboardId || !componentId) return;
|
||||||
|
patchStyleEdit(artboardId, componentId, prop, val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchChildren = (selector: string, prop: string, val: string) => {
|
||||||
|
if (!artboardId || !componentId) return;
|
||||||
|
patchChildrenStyleEdit(artboardId, componentId, selector, prop, val);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Call-site display ──────────────────────────────────────────────────────
|
||||||
|
const callSite = componentData?.callSite;
|
||||||
|
const callSiteLabel = callSite
|
||||||
|
? (() => {
|
||||||
|
const parts = callSite.fileName.replace(/\\/g, '/').split('/');
|
||||||
|
return `${parts.slice(-2).join('/')}:${callSite.lineNumber}`;
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// ── Indexer dot ────────────────────────────────────────────────────────────
|
||||||
|
const indexerDot = {
|
||||||
|
offline: { color: T.dim, title: 'CLI indexer offline' },
|
||||||
|
indexing: { color: '#FFBA7B', title: 'Indexing…' },
|
||||||
|
ready: { color: '#7DD3A8', title: 'Indexer ready' },
|
||||||
|
}[indexerStatus];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── Component identity header ───────────────────────────────────── */}
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 14px 8px', borderBottom: `1px solid ${T.sep}`,
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 4,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem',
|
||||||
|
fontWeight: 600, color: T.fg, letterSpacing: '-0.01em',
|
||||||
|
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{componentData?.name ?? componentId}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
title={indexerDot.title}
|
||||||
|
style={{
|
||||||
|
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: indexerDot.color,
|
||||||
|
boxShadow: indexerStatus === 'ready' ? `0 0 5px ${indexerDot.color}` : 'none',
|
||||||
|
transition: 'background 0.3s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{callSiteLabel && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
|
||||||
|
color: T.dim, letterSpacing: '0.02em',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={`${callSite?.fileName}:${callSite?.lineNumber}`}
|
||||||
|
>
|
||||||
|
↳ {callSiteLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── DLF violation banner ────────────────────────────────────────── */}
|
||||||
|
{dlfViolations.length > 0 && <DlfViolationBanner violations={dlfViolations} />}
|
||||||
|
|
||||||
|
{/* ── Section components ──────────────────────────────────────────── */}
|
||||||
|
<FrameSection styles={styles} onPatch={patch} />
|
||||||
|
<ConstraintsSection styles={styles} onPatch={patch} />
|
||||||
|
<LayoutSection styles={styles} onPatch={patch} />
|
||||||
|
<FillSection styles={styles} onPatch={patch} />
|
||||||
|
<StrokeSection styles={styles} onPatch={patch} />
|
||||||
|
<TypographySection
|
||||||
|
styles={styles}
|
||||||
|
hasDirectText={selectedComponentHasDirectText}
|
||||||
|
hasParagraphChildren={selectedComponentHasParagraphChildren}
|
||||||
|
onPatch={patch}
|
||||||
|
onPatchChildren={patchChildren}
|
||||||
|
/>
|
||||||
|
<EffectsSection styles={styles} onPatch={patch} />
|
||||||
|
<BoxModelSection styles={styles} onPatch={patch} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export so Inspector can still do a single-line import
|
||||||
|
export { DlfViolationBanner };
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
// ── Props Tab (Phase 2) ───────────────────────────────────────────────────────
|
// ── Props Tab (Phase 2 + §5.11) ───────────────────────────────────────────────
|
||||||
// Displays artboard metadata, editable render URL / route, selected component
|
// Displays artboard metadata, editable render URL / route, selected component
|
||||||
// props, and the Drift Report action. Extracted from Inspector.tsx as per
|
// props with TypeScript type badges, isolation prop editor, and the Drift Report
|
||||||
// spec SOURCE-AWARE-CANVAS.md Phase 2.
|
// action. Extracted from Inspector.tsx as per spec SOURCE-AWARE-CANVAS.md Phase 2.
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -19,6 +19,95 @@ const TYPE_COLORS: Record<string, string> = {
|
|||||||
b: '#FFBA7B',
|
b: '#FFBA7B',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── TypeScript type badge (spec §5.11) ────────────────────────────────────────
|
||||||
|
|
||||||
|
type TsBadgeKind = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null' | 'unknown';
|
||||||
|
|
||||||
|
function inferTsKind(v: unknown): TsBadgeKind {
|
||||||
|
if (v === null || v === undefined) return 'null';
|
||||||
|
if (Array.isArray(v)) return 'array';
|
||||||
|
const t = typeof v;
|
||||||
|
if (t === 'string') return 'string';
|
||||||
|
if (t === 'number') return 'number';
|
||||||
|
if (t === 'boolean') return 'boolean';
|
||||||
|
if (t === 'object') return 'object';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const TS_BADGE_COLORS: Record<TsBadgeKind, { fg: string; bg: string }> = {
|
||||||
|
string: { fg: '#7DD3A8', bg: 'rgba(125,211,168,0.10)' },
|
||||||
|
number: { fg: '#7EB8FF', bg: 'rgba(126,184,255,0.10)' },
|
||||||
|
boolean: { fg: '#FFBA7B', bg: 'rgba(255,186,123,0.10)' },
|
||||||
|
object: { fg: '#C084FC', bg: 'rgba(192,132,252,0.10)' },
|
||||||
|
array: { fg: '#C084FC', bg: 'rgba(192,132,252,0.10)' },
|
||||||
|
null: { fg: '#6B7280', bg: 'rgba(107,114,128,0.10)' },
|
||||||
|
unknown: { fg: '#6B7280', bg: 'rgba(107,114,128,0.10)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function TypeBadge({ kind }: { kind: TsBadgeKind }) {
|
||||||
|
const c = TS_BADGE_COLORS[kind];
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.45rem',
|
||||||
|
color: c.fg, background: c.bg,
|
||||||
|
border: `1px solid ${c.fg}33`,
|
||||||
|
borderRadius: 3, padding: '0 3px', flexShrink: 0,
|
||||||
|
letterSpacing: '0.04em', lineHeight: '14px',
|
||||||
|
}}>
|
||||||
|
{kind}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Isolation prop editor row (spec §5.11) ────────────────────────────────────
|
||||||
|
// Editable input for a single isolation prop override. Displays the runtime
|
||||||
|
// value and TypeScript type; edits are committed on blur/Enter.
|
||||||
|
|
||||||
|
function IsolationPropRow({
|
||||||
|
propKey, runtimeValue, override, kind,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
propKey: string;
|
||||||
|
runtimeValue: unknown;
|
||||||
|
override: unknown;
|
||||||
|
kind: TsBadgeKind;
|
||||||
|
onChange: (key: string, rawValue: string) => void;
|
||||||
|
}) {
|
||||||
|
const T = useCanvasTheme();
|
||||||
|
const displayedValue = override !== undefined ? override : runtimeValue;
|
||||||
|
const [draft, setDraft] = useState(String(displayedValue ?? ''));
|
||||||
|
|
||||||
|
const commit = useCallback(() => {
|
||||||
|
onChange(propKey, draft);
|
||||||
|
}, [propKey, draft, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 6 }}>
|
||||||
|
<TypeBadge kind={kind} />
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem',
|
||||||
|
color: T.key, flexShrink: 0, minWidth: 60,
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{propKey}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={e => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') commit(); }}
|
||||||
|
style={{
|
||||||
|
flex: 1, minWidth: 0, fontSize: '0.5625rem',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
background: T.bgDeep, border: `1px solid ${T.border}`,
|
||||||
|
borderRadius: 4, padding: '3px 6px',
|
||||||
|
color: override !== undefined ? T.accent : T.fg, outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface PropsTabProps {
|
interface PropsTabProps {
|
||||||
artboard: Artboard | null;
|
artboard: Artboard | null;
|
||||||
selectedComponentData: FiberNode | null;
|
selectedComponentData: FiberNode | null;
|
||||||
@@ -39,6 +128,31 @@ export function PropsTab({
|
|||||||
const [editingRoute, setEditingRoute] = useState(false);
|
const [editingRoute, setEditingRoute] = useState(false);
|
||||||
const [routeDraft, setRouteDraft] = useState('');
|
const [routeDraft, setRouteDraft] = useState('');
|
||||||
|
|
||||||
|
// ── Isolation prop overrides (spec §5.11) ─────────────────────────────────
|
||||||
|
// Stored in artboard.isolation_props (a direct DB column).
|
||||||
|
const isolationProps = (artboard?.isolation_props ?? {}) as Record<string, unknown>;
|
||||||
|
const isIsolation = artboard?.artboard_type === 'isolation';
|
||||||
|
|
||||||
|
const handleIsolationPropChange = useCallback(async (propKey: string, rawValue: string) => {
|
||||||
|
if (!artboard) return;
|
||||||
|
// Coerce the string input to the original runtime type
|
||||||
|
const runtimeVal = selectedComponentData?.props?.[propKey];
|
||||||
|
let coerced: unknown = rawValue;
|
||||||
|
if (typeof runtimeVal === 'number') {
|
||||||
|
const n = Number(rawValue);
|
||||||
|
coerced = isNaN(n) ? rawValue : n;
|
||||||
|
} else if (typeof runtimeVal === 'boolean') {
|
||||||
|
coerced = rawValue === 'true';
|
||||||
|
}
|
||||||
|
const updated = { ...isolationProps, [propKey]: coerced };
|
||||||
|
try {
|
||||||
|
await patchArtboard(artboard.id, { isolation_props: updated });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PropsTab] isolation_props patch failed', err);
|
||||||
|
}
|
||||||
|
}, [artboard, isolationProps, selectedComponentData?.props, workspaceId, projectId, queryClient]);
|
||||||
|
|
||||||
// Drift report
|
// Drift report
|
||||||
const [driftStatus, setDriftStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
|
const [driftStatus, setDriftStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
|
||||||
const [driftReport, setDriftReport] = useState('');
|
const [driftReport, setDriftReport] = useState('');
|
||||||
@@ -121,15 +235,36 @@ export function PropsTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Selected fiber component props ─────────────────────────────── */}
|
{/* ── Selected fiber component props (spec §5.11) ─────────────────── */}
|
||||||
{selectedComponentData && (
|
{selectedComponentData && (
|
||||||
<>
|
<>
|
||||||
<Section label={`↳ ${selectedComponentData.name}`}>
|
<Section label={`↳ ${selectedComponentData.name}`}>
|
||||||
{Object.entries(selectedComponentData.props ?? {}).map(([k, v]) => {
|
{Object.entries(selectedComponentData.props ?? {}).map(([k, v]) => {
|
||||||
const t = typeof v;
|
const kind = inferTsKind(v);
|
||||||
const color = t === 'number' ? N : t === 'boolean' ? B : S;
|
const color = kind === 'number' ? N : kind === 'boolean' ? B : S;
|
||||||
const disp = t === 'string' ? `"${v as string}"` : String(v);
|
const disp = typeof v === 'string' ? `"${v}"` : String(v);
|
||||||
return <PropRow key={k} label={k} value={disp} color={color} />;
|
|
||||||
|
if (isIsolation) {
|
||||||
|
// Isolation artboard: show editable override input
|
||||||
|
return (
|
||||||
|
<IsolationPropRow
|
||||||
|
key={k}
|
||||||
|
propKey={k}
|
||||||
|
runtimeValue={v}
|
||||||
|
override={isolationProps[k]}
|
||||||
|
kind={kind}
|
||||||
|
onChange={handleIsolationPropChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard artboard: read-only with type badge
|
||||||
|
return (
|
||||||
|
<div key={k} style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 5 }}>
|
||||||
|
<TypeBadge kind={kind} />
|
||||||
|
<PropRow label={k} value={disp} color={color} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
{Object.keys(selectedComponentData.props ?? {}).length === 0 && (
|
{Object.keys(selectedComponentData.props ?? {}).length === 0 && (
|
||||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useMemo, type ReactNode } from 'react';
|
import { useState, useCallback, useMemo, useEffect, useRef, type ReactNode } from 'react';
|
||||||
import { useFileTree, FileTree } from '@pierre/trees/react';
|
import { useFileTree, FileTree } from '@pierre/trees/react';
|
||||||
import { themeToTreeStyles } from '@pierre/trees';
|
import { themeToTreeStyles } from '@pierre/trees';
|
||||||
import { SquareRegular } from '@fluentui/react-icons';
|
import { SquareRegular } from '@fluentui/react-icons';
|
||||||
@@ -10,8 +10,71 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||||
import { useTheme } from '@/store/theme';
|
import { useTheme } from '@/store/theme';
|
||||||
|
|
||||||
|
// ── Auto-arrange algorithm (spec §3.4) ────────────────────────────────────────
|
||||||
|
// Lays artboards out in a grid: max 3 per row, 200px gap between each.
|
||||||
|
// Rows are sized to the tallest artboard in the row.
|
||||||
|
const AUTO_ARRANGE_GAP = 200;
|
||||||
|
const AUTO_ARRANGE_PER_ROW = 3;
|
||||||
|
|
||||||
|
async function autoArrangeArtboards(
|
||||||
|
artboards: Array<{ id: string; metadata_jsonb: Record<string, unknown> }>,
|
||||||
|
) {
|
||||||
|
// Sort by current canvas position — top-to-bottom then left-to-right.
|
||||||
|
const sorted = [...artboards].sort((a, b) => {
|
||||||
|
const ay = Number(a.metadata_jsonb['y'] ?? 0);
|
||||||
|
const by = Number(b.metadata_jsonb['y'] ?? 0);
|
||||||
|
const ax = Number(a.metadata_jsonb['x'] ?? 0);
|
||||||
|
const bx = Number(b.metadata_jsonb['x'] ?? 0);
|
||||||
|
return ay !== by ? ay - by : ax - bx;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-compute row max heights for column-y offsets.
|
||||||
|
const rowMaxHeights: number[] = [];
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
const row = Math.floor(i / AUTO_ARRANGE_PER_ROW);
|
||||||
|
const h = Number(sorted[i]!.metadata_jsonb['height'] ?? 900);
|
||||||
|
rowMaxHeights[row] = Math.max(rowMaxHeights[row] ?? 0, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute row y offsets (cumulative sum of maxHeights + gaps).
|
||||||
|
const rowYOffset: number[] = [];
|
||||||
|
let cumY = 0;
|
||||||
|
for (let r = 0; r < rowMaxHeights.length; r++) {
|
||||||
|
rowYOffset[r] = cumY;
|
||||||
|
cumY += (rowMaxHeights[r] ?? 900) + AUTO_ARRANGE_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch each artboard with its new grid position (fire in parallel).
|
||||||
|
await Promise.all(
|
||||||
|
sorted.map((ab, i) => {
|
||||||
|
const col = i % AUTO_ARRANGE_PER_ROW;
|
||||||
|
const row = Math.floor(i / AUTO_ARRANGE_PER_ROW);
|
||||||
|
|
||||||
|
// Column x: sum widths + gaps of artboards before this one in the same row.
|
||||||
|
let x = 0;
|
||||||
|
for (let j = 0; j < col; j++) {
|
||||||
|
const prev = sorted[row * AUTO_ARRANGE_PER_ROW + j];
|
||||||
|
x += Number(prev?.metadata_jsonb['width'] ?? 1440) + AUTO_ARRANGE_GAP;
|
||||||
|
}
|
||||||
|
const y = rowYOffset[row] ?? 0;
|
||||||
|
|
||||||
|
return patchArtboard(ab.id, {
|
||||||
|
metadata_jsonb: { ...ab.metadata_jsonb, x, y },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type NavTab = 'artboards' | 'routes';
|
type NavTab = 'artboards' | 'routes';
|
||||||
|
|
||||||
|
// ── Context menu state shape ──────────────────────────────────────────────────
|
||||||
|
interface ContextMenuState {
|
||||||
|
artboardId: string;
|
||||||
|
label: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function ArtboardNavigator() {
|
export function ArtboardNavigator() {
|
||||||
const T = useCanvasTheme();
|
const T = useCanvasTheme();
|
||||||
const mode = useTheme((s) => s.mode);
|
const mode = useTheme((s) => s.mode);
|
||||||
@@ -20,6 +83,55 @@ export function ArtboardNavigator() {
|
|||||||
const { artboards, rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
|
const { artboards, rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// ── Right-click context menu (spec §3.5) ───────────────────────────────────
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close context menu on outside click or Escape.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contextMenu) return;
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setContextMenu(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setContextMenu(null);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', onPointerDown);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [contextMenu]);
|
||||||
|
|
||||||
|
// ── Auto-arrange handler (spec §3.4) ───────────────────────────────────────
|
||||||
|
const handleAutoArrange = useCallback(async () => {
|
||||||
|
if (!rawArtboards.length) return;
|
||||||
|
await autoArrangeArtboards(
|
||||||
|
rawArtboards.map((ab) => ({ id: ab.id, metadata_jsonb: ab.metadata_jsonb as Record<string, unknown> })),
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
||||||
|
}, [rawArtboards, workspaceId, projectId, queryClient]);
|
||||||
|
|
||||||
|
// ── Context menu actions ────────────────────────────────────────────────────
|
||||||
|
const setAsCover = useCallback(async (artboardId: string) => {
|
||||||
|
setContextMenu(null);
|
||||||
|
// Clear isCover from all artboards, then set it on the target.
|
||||||
|
await Promise.all(
|
||||||
|
rawArtboards.map((ab) =>
|
||||||
|
patchArtboard(ab.id, {
|
||||||
|
metadata_jsonb: {
|
||||||
|
...(ab.metadata_jsonb as Record<string, unknown>),
|
||||||
|
isCover: ab.id === artboardId ? true : undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
||||||
|
}, [rawArtboards, workspaceId, projectId, queryClient]);
|
||||||
|
|
||||||
// Aggregate unique routes across all artboards, deduplicated by path
|
// Aggregate unique routes across all artboards, deduplicated by path
|
||||||
const allRoutes = useMemo(() => {
|
const allRoutes = useMemo(() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -151,30 +263,82 @@ export function ArtboardNavigator() {
|
|||||||
|
|
||||||
{/* ── Artboards tab ── */}
|
{/* ── Artboards tab ── */}
|
||||||
{navTab === 'artboards' && (
|
{navTab === 'artboards' && (
|
||||||
<div style={{ padding: '2px 6px 0' }}>
|
<>
|
||||||
{artboards.map((ab) => {
|
{/* Re-arrange button (spec §3.4) */}
|
||||||
const sel = selectedArtboardId === ab.id;
|
{rawArtboards.length > 1 && (
|
||||||
const live = liveArtboardIds.has(ab.id);
|
<div style={{ padding: '2px 8px 4px', flexShrink: 0 }}>
|
||||||
return (
|
<button
|
||||||
<NavRow
|
onClick={() => void handleAutoArrange()}
|
||||||
key={ab.id}
|
title="Re-arrange all artboards in a grid (200px gap, 3 per row)"
|
||||||
T={T}
|
style={{
|
||||||
selected={sel}
|
width: '100%', fontFamily: "'JetBrains Mono', monospace",
|
||||||
live={live}
|
fontSize: '0.5rem', letterSpacing: '0.06em', textTransform: 'uppercase',
|
||||||
onClick={() => selectArtboard(ab.id)}
|
background: 'transparent', border: `1px solid ${T.sep}`,
|
||||||
icon={
|
borderRadius: 4, padding: '3px 0', color: T.dim, cursor: 'pointer',
|
||||||
<SquareRegular
|
transition: 'color 0.12s, border-color 0.12s',
|
||||||
style={{ fontSize: 11, color: sel ? T.accent : T.dim, flexShrink: 0 }}
|
}}
|
||||||
/>
|
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.color = T.fg; (e.currentTarget as HTMLButtonElement).style.borderColor = T.border; }}
|
||||||
}
|
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.color = T.dim; (e.currentTarget as HTMLButtonElement).style.borderColor = T.sep; }}
|
||||||
label={ab.label}
|
>
|
||||||
onRename={() => void renameArtboard(ab.id, ab.label)}
|
⊞ Re-arrange all
|
||||||
onFork={() => void forkArtboard(ab.id, ab.label)}
|
</button>
|
||||||
onDelete={() => void deleteArtboard(ab.id, ab.label)}
|
</div>
|
||||||
/>
|
)}
|
||||||
);
|
<div style={{ padding: '2px 6px 0' }}>
|
||||||
})}
|
{artboards.map((ab) => {
|
||||||
</div>
|
const sel = selectedArtboardId === ab.id;
|
||||||
|
const live = liveArtboardIds.has(ab.id);
|
||||||
|
const isCover = !!(rawArtboards.find(r => r.id === ab.id)?.metadata_jsonb as Record<string, unknown> | undefined)?.['isCover'];
|
||||||
|
return (
|
||||||
|
<NavRow
|
||||||
|
key={ab.id}
|
||||||
|
T={T}
|
||||||
|
selected={sel}
|
||||||
|
live={live}
|
||||||
|
isCover={isCover}
|
||||||
|
onClick={() => selectArtboard(ab.id)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setContextMenu({ artboardId: ab.id, label: ab.label, x: e.clientX, y: e.clientY });
|
||||||
|
}}
|
||||||
|
icon={
|
||||||
|
<SquareRegular
|
||||||
|
style={{ fontSize: 11, color: sel ? T.accent : T.dim, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={ab.label}
|
||||||
|
onRename={() => void renameArtboard(ab.id, ab.label)}
|
||||||
|
onFork={() => void forkArtboard(ab.id, ab.label)}
|
||||||
|
onDelete={() => void deleteArtboard(ab.id, ab.label)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Right-click context menu (spec §3.5) ── */}
|
||||||
|
{contextMenu && (
|
||||||
|
<ArtboardContextMenu
|
||||||
|
ref={contextMenuRef}
|
||||||
|
T={T}
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
onRename={() => {
|
||||||
|
setContextMenu(null);
|
||||||
|
void renameArtboard(contextMenu.artboardId, contextMenu.label);
|
||||||
|
}}
|
||||||
|
onDuplicate={() => {
|
||||||
|
setContextMenu(null);
|
||||||
|
void forkArtboard(contextMenu.artboardId, contextMenu.label);
|
||||||
|
}}
|
||||||
|
onSetAsCover={() => void setAsCover(contextMenu.artboardId)}
|
||||||
|
onDelete={() => {
|
||||||
|
setContextMenu(null);
|
||||||
|
void deleteArtboard(contextMenu.artboardId, contextMenu.label);
|
||||||
|
}}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Routes tab ── */}
|
{/* ── Routes tab ── */}
|
||||||
@@ -494,13 +658,90 @@ function RouteRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Context menu component (spec §3.5) ──────────────────── */
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const ArtboardContextMenu = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{
|
||||||
|
T: CanvasTokens;
|
||||||
|
x: number; y: number;
|
||||||
|
onRename: () => void;
|
||||||
|
onDuplicate: () => void;
|
||||||
|
onSetAsCover: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
>(({ T, x, y, onRename, onDuplicate, onSetAsCover, onDelete, onClose }, ref) => {
|
||||||
|
const items: Array<{ label: string; action: () => void; danger?: boolean }> = [
|
||||||
|
{ label: 'Rename', action: onRename },
|
||||||
|
{ label: 'Duplicate', action: onDuplicate },
|
||||||
|
{ label: 'Set as Cover', action: onSetAsCover },
|
||||||
|
{ label: 'Delete', action: onDelete, danger: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', left: x, top: y, zIndex: 9999,
|
||||||
|
background: T.bgDeep,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
borderRadius: 7,
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.45)',
|
||||||
|
padding: '4px',
|
||||||
|
minWidth: 140,
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<ContextMenuItem key={item.label} T={T} danger={item.danger} onAction={item.action} onClose={onClose}>
|
||||||
|
{item.label}
|
||||||
|
</ContextMenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ArtboardContextMenu.displayName = 'ArtboardContextMenu';
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
T, children, danger, onAction, onClose,
|
||||||
|
}: {
|
||||||
|
T: CanvasTokens;
|
||||||
|
children: string;
|
||||||
|
danger?: boolean | undefined;
|
||||||
|
onAction: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [hov, setHov] = useState(false);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setHov(true)}
|
||||||
|
onMouseLeave={() => setHov(false)}
|
||||||
|
onClick={() => { onAction(); onClose(); }}
|
||||||
|
style={{
|
||||||
|
padding: '5px 10px', borderRadius: 4, cursor: 'pointer',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
|
||||||
|
color: danger ? (hov ? '#FF6060' : '#FF8080') : (hov ? T.fg : T.fgMuted),
|
||||||
|
background: hov ? (danger ? 'rgba(255,80,80,0.10)' : T.hoverBg) : 'transparent',
|
||||||
|
transition: 'background 0.08s, color 0.08s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Artboard row ─────────────────────────────────────────── */
|
/* ── Artboard row ─────────────────────────────────────────── */
|
||||||
|
|
||||||
function NavRow({
|
function NavRow({
|
||||||
T,
|
T,
|
||||||
selected = false,
|
selected = false,
|
||||||
live = false,
|
live = false,
|
||||||
|
isCover = false,
|
||||||
onClick,
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
onRename,
|
onRename,
|
||||||
@@ -510,7 +751,9 @@ function NavRow({
|
|||||||
T: CanvasTokens;
|
T: CanvasTokens;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
live?: boolean;
|
live?: boolean;
|
||||||
|
isCover?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
onRename?: () => void;
|
onRename?: () => void;
|
||||||
@@ -522,6 +765,7 @@ function NavRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
onMouseEnter={() => setHov(true)}
|
onMouseEnter={() => setHov(true)}
|
||||||
onMouseLeave={() => setHov(false)}
|
onMouseLeave={() => setHov(false)}
|
||||||
style={{
|
style={{
|
||||||
@@ -544,6 +788,18 @@ function NavRow({
|
|||||||
{icon}
|
{icon}
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||||
|
|
||||||
|
{/* Cover badge */}
|
||||||
|
{isCover && !hov && !live && (
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.45rem',
|
||||||
|
color: T.accent, background: T.accentBg,
|
||||||
|
border: `1px solid ${T.accent}33`,
|
||||||
|
borderRadius: 3, padding: '0 3px', flexShrink: 0, letterSpacing: '0.04em',
|
||||||
|
}}>
|
||||||
|
cover
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Live render indicator — pulsing green dot */}
|
{/* Live render indicator — pulsing green dot */}
|
||||||
{live && !hov && (
|
{live && !hov && (
|
||||||
<span style={{
|
<span style={{
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const BTN_PRIMARY: React.CSSProperties = {
|
|||||||
type TeamRole = 'OWNER' | 'DESIGNER' | 'ENGINEER' | 'PM' | 'VIEWER';
|
type TeamRole = 'OWNER' | 'DESIGNER' | 'ENGINEER' | 'PM' | 'VIEWER';
|
||||||
|
|
||||||
function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
|
function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
|
||||||
const [userId, setUserId] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [role, setRole] = useState<TeamRole>('DESIGNER');
|
const [role, setRole] = useState<TeamRole>('DESIGNER');
|
||||||
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'conflict' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'conflict' | 'error'>('idle');
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
@@ -60,7 +60,7 @@ function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
|
|||||||
useEffect(() => { return () => { clearTimeout(resetTimer.current); }; }, []);
|
useEffect(() => { return () => { clearTimeout(resetTimer.current); }; }, []);
|
||||||
|
|
||||||
const submit = useCallback(async () => {
|
const submit = useCallback(async () => {
|
||||||
const trimmed = userId.trim();
|
const trimmed = email.trim().toLowerCase();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
clearTimeout(resetTimer.current);
|
clearTimeout(resetTimer.current);
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
@@ -69,7 +69,7 @@ function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
|
|||||||
const res = await fetch(`/api/workspace/${workspaceId}/invite`, {
|
const res = await fetch(`/api/workspace/${workspaceId}/invite`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ userId: trimmed, role }),
|
body: JSON.stringify({ email: trimmed, role }),
|
||||||
});
|
});
|
||||||
if (res.status === 409) { setStatus('conflict'); return; }
|
if (res.status === 409) { setStatus('conflict'); return; }
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -77,14 +77,14 @@ function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
|
|||||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
setStatus('done');
|
setStatus('done');
|
||||||
setUserId('');
|
setEmail('');
|
||||||
resetTimer.current = setTimeout(() => setStatus('idle'), 3000);
|
resetTimer.current = setTimeout(() => setStatus('idle'), 3000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErrorMsg(e instanceof Error ? e.message : 'Invite failed');
|
setErrorMsg(e instanceof Error ? e.message : 'Invite failed');
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
resetTimer.current = setTimeout(() => setStatus('idle'), 4000);
|
resetTimer.current = setTimeout(() => setStatus('idle'), 4000);
|
||||||
}
|
}
|
||||||
}, [userId, role, workspaceId]);
|
}, [email, role, workspaceId]);
|
||||||
|
|
||||||
const roles: TeamRole[] = ['DESIGNER', 'ENGINEER', 'PM', 'VIEWER', 'OWNER'];
|
const roles: TeamRole[] = ['DESIGNER', 'ENGINEER', 'PM', 'VIEWER', 'OWNER'];
|
||||||
|
|
||||||
@@ -110,21 +110,22 @@ function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User ID input + submit */}
|
{/* Email input + submit */}
|
||||||
<label style={LABEL} htmlFor="invite-uid">Clerk user ID</label>
|
<label style={LABEL} htmlFor="invite-email">Email address</label>
|
||||||
<div style={{ display: 'flex', gap: 10 }}>
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
<input
|
<input
|
||||||
id="invite-uid"
|
id="invite-email"
|
||||||
|
type="email"
|
||||||
style={INPUT}
|
style={INPUT}
|
||||||
placeholder="user_2abc…"
|
placeholder="colleague@company.com"
|
||||||
value={userId}
|
value={email}
|
||||||
onChange={e => setUserId(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') void submit(); }}
|
onKeyDown={e => { if (e.key === 'Enter') void submit(); }}
|
||||||
disabled={status === 'loading'}
|
disabled={status === 'loading'}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => void submit()}
|
onClick={() => void submit()}
|
||||||
disabled={status === 'loading' || !userId.trim()}
|
disabled={status === 'loading' || !email.trim()}
|
||||||
style={{
|
style={{
|
||||||
...BTN_PRIMARY,
|
...BTN_PRIMARY,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -356,7 +357,7 @@ export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }
|
|||||||
Team
|
Team
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ fontSize: '0.8125rem', color: 'var(--card-muted)', margin: '0 0 20px', lineHeight: 1.6 }}>
|
<p style={{ fontSize: '0.8125rem', color: 'var(--card-muted)', margin: '0 0 20px', lineHeight: 1.6 }}>
|
||||||
Add a team member using their Clerk user ID. You can find this in the Clerk dashboard under Users.
|
Add a team member using their email address. They must sign in with the same address.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<TeamInviteForm workspaceId={workspaceId} />
|
<TeamInviteForm workspaceId={workspaceId} />
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ async function fetchArtboards(workspaceId: string, projectId?: string): Promise<
|
|||||||
return { rows, canvas: rows.map(toCanvasArtboard).filter((ab): ab is CanvasArtboard => ab !== null) };
|
return { rows, canvas: rows.map(toCanvasArtboard).filter((ab): ab is CanvasArtboard => ab !== null) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PATCH an artboard (name and/or metadata_jsonb). */
|
/** PATCH an artboard (name, metadata_jsonb, and/or isolation_props). */
|
||||||
export async function patchArtboard(
|
export async function patchArtboard(
|
||||||
id: string,
|
id: string,
|
||||||
patch: { name?: string; metadata_jsonb?: Record<string, unknown> },
|
patch: { name?: string; metadata_jsonb?: Record<string, unknown>; isolation_props?: Record<string, unknown> },
|
||||||
): Promise<Artboard> {
|
): Promise<Artboard> {
|
||||||
const res = await fetch(`/api/artboards/${encodeURIComponent(id)}`, {
|
const res = await fetch(`/api/artboards/${encodeURIComponent(id)}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
// ── useDlf hook ───────────────────────────────────────────────────────────────
|
// ── useDlf hook ───────────────────────────────────────────────────────────────
|
||||||
// Fetches the active Design Language File for the current workspace and returns
|
// Fetches the active Design Language for the current workspace and returns its
|
||||||
// its parsed body as a typed DesignLanguageFileBody (tokens, component rules,
|
// parsed body as a typed DesignLanguageFileBody (tokens, component rules,
|
||||||
// screen rules, voice, accessibility). The raw DB row's schema_jsonb field is
|
// screen rules, voice, accessibility) when the raw_json matches the old DLF
|
||||||
// validated through the Zod schema at cache-write time so every consumer gets
|
// spec format (version "1.0").
|
||||||
// a fully typed result without re-parsing on each render.
|
|
||||||
//
|
//
|
||||||
// Stale time is 5 minutes — design systems change infrequently (deploy-time
|
// Phase 6 note: the route now returns a DesignLanguage row (migration 014)
|
||||||
// events) so we avoid redundant round-trips during normal editing sessions.
|
// whose raw_json may be a W3C DTCG / Style Dictionary / flat-CSS-vars token
|
||||||
|
// file rather than the old DLF body. We attempt to validate raw_json through
|
||||||
|
// DesignLanguageFileBodySchema; on failure we return null so the inspector
|
||||||
|
// gracefully omits constraint checking rather than crashing.
|
||||||
|
//
|
||||||
|
// Stale time is 5 minutes — design systems change at deploy-time, not
|
||||||
|
// interactively, so we avoid redundant round-trips during normal editing.
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import type { DesignLanguageFile } from '@originmain/origin-graph';
|
import type { DesignLanguage } from '@originmain/origin-graph';
|
||||||
import { DesignLanguageFileBodySchema, type DesignLanguageFileBody } from '@originmain/design-language';
|
import { DesignLanguageFileBodySchema, type DesignLanguageFileBody } from '@originmain/design-language';
|
||||||
|
|
||||||
// ── Fetch + parse ─────────────────────────────────────────────────────────────
|
// ── Fetch + parse ─────────────────────────────────────────────────────────────
|
||||||
@@ -19,19 +24,16 @@ async function fetchDlf(workspaceId: string): Promise<DesignLanguageFileBody | n
|
|||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error(`DLF fetch failed: ${res.status}`);
|
if (!res.ok) throw new Error(`DLF fetch failed: ${res.status}`);
|
||||||
|
|
||||||
// API returns the raw DB row or null when no DLF is uploaded yet.
|
// API returns the active DesignLanguage row or null when nothing is uploaded.
|
||||||
const file = (await res.json()) as DesignLanguageFile | null;
|
const dl = (await res.json()) as DesignLanguage | null;
|
||||||
if (!file) return null;
|
if (!dl) return null;
|
||||||
|
|
||||||
|
// Attempt to validate raw_json through the old DLF body schema.
|
||||||
|
// Phase 6 token files (W3C DTCG etc.) will not match — we return null
|
||||||
|
// rather than throwing so constraint checking simply goes quiet.
|
||||||
|
const parsed = DesignLanguageFileBodySchema.safeParse(dl.raw_json);
|
||||||
|
if (!parsed.success) return null;
|
||||||
|
|
||||||
// Validate schema_jsonb through the typed Zod schema.
|
|
||||||
// We throw on failure so TanStack Query surfaces it via query.error —
|
|
||||||
// callers can distinguish "no DLF" (null) from "malformed DLF" (error).
|
|
||||||
const parsed = DesignLanguageFileBodySchema.safeParse(file.schema_jsonb);
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new Error(
|
|
||||||
`Design language file schema is invalid: ${parsed.error.errors.map(e => e.message).join('; ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return parsed.data;
|
return parsed.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,17 +45,15 @@ export function useDlf(workspaceId: string | null | undefined) {
|
|||||||
queryFn: () => fetchDlf(workspaceId!),
|
queryFn: () => fetchDlf(workspaceId!),
|
||||||
enabled: Boolean(workspaceId),
|
enabled: Boolean(workspaceId),
|
||||||
// Design language files change at deploy-time, not interactively.
|
// Design language files change at deploy-time, not interactively.
|
||||||
// 5-minute staleness keeps the inspector snappy without burning requests.
|
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
// Retry once on transient network errors, then surface the error.
|
|
||||||
retry: 1,
|
retry: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** Parsed DLF body, or null if no file is uploaded for this workspace. */
|
/** Parsed DLF body, or null if no file is uploaded or it uses the new token format. */
|
||||||
dlf: query.data ?? null,
|
dlf: query.data ?? null,
|
||||||
isLoading: query.isLoading,
|
isLoading: query.isLoading,
|
||||||
/** Set when the DLF fetch succeeded but the schema failed Zod validation. */
|
/** Set when the DLF fetch itself failed (network / server error). */
|
||||||
error: query.error,
|
error: query.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -40,7 +40,7 @@ function colorDistance(a: string, b: string): number {
|
|||||||
|
|
||||||
// ── Numeric resolution ────────────────────────────────────────────────────────
|
// ── Numeric resolution ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function parseNumericPx(value: string): number | null {
|
function parseNumericPx(value: string, rootFontSizePx = 16): number | null {
|
||||||
const v = value.trim();
|
const v = value.trim();
|
||||||
if (v.endsWith('px')) {
|
if (v.endsWith('px')) {
|
||||||
const n = parseFloat(v);
|
const n = parseFloat(v);
|
||||||
@@ -48,16 +48,16 @@ function parseNumericPx(value: string): number | null {
|
|||||||
}
|
}
|
||||||
if (v.endsWith('rem')) {
|
if (v.endsWith('rem')) {
|
||||||
const n = parseFloat(v);
|
const n = parseFloat(v);
|
||||||
return isNaN(n) ? null : n * 16; // normalise with standard 16px base
|
return isNaN(n) ? null : n * rootFontSizePx;
|
||||||
}
|
}
|
||||||
const n = parseFloat(v);
|
const n = parseFloat(v);
|
||||||
if (!isNaN(n) && v === String(n)) return n;
|
if (!isNaN(n) && v === String(n)) return n;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function numericDistance(a: string, b: string): number {
|
function numericDistance(a: string, b: string, rootFontSizePx = 16): number {
|
||||||
const na = parseNumericPx(a);
|
const na = parseNumericPx(a, rootFontSizePx);
|
||||||
const nb = parseNumericPx(b);
|
const nb = parseNumericPx(b, rootFontSizePx);
|
||||||
if (na === null || nb === null) return Infinity;
|
if (na === null || nb === null) return Infinity;
|
||||||
return Math.abs(na - nb);
|
return Math.abs(na - nb);
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ export function resolveValueToToken(
|
|||||||
distance = colorDistance(normalised, tokenNorm);
|
distance = colorDistance(normalised, tokenNorm);
|
||||||
if (distance > COLOR_DISTANCE_THRESHOLD) continue;
|
if (distance > COLOR_DISTANCE_THRESHOLD) continue;
|
||||||
} else {
|
} else {
|
||||||
distance = numericDistance(normalised, tokenNorm);
|
distance = numericDistance(normalised, tokenNorm, rootFontSizePx);
|
||||||
if (distance > NUMERIC_DISTANCE_THRESHOLD) continue;
|
if (distance > NUMERIC_DISTANCE_THRESHOLD) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ export function resolveValueToTokens(
|
|||||||
distance = colorDistance(normalised, tokenNorm);
|
distance = colorDistance(normalised, tokenNorm);
|
||||||
if (distance > COLOR_DISTANCE_THRESHOLD) continue;
|
if (distance > COLOR_DISTANCE_THRESHOLD) continue;
|
||||||
} else {
|
} else {
|
||||||
distance = numericDistance(normalised, tokenNorm);
|
distance = numericDistance(normalised, tokenNorm, rootFontSizePx);
|
||||||
if (distance > NUMERIC_DISTANCE_THRESHOLD) continue;
|
if (distance > NUMERIC_DISTANCE_THRESHOLD) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import type {
|
|||||||
Origin,
|
Origin,
|
||||||
IntentDiff,
|
IntentDiff,
|
||||||
DesignLanguageFile,
|
DesignLanguageFile,
|
||||||
|
DesignLanguage,
|
||||||
|
DesignLanguageVersion,
|
||||||
|
InsertDesignLanguage,
|
||||||
AgentSession,
|
AgentSession,
|
||||||
TeamMember,
|
TeamMember,
|
||||||
Project,
|
Project,
|
||||||
@@ -31,6 +34,7 @@ export interface DbClient {
|
|||||||
select(cols?: string): DbQuery;
|
select(cols?: string): DbQuery;
|
||||||
insert(row: unknown): DbMutation;
|
insert(row: unknown): DbMutation;
|
||||||
update(row: unknown): DbMutation;
|
update(row: unknown): DbMutation;
|
||||||
|
upsert(row: unknown, opts?: { onConflict?: string }): DbMutation;
|
||||||
delete(): DbMutation;
|
delete(): DbMutation;
|
||||||
};
|
};
|
||||||
rpc(fn: string, args?: unknown): Promise<{ data: unknown; error: DbError | null }>;
|
rpc(fn: string, args?: unknown): Promise<{ data: unknown; error: DbError | null }>;
|
||||||
@@ -236,6 +240,66 @@ export async function getActiveDesignLanguageFile(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 6: design_languages queries (migration 014) ────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the single active design language row for a workspace, or null if none
|
||||||
|
* has been uploaded yet. Uses the UNIQUE(workspace_id) constraint — one row per
|
||||||
|
* workspace, no is_active flag needed.
|
||||||
|
*/
|
||||||
|
export async function getDesignLanguage(
|
||||||
|
db: DbClient,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<DesignLanguage | null> {
|
||||||
|
const { data, error } = await (db
|
||||||
|
.from('design_languages')
|
||||||
|
.select('*')
|
||||||
|
.eq('workspace_id', workspaceId)
|
||||||
|
.limit(1)
|
||||||
|
.single() as Promise<{ data: DesignLanguage | null; error: DbError | null }>);
|
||||||
|
if (error?.code === 'PGRST116') return null; // No rows — no token file uploaded yet
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a design language row (INSERT … ON CONFLICT DO UPDATE).
|
||||||
|
* The DB enforces UNIQUE(workspace_id) so this is safe for concurrent callers.
|
||||||
|
* Also inserts a history row into design_language_versions (handled by trigger).
|
||||||
|
*/
|
||||||
|
export async function upsertDesignLanguage(
|
||||||
|
db: DbClient,
|
||||||
|
row: InsertDesignLanguage,
|
||||||
|
): Promise<DesignLanguage> {
|
||||||
|
const { data, error } = await (db
|
||||||
|
.from('design_languages')
|
||||||
|
.upsert(row, { onConflict: 'workspace_id' })
|
||||||
|
.select()
|
||||||
|
.single() as Promise<{ data: DesignLanguage; error: DbError | null }>);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the version history for a design language, newest first.
|
||||||
|
* The prune trigger keeps at most 10 rows per design_language_id.
|
||||||
|
*/
|
||||||
|
export async function getDesignLanguageVersions(
|
||||||
|
db: DbClient,
|
||||||
|
designLanguageId: string,
|
||||||
|
): Promise<DesignLanguageVersion[]> {
|
||||||
|
const { data, error } = await (db
|
||||||
|
.from('design_language_versions')
|
||||||
|
.select('*')
|
||||||
|
.eq('design_language_id', designLanguageId)
|
||||||
|
.order('version', { ascending: false }) as unknown as Promise<{
|
||||||
|
data: DesignLanguageVersion[];
|
||||||
|
error: DbError | null;
|
||||||
|
}>);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
return data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Origin queries ────────────────────────────────────────────────────────────
|
// ── Origin queries ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getOrigin(db: DbClient, id: string): Promise<Origin> {
|
export async function getOrigin(db: DbClient, id: string): Promise<Origin> {
|
||||||
@@ -342,13 +406,14 @@ export async function addTeamMember(db: DbClient, row: InsertTeamMember): Promis
|
|||||||
export async function removeTeamMember(
|
export async function removeTeamMember(
|
||||||
db: DbClient,
|
db: DbClient,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
/** The email address of the member to remove. */
|
||||||
|
email: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await (db
|
const { error } = await (db
|
||||||
.from('team_members')
|
.from('team_members')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('workspace_id', workspaceId)
|
.eq('workspace_id', workspaceId)
|
||||||
.eq('user_id', userId) as unknown as Promise<{ data: unknown; error: DbError | null }>);
|
.eq('email', email) as unknown as Promise<{ data: unknown; error: DbError | null }>);
|
||||||
if (error) throw new Error(error.message);
|
if (error) throw new Error(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export type WorkspacePlan = z.infer<typeof WorkspacePlanSchema>;
|
|||||||
export const WorkspaceSchema = z.object({
|
export const WorkspaceSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
owner_id: z.string(),
|
owner_email: z.string().email(),
|
||||||
plan: WorkspacePlanSchema,
|
plan: WorkspacePlanSchema,
|
||||||
settings_jsonb: z.record(z.unknown()),
|
settings_jsonb: z.record(z.unknown()),
|
||||||
created_at: z.string().datetime(),
|
created_at: z.string().datetime(),
|
||||||
@@ -111,7 +111,7 @@ export type Origin = z.infer<typeof OriginSchema>;
|
|||||||
export const IntentDiffSchema = z.object({
|
export const IntentDiffSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
artboard_id: z.string().uuid(),
|
artboard_id: z.string().uuid(),
|
||||||
author_id: z.string(),
|
author_email: z.string().email(),
|
||||||
/** Spec column name: changes (renamed from changes_jsonb in migration 008) */
|
/** Spec column name: changes (renamed from changes_jsonb in migration 008) */
|
||||||
changes: z.record(z.unknown()),
|
changes: z.record(z.unknown()),
|
||||||
/** Spec column name: aggregate_summary (renamed from summary in migration 008) */
|
/** Spec column name: aggregate_summary (renamed from summary in migration 008) */
|
||||||
@@ -157,7 +157,7 @@ export type DesignLanguageFile = z.infer<typeof DesignLanguageFileSchema>;
|
|||||||
export const TeamMemberSchema = z.object({
|
export const TeamMemberSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
workspace_id: z.string().uuid(),
|
workspace_id: z.string().uuid(),
|
||||||
user_id: z.string(),
|
email: z.string().email(),
|
||||||
role: TeamRoleSchema,
|
role: TeamRoleSchema,
|
||||||
created_at: z.string().datetime(),
|
created_at: z.string().datetime(),
|
||||||
updated_at: z.string().datetime(),
|
updated_at: z.string().datetime(),
|
||||||
@@ -199,3 +199,46 @@ export type InsertAgentSession = Omit<AgentSession, 'id' | 'created
|
|||||||
export type InsertDesignLanguageFile = Omit<DesignLanguageFile, 'id' | 'created_at' | 'updated_at'>;
|
export type InsertDesignLanguageFile = Omit<DesignLanguageFile, 'id' | 'created_at' | 'updated_at'>;
|
||||||
export type InsertTeamMember = Omit<TeamMember, 'id' | 'created_at' | 'updated_at'>;
|
export type InsertTeamMember = Omit<TeamMember, 'id' | 'created_at' | 'updated_at'>;
|
||||||
export type InsertProject = Omit<Project, 'id' | 'created_at' | 'updated_at'>;
|
export type InsertProject = Omit<Project, 'id' | 'created_at' | 'updated_at'>;
|
||||||
|
|
||||||
|
// ── Phase 6: design_languages (new token-file schema, migration 014) ──────────
|
||||||
|
|
||||||
|
/** Allowed source format discriminants (matches migration 014 CHECK constraint). */
|
||||||
|
export const SourceFormatSchema = z.enum(['dtcg', 'style-dictionary', 'flat-css-vars']);
|
||||||
|
export type SourceFormat = z.infer<typeof SourceFormatSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the `design_languages` table (migration 014).
|
||||||
|
* One row per workspace — UPSERT on every token file upload.
|
||||||
|
*/
|
||||||
|
export const DesignLanguageSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
workspace_id: z.string().uuid(),
|
||||||
|
name: z.string(),
|
||||||
|
/** Raw token file JSON as uploaded (before normalisation). */
|
||||||
|
raw_json: z.record(z.unknown()),
|
||||||
|
/** Normalised DesignToken[] flat array (from Phase 6 parser). */
|
||||||
|
normalized: z.array(z.record(z.unknown())),
|
||||||
|
source_format: SourceFormatSchema,
|
||||||
|
token_count: z.number().int(),
|
||||||
|
version: z.number().int(),
|
||||||
|
created_at: z.string().datetime(),
|
||||||
|
updated_at: z.string().datetime(),
|
||||||
|
});
|
||||||
|
export type DesignLanguage = z.infer<typeof DesignLanguageSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the `design_language_versions` table (migration 014).
|
||||||
|
* Append-only version history; pruned to last 10 per design_language.
|
||||||
|
*/
|
||||||
|
export const DesignLanguageVersionSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
design_language_id: z.string().uuid(),
|
||||||
|
version: z.number().int(),
|
||||||
|
raw_json: z.record(z.unknown()),
|
||||||
|
normalized: z.array(z.record(z.unknown())),
|
||||||
|
source_format: SourceFormatSchema,
|
||||||
|
created_at: z.string().datetime(),
|
||||||
|
});
|
||||||
|
export type DesignLanguageVersion = z.infer<typeof DesignLanguageVersionSchema>;
|
||||||
|
|
||||||
|
export type InsertDesignLanguage = Omit<DesignLanguage, 'id' | 'created_at' | 'updated_at'>;
|
||||||
|
|||||||
Generated
+20
@@ -124,6 +124,9 @@ importers:
|
|||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.62.0
|
specifier: ^5.62.0
|
||||||
version: 5.99.2(react@19.2.5)
|
version: 5.99.2(react@19.2.5)
|
||||||
|
'@tanstack/react-virtual':
|
||||||
|
specifier: ^3.13.24
|
||||||
|
version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
'@trpc/client':
|
'@trpc/client':
|
||||||
specifier: ^11.17.0
|
specifier: ^11.17.0
|
||||||
version: 11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)
|
version: 11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)
|
||||||
@@ -1795,6 +1798,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18 || ^19
|
react: ^18 || ^19
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.13.24':
|
||||||
|
resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.14.0':
|
||||||
|
resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
|
||||||
|
|
||||||
'@trpc/client@11.17.0':
|
'@trpc/client@11.17.0':
|
||||||
resolution: {integrity: sha512-KpJBFrbKTDeVCFv/3ckL1XBBH5Yssn8hethI/rUy7GIpTj+VzjtPjykDqJpzobuVOz+d26cXCSu1t4I6MYI5Zg==}
|
resolution: {integrity: sha512-KpJBFrbKTDeVCFv/3ckL1XBBH5Yssn8hethI/rUy7GIpTj+VzjtPjykDqJpzobuVOz+d26cXCSu1t4I6MYI5Zg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -4721,6 +4733,14 @@ snapshots:
|
|||||||
'@tanstack/query-core': 5.99.2
|
'@tanstack/query-core': 5.99.2
|
||||||
react: 19.2.5
|
react: 19.2.5
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.14.0
|
||||||
|
react: 19.2.5
|
||||||
|
react-dom: 19.2.5(react@19.2.5)
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.14.0': {}
|
||||||
|
|
||||||
'@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)':
|
'@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@trpc/server': 11.17.0(typescript@5.9.3)
|
'@trpc/server': 11.17.0(typescript@5.9.3)
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
-- ── Migration 014: design_languages & design_language_versions ────────────────
|
||||||
|
-- spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.10 "DB Schema"
|
||||||
|
--
|
||||||
|
-- Stores the active Design Language (token file) for each workspace and
|
||||||
|
-- maintains a versioned history so designers can roll back to any prior upload.
|
||||||
|
--
|
||||||
|
-- Tables:
|
||||||
|
-- design_languages — one row per workspace (current active version)
|
||||||
|
-- design_language_versions — append-only version history (last 10 kept by trigger)
|
||||||
|
--
|
||||||
|
-- Identity note:
|
||||||
|
-- team_members.email is the authoritative user identifier (migration 015
|
||||||
|
-- renames user_id → email). RLS policies here use auth.jwt() ->> 'email'
|
||||||
|
-- to match the Clerk JWT template that injects the user's primary email as
|
||||||
|
-- the `email` claim.
|
||||||
|
--
|
||||||
|
-- Security:
|
||||||
|
-- RLS enabled on both tables; users can only access their own workspace rows.
|
||||||
|
-- The prune trigger runs as the table owner (SECURITY DEFINER) so it can
|
||||||
|
-- delete old version rows regardless of the invoking user's RLS policies.
|
||||||
|
|
||||||
|
-- ── design_languages ─────────────────────────────────────────────────────────
|
||||||
|
-- One row per workspace — UPSERT on every upload to keep this table small.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS design_languages (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id uuid NOT NULL UNIQUE,
|
||||||
|
name text NOT NULL DEFAULT 'Design Language',
|
||||||
|
-- Full raw token file JSON as uploaded (before normalisation).
|
||||||
|
raw_json jsonb NOT NULL,
|
||||||
|
-- Normalised DesignToken[] flat array (produced by the Phase 6 parser).
|
||||||
|
normalized jsonb NOT NULL,
|
||||||
|
-- Detected source format: 'dtcg' | 'style-dictionary' | 'flat-css-vars'
|
||||||
|
source_format text NOT NULL,
|
||||||
|
-- Number of tokens in the normalised array (for quick display).
|
||||||
|
token_count integer NOT NULL DEFAULT 0,
|
||||||
|
-- Monotonically increasing version counter — starts at 1.
|
||||||
|
version integer NOT NULL DEFAULT 1,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Enforce allowed source_format values.
|
||||||
|
ALTER TABLE design_languages
|
||||||
|
ADD CONSTRAINT design_languages_source_format_check
|
||||||
|
CHECK (source_format IN ('dtcg', 'style-dictionary', 'flat-css-vars'));
|
||||||
|
|
||||||
|
-- Index for workspace lookups (already implicit from UNIQUE constraint, but
|
||||||
|
-- explicit for clarity in query plans).
|
||||||
|
CREATE INDEX IF NOT EXISTS design_languages_workspace_id_idx
|
||||||
|
ON design_languages (workspace_id);
|
||||||
|
|
||||||
|
-- ── design_language_versions ──────────────────────────────────────────────────
|
||||||
|
-- Append-only history; pruned to last 10 entries per design_language by trigger.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS design_language_versions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
design_language_id uuid NOT NULL REFERENCES design_languages(id) ON DELETE CASCADE,
|
||||||
|
version integer NOT NULL,
|
||||||
|
raw_json jsonb NOT NULL,
|
||||||
|
normalized jsonb NOT NULL,
|
||||||
|
source_format text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (design_language_id, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE design_language_versions
|
||||||
|
ADD CONSTRAINT design_language_versions_source_format_check
|
||||||
|
CHECK (source_format IN ('dtcg', 'style-dictionary', 'flat-css-vars'));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS design_language_versions_dl_id_idx
|
||||||
|
ON design_language_versions (design_language_id);
|
||||||
|
|
||||||
|
-- ── Prune trigger ─────────────────────────────────────────────────────────────
|
||||||
|
-- After every INSERT into design_language_versions, delete all rows for the
|
||||||
|
-- same design_language_id except the 10 most-recent (by version DESC).
|
||||||
|
-- Keeps storage bounded without requiring a separate cron job.
|
||||||
|
-- Runs as SECURITY DEFINER so it can bypass RLS on the versions table.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION prune_design_language_versions()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM design_language_versions
|
||||||
|
WHERE design_language_id = NEW.design_language_id
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT id
|
||||||
|
FROM design_language_versions
|
||||||
|
WHERE design_language_id = NEW.design_language_id
|
||||||
|
ORDER BY version DESC
|
||||||
|
LIMIT 10
|
||||||
|
);
|
||||||
|
RETURN NULL; -- AFTER trigger; return value is ignored
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER prune_design_language_versions_trigger
|
||||||
|
AFTER INSERT ON design_language_versions
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION prune_design_language_versions();
|
||||||
|
|
||||||
|
-- ── updated_at trigger ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_trigger
|
||||||
|
WHERE tgname = 'design_languages_set_updated_at'
|
||||||
|
AND tgrelid = 'design_languages'::regclass
|
||||||
|
) THEN
|
||||||
|
CREATE TRIGGER design_languages_set_updated_at
|
||||||
|
BEFORE UPDATE ON design_languages
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ── Row Level Security ────────────────────────────────────────────────────────
|
||||||
|
-- API routes use the service-role key (bypasses RLS entirely).
|
||||||
|
-- These policies guard direct browser / anon-key access.
|
||||||
|
--
|
||||||
|
-- User identity: auth.jwt() ->> 'email' — set by the Clerk Supabase JWT
|
||||||
|
-- template which injects the user's verified primary email as the `email` claim.
|
||||||
|
-- This matches team_members.email (the authoritative identity column after
|
||||||
|
-- migration 015 renames user_id → email).
|
||||||
|
--
|
||||||
|
-- NOTE: these policies reference team_members.email. Migration 015 performs
|
||||||
|
-- that rename; if run before 015, team_members.user_id must be substituted
|
||||||
|
-- below. In practice migrations run in order so 014 always precedes 015.
|
||||||
|
-- The policies are dropped and recreated by migration 015 once the column
|
||||||
|
-- exists under its final name.
|
||||||
|
|
||||||
|
ALTER TABLE design_languages ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE design_language_versions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Deny all anon access (belt-and-suspenders with the service-role default).
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_policies
|
||||||
|
WHERE tablename = 'design_languages' AND policyname = 'dl_no_anon'
|
||||||
|
) THEN
|
||||||
|
CREATE POLICY dl_no_anon ON design_languages FOR ALL TO anon USING (false);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_policies
|
||||||
|
WHERE tablename = 'design_language_versions' AND policyname = 'dlv_no_anon'
|
||||||
|
) THEN
|
||||||
|
CREATE POLICY dlv_no_anon ON design_language_versions FOR ALL TO anon USING (false);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- design_languages: workspace members may read / write their workspace row.
|
||||||
|
-- (Migration 015 drops and recreates these after renaming user_id → email.)
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_policies
|
||||||
|
WHERE tablename = 'design_languages'
|
||||||
|
AND policyname = 'dl_workspace_member_select'
|
||||||
|
) THEN
|
||||||
|
CREATE POLICY dl_workspace_member_select ON design_languages
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
workspace_id IN (
|
||||||
|
SELECT workspace_id FROM team_members
|
||||||
|
WHERE user_id = auth.jwt() ->> 'email'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_policies
|
||||||
|
WHERE tablename = 'design_languages'
|
||||||
|
AND policyname = 'dl_workspace_member_write'
|
||||||
|
) THEN
|
||||||
|
CREATE POLICY dl_workspace_member_write ON design_languages
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
workspace_id IN (
|
||||||
|
SELECT workspace_id FROM team_members
|
||||||
|
WHERE user_id = auth.jwt() ->> 'email'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- design_language_versions: workspace members may read; inserts go via the
|
||||||
|
-- application layer (service role), deletes via the prune trigger.
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_policies
|
||||||
|
WHERE tablename = 'design_language_versions'
|
||||||
|
AND policyname = 'dlv_workspace_member_select'
|
||||||
|
) THEN
|
||||||
|
CREATE POLICY dlv_workspace_member_select ON design_language_versions
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
design_language_id IN (
|
||||||
|
SELECT dl.id FROM design_languages dl
|
||||||
|
JOIN team_members tm ON tm.workspace_id = dl.workspace_id
|
||||||
|
WHERE tm.user_id = auth.jwt() ->> 'email'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
-- ── Migration 015: Email as universal user identity ───────────────────────────
|
||||||
|
--
|
||||||
|
-- Background
|
||||||
|
-- ----------
|
||||||
|
-- Prior migrations stored Clerk opaque user IDs (e.g. `user_2abc…`) in every
|
||||||
|
-- `user_id` / `owner_id` / `author_id` column. Those IDs are internal to Clerk
|
||||||
|
-- and cannot be resolved back to humans without an extra Clerk API call. The
|
||||||
|
-- only durable, human-readable identity available in this stack is the user's
|
||||||
|
-- primary email address.
|
||||||
|
--
|
||||||
|
-- What this migration does
|
||||||
|
-- ------------------------
|
||||||
|
-- 1. Renames identity columns in all tables:
|
||||||
|
-- team_members.user_id → email
|
||||||
|
-- workspaces.owner_id → owner_email
|
||||||
|
-- intent_diffs.author_id → author_email
|
||||||
|
--
|
||||||
|
-- 2. Drops all stale RLS policies that referenced the old column names or
|
||||||
|
-- used `auth.uid()::text` (which returns a UUID-shaped sub claim, not an
|
||||||
|
-- email) and recreates every policy to compare against
|
||||||
|
-- `auth.jwt() ->> 'email'` — the email claim injected by the Clerk
|
||||||
|
-- Supabase JWT template.
|
||||||
|
--
|
||||||
|
-- 3. Updates the `is_workspace_member` helper function from migration 003
|
||||||
|
-- to use the renamed column.
|
||||||
|
--
|
||||||
|
-- Application changes (applied in tandem)
|
||||||
|
-- ----------------------------------------
|
||||||
|
-- • API routes use currentUser().primaryEmailAddress.emailAddress instead of
|
||||||
|
-- auth().userId for all DB identity lookups.
|
||||||
|
-- • The invite UI accepts an email address instead of a Clerk user ID.
|
||||||
|
--
|
||||||
|
-- Idempotency
|
||||||
|
-- -----------
|
||||||
|
-- Column renames are guarded by DO $$ blocks that skip if the target column
|
||||||
|
-- already exists. Policy drops use DROP … IF EXISTS. Safe to re-run.
|
||||||
|
|
||||||
|
-- ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. Column renames
|
||||||
|
-- ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- ── team_members: user_id → email ────────────────────────────────────────────
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'team_members' AND column_name = 'user_id'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'team_members' AND column_name = 'email'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE team_members RENAME COLUMN user_id TO email;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Replace the unique constraint (workspace_id, user_id) with (workspace_id, email).
|
||||||
|
ALTER TABLE team_members
|
||||||
|
DROP CONSTRAINT IF EXISTS team_members_workspace_id_user_id_key;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'team_members_workspace_id_email_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE team_members
|
||||||
|
ADD CONSTRAINT team_members_workspace_id_email_key UNIQUE (workspace_id, email);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Replace the index.
|
||||||
|
DROP INDEX IF EXISTS team_members_user_id_idx;
|
||||||
|
CREATE INDEX IF NOT EXISTS team_members_email_idx ON team_members (email);
|
||||||
|
|
||||||
|
-- ── workspaces: owner_id → owner_email ───────────────────────────────────────
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'workspaces' AND column_name = 'owner_id'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'workspaces' AND column_name = 'owner_email'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE workspaces RENAME COLUMN owner_id TO owner_email;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS workspaces_owner_id_idx;
|
||||||
|
CREATE INDEX IF NOT EXISTS workspaces_owner_email_idx ON workspaces (owner_email);
|
||||||
|
|
||||||
|
-- ── intent_diffs: author_id → author_email ───────────────────────────────────
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'intent_diffs' AND column_name = 'author_id'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'intent_diffs' AND column_name = 'author_email'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE intent_diffs RENAME COLUMN author_id TO author_email;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. Drop all stale RLS policies
|
||||||
|
-- ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- workspaces
|
||||||
|
DROP POLICY IF EXISTS workspaces_select ON workspaces;
|
||||||
|
DROP POLICY IF EXISTS workspaces_insert ON workspaces;
|
||||||
|
DROP POLICY IF EXISTS workspaces_update ON workspaces;
|
||||||
|
DROP POLICY IF EXISTS "workspace owner access" ON workspaces;
|
||||||
|
|
||||||
|
-- artboards
|
||||||
|
DROP POLICY IF EXISTS artboards_select ON artboards;
|
||||||
|
DROP POLICY IF EXISTS artboards_insert ON artboards;
|
||||||
|
DROP POLICY IF EXISTS artboards_update ON artboards;
|
||||||
|
DROP POLICY IF EXISTS artboards_delete ON artboards;
|
||||||
|
DROP POLICY IF EXISTS "workspace member artboard access" ON artboards;
|
||||||
|
|
||||||
|
-- origins
|
||||||
|
DROP POLICY IF EXISTS "workspace member origin access" ON origins;
|
||||||
|
|
||||||
|
-- intent_diffs
|
||||||
|
DROP POLICY IF EXISTS intent_diffs_select ON intent_diffs;
|
||||||
|
DROP POLICY IF EXISTS intent_diffs_insert ON intent_diffs;
|
||||||
|
DROP POLICY IF EXISTS intent_diffs_update ON intent_diffs;
|
||||||
|
DROP POLICY IF EXISTS "workspace member diff access" ON intent_diffs;
|
||||||
|
|
||||||
|
-- agent_sessions
|
||||||
|
DROP POLICY IF EXISTS agent_sessions_select ON agent_sessions;
|
||||||
|
DROP POLICY IF EXISTS agent_sessions_insert ON agent_sessions;
|
||||||
|
DROP POLICY IF EXISTS agent_sessions_update ON agent_sessions;
|
||||||
|
DROP POLICY IF EXISTS "workspace member session access" ON agent_sessions;
|
||||||
|
|
||||||
|
-- design_language_files
|
||||||
|
DROP POLICY IF EXISTS dlf_select ON design_language_files;
|
||||||
|
DROP POLICY IF EXISTS dlf_insert ON design_language_files;
|
||||||
|
DROP POLICY IF EXISTS dlf_update ON design_language_files;
|
||||||
|
DROP POLICY IF EXISTS "workspace member dlf access" ON design_language_files;
|
||||||
|
DROP POLICY IF EXISTS dlf_workspace_select ON design_language_files;
|
||||||
|
DROP POLICY IF EXISTS dlf_workspace_write ON design_language_files;
|
||||||
|
|
||||||
|
-- team_members
|
||||||
|
DROP POLICY IF EXISTS team_members_select ON team_members;
|
||||||
|
DROP POLICY IF EXISTS team_members_insert ON team_members;
|
||||||
|
DROP POLICY IF EXISTS team_members_delete ON team_members;
|
||||||
|
|
||||||
|
-- design_languages (migration 014)
|
||||||
|
DROP POLICY IF EXISTS dl_workspace_member_select ON design_languages;
|
||||||
|
DROP POLICY IF EXISTS dl_workspace_member_write ON design_languages;
|
||||||
|
DROP POLICY IF EXISTS "design_languages_workspace_member_select" ON design_languages;
|
||||||
|
DROP POLICY IF EXISTS "design_languages_workspace_member_insert" ON design_languages;
|
||||||
|
DROP POLICY IF EXISTS "design_languages_workspace_member_update" ON design_languages;
|
||||||
|
|
||||||
|
-- design_language_versions (migration 014)
|
||||||
|
DROP POLICY IF EXISTS dlv_workspace_member_select ON design_language_versions;
|
||||||
|
DROP POLICY IF EXISTS "design_language_versions_workspace_member_select" ON design_language_versions;
|
||||||
|
DROP POLICY IF EXISTS "design_language_versions_workspace_member_insert" ON design_language_versions;
|
||||||
|
|
||||||
|
-- ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Update the is_workspace_member helper (migration 003)
|
||||||
|
-- ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Recreate with team_members.email and auth.jwt() ->> 'email'.
|
||||||
|
CREATE OR REPLACE FUNCTION is_workspace_member(ws_id UUID)
|
||||||
|
RETURNS BOOLEAN LANGUAGE sql SECURITY DEFINER AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM team_members
|
||||||
|
WHERE workspace_id = ws_id
|
||||||
|
AND email = auth.jwt() ->> 'email'
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. Recreate all RLS policies with email-based identity
|
||||||
|
-- ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
--
|
||||||
|
-- Identity predicate throughout: auth.jwt() ->> 'email'
|
||||||
|
-- This reads the `email` claim from the Clerk Supabase JWT template.
|
||||||
|
--
|
||||||
|
-- For workspace owners the check is: workspaces.owner_email = auth.jwt() ->> 'email'
|
||||||
|
-- For general membership: team_members.email = auth.jwt() ->> 'email'
|
||||||
|
|
||||||
|
-- ── workspaces ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE POLICY workspaces_select ON workspaces
|
||||||
|
FOR SELECT
|
||||||
|
USING (is_workspace_member(id));
|
||||||
|
|
||||||
|
CREATE POLICY workspaces_insert ON workspaces
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (owner_email = auth.jwt() ->> 'email');
|
||||||
|
|
||||||
|
CREATE POLICY workspaces_update ON workspaces
|
||||||
|
FOR UPDATE
|
||||||
|
USING (owner_email = auth.jwt() ->> 'email');
|
||||||
|
|
||||||
|
-- ── artboards ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE POLICY artboards_select ON artboards
|
||||||
|
FOR SELECT USING (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
CREATE POLICY artboards_insert ON artboards
|
||||||
|
FOR INSERT WITH CHECK (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
CREATE POLICY artboards_update ON artboards
|
||||||
|
FOR UPDATE USING (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
CREATE POLICY artboards_delete ON artboards
|
||||||
|
FOR DELETE USING (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
-- ── origins ───────────────────────────────────────────────────────────────────
|
||||||
|
-- Accepts both FK directions (spec: origins.artboard_id; legacy: artboards.origin_id).
|
||||||
|
|
||||||
|
CREATE POLICY "workspace member origin access" ON origins
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
-- spec-compliant: origins.artboard_id set to the artboard's uuid
|
||||||
|
artboard_id IN (
|
||||||
|
SELECT id FROM artboards
|
||||||
|
WHERE is_workspace_member(workspace_id)
|
||||||
|
)
|
||||||
|
OR
|
||||||
|
-- legacy: artboards.origin_id points at this origin row
|
||||||
|
id IN (
|
||||||
|
SELECT origin_id FROM artboards
|
||||||
|
WHERE is_workspace_member(workspace_id)
|
||||||
|
AND origin_id IS NOT NULL
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── intent_diffs ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE POLICY intent_diffs_select ON intent_diffs
|
||||||
|
FOR SELECT
|
||||||
|
USING (is_workspace_member(
|
||||||
|
(SELECT workspace_id FROM artboards WHERE id = artboard_id)
|
||||||
|
));
|
||||||
|
|
||||||
|
CREATE POLICY intent_diffs_insert ON intent_diffs
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (is_workspace_member(
|
||||||
|
(SELECT workspace_id FROM artboards WHERE id = artboard_id)
|
||||||
|
));
|
||||||
|
|
||||||
|
CREATE POLICY intent_diffs_update ON intent_diffs
|
||||||
|
FOR UPDATE
|
||||||
|
USING (is_workspace_member(
|
||||||
|
(SELECT workspace_id FROM artboards WHERE id = artboard_id)
|
||||||
|
));
|
||||||
|
|
||||||
|
-- ── agent_sessions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE POLICY agent_sessions_select ON agent_sessions
|
||||||
|
FOR SELECT
|
||||||
|
USING (is_workspace_member(
|
||||||
|
(SELECT workspace_id FROM artboards WHERE id = artboard_id)
|
||||||
|
));
|
||||||
|
|
||||||
|
CREATE POLICY agent_sessions_insert ON agent_sessions
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (is_workspace_member(
|
||||||
|
(SELECT workspace_id FROM artboards WHERE id = artboard_id)
|
||||||
|
));
|
||||||
|
|
||||||
|
CREATE POLICY agent_sessions_update ON agent_sessions
|
||||||
|
FOR UPDATE
|
||||||
|
USING (is_workspace_member(
|
||||||
|
(SELECT workspace_id FROM artboards WHERE id = artboard_id)
|
||||||
|
));
|
||||||
|
|
||||||
|
-- ── design_language_files ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE POLICY dlf_workspace_select ON design_language_files
|
||||||
|
FOR SELECT
|
||||||
|
USING (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
CREATE POLICY dlf_workspace_write ON design_language_files
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
workspace_id IN (
|
||||||
|
SELECT workspace_id FROM team_members
|
||||||
|
WHERE email = auth.jwt() ->> 'email'
|
||||||
|
AND role IN ('OWNER', 'DESIGNER')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── team_members ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE POLICY team_members_select ON team_members
|
||||||
|
FOR SELECT USING (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
-- Only workspace owners can add members.
|
||||||
|
CREATE POLICY team_members_insert ON team_members
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM workspaces
|
||||||
|
WHERE id = workspace_id
|
||||||
|
AND owner_email = auth.jwt() ->> 'email'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Owners can remove anyone; members can remove themselves.
|
||||||
|
CREATE POLICY team_members_delete ON team_members
|
||||||
|
FOR DELETE
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM workspaces
|
||||||
|
WHERE id = workspace_id
|
||||||
|
AND owner_email = auth.jwt() ->> 'email'
|
||||||
|
)
|
||||||
|
OR email = auth.jwt() ->> 'email'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── design_languages ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE POLICY dl_workspace_member_select ON design_languages
|
||||||
|
FOR SELECT
|
||||||
|
USING (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
CREATE POLICY dl_workspace_member_write ON design_languages
|
||||||
|
FOR ALL
|
||||||
|
USING (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
-- ── design_language_versions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE POLICY dlv_workspace_member_select ON design_language_versions
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
design_language_id IN (
|
||||||
|
SELECT dl.id FROM design_languages dl
|
||||||
|
WHERE is_workspace_member(dl.workspace_id)
|
||||||
|
)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user