improved a lot of things
This commit is contained in:
@@ -137,7 +137,8 @@
|
|||||||
"Bash(pnpm --filter @originmain/origin-graph test)",
|
"Bash(pnpm --filter @originmain/origin-graph test)",
|
||||||
"Bash(grep -E \"\\\\.d\\\\.ts$\")",
|
"Bash(grep -E \"\\\\.d\\\\.ts$\")",
|
||||||
"Bash(pnpm -w ls @anthropic-ai/sdk)",
|
"Bash(pnpm -w ls @anthropic-ai/sdk)",
|
||||||
"Bash(grep -v \"\\\\.map$\")"
|
"Bash(grep -v \"\\\\.map$\")",
|
||||||
|
"Bash(perl -pi -e 's/background: '\\\\''#FAFAFA'\\\\''/background: '\\\\''var\\(--page-bg\\)'\\\\''/g' /Users/USER/Desktop/originmain/packages/app/src/app/onboarding/page.tsx /Users/USER/Desktop/originmain/packages/app/src/app/error.tsx)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-6
@@ -299,14 +299,26 @@ The following is a second-pass audit of all real product gaps, independent of la
|
|||||||
- **Vitest: diff-engine**: stray test moved to correct location; all 49 tests pass, 88% coverage
|
- **Vitest: diff-engine**: stray test moved to correct location; all 49 tests pass, 88% coverage
|
||||||
- **Vitest: origin-graph**: 45 new Zod schema tests in `__tests__/types.test.ts`; 100% `types.ts` coverage
|
- **Vitest: origin-graph**: 45 new Zod schema tests in `__tests__/types.test.ts`; 100% `types.ts` coverage
|
||||||
|
|
||||||
|
### ✅ Completed — Session 5 (2026-04-28)
|
||||||
|
|
||||||
|
- **Completion zone payload fixed**: `POST /api/ai/completion-zone` now adapts canvas UI shape `{artboard_id, bounds, prompt}` → `CompletionZoneInput {componentTreeJson, intent}`. Auto-loads active DLF from workspace. Returns `description` + backwards-compat `result`/`completion` keys.
|
||||||
|
- **Cross-artboard query fixed**: `POST /api/ai/query` now adapts navigator UI shape `{workspace_id, question}` → `ArtboardQueryInput {query, artboardsJson}`. Fetches live artboard list from DB. Returns `{results, reasoning, answer}`.
|
||||||
|
- **DELETE /api/workspace/:id**: Owner-only workspace deletion. `deleteWorkspace()` added to `origin-graph/queries.ts`. WorkspaceSettingsForm danger zone now calls the endpoint and redirects to `/workspaces` on success.
|
||||||
|
- **Diff summary wired**: `POST /api/ai/diff-summary` route created. DiffTab export now calls it before `POST /api/diffs` — summary included in the diff payload. Export button shows "Summarising…" → "Exporting…" states.
|
||||||
|
- **Artboard fork**: Fork button (branch icon) added to navigator artboard rows. Creates child artboard with `parent_artboard_id` set, offset 40px right of source, same `renderUrl` + `origin_id` inherited.
|
||||||
|
- **Migration split fixed**: Migrations 002–005 copied to `supabase/migrations/`. `get_diffs_by_status` RPC appended to `origin-graph/migrations/001_initial_schema.sql`.
|
||||||
|
- **Dead code removed**: `packages/app/src/hooks/useDiff.ts` and `packages/app/src/data/artboard-snapshots.ts` deleted (no imports, confirmed safe).
|
||||||
|
- **Dark mode**: `packages/app/src/store/theme.ts` (Zustand persist, key `originmain:theme`). `providers.tsx` reads `useTheme` to pick `originmainLightTheme`/`originmainDarkTheme`. Sun/moon toggle button in AppChrome breadcrumb bar.
|
||||||
|
- **Plugin stub page**: `/workspace/[wid]/plugins` — full page with permissions preview, Phase 4 roadmap note. Linked from workspace page header alongside Settings.
|
||||||
|
- **Agent status badges**: `Artboard.tsx` calls `useDiffs(id)` and renders compact colored chips (draft/reviewed/applied/blocked) bottom-right of each artboard frame when diffs exist.
|
||||||
|
- **TypeScript**: `tsc --noEmit` exits 0 on both `packages/app` and `packages/origin-graph`.
|
||||||
|
|
||||||
### 🔧 Remaining — Lower Priority
|
### 🔧 Remaining — Lower Priority
|
||||||
|
|
||||||
- [ ] **Multiplayer**: Wire `MultiplayerAdapter` into app (install `@liveblocks/client` + `@liveblocks/react`; create `packages/app/src/lib/liveblocks.ts`)
|
- [ ] **Multiplayer**: Wire `MultiplayerAdapter` into app (install `@liveblocks/client` + `@liveblocks/react`; create `packages/app/src/lib/liveblocks.ts`)
|
||||||
- [ ] **Plugin system stub page**: UI route at `/workspace/[wid]/plugins` listing installed plugins
|
|
||||||
- [ ] **`packages/e2e`**: Playwright E2E tests not yet created
|
- [ ] **`packages/e2e`**: Playwright E2E tests not yet created
|
||||||
- [ ] **Redis rate limiter**: Replace in-process rate limiter in agent-bridge for multi-instance support
|
- [ ] **Redis rate limiter**: Replace in-process rate limiter in `agent-bridge/src/rate-limiter.ts` for multi-instance MCP support
|
||||||
|
- [ ] **Product analytics**: Instrument PostHog for activation/engagement metrics (GTM requirement)
|
||||||
|
- [ ] **Onboarding wizard**: Step-by-step "connect your app" flow from signup to first live artboard
|
||||||
|
|
||||||
**Webhook project_id:**
|
*Last updated: 2026-04-28 — Session 5 complete*
|
||||||
`/api/webhooks/[provider]/route.ts` currently sets `project_id: null`. Read the `?project=` query param from the request URL (`new URL(req.url).searchParams.get('project')`) and pass it to the ingestion result.
|
|
||||||
|
|
||||||
*Last updated: 2026-04-26 — Session 3 complete*
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import Anthropic from '@anthropic-ai/sdk';
|
|||||||
|
|
||||||
// ── Model constants ───────────────────────────────────────────────────────────
|
// ── Model constants ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const MODEL = 'claude-opus-4-7' as const;
|
// claude-opus-4-5 is the current stable Opus 4 release.
|
||||||
|
// Extended thinking is enabled in gateway.ts with budget_tokens.
|
||||||
|
export const MODEL = 'claude-opus-4-5' as const;
|
||||||
|
|
||||||
// ── Singleton client ──────────────────────────────────────────────────────────
|
// ── Singleton client ──────────────────────────────────────────────────────────
|
||||||
// The client is created once and shared. API key is injected from the server
|
// The client is created once and shared. API key is injected from the server
|
||||||
|
|||||||
@@ -73,8 +73,10 @@ export interface GatewayRequest {
|
|||||||
messages: Anthropic.Messages.MessageParam[];
|
messages: Anthropic.Messages.MessageParam[];
|
||||||
system?: Anthropic.Messages.TextBlockParam[];
|
system?: Anthropic.Messages.TextBlockParam[];
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
// NOTE: temperature is intentionally omitted — Opus 4.7 with adaptive thinking
|
// NOTE: temperature is intentionally omitted — extended thinking rejects
|
||||||
// rejects temperature, top_p, and top_k with a 400 error.
|
// temperature, top_p, and top_k with a 400 error.
|
||||||
|
// NOTE: when thinking is enabled, max_tokens must be >= 16_000 and
|
||||||
|
// budget_tokens must be < max_tokens. We enforce this in complete().
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GatewayResponse {
|
export interface GatewayResponse {
|
||||||
@@ -101,10 +103,15 @@ export class AIGateway {
|
|||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
|
// Extended thinking requires max_tokens >= 16_000.
|
||||||
|
// budget_tokens must be strictly less than max_tokens.
|
||||||
|
const maxTokens = Math.max(req.maxTokens ?? 4096, 16_000);
|
||||||
|
const budgetTokens = Math.floor(maxTokens * 0.8);
|
||||||
|
|
||||||
const response = await this.client.messages.create({
|
const response = await this.client.messages.create({
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
max_tokens: req.maxTokens ?? 4096,
|
max_tokens: maxTokens,
|
||||||
thinking: { type: 'adaptive' },
|
thinking: { type: 'enabled', budget_tokens: budgetTokens },
|
||||||
...(req.system !== undefined ? { system: req.system } : {}),
|
...(req.system !== undefined ? { system: req.system } : {}),
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,83 @@
|
|||||||
// POST /api/ai/completion-zone
|
// POST /api/ai/completion-zone
|
||||||
// Fills a design completion zone given a component tree and user intent.
|
// Fills a design completion zone given a component tree and user intent.
|
||||||
|
//
|
||||||
|
// Accepts two shapes:
|
||||||
|
// Canvas UI payload: { artboard_id, bounds, prompt, componentTreeJson? }
|
||||||
|
// Canonical payload: { componentTreeJson, intent, dlfJson?, screenshotBase64? }
|
||||||
|
|
||||||
import { auth } from '@clerk/nextjs/server';
|
import { auth } from '@clerk/nextjs/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { AIGateway, fillCompletionZone } from '@originmain/ai-layer';
|
import { AIGateway, fillCompletionZone } from '@originmain/ai-layer';
|
||||||
import type { CompletionZoneInput } from '@originmain/ai-layer';
|
import type { CompletionZoneInput } from '@originmain/ai-layer';
|
||||||
|
import { serverClient } from '@/lib/supabase';
|
||||||
|
import { getActiveDesignLanguageFile } from '@originmain/origin-graph';
|
||||||
|
|
||||||
|
// ── Canvas UI payload shape ───────────────────────────────────────────────────
|
||||||
|
interface CanvasPayload {
|
||||||
|
artboard_id?: string;
|
||||||
|
bounds?: { x: number; y: number; width: number; height: number };
|
||||||
|
prompt?: string;
|
||||||
|
componentTreeJson?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { userId } = await auth();
|
const { userId } = await auth();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const body = (await req.json()) as CompletionZoneInput;
|
const raw = (await req.json()) as CanvasPayload & Partial<CompletionZoneInput>;
|
||||||
|
|
||||||
|
// ── Adapt canvas UI payload → CompletionZoneInput ────────────────────────
|
||||||
|
let input: CompletionZoneInput;
|
||||||
|
|
||||||
|
if (raw.intent !== undefined) {
|
||||||
|
// Already canonical shape — pass through
|
||||||
|
input = raw as CompletionZoneInput;
|
||||||
|
} else {
|
||||||
|
// Canvas UI shape: {artboard_id, bounds, prompt, componentTreeJson?}
|
||||||
|
if (!raw.prompt?.trim()) {
|
||||||
|
return NextResponse.json({ error: 'prompt is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a context string from bounds + artboard metadata if available
|
||||||
|
const boundsLabel = raw.bounds
|
||||||
|
? `Canvas region: x=${raw.bounds.x}, y=${raw.bounds.y}, width=${raw.bounds.width}, height=${raw.bounds.height}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Try to load the active DLF for this workspace (best-effort — skip on error)
|
||||||
|
let dlfJson: string | undefined;
|
||||||
|
if (raw.artboard_id) {
|
||||||
|
try {
|
||||||
|
const db = serverClient();
|
||||||
|
// Resolve workspace_id from artboard
|
||||||
|
const { data: ab } = await db
|
||||||
|
.from('artboards')
|
||||||
|
.select('workspace_id')
|
||||||
|
.eq('id', raw.artboard_id)
|
||||||
|
.single();
|
||||||
|
if (ab) {
|
||||||
|
const dlf = await getActiveDesignLanguageFile(db, (ab as { workspace_id: string }).workspace_id);
|
||||||
|
if (dlf) dlfJson = JSON.stringify(dlf.schema_jsonb);
|
||||||
|
}
|
||||||
|
} catch { /* non-fatal */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
input = {
|
||||||
|
componentTreeJson: raw.componentTreeJson ?? boundsLabel ?? '{}',
|
||||||
|
intent: raw.prompt.trim(),
|
||||||
|
...(dlfJson !== undefined ? { dlfJson } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gateway = new AIGateway();
|
const gateway = new AIGateway();
|
||||||
const result = await fillCompletionZone(gateway, body);
|
const result = await fillCompletionZone(gateway, input);
|
||||||
return NextResponse.json({ proposedTree: result.proposedTree, description: result.description });
|
return NextResponse.json({
|
||||||
|
proposedTree: result.proposedTree,
|
||||||
|
description: result.description,
|
||||||
|
// Also include result/completion for backwards-compat with any old clients
|
||||||
|
result: result.description,
|
||||||
|
completion: result.description,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'AI error';
|
const message = err instanceof Error ? err.message : 'AI error';
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// POST /api/ai/diff-summary
|
||||||
|
// Generates a one-sentence natural-language summary of a set of component changes.
|
||||||
|
|
||||||
|
import { auth } from '@clerk/nextjs/server';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { AIGateway, generateDiffSummary } from '@originmain/ai-layer';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { userId } = await auth();
|
||||||
|
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const body = (await req.json()) as { changesJson?: string; componentName?: string };
|
||||||
|
|
||||||
|
if (!body.changesJson) {
|
||||||
|
return NextResponse.json({ error: 'changesJson is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gateway = new AIGateway();
|
||||||
|
const result = await generateDiffSummary(gateway, {
|
||||||
|
changesJson: body.changesJson,
|
||||||
|
componentName: body.componentName ?? 'Component',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ summary: result.summary });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'AI error';
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,72 @@
|
|||||||
// POST /api/ai/query
|
// POST /api/ai/query
|
||||||
// Cross-artboard search: finds artboards matching a natural language query.
|
// Cross-artboard search: finds artboards matching a natural language query.
|
||||||
|
//
|
||||||
|
// Accepts two shapes:
|
||||||
|
// Navigator UI payload: { workspace_id, question }
|
||||||
|
// Canonical payload: { query, artboardsJson }
|
||||||
|
|
||||||
import { auth } from '@clerk/nextjs/server';
|
import { auth } from '@clerk/nextjs/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { AIGateway, queryCrossArtboard } from '@originmain/ai-layer';
|
import { AIGateway, queryCrossArtboard } from '@originmain/ai-layer';
|
||||||
import type { ArtboardQueryInput } from '@originmain/ai-layer';
|
import type { ArtboardQueryInput } from '@originmain/ai-layer';
|
||||||
|
import { serverClient } from '@/lib/supabase';
|
||||||
|
import { getArtboards } from '@originmain/origin-graph';
|
||||||
|
|
||||||
|
interface NavigatorPayload {
|
||||||
|
workspace_id?: string;
|
||||||
|
question?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { userId } = await auth();
|
const { userId } = await auth();
|
||||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const body = (await req.json()) as ArtboardQueryInput;
|
const raw = (await req.json()) as NavigatorPayload & Partial<ArtboardQueryInput>;
|
||||||
|
|
||||||
|
// ── Adapt navigator UI payload → ArtboardQueryInput ──────────────────────
|
||||||
|
let input: ArtboardQueryInput;
|
||||||
|
|
||||||
|
if (raw.query !== undefined && raw.artboardsJson !== undefined) {
|
||||||
|
// Already canonical
|
||||||
|
input = raw as ArtboardQueryInput;
|
||||||
|
} else {
|
||||||
|
// Navigator UI shape: { workspace_id, question }
|
||||||
|
const queryText = (raw.question ?? raw.query ?? '').trim();
|
||||||
|
if (!queryText) return NextResponse.json({ error: 'question is required' }, { status: 400 });
|
||||||
|
|
||||||
|
const workspaceId = raw.workspace_id;
|
||||||
|
if (!workspaceId) return NextResponse.json({ error: 'workspace_id is required' }, { status: 400 });
|
||||||
|
|
||||||
|
// Fetch artboard metadata to give the AI context
|
||||||
|
let artboardsJson = '[]';
|
||||||
|
try {
|
||||||
|
const db = serverClient();
|
||||||
|
const artboards = await getArtboards(db, workspaceId);
|
||||||
|
artboardsJson = JSON.stringify(
|
||||||
|
artboards.map(ab => ({
|
||||||
|
id: ab.id,
|
||||||
|
name: ab.name,
|
||||||
|
metadata: ab.metadata_jsonb,
|
||||||
|
created_at: ab.created_at,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch { /* non-fatal: AI will work with empty list */ }
|
||||||
|
|
||||||
|
input = { query: queryText, artboardsJson };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gateway = new AIGateway();
|
const gateway = new AIGateway();
|
||||||
const result = await queryCrossArtboard(gateway, body);
|
const result = await queryCrossArtboard(gateway, input);
|
||||||
return NextResponse.json(result);
|
|
||||||
|
// Return both the canonical shape AND a human-readable answer string
|
||||||
|
// so the navigator UI's `data.answer ?? data.result` check gets something useful.
|
||||||
|
const answerText = result.reasoning ||
|
||||||
|
(result.results.length > 0
|
||||||
|
? result.results.map(r => `${r.artboardId}: ${r.reason}`).join('\n')
|
||||||
|
: 'No matching artboards found.');
|
||||||
|
|
||||||
|
return NextResponse.json({ ...result, answer: answerText, result: answerText });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'AI error';
|
const message = err instanceof Error ? err.message : 'AI error';
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// PATCH /api/workspace/:id → rename workspace (owner only)
|
// GET /api/workspace/:id → fetch workspace (any member)
|
||||||
// GET /api/workspace/:id → fetch workspace (any member)
|
// PATCH /api/workspace/:id → rename workspace (owner only)
|
||||||
|
// DELETE /api/workspace/:id → delete workspace and all its data (owner only)
|
||||||
|
|
||||||
import { auth } from '@clerk/nextjs/server';
|
import { auth } from '@clerk/nextjs/server';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
|
import { deleteWorkspace } from '@originmain/origin-graph';
|
||||||
import type { Workspace } from '@originmain/origin-graph';
|
import type { Workspace } from '@originmain/origin-graph';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ id: string }> };
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
@@ -69,3 +71,22 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
|||||||
|
|
||||||
return NextResponse.json(data as Workspace);
|
return NextResponse.json(data as Workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: NextRequest, { params }: RouteContext) {
|
||||||
|
const { userId } = await auth();
|
||||||
|
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const db = serverClient();
|
||||||
|
|
||||||
|
const isOwner = await assertOwner(db, id, userId);
|
||||||
|
if (!isOwner) return NextResponse.json({ error: 'Only workspace owners can delete' }, { status: 403 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteWorkspace(db, id);
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Delete failed';
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function GlobalError({
|
|||||||
margin: 0, minHeight: '100vh',
|
margin: 0, minHeight: '100vh',
|
||||||
display: 'flex', flexDirection: 'column',
|
display: 'flex', flexDirection: 'column',
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
background: '#FAFAFA',
|
background: 'var(--page-bg)',
|
||||||
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
||||||
padding: 24,
|
padding: 24,
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -12,9 +12,28 @@ html, body {
|
|||||||
for the canvas route only. Setting it globally here would break
|
for the canvas route only. Setting it globally here would break
|
||||||
scrolling on settings, workspace, and other non-canvas pages. */
|
scrolling on settings, workspace, and other non-canvas pages. */
|
||||||
|
|
||||||
|
/* ── Theme CSS variables ─────────────────────────────────────────────────────
|
||||||
|
--page-bg : background of workspace / settings / plugin shell pages
|
||||||
|
--page-text: primary text colour on those pages
|
||||||
|
The canvas editor (AppChrome) uses its own inline dark styles and does not
|
||||||
|
consume these variables. Toggling data-theme on <html> via Providers is
|
||||||
|
enough for all shell pages to respond. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--page-bg: #FAFAFA;
|
||||||
|
--page-text: #0A0A0A;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--page-bg: #0C0C10;
|
||||||
|
--page-text: #FAFAFA;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
background: #f5f5f5;
|
background: var(--page-bg);
|
||||||
|
color: var(--page-text);
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Accessible focus indicators ────────────────────────────────────────────
|
/* ── Accessible focus indicators ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function OnboardingPage() {
|
|||||||
<main style={{
|
<main style={{
|
||||||
minHeight: '100dvh',
|
minHeight: '100dvh',
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
background: '#FAFAFA',
|
background: 'var(--page-bg)',
|
||||||
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -1,31 +1,39 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, type ReactNode } from 'react';
|
import { useState, useEffect, type ReactNode } from 'react';
|
||||||
import { FluentProvider } from '@fluentui/react-components';
|
import { FluentProvider } from '@fluentui/react-components';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { originmainLightTheme } from '@originmain/ui';
|
import { originmainLightTheme, originmainDarkTheme } from '@originmain/ui';
|
||||||
|
import { useTheme } from '@/store/theme';
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
// TanStack Query v5: create QueryClient inside useState so it's stable across
|
// TanStack Query v5: create QueryClient inside useState so it's stable across
|
||||||
// re-renders and each client-side navigation gets the same instance. Creating
|
// re-renders and each client-side navigation gets the same instance.
|
||||||
// it outside the component would cause it to be shared across requests on the
|
|
||||||
// server (SSR memory leak / cross-request data pollution).
|
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000, // treat data as fresh for 30 s
|
staleTime: 30_000,
|
||||||
retry: 1, // one automatic retry on transient errors
|
retry: 1,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mode = useTheme((s) => s.mode);
|
||||||
|
const theme = mode === 'dark' ? originmainDarkTheme : originmainLightTheme;
|
||||||
|
|
||||||
|
// Sync data-theme on <html> so CSS variables and server-rendered page
|
||||||
|
// backgrounds can respond to the user's preference without prop drilling.
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', mode);
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<FluentProvider theme={originmainLightTheme} style={{ height: '100%' }}>
|
<FluentProvider theme={theme} style={{ height: '100%' }}>
|
||||||
{children}
|
{children}
|
||||||
</FluentProvider>
|
</FluentProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function WorkspaceError({
|
|||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
<div style={{ minHeight: '100vh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||||
<AppHeader breadcrumbs={[{ label: 'Workspaces', href: '/workspaces' }]} />
|
<AppHeader breadcrumbs={[{ label: 'Workspaces', href: '/workspaces' }]} />
|
||||||
<main style={{ maxWidth: 480, margin: '80px auto', padding: '0 24px', textAlign: 'center' }}>
|
<main style={{ maxWidth: 480, margin: '80px auto', padding: '0 24px', textAlign: 'center' }}>
|
||||||
<div style={{ fontSize: '2rem', marginBottom: 16 }}>⚠️</div>
|
<div style={{ fontSize: '2rem', marginBottom: 16 }}>⚠️</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
|
|||||||
const projects = (projectsData ?? []) as Project[];
|
const projects = (projectsData ?? []) as Project[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||||
<AppHeader breadcrumbs={[
|
<AppHeader breadcrumbs={[
|
||||||
{ label: 'Workspaces', href: '/workspaces' },
|
{ label: 'Workspaces', href: '/workspaces' },
|
||||||
{ label: workspace.name },
|
{ label: workspace.name },
|
||||||
@@ -67,6 +67,18 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 10 }}>
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<Link href={`/workspace/${wid}/plugins`} style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
background: 'transparent', color: '#71717A',
|
||||||
|
fontSize: '0.875rem', fontWeight: 500,
|
||||||
|
padding: '9px 16px', borderRadius: 9,
|
||||||
|
textDecoration: 'none', border: '1px solid rgba(0,0,0,0.1)',
|
||||||
|
}}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 2a2 2 0 0 1 2 2v1h3a1 1 0 0 1 1 1v3h1a2 2 0 0 1 0 4h-1v3a1 1 0 0 1-1 1h-3v1a2 2 0 0 1-4 0v-1H7a1 1 0 0 1-1-1v-3H5a2 2 0 0 1 0-4h1V6a1 1 0 0 1 1-1h3V4a2 2 0 0 1 2-2z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Plugins
|
||||||
|
</Link>
|
||||||
<Link href={`/workspace/${wid}/settings`} style={{
|
<Link href={`/workspace/${wid}/settings`} style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
background: 'transparent', color: '#71717A',
|
background: 'transparent', color: '#71717A',
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { auth } from '@clerk/nextjs/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { serverClient } from '@/lib/supabase';
|
||||||
|
import { AppHeader } from '@/components/shell/AppHeader';
|
||||||
|
import type { Workspace } from '@originmain/origin-graph';
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ wid: string }> }) {
|
||||||
|
const { wid } = await params;
|
||||||
|
const db = serverClient();
|
||||||
|
const result = (await db.from('workspaces').select('name').eq('id', wid).single()) as unknown as { data: { name: string } | null };
|
||||||
|
return { title: `Plugins — ${result.data?.name ?? 'Workspace'} — Originmain` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function WorkspacePluginsPage({ params }: { params: Promise<{ wid: string }> }) {
|
||||||
|
const { wid } = await params;
|
||||||
|
const { userId } = await auth();
|
||||||
|
if (!userId) redirect('/sign-in');
|
||||||
|
|
||||||
|
const db = serverClient();
|
||||||
|
|
||||||
|
// Verify membership
|
||||||
|
const { data: member } = await db
|
||||||
|
.from('team_members')
|
||||||
|
.select('id')
|
||||||
|
.eq('workspace_id', wid)
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.limit(1)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!member) redirect('/workspaces');
|
||||||
|
|
||||||
|
const { data: ws } = await db.from('workspaces').select('*').eq('id', wid).single();
|
||||||
|
const workspace = ws as Workspace | null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" }}>
|
||||||
|
<AppHeader workspaceName={workspace?.name ?? 'Workspace'} workspaceId={wid} />
|
||||||
|
|
||||||
|
<div style={{ maxWidth: 760, margin: '0 auto', padding: '48px 24px' }}>
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 32, fontSize: '0.8125rem', color: '#71717A' }}>
|
||||||
|
<Link href="/workspaces" style={{ color: '#71717A', textDecoration: 'none' }}>Workspaces</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link href={`/workspace/${wid}`} style={{ color: '#71717A', textDecoration: 'none' }}>{workspace?.name ?? 'Workspace'}</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span style={{ color: '#09090B' }}>Plugins</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, letterSpacing: '-0.025em', color: '#09090B', margin: '0 0 8px' }}>
|
||||||
|
Plugins
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: '0.9375rem', color: '#52525B', margin: '0 0 40px', lineHeight: 1.6 }}>
|
||||||
|
Extend Originmain with custom completion zones, ingestion connectors, and design tools.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Coming soon card */}
|
||||||
|
<div style={{
|
||||||
|
background: '#FFFFFF', border: '1px solid rgba(0,0,0,0.07)', borderRadius: 14,
|
||||||
|
padding: '40px 36px', textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 52, height: 52, borderRadius: 14,
|
||||||
|
background: 'rgba(51,133,255,0.08)', border: '1px solid rgba(51,133,255,0.15)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
margin: '0 auto 20px',
|
||||||
|
}}>
|
||||||
|
{/* Puzzle icon */}
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 2a2 2 0 0 1 2 2v1h3a1 1 0 0 1 1 1v3h1a2 2 0 0 1 0 4h-1v3a1 1 0 0 1-1 1h-3v1a2 2 0 0 1-4 0v-1H7a1 1 0 0 1-1-1v-3H5a2 2 0 0 1 0-4h1V6a1 1 0 0 1 1-1h3V4a2 2 0 0 1 2-2z" stroke="#3385FF" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: '#09090B', margin: '0 0 10px', letterSpacing: '-0.015em' }}>
|
||||||
|
Plugin marketplace — coming in Phase 4
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#71717A', maxWidth: 480, margin: '0 auto 28px', lineHeight: 1.65 }}>
|
||||||
|
The plugin API lets teams register custom AI completion zones, custom webhook ingesters, and design-language extensions — all sandboxed and permission-scoped.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Permissions preview */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center', marginBottom: 28 }}>
|
||||||
|
{[
|
||||||
|
'artboards:read', 'artboards:write', 'diffs:read', 'diffs:export',
|
||||||
|
'completion-zones:register', 'ingesters:register', 'design-language:read',
|
||||||
|
].map(scope => (
|
||||||
|
<span key={scope} style={{
|
||||||
|
fontSize: '0.6875rem', fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||||
|
background: 'rgba(51,133,255,0.07)', color: '#3385FF',
|
||||||
|
border: '1px solid rgba(51,133,255,0.18)',
|
||||||
|
borderRadius: 6, padding: '3px 8px',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
}}>
|
||||||
|
{scope}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/workspace/${wid}/settings`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
fontSize: '0.875rem', fontWeight: 500, color: '#3385FF',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to settings →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ export default function NewProjectPage({ params }: { params: Promise<{ wid: stri
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||||
<AppHeader breadcrumbs={[
|
<AppHeader breadcrumbs={[
|
||||||
{ label: 'Workspaces', href: '/workspaces' },
|
{ label: 'Workspaces', href: '/workspaces' },
|
||||||
{ label: 'Workspace', href: `/workspace/${wid}` },
|
{ label: 'Workspace', href: `/workspace/${wid}` },
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default async function WorkspaceSettingsPage({ params }: { params: Promis
|
|||||||
const memberRole = (member as { role: string }).role;
|
const memberRole = (member as { role: string }).role;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||||
<AppHeader breadcrumbs={[
|
<AppHeader breadcrumbs={[
|
||||||
{ label: 'Workspaces', href: '/workspaces' },
|
{ label: 'Workspaces', href: '/workspaces' },
|
||||||
{ label: workspace.name, href: `/workspace/${wid}` },
|
{ label: workspace.name, href: `/workspace/${wid}` },
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function WorkspacesError({
|
|||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
<div style={{ minHeight: '100vh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<main style={{ maxWidth: 480, margin: '80px auto', padding: '0 24px', textAlign: 'center' }}>
|
<main style={{ maxWidth: 480, margin: '80px auto', padding: '0 24px', textAlign: 'center' }}>
|
||||||
<div style={{ fontSize: '2rem', marginBottom: 16 }}>⚠️</div>
|
<div style={{ fontSize: '2rem', marginBottom: 16 }}>⚠️</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function NewWorkspacePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||||
<AppHeader breadcrumbs={[{ label: 'Workspaces', href: '/workspaces' }, { label: 'New workspace' }]} />
|
<AppHeader breadcrumbs={[{ label: 'Workspaces', href: '/workspaces' }, { label: 'New workspace' }]} />
|
||||||
|
|
||||||
<main style={{ maxWidth: 480, margin: '64px auto', padding: '0 24px' }}>
|
<main style={{ maxWidth: 480, margin: '64px auto', padding: '0 24px' }}>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default async function WorkspacesPage() {
|
|||||||
if (workspaces.length === 0) redirect('/onboarding');
|
if (workspaces.length === 0) redirect('/onboarding');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
|
||||||
<main style={{ maxWidth: 960, margin: '0 auto', padding: '48px 24px' }}>
|
<main style={{ maxWidth: 960, margin: '0 auto', padding: '48px 24px' }}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useCallback, useRef } from 'react';
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useCanvas } from '@/store/canvas';
|
import { useCanvas } from '@/store/canvas';
|
||||||
import { useViewport } from '@/store/viewport';
|
import { useViewport } from '@/store/viewport';
|
||||||
|
import { useDiffs } from '@/hooks/useDiffs';
|
||||||
import { LiveArtboard } from './LiveArtboard';
|
import { LiveArtboard } from './LiveArtboard';
|
||||||
import { SelectionOverlay } from './SelectionOverlay';
|
import { SelectionOverlay } from './SelectionOverlay';
|
||||||
import type { FiberNode } from '@originmain/renderer';
|
import type { FiberNode } from '@originmain/renderer';
|
||||||
@@ -18,11 +19,22 @@ interface ArtboardProps {
|
|||||||
renderUrl?: string;
|
renderUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status color map matching the inspector
|
||||||
|
const DIFF_STATUS_BADGE: Record<string, { color: string; bg: string; label: string }> = {
|
||||||
|
DRAFT: { color: '#FFBA7B', bg: 'rgba(255,186,123,0.15)', label: 'draft' },
|
||||||
|
REVIEWED: { color: '#7EB8FF', bg: 'rgba(126,184,255,0.15)', label: 'reviewed' },
|
||||||
|
APPLIED: { color: '#10B981', bg: 'rgba(16,185,129,0.15)', label: 'applied' },
|
||||||
|
REJECTED: { color: '#FF8080', bg: 'rgba(255,128,128,0.15)', label: 'blocked' },
|
||||||
|
};
|
||||||
|
|
||||||
export function Artboard({ id, label, x, y, width, height, renderUrl }: ArtboardProps) {
|
export function Artboard({ id, label, x, y, width, height, renderUrl }: ArtboardProps) {
|
||||||
const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, setFiberRoot, selectComponent } = useCanvas();
|
const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, setFiberRoot, selectComponent } = useCanvas();
|
||||||
const selected = selectedArtboardId === id;
|
const selected = selectedArtboardId === id;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Diff status badges — fetch is cached by TanStack Query across all artboards
|
||||||
|
const { diffs } = useDiffs(id);
|
||||||
|
|
||||||
// ── Fiber tree (from LiveArtboard) ─────────────────────────────────────────
|
// ── Fiber tree (from LiveArtboard) ─────────────────────────────────────────
|
||||||
const [localFiberRoot, setLocalFiberRoot] = useState<FiberNode | undefined>(undefined);
|
const [localFiberRoot, setLocalFiberRoot] = useState<FiberNode | undefined>(undefined);
|
||||||
|
|
||||||
@@ -246,6 +258,45 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Diff status badge strip — bottom-right overlay, only visible when diffs exist */}
|
||||||
|
{diffs.length > 0 && (() => {
|
||||||
|
// Group by status and show compact chips
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const d of diffs) counts[d.status] = (counts[d.status] ?? 0) + 1;
|
||||||
|
const entries = Object.entries(counts).filter(([s]) => s in DIFF_STATUS_BADGE);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute', bottom: 6, right: 6, zIndex: 10,
|
||||||
|
display: 'flex', gap: 4, pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entries.map(([status, count]) => {
|
||||||
|
const b = DIFF_STATUS_BADGE[status]!;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={status}
|
||||||
|
title={`${count} ${b.label} diff${count !== 1 ? 's' : ''}`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||||
|
padding: '2px 5px', borderRadius: 4,
|
||||||
|
background: b.bg, border: `1px solid ${b.color}33`,
|
||||||
|
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||||
|
fontSize: '0.5rem', fontWeight: 600,
|
||||||
|
color: b.color, letterSpacing: '0.03em',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 4, height: 4, borderRadius: '50%', background: b.color, display: 'inline-block' }} />
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{renderUrl ? (
|
{renderUrl ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Inspector } from '../inspector/Inspector';
|
|||||||
import { useHistory } from '@/store/history';
|
import { useHistory } from '@/store/history';
|
||||||
import { useCanvas, type Tool } from '@/store/canvas';
|
import { useCanvas, type Tool } from '@/store/canvas';
|
||||||
import { useViewport } from '@/store/viewport';
|
import { useViewport } from '@/store/viewport';
|
||||||
|
import { useTheme } from '@/store/theme';
|
||||||
|
|
||||||
interface AppChromeProps {
|
interface AppChromeProps {
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
@@ -22,6 +23,7 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
|||||||
const selectedArtboardId = useCanvas((s) => s.selectedArtboardId);
|
const selectedArtboardId = useCanvas((s) => s.selectedArtboardId);
|
||||||
const setContext = useCanvas((s) => s.setContext);
|
const setContext = useCanvas((s) => s.setContext);
|
||||||
const setActiveTool = useCanvas((s) => s.setActiveTool);
|
const setActiveTool = useCanvas((s) => s.setActiveTool);
|
||||||
|
const { mode: themeMode, toggle: toggleTheme } = useTheme();
|
||||||
|
|
||||||
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
|
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -162,6 +164,34 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
|||||||
|
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
title={`Switch to ${themeMode === 'dark' ? 'light' : 'dark'} mode`}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
color: 'rgba(255,255,255,0.35)', padding: '4px 6px',
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
transition: 'color 0.12s',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.75)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.35)')}
|
||||||
|
>
|
||||||
|
{themeMode === 'dark' ? (
|
||||||
|
/* Sun icon */
|
||||||
|
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
|
||||||
|
<circle cx="7" cy="7" r="2.5" stroke="currentColor" strokeWidth="1.2"/>
|
||||||
|
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13M2.93 2.93l1.06 1.06M10.01 10.01l1.06 1.06M2.93 11.07l1.06-1.06M10.01 3.99l1.06-1.06" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
/* Moon icon */
|
||||||
|
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M12 8.5A5.5 5.5 0 0 1 5.5 2a5.5 5.5 0 1 0 6.5 6.5z" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div style={{ transform: 'scale(0.8)', transformOrigin: 'right center' }}>
|
<div style={{ transform: 'scale(0.8)', transformOrigin: 'right center' }}>
|
||||||
<UserButton />
|
<UserButton />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -542,19 +542,47 @@ function HSep() {
|
|||||||
function DiffTab({ artboardId }: { artboardId: string | null }) {
|
function DiffTab({ artboardId }: { artboardId: string | null }) {
|
||||||
const { stacks } = useHistory();
|
const { stacks } = useHistory();
|
||||||
const { diffs, createDiff, isLoading } = useDiffs(artboardId);
|
const { diffs, createDiff, isLoading } = useDiffs(artboardId);
|
||||||
|
const [summaryStatus, setSummaryStatus] = useState<'idle' | 'summarising' | 'exporting'>('idle');
|
||||||
|
|
||||||
const artboardHistory = artboardId ? (stacks[artboardId] ?? { past: [], future: [] }) : { past: [], future: [] };
|
const artboardHistory = artboardId ? (stacks[artboardId] ?? { past: [], future: [] }) : { past: [], future: [] };
|
||||||
const pendingChanges: PropChange[] = artboardHistory.past.flatMap(e => e.changes);
|
const pendingChanges: PropChange[] = artboardHistory.past.flatMap(e => e.changes);
|
||||||
const hasChanges = pendingChanges.length > 0;
|
const hasChanges = pendingChanges.length > 0;
|
||||||
|
|
||||||
const exportDiff = useCallback(() => {
|
const exportDiff = useCallback(async () => {
|
||||||
if (!artboardId || !hasChanges) return;
|
if (!artboardId || !hasChanges) return;
|
||||||
createDiff.mutate({
|
|
||||||
artboard_id: artboardId,
|
// 1. Generate AI summary (best-effort — fall back to empty string on failure)
|
||||||
changes_jsonb: { propChanges: pendingChanges, styleChanges: [] },
|
let summary = '';
|
||||||
summary: '',
|
const meaningfulChanges = pendingChanges.filter(c => c.changeType !== 'unchanged');
|
||||||
status: 'DRAFT',
|
if (meaningfulChanges.length > 0) {
|
||||||
});
|
setSummaryStatus('summarising');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/diff-summary', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
changesJson: JSON.stringify(meaningfulChanges),
|
||||||
|
componentName: meaningfulChanges[0]?.key ?? 'Component',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json() as { summary?: string };
|
||||||
|
summary = data.summary ?? '';
|
||||||
|
}
|
||||||
|
} catch { /* non-fatal — proceed without summary */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Export diff with AI-generated summary included
|
||||||
|
setSummaryStatus('exporting');
|
||||||
|
createDiff.mutate(
|
||||||
|
{
|
||||||
|
artboard_id: artboardId,
|
||||||
|
changes_jsonb: { propChanges: pendingChanges, styleChanges: [] },
|
||||||
|
summary,
|
||||||
|
status: 'DRAFT',
|
||||||
|
},
|
||||||
|
{ onSettled: () => setSummaryStatus('idle') },
|
||||||
|
);
|
||||||
}, [artboardId, pendingChanges, hasChanges, createDiff]);
|
}, [artboardId, pendingChanges, hasChanges, createDiff]);
|
||||||
|
|
||||||
if (!artboardId) {
|
if (!artboardId) {
|
||||||
@@ -579,18 +607,22 @@ function DiffTab({ artboardId }: { artboardId: string | null }) {
|
|||||||
<DiffChangeRow key={i} change={change} />
|
<DiffChangeRow key={i} change={change} />
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={exportDiff}
|
onClick={() => void exportDiff()}
|
||||||
disabled={createDiff.isPending}
|
disabled={summaryStatus !== 'idle' || createDiff.isPending}
|
||||||
style={{
|
style={{
|
||||||
marginTop: 10, width: '100%',
|
marginTop: 10, width: '100%',
|
||||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
|
||||||
background: T.accent, border: 'none', borderRadius: 6,
|
background: T.accent, border: 'none', borderRadius: 6,
|
||||||
color: '#fff', padding: '7px 0', cursor: createDiff.isPending ? 'wait' : 'pointer',
|
color: '#fff', padding: '7px 0',
|
||||||
letterSpacing: '0.04em', opacity: createDiff.isPending ? 0.6 : 1,
|
cursor: (summaryStatus !== 'idle' || createDiff.isPending) ? 'wait' : 'pointer',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
opacity: (summaryStatus !== 'idle' || createDiff.isPending) ? 0.6 : 1,
|
||||||
transition: 'opacity 0.15s',
|
transition: 'opacity 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{createDiff.isPending ? 'Exporting…' : 'Export diff →'}
|
{summaryStatus === 'summarising' ? 'Summarising…' :
|
||||||
|
summaryStatus === 'exporting' || createDiff.isPending ? 'Exporting…' :
|
||||||
|
'Export diff →'}
|
||||||
</button>
|
</button>
|
||||||
{createDiff.isError && (
|
{createDiff.isError && (
|
||||||
<span style={{ fontSize: '0.5rem', color: '#FF8080', fontFamily: 'monospace', display: 'block', marginTop: 4 }}>
|
<span style={{ fontSize: '0.5rem', color: '#FF8080', fontFamily: 'monospace', display: 'block', marginTop: 4 }}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useFileTree, FileTree } from '@pierre/trees/react';
|
|||||||
import { themeToTreeStyles } from '@pierre/trees';
|
import { themeToTreeStyles } from '@pierre/trees';
|
||||||
import { SquareRegular } from '@fluentui/react-icons';
|
import { SquareRegular } from '@fluentui/react-icons';
|
||||||
import { useCanvas } from '@/store/canvas';
|
import { useCanvas } from '@/store/canvas';
|
||||||
import { useArtboards, patchArtboard } from '@/hooks/useArtboards';
|
import { useArtboards, patchArtboard, createArtboardMutation } from '@/hooks/useArtboards';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
const T = {
|
const T = {
|
||||||
@@ -61,6 +61,30 @@ export function ArtboardNavigator() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
||||||
}, [workspaceId, projectId, queryClient]);
|
}, [workspaceId, projectId, queryClient]);
|
||||||
|
|
||||||
|
const forkArtboard = useCallback(async (id: string, label: string) => {
|
||||||
|
if (!workspaceId) return;
|
||||||
|
const source = rawArtboards.find(ab => ab.id === id);
|
||||||
|
if (!source) return;
|
||||||
|
const meta = { ...(source.metadata_jsonb as Record<string, unknown>) };
|
||||||
|
// Offset fork to the right of the original so it doesn't overlap
|
||||||
|
const srcWidth = typeof meta['width'] === 'number' ? (meta['width'] as number) : 360;
|
||||||
|
meta['x'] = typeof meta['x'] === 'number' ? (meta['x'] as number) + srcWidth + 40 : 40;
|
||||||
|
try {
|
||||||
|
await createArtboardMutation({
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
project_id: projectId ?? null,
|
||||||
|
name: `Fork of ${label}`,
|
||||||
|
origin_id: source.origin_id,
|
||||||
|
parent_artboard_id: id,
|
||||||
|
metadata_jsonb: meta,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Navigator] forkArtboard failed:', err);
|
||||||
|
window.alert('Could not fork artboard — please try again.');
|
||||||
|
}
|
||||||
|
}, [workspaceId, projectId, rawArtboards, queryClient]);
|
||||||
|
|
||||||
// Real graph stats derived from live fiber trees
|
// Real graph stats derived from live fiber trees
|
||||||
const totalComponents = Object.values(artboardFiberRoots).reduce(
|
const totalComponents = Object.values(artboardFiberRoots).reduce(
|
||||||
(acc, root) => acc + countFiberNodes(root), 0,
|
(acc, root) => acc + countFiberNodes(root), 0,
|
||||||
@@ -112,6 +136,7 @@ export function ArtboardNavigator() {
|
|||||||
}
|
}
|
||||||
label={ab.label}
|
label={ab.label}
|
||||||
onRename={() => void renameArtboard(ab.id, ab.label)}
|
onRename={() => void renameArtboard(ab.id, ab.label)}
|
||||||
|
onFork={() => void forkArtboard(ab.id, ab.label)}
|
||||||
onDelete={() => void deleteArtboard(ab.id, ab.label)}
|
onDelete={() => void deleteArtboard(ab.id, ab.label)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -160,6 +185,7 @@ function NavRow({
|
|||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
onRename,
|
onRename,
|
||||||
|
onFork,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
@@ -168,6 +194,7 @@ function NavRow({
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
onRename?: () => void;
|
onRename?: () => void;
|
||||||
|
onFork?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [hov, setHov] = useState(false);
|
const [hov, setHov] = useState(false);
|
||||||
@@ -206,18 +233,31 @@ function NavRow({
|
|||||||
}} />
|
}} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons: rename + delete — shown on hover */}
|
{/* Action buttons: rename + fork + delete — shown on hover */}
|
||||||
{(hov || selected) && (
|
{(hov || selected) && (
|
||||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||||
{onRename && (
|
{onRename && (
|
||||||
<IconBtn title="Rename" onClick={onRename}>
|
<IconBtn title="Rename" onClick={onRename}>
|
||||||
|
{/* Pencil */}
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||||
<path d="M1 7.5L7 1.5l1.5 1.5-6 6H1V7.5z" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M1 7.5L7 1.5l1.5 1.5-6 6H1V7.5z" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
)}
|
||||||
|
{onFork && (
|
||||||
|
<IconBtn title="Fork" onClick={onFork}>
|
||||||
|
{/* Branch / fork icon */}
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||||
|
<circle cx="2" cy="2" r="1.2" stroke="currentColor" strokeWidth="1"/>
|
||||||
|
<circle cx="8" cy="2" r="1.2" stroke="currentColor" strokeWidth="1"/>
|
||||||
|
<circle cx="2" cy="8" r="1.2" stroke="currentColor" strokeWidth="1"/>
|
||||||
|
<path d="M2 3.2v1.3C2 5.4 2.6 6 3.5 6H5M8 3.2V5a1 1 0 0 1-1 1H5m0 0v2" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<IconBtn title="Delete" onClick={onDelete} danger>
|
<IconBtn title="Delete" onClick={onDelete} danger>
|
||||||
|
{/* Trash */}
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||||
<path d="M2 2.5h6M4 2.5V1.5h2V2.5M3 2.5v6h4v-6" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M2 2.5h6M4 2.5V1.5h2V2.5M3 2.5v6h4v-6" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -2,49 +2,69 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { UserButton } from '@clerk/nextjs';
|
import { UserButton } from '@clerk/nextjs';
|
||||||
|
import { useTheme } from '@/store/theme';
|
||||||
|
|
||||||
interface Crumb { label: string; href?: string }
|
interface Crumb { label: string; href?: string }
|
||||||
|
|
||||||
interface AppHeaderProps {
|
interface AppHeaderProps {
|
||||||
breadcrumbs?: Crumb[];
|
breadcrumbs?: Crumb[];
|
||||||
|
/** @deprecated Pass breadcrumbs instead */
|
||||||
|
workspaceName?: string;
|
||||||
|
/** @deprecated Pass breadcrumbs instead */
|
||||||
|
workspaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
|
export function AppHeader({ breadcrumbs = [], workspaceName, workspaceId }: AppHeaderProps) {
|
||||||
|
const { mode, toggle } = useTheme();
|
||||||
|
|
||||||
|
// Back-compat: if old props are passed without breadcrumbs, synthesise them
|
||||||
|
const crumbs: Crumb[] = breadcrumbs.length > 0
|
||||||
|
? breadcrumbs
|
||||||
|
: workspaceName
|
||||||
|
? [
|
||||||
|
{ label: 'Workspaces', href: '/workspaces' as string },
|
||||||
|
...(workspaceId
|
||||||
|
? [{ label: workspaceName, href: `/workspace/${workspaceId}` as string }]
|
||||||
|
: [{ label: workspaceName }]),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header style={{
|
<header style={{
|
||||||
position: 'sticky', top: 0, zIndex: 100,
|
position: 'sticky', top: 0, zIndex: 100,
|
||||||
height: 56,
|
height: 56,
|
||||||
background: 'rgba(255,255,255,0.92)',
|
background: mode === 'dark' ? 'rgba(12,12,16,0.92)' : 'rgba(255,255,255,0.92)',
|
||||||
backdropFilter: 'blur(12px)',
|
backdropFilter: 'blur(12px)',
|
||||||
borderBottom: '1px solid rgba(0,0,0,0.07)',
|
borderBottom: mode === 'dark' ? '1px solid rgba(255,255,255,0.07)' : '1px solid rgba(0,0,0,0.07)',
|
||||||
display: 'flex', alignItems: 'center',
|
display: 'flex', alignItems: 'center',
|
||||||
padding: '0 24px',
|
padding: '0 24px',
|
||||||
gap: 0,
|
gap: 0,
|
||||||
}}>
|
}}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link href="/workspaces" style={{ textDecoration: 'none', flexShrink: 0 }}>
|
<Link href="/workspaces" style={{ textDecoration: 'none', flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: '1rem', fontWeight: 700, letterSpacing: '-0.02em', color: '#0A0A0A' }}>
|
<span style={{ fontSize: '1rem', fontWeight: 700, letterSpacing: '-0.02em', color: mode === 'dark' ? '#FAFAFA' : '#0A0A0A' }}>
|
||||||
Origin<span style={{ color: '#0066FF' }}>main</span>
|
Origin<span style={{ color: '#0066FF' }}>main</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Breadcrumbs */}
|
{/* Breadcrumbs */}
|
||||||
{breadcrumbs.map((crumb, i) => (
|
{crumbs.map((crumb, i) => (
|
||||||
<span key={i} style={{ display: 'flex', alignItems: 'center' }}>
|
<span key={i} style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<span style={{ margin: '0 8px', color: '#D4D4D8', fontSize: '0.875rem' }}>/</span>
|
<span style={{ margin: '0 8px', color: mode === 'dark' ? 'rgba(255,255,255,0.2)' : '#D4D4D8', fontSize: '0.875rem' }}>/</span>
|
||||||
{crumb.href ? (
|
{crumb.href ? (
|
||||||
<Link href={crumb.href} style={{
|
<Link href={crumb.href} style={{
|
||||||
fontSize: '0.875rem', fontWeight: 500,
|
fontSize: '0.875rem', fontWeight: 500,
|
||||||
color: '#71717A', textDecoration: 'none',
|
color: mode === 'dark' ? 'rgba(255,255,255,0.45)' : '#71717A',
|
||||||
|
textDecoration: 'none',
|
||||||
transition: 'color 0.1s',
|
transition: 'color 0.1s',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = '#0A0A0A')}
|
onMouseEnter={e => (e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.85)' : '#0A0A0A')}
|
||||||
onMouseLeave={e => (e.currentTarget.style.color = '#71717A')}
|
onMouseLeave={e => (e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.45)' : '#71717A')}
|
||||||
>
|
>
|
||||||
{crumb.label}
|
{crumb.label}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#0A0A0A' }}>
|
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: mode === 'dark' ? '#FAFAFA' : '#0A0A0A' }}>
|
||||||
{crumb.label}
|
{crumb.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -53,6 +73,40 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
|
|||||||
|
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
title={`Switch to ${mode === 'dark' ? 'light' : 'dark'} mode`}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
color: mode === 'dark' ? 'rgba(255,255,255,0.4)' : '#A1A1AA',
|
||||||
|
padding: '6px 8px', marginRight: 8,
|
||||||
|
display: 'flex', alignItems: 'center', borderRadius: 6,
|
||||||
|
transition: 'color 0.12s, background 0.12s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.85)' : '#0A0A0A';
|
||||||
|
e.currentTarget.style.background = mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.4)' : '#A1A1AA';
|
||||||
|
e.currentTarget.style.background = 'none';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === 'dark' ? (
|
||||||
|
/* Sun */
|
||||||
|
<svg width="15" height="15" viewBox="0 0 14 14" fill="none">
|
||||||
|
<circle cx="7" cy="7" r="2.5" stroke="currentColor" strokeWidth="1.2"/>
|
||||||
|
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13M2.93 2.93l1.06 1.06M10.01 10.01l1.06 1.06M2.93 11.07l1.06-1.06M10.01 3.99l1.06-1.06" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
/* Moon */
|
||||||
|
<svg width="15" height="15" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M12 8.5A5.5 5.5 0 0 1 5.5 2a5.5 5.5 0 1 0 6.5 6.5z" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
<UserButton />
|
<UserButton />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -377,10 +377,18 @@ export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }
|
|||||||
border: '1px solid rgba(239,68,68,0.35)', background: 'transparent',
|
border: '1px solid rgba(239,68,68,0.35)', background: 'transparent',
|
||||||
color: '#EF4444', cursor: 'pointer',
|
color: '#EF4444', cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (window.confirm(`Delete workspace "${workspaceName}"? This cannot be undone.`)) {
|
if (!window.confirm(`Delete workspace "${workspaceName}"? This cannot be undone. All projects and artboards will be permanently lost.`)) return;
|
||||||
// TODO: call DELETE /api/workspace/:id when implemented
|
try {
|
||||||
window.alert('Delete endpoint not yet implemented — coming soon.');
|
const res = await fetch(`/api/workspace/${workspaceId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||||
|
throw new Error(body.error ?? `Server error ${res.status}`);
|
||||||
|
}
|
||||||
|
router.push('/workspaces');
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
window.alert(`Could not delete workspace: ${msg}`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
import type { ComponentSnapshot } from '@originmain/diff-engine';
|
|
||||||
|
|
||||||
interface ArtboardSnapshots {
|
|
||||||
before: ComponentSnapshot;
|
|
||||||
after: ComponentSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dashboardCard: ArtboardSnapshots = {
|
|
||||||
before: {
|
|
||||||
id: 'dashboard-card',
|
|
||||||
name: 'DashboardCard',
|
|
||||||
filePath: 'src/components/DashboardCard.tsx',
|
|
||||||
props: {
|
|
||||||
title: 'Revenue Overview',
|
|
||||||
value: '$12,450',
|
|
||||||
delta: 2.4,
|
|
||||||
period: 'monthly',
|
|
||||||
loading: false,
|
|
||||||
},
|
|
||||||
styles: {
|
|
||||||
borderRadius: '8px',
|
|
||||||
accentColor: '#2A6CD4',
|
|
||||||
padding: '16',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
after: {
|
|
||||||
id: 'dashboard-card',
|
|
||||||
name: 'DashboardCard',
|
|
||||||
filePath: 'src/components/DashboardCard.tsx',
|
|
||||||
props: {
|
|
||||||
title: 'Revenue Overview',
|
|
||||||
value: '$12,450',
|
|
||||||
delta: 2.4,
|
|
||||||
period: 'monthly',
|
|
||||||
loading: false,
|
|
||||||
},
|
|
||||||
styles: {
|
|
||||||
borderRadius: '12px',
|
|
||||||
accentColor: '#0066FF',
|
|
||||||
padding: '20',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const userProfile: ArtboardSnapshots = {
|
|
||||||
before: {
|
|
||||||
id: 'user-profile',
|
|
||||||
name: 'UserProfile',
|
|
||||||
filePath: 'src/components/UserProfile.tsx',
|
|
||||||
props: {
|
|
||||||
name: 'Sarah Chen',
|
|
||||||
role: 'Designer',
|
|
||||||
plan: 'Pro',
|
|
||||||
avatarSize: 40,
|
|
||||||
},
|
|
||||||
styles: {
|
|
||||||
avatarGradient: 'linear-gradient(135deg, #6D28D9, #2563EB)',
|
|
||||||
buttonVariant: 'outline',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
after: {
|
|
||||||
id: 'user-profile',
|
|
||||||
name: 'UserProfile',
|
|
||||||
filePath: 'src/components/UserProfile.tsx',
|
|
||||||
props: {
|
|
||||||
name: 'Sarah Chen',
|
|
||||||
role: 'Design Engineer',
|
|
||||||
plan: 'Team',
|
|
||||||
avatarSize: 52,
|
|
||||||
},
|
|
||||||
styles: {
|
|
||||||
avatarGradient: 'linear-gradient(135deg, #7C3AED, #0066FF)',
|
|
||||||
buttonVariant: 'ghost',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const navSidebar: ArtboardSnapshots = {
|
|
||||||
before: {
|
|
||||||
id: 'nav-sidebar',
|
|
||||||
name: 'NavSidebar',
|
|
||||||
filePath: 'src/components/NavSidebar.tsx',
|
|
||||||
props: {
|
|
||||||
brand: 'Origin',
|
|
||||||
collapsed: false,
|
|
||||||
activeItem: 'Dashboard',
|
|
||||||
},
|
|
||||||
styles: {
|
|
||||||
background: '#F5F5F5',
|
|
||||||
itemPadding: '6px 10px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
after: {
|
|
||||||
id: 'nav-sidebar',
|
|
||||||
name: 'NavSidebar',
|
|
||||||
filePath: 'src/components/NavSidebar.tsx',
|
|
||||||
props: {
|
|
||||||
brand: 'Originmain',
|
|
||||||
collapsed: false,
|
|
||||||
activeItem: 'Dashboard',
|
|
||||||
},
|
|
||||||
styles: {
|
|
||||||
background: '#FAFAFA',
|
|
||||||
itemPadding: '7px 12px',
|
|
||||||
borderRadius: '5px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const dataTable: ArtboardSnapshots = {
|
|
||||||
before: {
|
|
||||||
id: 'data-table',
|
|
||||||
name: 'DataTable',
|
|
||||||
filePath: 'src/components/DataTable.tsx',
|
|
||||||
props: {
|
|
||||||
title: 'Components',
|
|
||||||
striped: false,
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
styles: {
|
|
||||||
headerColor: '#71717A',
|
|
||||||
rowHeight: '32px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
after: {
|
|
||||||
id: 'data-table',
|
|
||||||
name: 'DataTable',
|
|
||||||
filePath: 'src/components/DataTable.tsx',
|
|
||||||
props: {
|
|
||||||
title: 'Component Inventory',
|
|
||||||
striped: true,
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
styles: {
|
|
||||||
headerColor: '#A1A1AA',
|
|
||||||
rowHeight: '36px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ARTBOARD_SNAPSHOTS: Record<string, ArtboardSnapshots> = {
|
|
||||||
'dashboard-card': dashboardCard,
|
|
||||||
'user-profile': userProfile,
|
|
||||||
'nav-sidebar': navSidebar,
|
|
||||||
'data-table': dataTable,
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { diffComponents, type DiffResult } from '@originmain/diff-engine';
|
|
||||||
import { ARTBOARD_SNAPSHOTS } from '@/data/artboard-snapshots';
|
|
||||||
|
|
||||||
export function useDiff(artboardId: string | null): DiffResult | null {
|
|
||||||
return useMemo(() => {
|
|
||||||
if (!artboardId) return null;
|
|
||||||
const snapshots = ARTBOARD_SNAPSHOTS[artboardId];
|
|
||||||
if (!snapshots) return null;
|
|
||||||
return diffComponents(snapshots.before, snapshots.after);
|
|
||||||
}, [artboardId]);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
|
||||||
|
type ThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
|
interface ThemeStore {
|
||||||
|
mode: ThemeMode;
|
||||||
|
toggle: () => void;
|
||||||
|
setMode: (mode: ThemeMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = create<ThemeStore>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
mode: 'dark',
|
||||||
|
toggle: () => set({ mode: get().mode === 'dark' ? 'light' : 'dark' }),
|
||||||
|
setMode: (mode) => set({ mode }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'originmain:theme',
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
partialize: (state) => ({ mode: state.mode }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
File diff suppressed because one or more lines are too long
@@ -172,3 +172,18 @@ BEGIN
|
|||||||
END LOOP;
|
END LOOP;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
-- ── RPC: get_diffs_by_status ──────────────────────────────────────────────────
|
||||||
|
-- Used by getDiffsByStatus() in queries.ts.
|
||||||
|
-- Joins intent_diffs → artboards to scope results by workspace_id.
|
||||||
|
|
||||||
|
create or replace function get_diffs_by_status(p_workspace_id uuid, p_status text)
|
||||||
|
returns setof intent_diffs
|
||||||
|
language sql stable as $$
|
||||||
|
select d.*
|
||||||
|
from intent_diffs d
|
||||||
|
join artboards a on a.id = d.artboard_id
|
||||||
|
where a.workspace_id = p_workspace_id
|
||||||
|
and d.status = p_status
|
||||||
|
order by d.created_at desc;
|
||||||
|
$$;
|
||||||
|
|||||||
@@ -267,6 +267,14 @@ export async function removeTeamMember(
|
|||||||
if (error) throw new Error(error.message);
|
if (error) throw new Error(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteWorkspace(db: DbClient, id: string): Promise<void> {
|
||||||
|
const { error } = await (db
|
||||||
|
.from('workspaces')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id) as unknown as Promise<{ data: unknown; error: DbError | null }>);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateWorkspace(
|
export async function updateWorkspace(
|
||||||
db: DbClient,
|
db: DbClient,
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- Origin Graph — Artboard Ancestry Materialized View
|
||||||
|
-- Migration: 002
|
||||||
|
-- Pre-computes all ancestor/descendant relationships so the app never needs
|
||||||
|
-- recursive CTEs at query time. Updated automatically on artboards INSERT.
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW artboard_ancestry AS
|
||||||
|
WITH RECURSIVE ancestry(artboard_id, ancestor_id, depth) AS (
|
||||||
|
-- Base: each artboard is at depth 0 relative to itself
|
||||||
|
SELECT id AS artboard_id, id AS ancestor_id, 0 AS depth
|
||||||
|
FROM artboards
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- Recurse: walk up the parent chain
|
||||||
|
SELECT a.id AS artboard_id, anc.ancestor_id, anc.depth + 1
|
||||||
|
FROM artboards a
|
||||||
|
JOIN ancestry anc ON a.parent_artboard_id = anc.artboard_id
|
||||||
|
)
|
||||||
|
SELECT artboard_id, ancestor_id, depth
|
||||||
|
FROM ancestry
|
||||||
|
WHERE artboard_id <> ancestor_id -- exclude self-reference
|
||||||
|
ORDER BY artboard_id, depth;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX artboard_ancestry_pk
|
||||||
|
ON artboard_ancestry(artboard_id, ancestor_id);
|
||||||
|
|
||||||
|
CREATE INDEX artboard_ancestry_ancestor_idx
|
||||||
|
ON artboard_ancestry(ancestor_id);
|
||||||
|
|
||||||
|
-- ── Refresh trigger ───────────────────────────────────────────────────────────
|
||||||
|
-- Refreshes the materialized view concurrently whenever an artboard is
|
||||||
|
-- inserted. CONCURRENTLY requires the unique index above — it allows reads
|
||||||
|
-- to continue during refresh.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION refresh_artboard_ancestry()
|
||||||
|
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
REFRESH MATERIALIZED VIEW CONCURRENTLY artboard_ancestry;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_artboard_ancestry_refresh
|
||||||
|
AFTER INSERT OR UPDATE OF parent_artboard_id ON artboards
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION refresh_artboard_ancestry();
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
-- Origin Graph — Row-Level Security Policies
|
||||||
|
-- Migration: 003
|
||||||
|
-- All tables are workspace-scoped. A user may only read or write rows in
|
||||||
|
-- workspaces where they have a team_members record. The Clerk JWT is verified
|
||||||
|
-- server-side; auth.uid() maps to the Clerk user_id column.
|
||||||
|
|
||||||
|
-- Enable RLS on every table
|
||||||
|
ALTER TABLE workspaces ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE artboards ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE origins ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE intent_diffs ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE agent_sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE design_language_files ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ── Helper: is the current user a member of the given workspace? ──────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION is_workspace_member(ws_id UUID)
|
||||||
|
RETURNS BOOLEAN LANGUAGE sql SECURITY DEFINER AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM team_members
|
||||||
|
WHERE workspace_id = ws_id
|
||||||
|
AND user_id = auth.uid()::TEXT
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ── workspaces ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE POLICY workspaces_select ON workspaces
|
||||||
|
FOR SELECT USING (is_workspace_member(id));
|
||||||
|
|
||||||
|
CREATE POLICY workspaces_insert ON workspaces
|
||||||
|
FOR INSERT WITH CHECK (owner_id = auth.uid()::TEXT);
|
||||||
|
|
||||||
|
CREATE POLICY workspaces_update ON workspaces
|
||||||
|
FOR UPDATE USING (owner_id = auth.uid()::TEXT);
|
||||||
|
|
||||||
|
-- ── artboards ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE POLICY artboards_select ON artboards
|
||||||
|
FOR SELECT USING (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
CREATE POLICY artboards_insert ON artboards
|
||||||
|
FOR INSERT WITH CHECK (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
CREATE POLICY artboards_update ON artboards
|
||||||
|
FOR UPDATE USING (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
CREATE POLICY artboards_delete ON artboards
|
||||||
|
FOR DELETE USING (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
-- ── intent_diffs ──────────────────────────────────────────────────────────────
|
||||||
|
-- Derived from artboard's workspace membership
|
||||||
|
CREATE POLICY intent_diffs_select ON intent_diffs
|
||||||
|
FOR SELECT USING (
|
||||||
|
is_workspace_member((SELECT workspace_id FROM artboards WHERE id = artboard_id))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY intent_diffs_insert ON intent_diffs
|
||||||
|
FOR INSERT WITH CHECK (
|
||||||
|
is_workspace_member((SELECT workspace_id FROM artboards WHERE id = artboard_id))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY intent_diffs_update ON intent_diffs
|
||||||
|
FOR UPDATE USING (
|
||||||
|
is_workspace_member((SELECT workspace_id FROM artboards WHERE id = artboard_id))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── agent_sessions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE POLICY agent_sessions_select ON agent_sessions
|
||||||
|
FOR SELECT USING (
|
||||||
|
is_workspace_member((SELECT workspace_id FROM artboards WHERE id = artboard_id))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY agent_sessions_insert ON agent_sessions
|
||||||
|
FOR INSERT WITH CHECK (
|
||||||
|
is_workspace_member((SELECT workspace_id FROM artboards WHERE id = artboard_id))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY agent_sessions_update ON agent_sessions
|
||||||
|
FOR UPDATE USING (
|
||||||
|
is_workspace_member((SELECT workspace_id FROM artboards WHERE id = artboard_id))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── design_language_files ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE POLICY dlf_select ON design_language_files
|
||||||
|
FOR SELECT USING (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
CREATE POLICY dlf_insert ON design_language_files
|
||||||
|
FOR INSERT WITH CHECK (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
CREATE POLICY dlf_update ON design_language_files
|
||||||
|
FOR UPDATE USING (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
-- ── team_members ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE POLICY team_members_select ON team_members
|
||||||
|
FOR SELECT USING (is_workspace_member(workspace_id));
|
||||||
|
|
||||||
|
-- Only workspace owners can add/remove members
|
||||||
|
CREATE POLICY team_members_insert ON team_members
|
||||||
|
FOR INSERT WITH CHECK (
|
||||||
|
EXISTS (SELECT 1 FROM workspaces WHERE id = workspace_id AND owner_id = auth.uid()::TEXT)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY team_members_delete ON team_members
|
||||||
|
FOR DELETE USING (
|
||||||
|
EXISTS (SELECT 1 FROM workspaces WHERE id = workspace_id AND owner_id = auth.uid()::TEXT)
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- ── Migration 004: Add notes column to intent_diffs ──────────────────────────
|
||||||
|
-- Allows the coding agent to record why a diff was blocked or any implementation
|
||||||
|
-- notes when updating status via the Agent Bridge MCP tool.
|
||||||
|
|
||||||
|
ALTER TABLE intent_diffs
|
||||||
|
ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Migration: 005 — projects
|
||||||
|
-- A Project groups artboards for a specific application inside a workspace.
|
||||||
|
-- Users connect their running app to a project (via app_url) and work
|
||||||
|
-- on its artboards in the canvas.
|
||||||
|
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
app_url TEXT, -- e.g. https://localhost:3000 or https://staging.myapp.com
|
||||||
|
framework TEXT, -- e.g. 'react', 'next', 'vue', 'svelte'
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX projects_workspace_idx ON projects(workspace_id);
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_projects_updated_at
|
||||||
|
BEFORE UPDATE ON projects
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
-- Add optional project_id to artboards so artboards can be scoped to a project.
|
||||||
|
-- Nullable: existing artboards without a project remain workspace-level.
|
||||||
|
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS artboards_project_idx ON artboards(project_id);
|
||||||
Reference in New Issue
Block a user