made tiny updates
This commit is contained in:
@@ -3,14 +3,17 @@
|
||||
// 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.
|
||||
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { currentUser } from '@clerk/nextjs/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { AIGateway, generateDriftReport } from '@originmain/ai-layer';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const user = await currentUser();
|
||||
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 {
|
||||
artboard_id?: string;
|
||||
@@ -48,7 +51,7 @@ export async function POST(req: NextRequest) {
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', artboard.workspace_id)
|
||||
.eq('user_id', userId)
|
||||
.eq('email', callerEmail)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
@@ -56,14 +59,15 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Fetch the most recently updated Design Language File for this workspace (optional)
|
||||
const { data: dlf } = await db
|
||||
.from('design_language_files')
|
||||
.select('schema_jsonb, name')
|
||||
// Fetch the active Design Language for this workspace (Phase 6 table).
|
||||
// raw_json holds the original uploaded token file; pass it to generateDriftReport
|
||||
// for context. Absence of a design language is non-fatal — analysis runs without it.
|
||||
const { data: dl } = await db
|
||||
.from('design_languages')
|
||||
.select('raw_json, name')
|
||||
.eq('workspace_id', artboard.workspace_id)
|
||||
.order('updated_at', { ascending: false })
|
||||
.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 artboardContext = [
|
||||
@@ -77,7 +81,7 @@ export async function POST(req: NextRequest) {
|
||||
const gateway = new AIGateway();
|
||||
const result = await generateDriftReport(gateway, {
|
||||
artboardContext,
|
||||
...(dlf ? { dlfJson: JSON.stringify(dlf.schema_jsonb) } : {}),
|
||||
...(dl ? { dlfJson: JSON.stringify(dl.raw_json) } : {}),
|
||||
...(body.screenshot_base64 ? { screenshotBase64: body.screenshot_base64 } : {}),
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
|
||||
@@ -36,6 +36,9 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
||||
const patch: Partial<InsertArtboard> = {};
|
||||
if (typeof body.name === 'string' && body.name.trim()) patch.name = body.name.trim();
|
||||
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)
|
||||
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
//
|
||||
// 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 { serverClient } from '@/lib/supabase';
|
||||
import { issueWorkspaceToken } from '@originmain/agent-bridge';
|
||||
@@ -59,38 +59,45 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
}
|
||||
|
||||
// ── Require authentication ────────────────────────────────────────────────
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
// Redirect to sign-in, then back here after login
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
const signInUrl = new URL('/sign-in', APP_URL);
|
||||
signInUrl.searchParams.set('redirect_url', req.nextUrl.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 ─────────────────────────────────────────────────────
|
||||
const db = serverClient();
|
||||
let workspaceId = workspaceIdParam;
|
||||
|
||||
if (!workspaceId) {
|
||||
// Use the user's first workspace membership
|
||||
// Use the user's first workspace membership.
|
||||
const { data: member } = await db
|
||||
.from('team_members')
|
||||
.select('workspace_id')
|
||||
.eq('user_id', userId)
|
||||
.eq('email', email)
|
||||
.limit(1)
|
||||
.single() as unknown as { data: { workspace_id: string } | null; error: unknown };
|
||||
|
||||
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;
|
||||
} 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
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', workspaceId)
|
||||
.eq('user_id', userId)
|
||||
.eq('email', email)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
|
||||
@@ -1,125 +1,129 @@
|
||||
// GET /api/design-language?workspaceId=<uuid> → active design language file
|
||||
// GET /api/design-language?workspaceId=<uuid>&all=1 → all versions (history)
|
||||
// POST /api/design-language → upload new version (deactivates prior)
|
||||
// GET /api/design-language?workspaceId=<uuid> → active DesignLanguage row
|
||||
// GET /api/design-language?workspaceId=<uuid>&all=1 → version history (newest first)
|
||||
// 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 { serverClient } from '@/lib/supabase';
|
||||
import { getActiveDesignLanguageFile } from '@originmain/origin-graph';
|
||||
import type { InsertDesignLanguageFile, DesignLanguageFile } from '@originmain/origin-graph';
|
||||
import {
|
||||
getDesignLanguage,
|
||||
upsertDesignLanguage,
|
||||
getDesignLanguageVersions,
|
||||
} from '@originmain/origin-graph';
|
||||
import type { InsertDesignLanguage } from '@originmain/origin-graph';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const workspaceId = req.nextUrl.searchParams.get('workspaceId');
|
||||
if (!workspaceId) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 });
|
||||
// ── Auth + workspace-member guard (shared) ────────────────────────────────────
|
||||
|
||||
async function assertMember(email: string, workspaceId: string) {
|
||||
const db = serverClient();
|
||||
|
||||
// Guard: caller must be a member of the target workspace.
|
||||
const { data: member } = await db
|
||||
const { data } = await db
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', workspaceId)
|
||||
.eq('user_id', userId)
|
||||
.eq('email', email)
|
||||
.limit(1)
|
||||
.single();
|
||||
if (!member) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
return Boolean(data);
|
||||
}
|
||||
|
||||
// ?all=1 returns the full version history, newest first.
|
||||
if (req.nextUrl.searchParams.get('all') === '1') {
|
||||
const { data, error } = await (db
|
||||
.from('design_language_files')
|
||||
.select('*')
|
||||
.eq('workspace_id', workspaceId)
|
||||
.order('version', { ascending: false }) as unknown as Promise<{
|
||||
data: DesignLanguageFile[];
|
||||
error: { message: string } | null;
|
||||
}>);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json(data ?? []);
|
||||
// ── GET ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function GET(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 });
|
||||
|
||||
const workspaceId = req.nextUrl.searchParams.get('workspaceId');
|
||||
if (!workspaceId) {
|
||||
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 {
|
||||
const file = await getActiveDesignLanguageFile(db, workspaceId);
|
||||
if (!file) return NextResponse.json(null);
|
||||
return NextResponse.json(file);
|
||||
body = (await req.json()) as Partial<InsertDesignLanguage>;
|
||||
} catch {
|
||||
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) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
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
|
||||
// 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 { serverClient } from '@/lib/supabase';
|
||||
import { getDiffs, createDiff } from '@originmain/origin-graph';
|
||||
import type { InsertIntentDiff } from '@originmain/origin-graph';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const artboardId = req.nextUrl.searchParams.get('artboardId');
|
||||
if (!artboardId) return NextResponse.json({ error: 'artboardId is required' }, { status: 400 });
|
||||
@@ -25,11 +25,14 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = (await req.json()) as Omit<InsertIntentDiff, 'author_id'>;
|
||||
const insert: InsertIntentDiff = { ...body, author_id: userId };
|
||||
const authorEmail = user.primaryEmailAddress?.emailAddress;
|
||||
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 {
|
||||
const db = serverClient();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* (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 { storePendingIntent } from '@originmain/agent-bridge';
|
||||
import { createDiff } from '@originmain/origin-graph';
|
||||
@@ -37,10 +37,11 @@ interface IntentPayload {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const user = await currentUser();
|
||||
if (!user) 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;
|
||||
try {
|
||||
@@ -87,7 +88,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const row = await createDiff(db, {
|
||||
artboard_id: artboardId,
|
||||
author_id: userId,
|
||||
author_email: authorEmail,
|
||||
changes: changesRecord,
|
||||
aggregate_summary: summary ?? `${componentName} style changes`,
|
||||
status: 'EXPORTED',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// POST /api/workspace/:id/invite → add a member by Clerk userId
|
||||
// DELETE /api/workspace/:id/invite → remove a member by Clerk userId (owners only)
|
||||
// POST /api/workspace/:id/invite → add a member by email address
|
||||
// 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 { serverClient } from '@/lib/supabase';
|
||||
import { addTeamMember, removeTeamMember } from '@originmain/origin-graph';
|
||||
@@ -9,33 +9,43 @@ import type { TeamRole } from '@originmain/origin-graph';
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export async function POST(req: NextRequest, { params }: Ctx) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const caller = await currentUser();
|
||||
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 db = serverClient();
|
||||
|
||||
// Only workspace owners can invite
|
||||
const { data: owner } = await db
|
||||
// Only workspace owners can invite.
|
||||
const { data: ownerRow } = await db
|
||||
.from('workspaces')
|
||||
.select('id')
|
||||
.eq('id', workspaceId)
|
||||
.eq('owner_id', userId)
|
||||
.eq('owner_email', callerEmail)
|
||||
.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 };
|
||||
if (!body.userId) return NextResponse.json({ error: 'userId is required' }, { status: 400 });
|
||||
const body = (await req.json().catch(() => ({}))) as { email?: string; role?: TeamRole };
|
||||
|
||||
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';
|
||||
|
||||
// Check if already a member
|
||||
// Check if already a member.
|
||||
const { data: existing } = await db
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', workspaceId)
|
||||
.eq('user_id', body.userId)
|
||||
.eq('email', inviteEmail)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
@@ -44,7 +54,7 @@ export async function POST(req: NextRequest, { params }: Ctx) {
|
||||
try {
|
||||
const member = await addTeamMember(db, {
|
||||
workspace_id: workspaceId,
|
||||
user_id: body.userId,
|
||||
email: inviteEmail,
|
||||
role,
|
||||
});
|
||||
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) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const caller = await currentUser();
|
||||
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 db = serverClient();
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { userId?: string };
|
||||
if (!body.userId) return NextResponse.json({ error: 'userId is required' }, { status: 400 });
|
||||
const body = (await req.json().catch(() => ({}))) as { email?: string };
|
||||
if (!body.email) return NextResponse.json({ error: 'email is required' }, { status: 400 });
|
||||
|
||||
// Only owners can remove; a member can remove themselves
|
||||
const { data: owner } = await db
|
||||
const targetEmail = body.email.toLowerCase().trim();
|
||||
|
||||
// Owners can remove anyone; members can remove themselves.
|
||||
const { data: ownerRow } = await db
|
||||
.from('workspaces')
|
||||
.select('id')
|
||||
.eq('id', workspaceId)
|
||||
.eq('owner_id', userId)
|
||||
.eq('owner_email', callerEmail)
|
||||
.single();
|
||||
|
||||
if (!owner && body.userId !== userId)
|
||||
if (!ownerRow && targetEmail !== callerEmail)
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
try {
|
||||
await removeTeamMember(db, workspaceId, body.userId);
|
||||
await removeTeamMember(db, workspaceId, targetEmail);
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (err) {
|
||||
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
|
||||
// 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 { serverClient } from '@/lib/supabase';
|
||||
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 }> };
|
||||
|
||||
async function assertMember(workspaceId: string, userId: string): Promise<boolean> {
|
||||
async function assertMember(workspaceId: string, email: string): Promise<boolean> {
|
||||
const db = serverClient();
|
||||
const { data } = await db
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', workspaceId)
|
||||
.eq('user_id', userId)
|
||||
.eq('email', email)
|
||||
.limit(1)
|
||||
.single();
|
||||
return !!data;
|
||||
}
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: Ctx) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
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 });
|
||||
|
||||
const { id: workspaceId, pid } = await params;
|
||||
if (!(await assertMember(workspaceId, userId)))
|
||||
if (!(await assertMember(workspaceId, email)))
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
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) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
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 });
|
||||
|
||||
const { id: workspaceId, pid } = await params;
|
||||
if (!(await assertMember(workspaceId, userId)))
|
||||
if (!(await assertMember(workspaceId, email)))
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
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) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
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 });
|
||||
|
||||
const { id: workspaceId, pid } = await params;
|
||||
if (!(await assertMember(workspaceId, userId)))
|
||||
if (!(await assertMember(workspaceId, email)))
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
// GET /api/workspace/[id]/projects — list projects in a workspace
|
||||
// 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 { serverClient } from '@/lib/supabase';
|
||||
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
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', workspaceId)
|
||||
.eq('user_id', userId)
|
||||
.eq('email', email)
|
||||
.limit(1)
|
||||
.single();
|
||||
return !!data;
|
||||
@@ -21,13 +25,16 @@ export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const 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 });
|
||||
|
||||
const { id: workspaceId } = await params;
|
||||
const db = serverClient();
|
||||
|
||||
if (!(await assertMember(db, workspaceId, userId)))
|
||||
if (!(await assertMember(db, workspaceId, email)))
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const { data, error } = await db
|
||||
@@ -45,13 +52,16 @@ export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
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 });
|
||||
|
||||
const { id: workspaceId } = await params;
|
||||
const db = serverClient();
|
||||
|
||||
if (!(await assertMember(db, workspaceId, userId)))
|
||||
if (!(await assertMember(db, workspaceId, email)))
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// PATCH /api/workspace/:id → rename workspace (owner only)
|
||||
// DELETE /api/workspace/:id → delete workspace and all its data (owner only)
|
||||
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { currentUser } from '@clerk/nextjs/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import { deleteWorkspace } from '@originmain/origin-graph';
|
||||
@@ -10,20 +10,29 @@ import type { Workspace } from '@originmain/origin-graph';
|
||||
|
||||
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
|
||||
.from('team_members')
|
||||
.select('role')
|
||||
.eq('workspace_id', workspaceId)
|
||||
.eq('user_id', userId)
|
||||
.eq('email', email)
|
||||
.limit(1)
|
||||
.single();
|
||||
return (data as { role: string } | null)?.role === 'OWNER';
|
||||
}
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: RouteContext) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const email = await getCallerEmail();
|
||||
if (!email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const db = serverClient();
|
||||
@@ -33,7 +42,7 @@ export async function GET(_req: NextRequest, { params }: RouteContext) {
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('email', email)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
@@ -46,13 +55,13 @@ export async function GET(_req: NextRequest, { params }: RouteContext) {
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const email = await getCallerEmail();
|
||||
if (!email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
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 });
|
||||
|
||||
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) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const email = await getCallerEmail();
|
||||
if (!email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
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 });
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Issues a signed HMAC workspace token for IDE integrations (Cursor, Claude Code).
|
||||
// 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 { serverClient } from '@/lib/supabase';
|
||||
import {
|
||||
@@ -16,18 +16,21 @@ export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
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 });
|
||||
|
||||
const { id: workspaceId } = await params;
|
||||
const db = serverClient();
|
||||
|
||||
// Verify membership
|
||||
// Verify membership.
|
||||
const { data: member } = await db
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', workspaceId)
|
||||
.eq('user_id', userId)
|
||||
.eq('email', email)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
|
||||
@@ -2,44 +2,42 @@
|
||||
// Returns the authenticated user's workspace. Auto-creates one on first visit
|
||||
// (FREE plan, name derived from Clerk user display name or email).
|
||||
|
||||
import { auth, currentUser } from '@clerk/nextjs/server';
|
||||
import { currentUser } from '@clerk/nextjs/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import type { Workspace, InsertWorkspace } from '@originmain/origin-graph';
|
||||
|
||||
export async function GET() {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const 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 });
|
||||
|
||||
const db = serverClient();
|
||||
|
||||
// Look up an existing workspace owned by this Clerk user.
|
||||
const { data: existing, error: findError } = await db
|
||||
.from('workspaces')
|
||||
.select('*')
|
||||
.eq('owner_id', userId)
|
||||
// Look up an existing workspace where this user is a member.
|
||||
const { data: existing } = await db
|
||||
.from('team_members')
|
||||
.select('workspace_id')
|
||||
.eq('email', email)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (findError && findError.code !== 'PGRST116') {
|
||||
return NextResponse.json({ error: findError.message }, { status: 500 });
|
||||
}
|
||||
.single() as unknown as { data: { workspace_id: string } | null };
|
||||
|
||||
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.
|
||||
const user = await currentUser();
|
||||
const name =
|
||||
user?.fullName ??
|
||||
user?.emailAddresses[0]?.emailAddress ??
|
||||
'My Workspace';
|
||||
// No workspace yet — auto-create one with a friendly name.
|
||||
const name = user.fullName ?? email;
|
||||
|
||||
const insert: InsertWorkspace = {
|
||||
owner_id: userId,
|
||||
owner_email: email,
|
||||
name,
|
||||
plan: 'FREE',
|
||||
settings_jsonb: {},
|
||||
@@ -55,10 +53,10 @@ export async function GET() {
|
||||
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({
|
||||
workspace_id: (created as Workspace).id,
|
||||
user_id: userId,
|
||||
email,
|
||||
role: 'OWNER',
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
// GET /api/workspaces — list every workspace the signed-in user belongs to
|
||||
// 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 { serverClient } from '@/lib/supabase';
|
||||
import type { Workspace, InsertWorkspace } from '@originmain/origin-graph';
|
||||
|
||||
export async function GET() {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const 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 });
|
||||
|
||||
const db = serverClient();
|
||||
|
||||
@@ -16,7 +19,7 @@ export async function GET() {
|
||||
const { data: memberships, error: mErr } = await db
|
||||
.from('team_members')
|
||||
.select('workspace_id')
|
||||
.eq('user_id', userId);
|
||||
.eq('email', email);
|
||||
|
||||
if (mErr) return NextResponse.json({ error: mErr.message }, { status: 500 });
|
||||
|
||||
@@ -35,8 +38,11 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
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 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const name = typeof body.name === 'string' && body.name.trim()
|
||||
@@ -48,7 +54,7 @@ export async function POST(req: NextRequest) {
|
||||
const db = serverClient();
|
||||
|
||||
const insert: InsertWorkspace = {
|
||||
owner_id: userId,
|
||||
owner_email: email,
|
||||
name,
|
||||
plan: 'FREE',
|
||||
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.
|
||||
await db.from('team_members').insert({
|
||||
workspace_id: workspace.id,
|
||||
user_id: userId,
|
||||
email,
|
||||
role: 'OWNER',
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { currentUser } from '@clerk/nextjs/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
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 }> }) {
|
||||
const { wid } = await params;
|
||||
const { userId } = await auth();
|
||||
if (!userId) redirect('/sign-in');
|
||||
const user = await currentUser();
|
||||
if (!user) redirect('/sign-in');
|
||||
|
||||
const email = user.primaryEmailAddress?.emailAddress ?? '';
|
||||
const db = serverClient();
|
||||
|
||||
// Verify membership
|
||||
@@ -28,7 +29,7 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', wid)
|
||||
.eq('user_id', userId)
|
||||
.eq('email', email)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { currentUser } from '@clerk/nextjs/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
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 }> }) {
|
||||
const { wid } = await params;
|
||||
const { userId } = await auth();
|
||||
if (!userId) redirect('/sign-in');
|
||||
const user = await currentUser();
|
||||
if (!user) redirect('/sign-in');
|
||||
|
||||
const email = user.primaryEmailAddress?.emailAddress ?? '';
|
||||
const db = serverClient();
|
||||
|
||||
// Verify membership
|
||||
@@ -24,7 +25,7 @@ export default async function WorkspacePluginsPage({ params }: { params: Promise
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', wid)
|
||||
.eq('user_id', userId)
|
||||
.eq('email', email)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { currentUser } from '@clerk/nextjs/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import { AppChrome } from '@/components/chrome/AppChrome';
|
||||
@@ -16,9 +16,10 @@ export default async function ProjectCanvasPage({
|
||||
params: Promise<{ wid: string; pid: string }>;
|
||||
}) {
|
||||
const { wid, pid } = await params;
|
||||
const { userId } = await auth();
|
||||
if (!userId) redirect('/sign-in');
|
||||
const user = await currentUser();
|
||||
if (!user) redirect('/sign-in');
|
||||
|
||||
const email = user.primaryEmailAddress?.emailAddress ?? '';
|
||||
const db = serverClient();
|
||||
|
||||
// Verify membership
|
||||
@@ -26,7 +27,7 @@ export default async function ProjectCanvasPage({
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', wid)
|
||||
.eq('user_id', userId)
|
||||
.eq('email', email)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { currentUser } from '@clerk/nextjs/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import { AppHeader } from '@/components/shell/AppHeader';
|
||||
@@ -19,9 +19,10 @@ export default async function ProjectSettingsPage({
|
||||
params: Promise<{ wid: string; pid: string }>;
|
||||
}) {
|
||||
const { wid, pid } = await params;
|
||||
const { userId } = await auth();
|
||||
if (!userId) redirect('/sign-in');
|
||||
const user = await currentUser();
|
||||
if (!user) redirect('/sign-in');
|
||||
|
||||
const email = user.primaryEmailAddress?.emailAddress ?? '';
|
||||
const db = serverClient();
|
||||
|
||||
// Verify membership and role
|
||||
@@ -29,7 +30,7 @@ export default async function ProjectSettingsPage({
|
||||
.from('team_members')
|
||||
.select('role')
|
||||
.eq('workspace_id', wid)
|
||||
.eq('user_id', userId)
|
||||
.eq('email', email)
|
||||
.limit(1)
|
||||
.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 { serverClient } from '@/lib/supabase';
|
||||
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 }> }) {
|
||||
const { wid } = await params;
|
||||
const { userId } = await auth();
|
||||
if (!userId) redirect('/sign-in');
|
||||
const user = await currentUser();
|
||||
if (!user) redirect('/sign-in');
|
||||
|
||||
const email = user.primaryEmailAddress?.emailAddress ?? '';
|
||||
const db = serverClient();
|
||||
|
||||
// Verify membership
|
||||
@@ -24,7 +25,7 @@ export default async function WorkspaceSettingsPage({ params }: { params: Promis
|
||||
.from('team_members')
|
||||
.select('id, role')
|
||||
.eq('workspace_id', wid)
|
||||
.eq('user_id', userId)
|
||||
.eq('email', email)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { currentUser } from '@clerk/nextjs/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
@@ -8,12 +8,12 @@ import type { Workspace } from '@originmain/origin-graph';
|
||||
|
||||
export const metadata = { title: 'Workspaces — Originmain' };
|
||||
|
||||
async function getWorkspaces(userId: string): Promise<Workspace[]> {
|
||||
async function getWorkspaces(email: string): Promise<Workspace[]> {
|
||||
const db = serverClient();
|
||||
const { data: memberships } = await db
|
||||
.from('team_members')
|
||||
.select('workspace_id')
|
||||
.eq('user_id', userId);
|
||||
.eq('email', email);
|
||||
|
||||
const ids = (memberships ?? []).map((m) => (m as { workspace_id: string }).workspace_id);
|
||||
if (ids.length === 0) return [];
|
||||
@@ -28,10 +28,11 @@ async function getWorkspaces(userId: string): Promise<Workspace[]> {
|
||||
}
|
||||
|
||||
export default async function WorkspacesPage() {
|
||||
const { userId } = await auth();
|
||||
if (!userId) redirect('/sign-in');
|
||||
const user = await currentUser();
|
||||
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
|
||||
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 channel = db
|
||||
.channel(`dlf:${workspaceId}`)
|
||||
.channel(`dl:${workspaceId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'design_language_files',
|
||||
table: 'design_languages',
|
||||
filter: `workspace_id=eq.${workspaceId}`,
|
||||
},
|
||||
(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;
|
||||
if (!row || !row['is_active']) return;
|
||||
if (!row) return;
|
||||
|
||||
// Re-parse tokens from the updated schema_jsonb.
|
||||
const schemaJsonb = row['schema_jsonb'];
|
||||
if (!schemaJsonb || typeof schemaJsonb !== 'object') return;
|
||||
// `normalized` is already a DesignToken[] stored as JSONB — use directly.
|
||||
const normalized = row['normalized'];
|
||||
if (!Array.isArray(normalized)) return;
|
||||
|
||||
import('@originmain/design-language').then(({ parseTokenFile }) => {
|
||||
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 */ });
|
||||
setDesignLanguageTokens(normalized as Parameters<typeof setDesignLanguageTokens>[0]);
|
||||
},
|
||||
)
|
||||
.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';
|
||||
|
||||
// ── Props Tab (Phase 2) ───────────────────────────────────────────────────────
|
||||
// ── Props Tab (Phase 2 + §5.11) ───────────────────────────────────────────────
|
||||
// Displays artboard metadata, editable render URL / route, selected component
|
||||
// props, and the Drift Report action. Extracted from Inspector.tsx as per
|
||||
// spec SOURCE-AWARE-CANVAS.md Phase 2.
|
||||
// props with TypeScript type badges, isolation prop editor, and the Drift Report
|
||||
// action. Extracted from Inspector.tsx as per spec SOURCE-AWARE-CANVAS.md Phase 2.
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
@@ -19,6 +19,95 @@ const TYPE_COLORS: Record<string, string> = {
|
||||
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 {
|
||||
artboard: Artboard | null;
|
||||
selectedComponentData: FiberNode | null;
|
||||
@@ -39,6 +128,31 @@ export function PropsTab({
|
||||
const [editingRoute, setEditingRoute] = useState(false);
|
||||
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
|
||||
const [driftStatus, setDriftStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
|
||||
const [driftReport, setDriftReport] = useState('');
|
||||
@@ -121,15 +235,36 @@ export function PropsTab({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Selected fiber component props ─────────────────────────────── */}
|
||||
{/* ── Selected fiber component props (spec §5.11) ─────────────────── */}
|
||||
{selectedComponentData && (
|
||||
<>
|
||||
<Section label={`↳ ${selectedComponentData.name}`}>
|
||||
{Object.entries(selectedComponentData.props ?? {}).map(([k, v]) => {
|
||||
const t = typeof v;
|
||||
const color = t === 'number' ? N : t === 'boolean' ? B : S;
|
||||
const disp = t === 'string' ? `"${v as string}"` : String(v);
|
||||
return <PropRow key={k} label={k} value={disp} color={color} />;
|
||||
const kind = inferTsKind(v);
|
||||
const color = kind === 'number' ? N : kind === 'boolean' ? B : S;
|
||||
const disp = typeof v === 'string' ? `"${v}"` : String(v);
|
||||
|
||||
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 && (
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'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 { themeToTreeStyles } from '@pierre/trees';
|
||||
import { SquareRegular } from '@fluentui/react-icons';
|
||||
@@ -10,8 +10,71 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
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';
|
||||
|
||||
// ── Context menu state shape ──────────────────────────────────────────────────
|
||||
interface ContextMenuState {
|
||||
artboardId: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function ArtboardNavigator() {
|
||||
const T = useCanvasTheme();
|
||||
const mode = useTheme((s) => s.mode);
|
||||
@@ -20,6 +83,55 @@ export function ArtboardNavigator() {
|
||||
const { artboards, rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
|
||||
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
|
||||
const allRoutes = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
@@ -151,30 +263,82 @@ export function ArtboardNavigator() {
|
||||
|
||||
{/* ── Artboards tab ── */}
|
||||
{navTab === 'artboards' && (
|
||||
<div style={{ padding: '2px 6px 0' }}>
|
||||
{artboards.map((ab) => {
|
||||
const sel = selectedArtboardId === ab.id;
|
||||
const live = liveArtboardIds.has(ab.id);
|
||||
return (
|
||||
<NavRow
|
||||
key={ab.id}
|
||||
T={T}
|
||||
selected={sel}
|
||||
live={live}
|
||||
onClick={() => selectArtboard(ab.id)}
|
||||
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>
|
||||
<>
|
||||
{/* Re-arrange button (spec §3.4) */}
|
||||
{rawArtboards.length > 1 && (
|
||||
<div style={{ padding: '2px 8px 4px', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => void handleAutoArrange()}
|
||||
title="Re-arrange all artboards in a grid (200px gap, 3 per row)"
|
||||
style={{
|
||||
width: '100%', fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem', letterSpacing: '0.06em', textTransform: 'uppercase',
|
||||
background: 'transparent', border: `1px solid ${T.sep}`,
|
||||
borderRadius: 4, padding: '3px 0', color: T.dim, cursor: 'pointer',
|
||||
transition: 'color 0.12s, border-color 0.12s',
|
||||
}}
|
||||
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; }}
|
||||
>
|
||||
⊞ Re-arrange all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: '2px 6px 0' }}>
|
||||
{artboards.map((ab) => {
|
||||
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 ── */}
|
||||
@@ -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 ─────────────────────────────────────────── */
|
||||
|
||||
function NavRow({
|
||||
T,
|
||||
selected = false,
|
||||
live = false,
|
||||
isCover = false,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
icon,
|
||||
label,
|
||||
onRename,
|
||||
@@ -510,7 +751,9 @@ function NavRow({
|
||||
T: CanvasTokens;
|
||||
selected?: boolean;
|
||||
live?: boolean;
|
||||
isCover?: boolean;
|
||||
onClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onRename?: () => void;
|
||||
@@ -522,6 +765,7 @@ function NavRow({
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
onMouseEnter={() => setHov(true)}
|
||||
onMouseLeave={() => setHov(false)}
|
||||
style={{
|
||||
@@ -544,6 +788,18 @@ function NavRow({
|
||||
{icon}
|
||||
<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 && !hov && (
|
||||
<span style={{
|
||||
|
||||
@@ -50,7 +50,7 @@ const BTN_PRIMARY: React.CSSProperties = {
|
||||
type TeamRole = 'OWNER' | 'DESIGNER' | 'ENGINEER' | 'PM' | 'VIEWER';
|
||||
|
||||
function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
|
||||
const [userId, setUserId] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState<TeamRole>('DESIGNER');
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'conflict' | 'error'>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
@@ -60,7 +60,7 @@ function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
|
||||
useEffect(() => { return () => { clearTimeout(resetTimer.current); }; }, []);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
const trimmed = userId.trim();
|
||||
const trimmed = email.trim().toLowerCase();
|
||||
if (!trimmed) return;
|
||||
clearTimeout(resetTimer.current);
|
||||
setStatus('loading');
|
||||
@@ -69,7 +69,7 @@ function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
|
||||
const res = await fetch(`/api/workspace/${workspaceId}/invite`, {
|
||||
method: 'POST',
|
||||
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.ok) {
|
||||
@@ -77,14 +77,14 @@ function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setStatus('done');
|
||||
setUserId('');
|
||||
setEmail('');
|
||||
resetTimer.current = setTimeout(() => setStatus('idle'), 3000);
|
||||
} catch (e) {
|
||||
setErrorMsg(e instanceof Error ? e.message : 'Invite failed');
|
||||
setStatus('error');
|
||||
resetTimer.current = setTimeout(() => setStatus('idle'), 4000);
|
||||
}
|
||||
}, [userId, role, workspaceId]);
|
||||
}, [email, role, workspaceId]);
|
||||
|
||||
const roles: TeamRole[] = ['DESIGNER', 'ENGINEER', 'PM', 'VIEWER', 'OWNER'];
|
||||
|
||||
@@ -110,21 +110,22 @@ function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* User ID input + submit */}
|
||||
<label style={LABEL} htmlFor="invite-uid">Clerk user ID</label>
|
||||
{/* Email input + submit */}
|
||||
<label style={LABEL} htmlFor="invite-email">Email address</label>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<input
|
||||
id="invite-uid"
|
||||
id="invite-email"
|
||||
type="email"
|
||||
style={INPUT}
|
||||
placeholder="user_2abc…"
|
||||
value={userId}
|
||||
onChange={e => setUserId(e.target.value)}
|
||||
placeholder="colleague@company.com"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void submit(); }}
|
||||
disabled={status === 'loading'}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void submit()}
|
||||
disabled={status === 'loading' || !userId.trim()}
|
||||
disabled={status === 'loading' || !email.trim()}
|
||||
style={{
|
||||
...BTN_PRIMARY,
|
||||
flexShrink: 0,
|
||||
@@ -356,7 +357,7 @@ export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }
|
||||
Team
|
||||
</h2>
|
||||
<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>
|
||||
|
||||
<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) };
|
||||
}
|
||||
|
||||
/** PATCH an artboard (name and/or metadata_jsonb). */
|
||||
/** PATCH an artboard (name, metadata_jsonb, and/or isolation_props). */
|
||||
export async function patchArtboard(
|
||||
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> {
|
||||
const res = await fetch(`/api/artboards/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
// ── useDlf hook ───────────────────────────────────────────────────────────────
|
||||
// Fetches the active Design Language File for the current workspace and returns
|
||||
// its parsed body as a typed DesignLanguageFileBody (tokens, component rules,
|
||||
// screen rules, voice, accessibility). The raw DB row's schema_jsonb field is
|
||||
// validated through the Zod schema at cache-write time so every consumer gets
|
||||
// a fully typed result without re-parsing on each render.
|
||||
// Fetches the active Design Language for the current workspace and returns its
|
||||
// parsed body as a typed DesignLanguageFileBody (tokens, component rules,
|
||||
// screen rules, voice, accessibility) when the raw_json matches the old DLF
|
||||
// spec format (version "1.0").
|
||||
//
|
||||
// Stale time is 5 minutes — design systems change infrequently (deploy-time
|
||||
// events) so we avoid redundant round-trips during normal editing sessions.
|
||||
// Phase 6 note: the route now returns a DesignLanguage row (migration 014)
|
||||
// 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 type { DesignLanguageFile } from '@originmain/origin-graph';
|
||||
import type { DesignLanguage } from '@originmain/origin-graph';
|
||||
import { DesignLanguageFileBodySchema, type DesignLanguageFileBody } from '@originmain/design-language';
|
||||
|
||||
// ── Fetch + parse ─────────────────────────────────────────────────────────────
|
||||
@@ -19,19 +24,16 @@ async function fetchDlf(workspaceId: string): Promise<DesignLanguageFileBody | n
|
||||
const res = await fetch(url);
|
||||
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.
|
||||
const file = (await res.json()) as DesignLanguageFile | null;
|
||||
if (!file) return null;
|
||||
// API returns the active DesignLanguage row or null when nothing is uploaded.
|
||||
const dl = (await res.json()) as DesignLanguage | 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;
|
||||
}
|
||||
|
||||
@@ -43,17 +45,15 @@ export function useDlf(workspaceId: string | null | undefined) {
|
||||
queryFn: () => fetchDlf(workspaceId!),
|
||||
enabled: Boolean(workspaceId),
|
||||
// Design language files change at deploy-time, not interactively.
|
||||
// 5-minute staleness keeps the inspector snappy without burning requests.
|
||||
staleTime: 5 * 60_000,
|
||||
// Retry once on transient network errors, then surface the error.
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
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,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user