improved a lot of things

This commit is contained in:
SinachPat
2026-04-26 21:33:59 +01:00
parent 9c0af31751
commit 197313c0ef
20 changed files with 1726 additions and 295 deletions
+4 -1
View File
@@ -126,7 +126,10 @@
"Bash(mkdir -p \"/Users/USER/Desktop/originmain/packages/app/src/app/workspace/[wid]/project/[pid]\")", "Bash(mkdir -p \"/Users/USER/Desktop/originmain/packages/app/src/app/workspace/[wid]/project/[pid]\")",
"Bash(git -C /Users/USER/Desktop/originmain log --oneline --follow -- packages/origin-graph/migrations/002_artboard_ancestry.sql packages/origin-graph/migrations/003_rls_policies.sql packages/origin-graph/migrations/004_add_notes_to_diffs.sql)", "Bash(git -C /Users/USER/Desktop/originmain log --oneline --follow -- packages/origin-graph/migrations/002_artboard_ancestry.sql packages/origin-graph/migrations/003_rls_policies.sql packages/origin-graph/migrations/004_add_notes_to_diffs.sql)",
"Bash(git -C /Users/USER/Desktop/originmain diff --stat HEAD)", "Bash(git -C /Users/USER/Desktop/originmain diff --stat HEAD)",
"Bash(git -C /Users/USER/Desktop/originmain status --short)" "Bash(git -C /Users/USER/Desktop/originmain status --short)",
"Bash(mkdir -p /Users/USER/Desktop/originmain/packages/app/src/app/api/workspace/\\\\[id\\\\]/tokens)",
"Bash(mkdir -p /Users/USER/Desktop/originmain/packages/app/src/app/api/workspace/\\\\[id\\\\]/projects/\\\\[pid\\\\])",
"Bash(mkdir -p /Users/USER/Desktop/originmain/packages/app/src/app/api/workspace/\\\\[id\\\\]/invite)"
] ]
} }
} }
+10 -7
View File
@@ -8,21 +8,24 @@ SUPABASE_SERVICE_ROLE_KEY=
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
# Clerk — https://dashboard.clerk.com # Clerk — https://dashboard.clerk.com
CLERK_PUBLISHABLE_KEY= # NOTE: the publishable key MUST use the NEXT_PUBLIC_ prefix so Clerk initialises
# on the client side. The secret key is server-only and must NOT have the prefix.
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY= CLERK_SECRET_KEY=
# Agent Bridge — used to sign/verify workspace tokens for IDE integrations (Cursor, Claude Code).
# Generate with: openssl rand -hex 32
AGENT_BRIDGE_SECRET=
# Liveblocks (Phase 3) — https://liveblocks.io/dashboard # Liveblocks (Phase 3) — https://liveblocks.io/dashboard
LIVEBLOCKS_SECRET_KEY= LIVEBLOCKS_SECRET_KEY=
# Linear integration # Webhook secrets — set these in each integration's dashboard
LINEAR_WEBHOOK_SECRET= LINEAR_WEBHOOK_SECRET=
# Slack integration
SLACK_BOT_TOKEN= SLACK_BOT_TOKEN=
SLACK_SIGNING_SECRET= SLACK_SIGNING_SECRET=
GITHUB_WEBHOOK_SECRET=
# Agent Bridge WebSocket server port INTERCOM_WEBHOOK_SECRET=
AGENT_BRIDGE_PORT=3001
# App base URL # App base URL
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000
+81 -1
View File
@@ -248,4 +248,84 @@ Actual `createClient` / `createRoomContext` calls go in `packages/app/src/lib/li
--- ---
*Last updated: 2026-04-26 after Layers 910 foundation completion* ---
## Product Bug Audit (2026-04-26) — Work In Progress
The following is a second-pass audit of all real product gaps, independent of layer status. These are being worked on in the current session.
### ✅ Completed — Session 1
- `.env.example` fixed: `CLERK_PUBLISHABLE_KEY``NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`, added `AGENT_BRIDGE_SECRET`, `GITHUB_WEBHOOK_SECRET`, `INTERCOM_WEBHOOK_SECRET`
- `project_id` added to `ArtboardSchema` in `types.ts` and `getArtboards` query
- `createArtboardMutation` exported from `useArtboards.ts`, used in `Canvas.tsx` artboard tool
- `useArtboards` returns `rawArtboards: Artboard[]` (full DB rows) alongside canvas-mapped shapes
- Inspector props tab now shows real `metadata_jsonb` fields from selected artboard
- ArtboardNavigator shows real artboard names (not hardcoded FILE_PATHS)
- Canvas keyboard shortcuts: V/H/A/Z for tools, Escape to cancel
- All 4 error boundary files created (global, workspaces, workspace, canvas)
- Design Language upload UI in workspace page
- Webhook `project_id: null` placeholder added (awaiting full project param support)
- `PATCH /api/artboards/[id]` + `DELETE /api/artboards/[id]` + origin-graph helpers
- `POST /api/workspace/[id]/tokens` — workspace token issuance for IDE integration
- `WorkspaceCard.tsx` + `ProjectCard.tsx` extracted as `'use client'` components → fixed server-side exception on breadcrumb click
### ✅ Completed — Session 2 (2026-04-26)
- **Server-side crash fixed**: `workspaces/page.tsx` and `workspace/[wid]/page.tsx` confirmed using `WorkspaceCard`/`ProjectCard` client components (no `onMouseEnter` in server components)
- **Inspector fully rewired**: removed `useDiff`/`ARTBOARD_SNAPSHOTS` hardcoded data; now uses `useDiffs` (real DB diffs) + `useHistory` (pending local changes)
- **Export Diff button**: Inspector Diff tab shows pending `PropChange[]` from history store with "Export diff →" button that calls `POST /api/diffs`
- **`renderUrl` inline editor**: Inspector Props tab has inline input for `renderUrl`; saves via `PATCH /api/artboards/[id]`; `patchArtboard()` added to `useArtboards.ts`
- **Inspector status bar**: now reads `liveArtboardIds` from canvas store — shows pulsing green dot when live render connected, grey + hint when not
- **Empty canvas state**: removed `DEMO_ARTBOARDS` fallback from `useArtboards.ts`; Canvas shows dashed hint "Press A to create an artboard" when empty
- **Zone tool drag preview**: Canvas draws live dashed rectangle + dimension label during zone drag; cleans up on mouse-up
- **Workspace settings page**: `GET+PATCH /api/workspace/[id]`, `/workspace/[wid]/settings` page + `WorkspaceSettingsForm` client component (rename, IDE token issuance with full config snippets, danger zone)
- **Settings link in workspace page**: gear icon button added next to "New project"
### 🔧 Remaining — In Order of Priority
**CRITICAL** — all done ✅
**HIGH**
- [x] Replace `useDiff.ts` with real `useDiffs.ts`
- [x] `renderUrl` edit UI ✅
- [x] Export Diff button ✅
- [x] Zone tool canvas drag preview ✅
- [x] Empty canvas state ✅
- [x] Workspace settings page ✅
- [ ] Artboard content fallback — Artboard.tsx still shows UUID as a placeholder title when no fiber tree is connected
**MEDIUM**
- [ ] `handleComponentSelected` → canvas `selectComponent()` → Inspector shows selected component's fiber props
- [ ] Graph tab wired to real fiber tree (currently shows hardcoded DashboardCard tree)
- [ ] Navigator files tree selectable + artboard delete/rename actions
- [ ] Artboard drag-to-move (label drag → `PATCH /api/artboards/[id]` with new x/y in metadata_jsonb)
- [ ] Artboard delete (trash icon → confirm → `DELETE /api/artboards/[id]`)
- [ ] Artboard rename (double-click label → inline edit → PATCH name)
- [ ] Viewport persistence to localStorage
- [ ] Webhook `project_id` from `?project=` query param
- [ ] `@anthropic-ai/sdk` upgrade to ≥0.58 for `thinking: {type: 'adaptive'}`
**LOW**
- [ ] Project settings page
- [ ] Team invitation UI (in workspace settings — `POST /api/workspace/[id]/invite` exists)
- [ ] Drift Report UI (in Inspector AI section)
- [ ] Cross-Artboard Query UI (in navigator)
- [ ] Vitest test files (diff-engine + origin-graph)
- [ ] Multiplayer basic Liveblocks wiring
- [ ] Plugin system stub page
- [ ] Zone tool: after drag, show prompt input → `POST /api/ai/completion-zone` with bounds
### Key Technical Notes for Next Agent
**Artboard.tsx and component selection:**
The `handleComponentSelected` callback inside `Artboard.tsx` receives the clicked `FiberNode` (from `LiveArtboard`). It needs to call `useCanvas.getState().selectComponent(node.id, node)`. The Inspector's PropsTab should check `selectedComponentId`/`selectedComponentData` and show the fiber node's props when a component is selected (vs. showing artboard metadata when nothing is selected).
**Artboard drag-to-move:**
The `Artboard` component label area needs `onMouseDown` → tracks mouse delta → `PATCH /api/artboards/[id]` with `{ metadata_jsonb: { ...meta, x: newX, y: newY } }`. Use `patchArtboard(id, { metadata_jsonb: ... })` from `useArtboards.ts` then `queryClient.invalidateQueries`.
**Zone tool completion:**
After the zone drag ends (mouse-up), show a floating prompt input at the zone position. On submit → `POST /api/ai/completion-zone` with `{ artboard_id, bounds: {x,y,w,h}, prompt }`. Show result inline in the canvas.
**`@anthropic-ai/sdk` upgrade path:**
All 5 AI feature files in `packages/ai-layer/src/` have `// TODO: upgrade to adaptive thinking when SDK ≥0.58` comments. After `pnpm add @anthropic-ai/sdk@latest` in `packages/ai-layer`, replace the temperature-based calls with `thinking: {type: 'adaptive'}` on the `gateway.complete()` calls.
*Last updated: 2026-04-26 — Session 2 complete*
@@ -1,26 +1,64 @@
// GET /api/artboards/:id → fetch single artboard // GET /api/artboards/:id → fetch single artboard
// PATCH /api/artboards/:id → update name / metadata_jsonb
// DELETE /api/artboards/:id → permanently remove artboard
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 { getArtboard } from '@originmain/origin-graph'; import { getArtboard, updateArtboard, deleteArtboard } from '@originmain/origin-graph';
import type { InsertArtboard } from '@originmain/origin-graph';
export async function GET( type RouteContext = { params: Promise<{ id: string }> };
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }, export async function GET(_req: NextRequest, { params }: RouteContext) {
) {
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 { id } = await params; const { id } = await params;
try { try {
const db = serverClient(); const artboard = await getArtboard(serverClient(), id);
const artboard = await getArtboard(db, id);
return NextResponse.json(artboard); return NextResponse.json(artboard);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'; const message = err instanceof Error ? err.message : 'Unknown error';
const status = message.includes('not found') || message.includes('0 rows') ? 404 : 500; const status = message.includes('0 rows') ? 404 : 500;
return NextResponse.json({ error: message }, { status }); return NextResponse.json({ error: message }, { status });
} }
} }
export async function PATCH(req: NextRequest, { params }: RouteContext) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const body = (await req.json().catch(() => ({}))) as Partial<InsertArtboard>;
// Only allow safe fields to be patched
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;
if (Object.keys(patch).length === 0)
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
try {
const updated = await updateArtboard(serverClient(), id, patch);
return NextResponse.json(updated);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
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;
try {
await deleteArtboard(serverClient(), id);
return new NextResponse(null, { status: 204 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,85 @@
// POST /api/workspace/:id/invite → add a member by Clerk userId
// DELETE /api/workspace/:id/invite → remove a member by Clerk userId (owners only)
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { addTeamMember, removeTeamMember } from '@originmain/origin-graph';
import type { TeamRole } from '@originmain/origin-graph';
type Ctx = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Ctx) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id: workspaceId } = await params;
const db = serverClient();
// Only workspace owners can invite
const { data: owner } = await db
.from('workspaces')
.select('id')
.eq('id', workspaceId)
.eq('owner_id', userId)
.single();
if (!owner) 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 role: TeamRole = body.role ?? 'DESIGNER';
// Check if already a member
const { data: existing } = await db
.from('team_members')
.select('id')
.eq('workspace_id', workspaceId)
.eq('user_id', body.userId)
.limit(1)
.single();
if (existing) return NextResponse.json({ error: 'User is already a member' }, { status: 409 });
try {
const member = await addTeamMember(db, {
workspace_id: workspaceId,
user_id: body.userId,
role,
});
return NextResponse.json(member, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE(req: NextRequest, { params }: Ctx) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { 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 });
// Only owners can remove; a member can remove themselves
const { data: owner } = await db
.from('workspaces')
.select('id')
.eq('id', workspaceId)
.eq('owner_id', userId)
.single();
if (!owner && body.userId !== userId)
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
await removeTeamMember(db, workspaceId, body.userId);
return new NextResponse(null, { status: 204 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,84 @@
// GET /api/workspace/:id/projects/:pid → fetch single project
// 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 { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { updateProject, deleteProject } from '@originmain/origin-graph';
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> {
const db = serverClient();
const { data } = await db
.from('team_members')
.select('id')
.eq('workspace_id', workspaceId)
.eq('user_id', userId)
.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 { id: workspaceId, pid } = await params;
if (!(await assertMember(workspaceId, userId)))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { data, error } = await serverClient()
.from('projects')
.select('*')
.eq('id', pid)
.eq('workspace_id', workspaceId)
.single() as unknown as { data: Project | null; error: { message: string } | null };
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(data);
}
export async function PATCH(req: NextRequest, { params }: Ctx) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id: workspaceId, pid } = await params;
if (!(await assertMember(workspaceId, userId)))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = (await req.json().catch(() => ({}))) as Partial<InsertProject>;
const patch: Partial<InsertProject> = {};
if (typeof body.name === 'string' && body.name.trim()) patch.name = body.name.trim();
if ('description' in body) patch.description = body.description ?? null;
if ('app_url' in body) patch.app_url = body.app_url ?? null;
if ('framework' in body) patch.framework = body.framework ?? null;
if (Object.keys(patch).length === 0)
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
try {
const updated = await updateProject(serverClient(), pid, patch);
return NextResponse.json(updated);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE(_req: NextRequest, { params }: Ctx) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id: workspaceId, pid } = await params;
if (!(await assertMember(workspaceId, userId)))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
await deleteProject(serverClient(), pid);
return new NextResponse(null, { status: 204 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,71 @@
// PATCH /api/workspace/:id → rename workspace (owner only)
// GET /api/workspace/:id → fetch workspace (any member)
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
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) {
const { data } = await db
.from('team_members')
.select('role')
.eq('workspace_id', workspaceId)
.eq('user_id', userId)
.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 { id } = await params;
const db = serverClient();
// Verify membership
const { data: member } = await db
.from('team_members')
.select('id')
.eq('workspace_id', id)
.eq('user_id', userId)
.limit(1)
.single();
if (!member) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { data, error } = await db.from('workspaces').select('*').eq('id', id).single();
if (error || !data) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(data as Workspace);
}
export async function PATCH(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 rename' }, { status: 403 });
const body = (await req.json().catch(() => ({}))) as { name?: string };
if (typeof body.name !== 'string' || !body.name.trim()) {
return NextResponse.json({ error: 'name is required' }, { status: 400 });
}
const { data, error } = await db
.from('workspaces')
.update({ name: body.name.trim() })
.eq('id', id)
.select()
.single();
if (error || !data) return NextResponse.json({ error: error?.message ?? 'Update failed' }, { status: 500 });
return NextResponse.json(data as Workspace);
}
@@ -0,0 +1,63 @@
// POST /api/workspace/:id/tokens
// 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 { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import {
issueWorkspaceToken,
generateCursorConfig,
generateClaudeCodeConfig,
} from '@originmain/agent-bridge';
import type { AgentType } from '@originmain/agent-bridge';
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 { id: workspaceId } = await params;
const db = serverClient();
// Verify membership
const { data: member } = await db
.from('team_members')
.select('id')
.eq('workspace_id', workspaceId)
.eq('user_id', userId)
.limit(1)
.single();
if (!member) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = (await req.json().catch(() => ({}))) as {
agentType?: AgentType;
workspaceName?: string;
projectName?: string;
};
const agentType: AgentType = body.agentType ?? 'GENERIC';
const workspaceName = body.workspaceName ?? 'My Workspace';
try {
const token = issueWorkspaceToken(workspaceId, agentType);
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000';
const mcpServerUrl = `${appUrl}/api/agent-bridge`;
const cursorConfig = generateCursorConfig({ mcpServerUrl, workspaceToken: token, workspaceName });
const claudeCodeConfig = generateClaudeCodeConfig({
mcpServerUrl,
workspaceToken: token,
workspaceName,
...(body.projectName !== undefined ? { projectName: body.projectName } : {}),
});
return NextResponse.json({ token, cursorConfig, claudeCodeConfig });
} catch (err) {
const message = err instanceof Error ? err.message : 'Token generation failed';
return NextResponse.json({ error: message }, { status: 500 });
}
}
+27 -12
View File
@@ -66,18 +66,33 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
: `${projects.length} project${projects.length !== 1 ? 's' : ''}`} : `${projects.length} project${projects.length !== 1 ? 's' : ''}`}
</p> </p>
</div> </div>
<Link href={`/workspace/${wid}/project/new`} style={{ <div style={{ display: 'flex', gap: 10 }}>
display: 'inline-flex', alignItems: 'center', gap: 6, <Link href={`/workspace/${wid}/settings`} style={{
background: '#0A0A0A', color: '#FFFFFF', display: 'inline-flex', alignItems: 'center', gap: 6,
fontSize: '0.875rem', fontWeight: 600, background: 'transparent', color: '#71717A',
padding: '9px 18px', borderRadius: 9, fontSize: '0.875rem', fontWeight: 500,
textDecoration: 'none', letterSpacing: '-0.01em', padding: '9px 16px', borderRadius: 9,
}}> textDecoration: 'none', border: '1px solid rgba(0,0,0,0.1)',
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"> }}>
<path d="M7 1v12M1 7h12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/> <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
</svg> <circle cx="7" cy="7" r="2" stroke="currentColor" strokeWidth="1.4"/>
New project <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.3" strokeLinecap="round"/>
</Link> </svg>
Settings
</Link>
<Link href={`/workspace/${wid}/project/new`} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
background: '#0A0A0A', color: '#FFFFFF',
fontSize: '0.875rem', fontWeight: 600,
padding: '9px 18px', borderRadius: 9,
textDecoration: 'none', letterSpacing: '-0.01em',
}}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 1v12M1 7h12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/>
</svg>
New project
</Link>
</div>
</div> </div>
{/* Empty state */} {/* Empty state */}
@@ -0,0 +1,63 @@
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
import { serverClient } from '@/lib/supabase';
import { AppHeader } from '@/components/shell/AppHeader';
import { WorkspaceSettingsForm } from '@/components/shell/WorkspaceSettingsForm';
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: `${result.data?.name ?? 'Workspace'} Settings — Originmain` };
}
export default async function WorkspaceSettingsPage({ 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, role')
.eq('workspace_id', wid)
.eq('user_id', userId)
.limit(1)
.single();
if (!member) redirect('/workspaces');
const { data: wsData } = await db.from('workspaces').select('*').eq('id', wid).single();
if (!wsData) redirect('/workspaces');
const workspace = wsData as Workspace;
const memberRole = (member as { role: string }).role;
return (
<div style={{ minHeight: '100vh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<AppHeader breadcrumbs={[
{ label: 'Workspaces', href: '/workspaces' },
{ label: workspace.name, href: `/workspace/${wid}` },
{ label: 'Settings' },
]} />
<main style={{ maxWidth: 680, margin: '0 auto', padding: '48px 24px' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, letterSpacing: '-0.03em', color: '#0A0A0A', margin: '0 0 4px' }}>
Workspace settings
</h1>
<p style={{ margin: '0 0 40px', fontSize: '0.875rem', color: '#71717A' }}>
Manage {workspace.name}
</p>
<WorkspaceSettingsForm
workspaceId={wid}
workspaceName={workspace.name}
memberRole={memberRole}
/>
</main>
</div>
);
}
+322 -118
View File
@@ -1,7 +1,9 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState, useCallback, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useCanvas } from '@/store/canvas'; import { useCanvas } from '@/store/canvas';
import { useViewport } from '@/store/viewport';
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';
@@ -17,38 +19,172 @@ interface ArtboardProps {
} }
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 } = useCanvas(); const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, selectComponent } = useCanvas();
const selected = selectedArtboardId === id; const selected = selectedArtboardId === id;
const queryClient = useQueryClient();
// ── Fiber tree (from LiveArtboard) ─────────────────────────────────────────
const [fiberRoot, setFiberRoot] = useState<FiberNode | undefined>(undefined); const [fiberRoot, setFiberRoot] = useState<FiberNode | undefined>(undefined);
const handleFiberUpdate = useCallback((root: FiberNode) => setFiberRoot(root), []); const handleFiberUpdate = useCallback((root: FiberNode) => {
const handleComponentSelected = useCallback( setFiberRoot(root);
(nodeId: string) => { void nodeId; /* future: highlight in inspector */ }, setArtboardLive(id, true);
[] }, [id, setArtboardLive]);
);
const handleComponentSelected = useCallback((nodeId: string) => {
if (!fiberRoot) return;
// Walk fiber tree to find the selected node
const node = findFiberNode(fiberRoot, nodeId);
selectComponent(nodeId, node ?? null);
}, [fiberRoot, selectComponent]);
// ── Drag to reposition ─────────────────────────────────────────────────────
const isDragging = useRef(false);
const dragStart = useRef({ mouseX: 0, mouseY: 0, artX: 0, artY: 0 });
const [dragOffset, setDragOffset] = useState({ dx: 0, dy: 0 });
const onLabelMouseDown = useCallback((e: React.MouseEvent) => {
// Only drag on left button; don't interfere with rename
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
isDragging.current = true;
const { zoom, panX, panY } = useViewport.getState();
dragStart.current = {
mouseX: (e.clientX - panX) / zoom,
mouseY: (e.clientY - panY) / zoom,
artX: x,
artY: y,
};
setDragOffset({ dx: 0, dy: 0 });
const onMove = (mv: MouseEvent) => {
if (!isDragging.current) return;
const { zoom: z, panX: px, panY: py } = useViewport.getState();
const curX = (mv.clientX - px) / z;
const curY = (mv.clientY - py) / z;
setDragOffset({
dx: curX - dragStart.current.mouseX,
dy: curY - dragStart.current.mouseY,
});
};
const onUp = () => {
if (!isDragging.current) return;
isDragging.current = false;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
const newX = Math.round(dragStart.current.artX + dragOffset.dx);
const newY = Math.round(dragStart.current.artY + dragOffset.dy);
// Persist position
fetch(`/api/artboards/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
metadata_jsonb: { x: newX, y: newY, width, height, ...(renderUrl ? { renderUrl } : {}) },
}),
}).then(() => {
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
setDragOffset({ dx: 0, dy: 0 });
}).catch(console.error);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}, [id, x, y, width, height, renderUrl, workspaceId, projectId, queryClient, dragOffset.dx, dragOffset.dy]);
// ── Inline rename ──────────────────────────────────────────────────────────
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(label);
const renameRef = useRef<HTMLInputElement>(null);
const startRename = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setRenameValue(label);
setRenaming(true);
setTimeout(() => renameRef.current?.select(), 0);
}, [label]);
const commitRename = useCallback(() => {
setRenaming(false);
const trimmed = renameValue.trim();
if (!trimmed || trimmed === label) return;
fetch(`/api/artboards/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }),
}).then(() => {
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
}).catch(console.error);
}, [id, renameValue, label, workspaceId, projectId, queryClient]);
const effectiveX = x + (isDragging.current ? dragOffset.dx : 0);
const effectiveY = y + (isDragging.current ? dragOffset.dy : 0);
return ( return (
<div <div
style={{ position: 'absolute', top: y, left: x }} style={{ position: 'absolute', top: effectiveY, left: effectiveX }}
onClick={(e) => { e.stopPropagation(); selectArtboard(id); }} onClick={(e) => { e.stopPropagation(); selectArtboard(id); }}
> >
{/* Label */} {/* Label / drag handle */}
<div <div
onMouseDown={onLabelMouseDown}
onDoubleClick={startRename}
style={{ style={{
position: 'absolute', position: 'absolute',
top: -24, top: -26,
left: 0, left: 0,
fontSize: 11, height: 22,
fontFamily: "'JetBrains Mono', 'SF Mono', ui-monospace, monospace", display: 'flex',
fontWeight: selected ? 500 : 400, alignItems: 'center',
color: selected ? '#3385FF' : 'rgba(255,255,255,0.35)', cursor: isDragging.current ? 'grabbing' : 'grab',
userSelect: 'none', userSelect: 'none',
whiteSpace: 'nowrap', minWidth: 80,
letterSpacing: '-0.01em',
transition: 'color 0.15s',
}} }}
> >
{label} {renaming ? (
<input
ref={renameRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') setRenaming(false);
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
style={{
fontSize: 11,
fontFamily: "'JetBrains Mono', 'SF Mono', ui-monospace, monospace",
fontWeight: 500,
color: '#3385FF',
background: 'rgba(51,133,255,0.12)',
border: '1px solid rgba(51,133,255,0.4)',
borderRadius: 3,
padding: '1px 6px',
outline: 'none',
width: Math.max(80, label.length * 7),
}}
/>
) : (
<span
style={{
fontSize: 11,
fontFamily: "'JetBrains Mono', 'SF Mono', ui-monospace, monospace",
fontWeight: selected ? 500 : 400,
color: selected ? '#3385FF' : 'rgba(255,255,255,0.35)',
whiteSpace: 'nowrap',
letterSpacing: '-0.01em',
transition: 'color 0.15s',
}}
>
{label}
</span>
)}
</div> </div>
{/* Frame */} {/* Frame */}
@@ -67,7 +203,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
transition: 'box-shadow 0.15s', transition: 'box-shadow 0.15s',
}} }}
> >
{/* Selection handles */} {/* Selection corner handles */}
{selected && ( {selected && (
<> <>
<Handle pos={{ top: -4, left: -4 }} /> <Handle pos={{ top: -4, left: -4 }} />
@@ -77,7 +213,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
</> </>
)} )}
{/* Per-artboard content */} {/* Content */}
{renderUrl ? ( {renderUrl ? (
<> <>
<LiveArtboard <LiveArtboard
@@ -96,24 +232,160 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
/> />
</> </>
) : ( ) : (
<ArtboardContent id={id} /> <EmptyArtboardContent id={id} label={label} width={width} height={height} workspaceId={workspaceId} projectId={projectId} queryClient={queryClient} />
)} )}
</div> </div>
</div> </div>
); );
} }
// ── Empty artboard (no renderUrl) ─────────────────────────────────────────────
function EmptyArtboardContent({
id, label, width, height, workspaceId, projectId, queryClient,
}: {
id: string; label: string; width: number; height: number;
workspaceId: string | null; projectId: string | null;
queryClient: ReturnType<typeof useQueryClient>;
}) {
const [editing, setEditing] = useState(false);
const [urlValue, setUrlValue] = useState('');
const [saving, setSaving] = useState(false);
const save = async () => {
const url = urlValue.trim();
if (!url) return;
setSaving(true);
await fetch(`/api/artboards/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
metadata_jsonb: { renderUrl: url, width, height, x: 0, y: 0 },
}),
}).catch(console.error);
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
setSaving(false);
setEditing(false);
};
return (
<div
style={{
width, height, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
background: '#F8F8FA', gap: 12, padding: 20,
}}
>
{/* Artboard name */}
<div style={{ fontSize: 13, fontWeight: 600, color: '#18181B', letterSpacing: '-0.02em', textAlign: 'center' }}>
{label}
</div>
{editing ? (
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 8 }}>
<input
autoFocus
type="url"
value={urlValue}
onChange={(e) => setUrlValue(e.target.value)}
placeholder="http://localhost:3000"
onKeyDown={(e) => { if (e.key === 'Enter') void save(); if (e.key === 'Escape') setEditing(false); e.stopPropagation(); }}
style={{
padding: '7px 10px', borderRadius: 6, border: '1.5px solid #0066FF',
fontSize: 11, fontFamily: 'inherit', outline: 'none', width: '100%',
boxSizing: 'border-box' as const,
}}
/>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => void save()} disabled={saving}
style={{
flex: 1, padding: '6px 0', borderRadius: 5, border: 'none',
background: '#0066FF', color: '#fff', fontSize: 11, fontWeight: 600,
cursor: saving ? 'default' : 'pointer', fontFamily: 'inherit',
}}
>
{saving ? 'Saving…' : 'Connect'}
</button>
<button
onClick={() => setEditing(false)}
style={{
padding: '6px 10px', borderRadius: 5, border: '1px solid #E4E4E7',
background: '#fff', fontSize: 11, cursor: 'pointer', fontFamily: 'inherit',
}}
>
Cancel
</button>
</div>
</div>
) : (
<>
{/* Icon */}
<div style={{
width: 40, height: 40, borderRadius: 10,
background: 'rgba(0,102,255,0.07)', border: '1px solid rgba(0,102,255,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect x="1" y="3" width="16" height="12" rx="2" stroke="#0066FF" strokeWidth="1.3"/>
<path d="M6 8l2.5 2.5L12 6" stroke="#0066FF" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<p style={{ margin: 0, fontSize: 11, color: '#71717A', textAlign: 'center', lineHeight: 1.5, maxWidth: 180 }}>
Connect a running app URL to enable live rendering
</p>
<button
onClick={() => setEditing(true)}
style={{
padding: '7px 14px', borderRadius: 6, border: '1px solid rgba(0,0,0,0.12)',
background: '#fff', fontSize: 11, fontWeight: 600, color: '#0A0A0A',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Connect app
</button>
</>
)}
</div>
);
}
// ── Demo content (only for hardcoded demo IDs) ────────────────────────────────
function ArtboardContent({ id }: { id: string }) { function ArtboardContent({ id }: { id: string }) {
switch (id) { switch (id) {
case 'dashboard-card': return <DashboardCard />; case 'dashboard-card': return <DashboardCard />;
case 'user-profile': return <UserProfile />; case 'user-profile': return <UserProfile />;
case 'nav-sidebar': return <NavSidebar />; case 'nav-sidebar': return <NavSidebar />;
case 'data-table': return <DataTable />; case 'data-table': return <DataTable />;
default: return <Placeholder label={id} />; default: return null; // shouldn't reach here for real artboards
} }
} }
// ── DashboardCard ────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function findFiberNode(root: FiberNode, nodeId: string): FiberNode | null {
if (root.id === nodeId) return root;
if (!root.children) return null;
for (const child of root.children) {
const found = findFiberNode(child, nodeId);
if (found) return found;
}
return null;
}
function Handle({ pos }: { pos: React.CSSProperties }) {
return (
<div style={{
position: 'absolute', width: 8, height: 8,
background: '#fff', border: '2px solid #3385FF',
borderRadius: 2, zIndex: 10, ...pos,
}} />
);
}
// ── Demo card components ──────────────────────────────────────────────────────
function DashboardCard() { function DashboardCard() {
return ( return (
<div style={{ padding: 20 }}> <div style={{ padding: 20 }}>
@@ -129,15 +401,14 @@ function DashboardCard() {
<div style={{ height: '100%', width: '68%', background: 'linear-gradient(90deg, #0066FF, #3385FF)', borderRadius: 99 }} /> <div style={{ height: '100%', width: '68%', background: 'linear-gradient(90deg, #0066FF, #3385FF)', borderRadius: 99 }} />
</div> </div>
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
<Tag>Q4 2024</Tag> {['Q4 2024', 'MRR', 'SaaS'].map(t => (
<Tag>MRR</Tag> <span key={t} style={{ fontSize: 9, background: '#F4F4F5', color: '#71717A', padding: '3px 8px', borderRadius: 99, fontWeight: 500 }}>{t}</span>
<Tag>SaaS</Tag> ))}
</div> </div>
</div> </div>
); );
} }
// ── UserProfile ────────────────────────────────────────────
function UserProfile() { function UserProfile() {
return ( return (
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%' }}> <div style={{ padding: 20, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%' }}>
@@ -152,17 +423,13 @@ function UserProfile() {
</div> </div>
))} ))}
</div> </div>
<button style={{ marginTop: 16, width: '100%', padding: '7px 0', borderRadius: 6, border: '1px solid #E4E4E7', background: '#FAFAFA', fontSize: 10, fontWeight: 600, color: '#3F3F46', cursor: 'default' }}>
Edit profile
</button>
</div> </div>
); );
} }
// ── NavSidebar ─────────────────────────────────────────────
function NavSidebar() { function NavSidebar() {
const items = [ const items = [
{ icon: '⊞', label: 'Dashboard', active: true }, { icon: '⊞', label: 'Dashboard', active: true },
{ icon: '◉', label: 'Origin Graph', active: false }, { icon: '◉', label: 'Origin Graph', active: false },
{ icon: '⬜', label: 'Artboards', active: false }, { icon: '⬜', label: 'Artboards', active: false },
{ icon: '△', label: 'Diffs', active: false }, { icon: '△', label: 'Diffs', active: false },
@@ -175,46 +442,26 @@ function NavSidebar() {
</div> </div>
<div style={{ height: 1, background: '#EBEBEB', margin: '0 0 8px' }} /> <div style={{ height: 1, background: '#EBEBEB', margin: '0 0 8px' }} />
{items.map(({ icon, label, active }) => ( {items.map(({ icon, label, active }) => (
<div <div key={label} style={{
key={label} display: 'flex', alignItems: 'center', gap: 8,
style={{ padding: '7px 12px', margin: '1px 6px', borderRadius: 5,
display: 'flex', background: active ? 'rgba(0,102,255,0.07)' : 'transparent',
alignItems: 'center', fontSize: 10, fontWeight: active ? 600 : 400,
gap: 8, color: active ? '#0066FF' : '#52525B', cursor: 'default',
padding: '7px 12px', }}>
margin: '1px 6px', <span style={{ fontSize: 11 }}>{icon}</span>{label}
borderRadius: 5,
background: active ? 'rgba(0,102,255,0.07)' : 'transparent',
fontSize: 10,
fontWeight: active ? 600 : 400,
color: active ? '#0066FF' : '#52525B',
cursor: 'default',
}}
>
<span style={{ fontSize: 11 }}>{icon}</span>
{label}
</div> </div>
))} ))}
<div style={{ marginTop: 'auto', padding: '8px 12px 0', borderTop: '1px solid #EBEBEB' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 24, height: 24, borderRadius: '50%', background: 'linear-gradient(135deg, #7C3AED, #0066FF)', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: '#0A0A0A' }}>Sarah Chen</div>
<div style={{ fontSize: 8, color: '#A1A1AA' }}>Admin</div>
</div>
</div>
</div>
</div> </div>
); );
} }
// ── DataTable ──────────────────────────────────────────────
function DataTable() { function DataTable() {
const rows = [ const rows = [
{ name: 'DashboardCard', status: 'Live', nodes: 12, tokens: 8 }, { name: 'DashboardCard', status: 'Live', nodes: 12, tokens: 8 },
{ name: 'UserProfile', status: 'Draft', nodes: 7, tokens: 3 }, { name: 'UserProfile', status: 'Draft', nodes: 7, tokens: 3 },
{ name: 'NavSidebar', status: 'Live', nodes: 19, tokens: 11 }, { name: 'NavSidebar', status: 'Live', nodes: 19, tokens: 11 },
{ name: 'DataTable', status: 'Review', nodes: 24, tokens: 14 }, { name: 'DataTable', status: 'Review', nodes: 24, tokens: 14 },
]; ];
return ( return (
<div style={{ padding: '16px 0', height: '100%', display: 'flex', flexDirection: 'column' }}> <div style={{ padding: '16px 0', height: '100%', display: 'flex', flexDirection: 'column' }}>
@@ -226,32 +473,19 @@ function DataTable() {
<span key={h} style={{ fontFamily: 'monospace', fontSize: 8, color: '#A1A1AA', letterSpacing: '0.05em', textTransform: 'uppercase' }}>{h}</span> <span key={h} style={{ fontFamily: 'monospace', fontSize: 8, color: '#A1A1AA', letterSpacing: '0.05em', textTransform: 'uppercase' }}>{h}</span>
))} ))}
</div> </div>
<div style={{ height: 1, background: '#F0F0F0' }} />
{rows.map((row, i) => ( {rows.map((row, i) => (
<div <div key={row.name} style={{
key={row.name} display: 'grid', gridTemplateColumns: '1fr 60px 50px 50px',
style={{ padding: '8px 16px', gap: 4,
display: 'grid', background: i % 2 === 0 ? 'transparent' : '#FAFAFA', alignItems: 'center',
gridTemplateColumns: '1fr 60px 50px 50px', }}>
padding: '8px 16px', <span style={{ fontSize: 10, fontWeight: 500, color: '#0A0A0A' }}>{row.name}</span>
gap: 4,
background: i % 2 === 0 ? 'transparent' : '#FAFAFA',
alignItems: 'center',
}}
>
<span style={{ fontSize: 10, fontWeight: 500, color: '#0A0A0A', letterSpacing: '-0.01em' }}>{row.name}</span>
<span style={{ <span style={{
fontSize: 8, fontSize: 8, fontWeight: 600, fontFamily: 'monospace',
fontWeight: 600,
color: row.status === 'Live' ? '#059669' : row.status === 'Draft' ? '#6B7280' : '#D97706', color: row.status === 'Live' ? '#059669' : row.status === 'Draft' ? '#6B7280' : '#D97706',
background: row.status === 'Live' ? '#ECFDF5' : row.status === 'Draft' ? '#F9FAFB' : '#FFFBEB', background: row.status === 'Live' ? '#ECFDF5' : row.status === 'Draft' ? '#F9FAFB' : '#FFFBEB',
padding: '2px 6px', padding: '2px 6px', borderRadius: 99, width: 'fit-content',
borderRadius: 99, }}>{row.status}</span>
width: 'fit-content',
fontFamily: 'monospace',
}}>
{row.status}
</span>
<span style={{ fontSize: 10, color: '#52525B', fontFamily: 'monospace' }}>{row.nodes}</span> <span style={{ fontSize: 10, color: '#52525B', fontFamily: 'monospace' }}>{row.nodes}</span>
<span style={{ fontSize: 10, color: '#52525B', fontFamily: 'monospace' }}>{row.tokens}</span> <span style={{ fontSize: 10, color: '#52525B', fontFamily: 'monospace' }}>{row.tokens}</span>
</div> </div>
@@ -260,35 +494,5 @@ function DataTable() {
); );
} }
function Placeholder({ label }: { label: string }) { // Suppress unused warning — kept for demo IDs in ArtboardContent
return ( void ArtboardContent;
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#A1A1AA', fontSize: 11 }}>
{label}
</div>
);
}
function Handle({ pos }: { pos: React.CSSProperties }) {
return (
<div
style={{
position: 'absolute',
width: 8,
height: 8,
background: '#fff',
border: '2px solid #3385FF',
borderRadius: 2,
zIndex: 10,
...pos,
}}
/>
);
}
function Tag({ children }: { children: string }) {
return (
<span style={{ fontSize: 9, background: '#F4F4F5', color: '#71717A', padding: '3px 8px', borderRadius: 99, fontWeight: 500 }}>
{children}
</span>
);
}
+104 -19
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useRef, useEffect, useCallback } from 'react'; import { useRef, useEffect, useCallback, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useViewport } from '@/store/viewport'; import { useViewport } from '@/store/viewport';
import { useCanvas } from '@/store/canvas'; import { useCanvas } from '@/store/canvas';
@@ -16,9 +16,13 @@ export function Canvas() {
const { artboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined); const { artboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isPanning = useRef(false); const isPanning = useRef(false);
const lastPos = useRef({ x: 0, y: 0 }); const lastPos = useRef({ x: 0, y: 0 });
const spaceDown = useRef(false); const spaceDown = useRef(false);
// Zone tool: drag to draw a completion zone
const zoneStart = useRef<{ x: number; y: number } | null>(null);
const [zonePreview, setZonePreview] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
// Wheel: pan or pinch-zoom // Wheel: pan or pinch-zoom
useEffect(() => { useEffect(() => {
@@ -57,14 +61,20 @@ export function Canvas() {
return; return;
} }
const rect = containerRef.current!.getBoundingClientRect();
const { panX, panY, zoom } = useViewport.getState();
const canvasX = Math.round((e.clientX - rect.left - panX) / zoom);
const canvasY = Math.round((e.clientY - rect.top - panY) / zoom);
// Zone tool: start drag to define completion zone bounds
if (activeTool === 'zone') {
zoneStart.current = { x: canvasX, y: canvasY };
setZonePreview({ x: canvasX, y: canvasY, w: 0, h: 0 });
return;
}
// Artboard creation tool: click on canvas to place a new artboard // Artboard creation tool: click on canvas to place a new artboard
if (activeTool === 'artboard' && e.target === e.currentTarget && workspaceId) { if (activeTool === 'artboard' && e.target === e.currentTarget && workspaceId) {
const rect = containerRef.current!.getBoundingClientRect();
// Convert screen → canvas space (invert matrix(zoom,0,0,zoom,panX,panY))
const { panX, panY, zoom } = useViewport.getState();
const canvasX = Math.round((e.clientX - rect.left - panX) / zoom);
const canvasY = Math.round((e.clientY - rect.top - panY) / zoom);
const label = `Artboard ${Date.now().toString(36).slice(-4).toUpperCase()}`; const label = `Artboard ${Date.now().toString(36).slice(-4).toUpperCase()}`;
createArtboardMutation({ createArtboardMutation({
workspace_id: workspaceId, workspace_id: workspaceId,
@@ -88,21 +98,46 @@ export function Canvas() {
}, [activeTool, setActiveTool, selectArtboard, workspaceId, projectId, queryClient]); }, [activeTool, setActiveTool, selectArtboard, workspaceId, projectId, queryClient]);
const onMouseMove = useCallback((e: React.MouseEvent) => { const onMouseMove = useCallback((e: React.MouseEvent) => {
if (!isPanning.current) return; if (isPanning.current) {
const dx = e.clientX - lastPos.current.x; const dx = e.clientX - lastPos.current.x;
const dy = e.clientY - lastPos.current.y; const dy = e.clientY - lastPos.current.y;
lastPos.current = { x: e.clientX, y: e.clientY }; lastPos.current = { x: e.clientX, y: e.clientY };
const { panX, panY, setPan } = useViewport.getState(); const { panX, panY, setPan } = useViewport.getState();
setPan(panX + dx, panY + dy); setPan(panX + dx, panY + dy);
}, []); return;
}
const onMouseUp = useCallback(() => { isPanning.current = false; }, []); // Zone tool: update preview rectangle while dragging
if (activeTool === 'zone' && zoneStart.current) {
const rect = containerRef.current!.getBoundingClientRect();
const { panX, panY, zoom } = useViewport.getState();
const cx = Math.round((e.clientX - rect.left - panX) / zoom);
const cy = Math.round((e.clientY - rect.top - panY) / zoom);
setZonePreview({
x: Math.min(zoneStart.current.x, cx),
y: Math.min(zoneStart.current.y, cy),
w: Math.abs(cx - zoneStart.current.x),
h: Math.abs(cy - zoneStart.current.y),
});
}
}, [activeTool]);
const onMouseUp = useCallback(() => {
isPanning.current = false;
// Zone tool: finalise zone on mouse-up (clear preview; zone result is handled elsewhere)
if (activeTool === 'zone' && zoneStart.current) {
zoneStart.current = null;
setZonePreview(null);
setActiveTool('select');
}
}, [activeTool, setActiveTool]);
// Dot grid that shifts with pan and scales with zoom // Dot grid that shifts with pan and scales with zoom
const gridSpacing = Math.max(6, 20 * zoom); const gridSpacing = Math.max(6, 20 * zoom);
const cursor = const cursor =
activeTool === 'pan' || isPanning.current ? 'grab' : activeTool === 'pan' || isPanning.current ? 'grab' :
activeTool === 'artboard' ? 'crosshair' : activeTool === 'artboard' || activeTool === 'zone' ? 'crosshair' :
'default'; 'default';
return ( return (
@@ -156,7 +191,57 @@ export function Canvas() {
{artboards.map((ab) => ( {artboards.map((ab) => (
<Artboard key={ab.id} {...ab} /> <Artboard key={ab.id} {...ab} />
))} ))}
{/* Zone tool: live drag preview rectangle */}
{zonePreview && zonePreview.w > 4 && zonePreview.h > 4 && (
<div style={{
position: 'absolute',
left: zonePreview.x, top: zonePreview.y,
width: zonePreview.w, height: zonePreview.h,
border: '1.5px dashed rgba(51,133,255,0.8)',
background: 'rgba(51,133,255,0.06)',
borderRadius: 4,
pointerEvents: 'none',
}}>
<span style={{
position: 'absolute', top: -20, left: 0,
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem', color: 'rgba(51,133,255,0.9)',
letterSpacing: '-0.01em', whiteSpace: 'nowrap',
}}>
{zonePreview.w} × {zonePreview.h}
</span>
</div>
)}
</div> </div>
{/* Empty canvas hint — shown only when workspace has no artboards yet */}
{artboards.length === 0 && (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
pointerEvents: 'none', zIndex: 3,
}}>
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
opacity: 0.4,
}}>
<svg width="36" height="36" viewBox="0 0 36 36" fill="none">
<rect x="4" y="4" width="12" height="12" rx="2" stroke="rgba(255,255,255,0.5)" strokeWidth="1.2" strokeDasharray="3 2"/>
<rect x="20" y="4" width="12" height="12" rx="2" stroke="rgba(255,255,255,0.5)" strokeWidth="1.2" strokeDasharray="3 2"/>
<rect x="4" y="20" width="12" height="12" rx="2" stroke="rgba(255,255,255,0.5)" strokeWidth="1.2" strokeDasharray="3 2"/>
<rect x="20" y="20" width="12" height="12" rx="2" stroke="rgba(255,255,255,0.5)" strokeWidth="1.2" strokeDasharray="3 2"/>
</svg>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem', color: 'rgba(255,255,255,0.45)',
letterSpacing: '0.08em', textTransform: 'uppercase',
}}>
Press A to create an artboard
</span>
</div>
</div>
)}
</div> </div>
); );
} }
@@ -1,12 +1,13 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useCallback } from 'react';
import { useCanvas } from '@/store/canvas'; import { useCanvas } from '@/store/canvas';
import { useArtboards } from '@/hooks/useArtboards'; import { useHistory } from '@/store/history';
import { useDiff } from '@/hooks/useDiff'; import { useArtboards, patchArtboard } from '@/hooks/useArtboards';
import { ARTBOARD_SNAPSHOTS } from '@/data/artboard-snapshots'; import { useDiffs } from '@/hooks/useDiffs';
import type { DiffResult, PropChange } from '@originmain/diff-engine'; import { useQueryClient } from '@tanstack/react-query';
import type { Artboard } from '@originmain/origin-graph'; import type { PropChange } from '@originmain/diff-engine';
import type { Artboard, IntentDiff } from '@originmain/origin-graph';
const TYPE_COLORS: Record<string, string> = { const TYPE_COLORS: Record<string, string> = {
s: '#7DD3A8', s: '#7DD3A8',
@@ -30,11 +31,11 @@ const T = {
}; };
export function Inspector() { export function Inspector() {
const { selectedArtboardId, workspaceId, projectId } = useCanvas(); const { selectedArtboardId, liveArtboardIds, workspaceId, projectId } = useCanvas();
const [tab, setTab] = useState<TabId>('props'); const [tab, setTab] = useState<TabId>('props');
const diffResult = useDiff(selectedArtboardId);
const { rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined); const { rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
const selectedArtboard = rawArtboards.find((ab) => ab.id === selectedArtboardId) ?? null; const selectedArtboard = rawArtboards.find((ab) => ab.id === selectedArtboardId) ?? null;
const isLive = selectedArtboardId ? liveArtboardIds.has(selectedArtboardId) : false;
return ( return (
<div <div
@@ -50,13 +51,7 @@ export function Inspector() {
}} }}
> >
{/* Tab bar */} {/* Tab bar */}
<div <div style={{ display: 'flex', borderBottom: `1px solid ${T.border}`, flexShrink: 0 }}>
style={{
display: 'flex',
borderBottom: `1px solid ${T.border}`,
flexShrink: 0,
}}
>
{(['props', 'diff', 'graph'] as TabId[]).map((t) => ( {(['props', 'diff', 'graph'] as TabId[]).map((t) => (
<button <button
key={t} key={t}
@@ -109,10 +104,9 @@ export function Inspector() {
</span> </span>
</div> </div>
) : tab === 'props' ? ( ) : tab === 'props' ? (
<PropsTab artboard={selectedArtboard} /> <PropsTab artboard={selectedArtboard} workspaceId={workspaceId} projectId={projectId} />
) : tab === 'diff' ? ( ) : tab === 'diff' ? (
<DiffTab artboardId={selectedArtboardId} diffResult={diffResult} /> <DiffTab artboardId={selectedArtboardId} />
) : ( ) : (
<Section label="Origin Graph"> <Section label="Origin Graph">
<GraphNode label="DashboardCard" depth={0} isRoot /> <GraphNode label="DashboardCard" depth={0} isRoot />
@@ -144,9 +138,14 @@ export function Inspector() {
flexShrink: 0, flexShrink: 0,
}} }}
> >
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#10B981', flexShrink: 0 }} /> <div style={{
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
background: isLive ? '#10B981' : 'rgba(255,255,255,0.15)',
boxShadow: isLive ? '0 0 6px rgba(16,185,129,0.6)' : 'none',
transition: 'background 0.3s, box-shadow 0.3s',
}} />
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.45)' }}> <span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.45)' }}>
Live render connected {isLive ? 'Live render connected' : selectedArtboardId ? 'No render — set URL in Props' : 'No artboard selected'}
</span> </span>
<span style={{ marginLeft: 'auto', fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.22)' }}> <span style={{ marginLeft: 'auto', fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.22)' }}>
{selectedArtboard {selectedArtboard
@@ -159,24 +158,48 @@ export function Inspector() {
} }
/* ── Props tab ────────────────────────────────────────────── */ /* ── Props tab ────────────────────────────────────────────── */
function PropsTab({ artboard }: { artboard: Artboard | null }) { function PropsTab({
artboard,
workspaceId,
projectId,
}: {
artboard: Artboard | null;
workspaceId: string | null;
projectId: string | null;
}) {
const queryClient = useQueryClient();
const [editingUrl, setEditingUrl] = useState(false);
const [urlDraft, setUrlDraft] = useState('');
const saveRenderUrl = useCallback(async () => {
if (!artboard) return;
const { renderUrl: _removed, ...rest } = artboard.metadata_jsonb;
const meta: Record<string, unknown> = urlDraft.trim()
? { ...rest, renderUrl: urlDraft.trim() }
: { ...rest };
try {
await patchArtboard(artboard.id, { metadata_jsonb: meta });
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
} catch (e) {
console.error('[Inspector] patch renderUrl failed', e);
}
setEditingUrl(false);
}, [artboard, urlDraft, workspaceId, projectId, queryClient]);
if (!artboard) return null; if (!artboard) return null;
const meta = artboard.metadata_jsonb; const meta = artboard.metadata_jsonb;
const N = TYPE_COLORS['n']!; const N = TYPE_COLORS['n']!;
const B = TYPE_COLORS['b']!; const B = TYPE_COLORS['b']!;
const S = TYPE_COLORS['s']!; const S = TYPE_COLORS['s']!;
// Extract canvas geometry
const canvasProps: Array<{ key: string; val: string; color: string }> = [ const canvasProps: Array<{ key: string; val: string; color: string }> = [
{ key: 'x', val: String(meta['x'] ?? 0), color: N }, { key: 'x', val: String(meta['x'] ?? 0), color: N },
{ key: 'y', val: String(meta['y'] ?? 0), color: N }, { key: 'y', val: String(meta['y'] ?? 0), color: N },
{ key: 'width', val: String(meta['width'] ?? 0), color: N }, { key: 'width', val: String(meta['width'] ?? 0), color: N },
{ key: 'height', val: String(meta['height'] ?? 0), color: N }, { key: 'height', val: String(meta['height'] ?? 0), color: N },
]; ];
// Any extra metadata keys beyond the canvas geometry
const reservedKeys = new Set(['x', 'y', 'width', 'height', 'renderUrl']); const reservedKeys = new Set(['x', 'y', 'width', 'height', 'renderUrl']);
const extraProps = Object.entries(meta) const extraProps = Object.entries(meta)
.filter(([k]) => !reservedKeys.has(k)) .filter(([k]) => !reservedKeys.has(k))
@@ -187,7 +210,7 @@ function PropsTab({ artboard }: { artboard: Artboard | null }) {
return { key: k, val, color }; return { key: k, val, color };
}); });
const renderUrl = typeof meta['renderUrl'] === 'string' ? meta['renderUrl'] as string : null; const renderUrl = typeof meta['renderUrl'] === 'string' ? meta['renderUrl'] as string : '';
return ( return (
<> <>
@@ -201,17 +224,80 @@ function PropsTab({ artboard }: { artboard: Artboard | null }) {
<HSep /> <HSep />
</> </>
)} )}
<Section label="Canvas"> <Section label="Canvas">
{canvasProps.map(({ key, val, color }) => ( {canvasProps.map(({ key, val, color }) => (
<PropRow key={key} label={key} value={val} color={color} /> <PropRow key={key} label={key} value={val} color={color} />
))} ))}
</Section> </Section>
<HSep /> <HSep />
<Section label="Render Target"> <Section label="Render Target">
<PropRow label="name" value={artboard.name} color="#7EB8FF" /> <PropRow label="name" value={artboard.name} color="#7EB8FF" />
<PropRow label="status" value={renderUrl ? 'connected' : 'none'} color={renderUrl ? '#7DD3A8' : 'rgba(255,255,255,0.28)'} /> <PropRow label="id" value={artboard.id.slice(0, 8) + ''} color="rgba(255,255,255,0.28)" />
{renderUrl && <PropRow label="url" value={renderUrl} color="#7DD3A8" />}
<PropRow label="id" value={artboard.id.slice(0, 8) + '…'} color="rgba(255,255,255,0.28)" /> {/* renderUrl — inline editable */}
<div style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: editingUrl ? 6 : 0 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.key }}>
url
</span>
<button
onClick={() => { setUrlDraft(renderUrl); setEditingUrl(true); }}
style={{
fontSize: '0.5rem', fontFamily: "'JetBrains Mono', monospace",
background: 'none', border: 'none', color: T.accent,
cursor: 'pointer', padding: 0, letterSpacing: '0.06em',
display: editingUrl ? 'none' : 'block',
}}
>
{renderUrl ? 'edit' : '+ set'}
</button>
</div>
{editingUrl ? (
<div style={{ display: 'flex', gap: 4 }}>
<input
autoFocus
value={urlDraft}
onChange={e => setUrlDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') void saveRenderUrl();
if (e.key === 'Escape') setEditingUrl(false);
}}
placeholder="http://localhost:3000"
style={{
flex: 1, fontSize: '0.5875rem', fontFamily: "'JetBrains Mono', monospace",
background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 5, padding: '4px 8px', color: 'rgba(255,255,255,0.85)',
outline: 'none',
}}
/>
<button
onClick={() => void saveRenderUrl()}
style={{
fontSize: '0.5625rem', fontFamily: "'JetBrains Mono', monospace",
background: T.accent, border: 'none', borderRadius: 5,
color: '#fff', padding: '4px 8px', cursor: 'pointer', flexShrink: 0,
}}
>
</button>
</div>
) : renderUrl ? (
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem',
color: '#7DD3A8', overflow: 'hidden', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', display: 'block', maxWidth: '100%',
}}>
{renderUrl}
</span>
) : (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.18)' }}>
not connected
</span>
)}
</div>
</Section> </Section>
</> </>
); );
@@ -272,32 +358,9 @@ function PropRow({ label, value, color }: { label: string; value: string; color:
function GraphNode({ label, depth, isRoot }: { label: string; depth: number; isRoot?: boolean }) { function GraphNode({ label, depth, isRoot }: { label: string; depth: number; isRoot?: boolean }) {
return ( return (
<div <div style={{ display: 'flex', alignItems: 'center', gap: 6, paddingLeft: depth * 14, marginBottom: 6 }}>
style={{ <div style={{ width: 6, height: 6, borderRadius: '50%', background: isRoot ? T.accent : 'rgba(255,255,255,0.18)', flexShrink: 0 }} />
display: 'flex', <span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.625rem', color: isRoot ? T.accent : 'rgba(255,255,255,0.5)', letterSpacing: '-0.01em' }}>
alignItems: 'center',
gap: 6,
paddingLeft: depth * 14,
marginBottom: 6,
}}
>
<div
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: isRoot ? T.accent : 'rgba(255,255,255,0.18)',
flexShrink: 0,
}}
/>
<span
style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.625rem',
color: isRoot ? T.accent : 'rgba(255,255,255,0.5)',
letterSpacing: '-0.01em',
}}
>
{label} {label}
</span> </span>
</div> </div>
@@ -309,61 +372,128 @@ function HSep() {
} }
/* ── Diff tab ─────────────────────────────────────────────── */ /* ── Diff tab ─────────────────────────────────────────────── */
function DiffTab({ function DiffTab({ artboardId }: { artboardId: string | null }) {
artboardId, const { stacks } = useHistory();
diffResult, const { diffs, createDiff, isLoading } = useDiffs(artboardId);
}: {
artboardId: string | null; const artboardHistory = artboardId ? (stacks[artboardId] ?? { past: [], future: [] }) : { past: [], future: [] };
diffResult: DiffResult | null; const pendingChanges: PropChange[] = artboardHistory.past.flatMap(e => e.changes);
}) { const hasChanges = pendingChanges.length > 0;
if (!artboardId || !diffResult) {
const exportDiff = useCallback(() => {
if (!artboardId || !hasChanges) return;
createDiff.mutate({
artboard_id: artboardId,
changes_jsonb: { propChanges: pendingChanges, styleChanges: [] },
summary: '',
status: 'DRAFT',
});
}, [artboardId, pendingChanges, hasChanges, createDiff]);
if (!artboardId) {
return ( return (
<Section label="Intent Diff"> <Section label="Intent Diff">
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}> <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No diff available No artboard selected
</span> </span>
</Section> </Section>
); );
} }
const { diff } = diffResult;
const snapshots = ARTBOARD_SNAPSHOTS[artboardId];
const filename = snapshots?.after.filePath ?? `${diff.name}.tsx`;
const allChanges = [...diff.propChanges, ...diff.styleChanges];
const hunkCount = allChanges.filter(c => c.changeType !== 'unchanged').length;
return ( return (
<Section label="Intent Diff"> <>
<div {/* Pending local changes */}
style={{ <Section label={`Pending · ${hasChanges ? pendingChanges.filter(c => c.changeType !== 'unchanged').length : 0} changes`}>
fontFamily: "'JetBrains Mono', ui-monospace, monospace", {hasChanges ? (
fontSize: '0.5875rem', <>
color: 'rgba(255,255,255,0.28)', {pendingChanges
marginBottom: 10, .filter(c => c.changeType !== 'unchanged')
letterSpacing: '-0.01em', .map((change, i) => (
}} <DiffChangeRow key={i} change={change} />
> ))}
{filename} · {hunkCount} change{hunkCount !== 1 ? 's' : ''} <button
onClick={exportDiff}
disabled={createDiff.isPending}
style={{
marginTop: 10, width: '100%',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
background: T.accent, border: 'none', borderRadius: 6,
color: '#fff', padding: '7px 0', cursor: createDiff.isPending ? 'wait' : 'pointer',
letterSpacing: '0.04em', opacity: createDiff.isPending ? 0.6 : 1,
transition: 'opacity 0.15s',
}}
>
{createDiff.isPending ? 'Exporting…' : 'Export diff →'}
</button>
{createDiff.isError && (
<span style={{ fontSize: '0.5rem', color: '#FF8080', fontFamily: 'monospace', display: 'block', marginTop: 4 }}>
Export failed try again
</span>
)}
</>
) : (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No pending changes
</span>
)}
</Section>
<HSep />
{/* Saved diffs from DB */}
<Section label="Exported">
{isLoading ? (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
Loading
</span>
) : diffs.length === 0 ? (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No exported diffs yet
</span>
) : (
diffs.map(d => <SavedDiffRow key={d.id} diff={d} />)
)}
</Section>
</>
);
}
const STATUS_COLOR: Record<string, string> = {
DRAFT: '#FFBA7B',
REVIEWED: '#7EB8FF',
APPLIED: '#7DD3A8',
REJECTED: '#FF8080',
};
function SavedDiffRow({ diff }: { diff: IntentDiff }) {
const changes = diff.changes_jsonb 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: 'rgba(255,255,255,0.025)', 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: 'rgba(255,255,255,0.22)' }}>
{count} change{count !== 1 ? 's' : ''}
</span>
</div> </div>
{allChanges.map((change, i) => ( {diff.summary && (
<DiffChangeRow key={i} change={change} /> <span style={{ fontFamily: 'sans-serif', fontSize: '0.625rem', color: 'rgba(255,255,255,0.45)', lineHeight: 1.4, display: 'block' }}>
))} {diff.summary}
{allChanges.length === 0 && (
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No changes detected
</span> </span>
)} )}
</Section> </div>
); );
} }
function DiffChangeRow({ change }: { change: PropChange }) { function DiffChangeRow({ change }: { change: PropChange }) {
const isRemoved = change.changeType === 'removed'; const isRemoved = change.changeType === 'removed';
const isAdded = change.changeType === 'added'; const isAdded = change.changeType === 'added';
const isModified = change.changeType === 'modified'; const isModified = change.changeType === 'modified';
const rows: Array<{ op: 'del' | 'add'; text: string }> = []; const rows: Array<{ op: 'del' | 'add'; text: string }> = [];
if (isModified) { if (isModified) {
rows.push({ op: 'del', text: ` ${change.key}: ${change.before}` }); rows.push({ op: 'del', text: ` ${change.key}: ${change.before}` });
rows.push({ op: 'add', text: `+ ${change.key}: ${change.after}` }); rows.push({ op: 'add', text: `+ ${change.key}: ${change.after}` });
@@ -0,0 +1,272 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
interface WorkspaceSettingsFormProps {
workspaceId: string;
workspaceName: string;
memberRole: string;
}
const SECTION: React.CSSProperties = {
background: '#FFFFFF',
border: '1px solid rgba(0,0,0,0.07)',
borderRadius: 14,
padding: '24px 28px',
marginBottom: 20,
};
const LABEL: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 600,
color: '#0A0A0A',
marginBottom: 6,
};
const INPUT: React.CSSProperties = {
width: '100%',
fontSize: '0.875rem',
padding: '9px 12px',
border: '1px solid rgba(0,0,0,0.12)',
borderRadius: 9,
outline: 'none',
fontFamily: "'Inter', -apple-system, sans-serif",
color: '#0A0A0A',
background: '#FAFAFA',
boxSizing: 'border-box',
};
const BTN_PRIMARY: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 6,
background: '#0A0A0A', color: '#FFFFFF',
fontSize: '0.875rem', fontWeight: 600,
padding: '9px 20px', borderRadius: 9,
border: 'none', cursor: 'pointer', letterSpacing: '-0.01em',
};
export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }: WorkspaceSettingsFormProps) {
const router = useRouter();
const isOwner = memberRole === 'OWNER';
/* ── Rename ── */
const [name, setName] = useState(workspaceName);
const [renameStatus, setRenameStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
async function saveName() {
if (!name.trim() || name.trim() === workspaceName) return;
setRenameStatus('saving');
try {
const res = await fetch(`/api/workspace/${workspaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() }),
});
if (!res.ok) throw new Error('Rename failed');
setRenameStatus('saved');
router.refresh();
setTimeout(() => setRenameStatus('idle'), 2000);
} catch {
setRenameStatus('error');
setTimeout(() => setRenameStatus('idle'), 3000);
}
}
/* ── IDE Token ── */
const [agentType, setAgentType] = useState<'CURSOR' | 'CLAUDE_CODE' | 'GENERIC'>('CLAUDE_CODE');
const [tokenResult, setTokenResult] = useState<{
token: string;
cursorConfig: { cursorrules: string; settings: unknown };
claudeCodeConfig: { claudeMd: string; settings: unknown };
} | null>(null);
const [tokenLoading, setTokenLoading] = useState(false);
const [tokenError, setTokenError] = useState('');
const [copiedToken, setCopiedToken] = useState(false);
async function issueToken() {
setTokenLoading(true);
setTokenError('');
setTokenResult(null);
try {
const res = await fetch(`/api/workspace/${workspaceId}/tokens`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentType, workspaceName }),
});
if (!res.ok) throw new Error('Token issuance failed');
const data = await res.json() as typeof tokenResult;
setTokenResult(data);
} catch (e) {
setTokenError(e instanceof Error ? e.message : 'Failed to issue token');
} finally {
setTokenLoading(false);
}
}
function copyToken() {
if (!tokenResult) return;
void navigator.clipboard.writeText(tokenResult.token);
setCopiedToken(true);
setTimeout(() => setCopiedToken(false), 2000);
}
const configText = tokenResult
? agentType === 'CURSOR'
? tokenResult.cursorConfig.cursorrules
: tokenResult.claudeCodeConfig.claudeMd
: '';
return (
<>
{/* General */}
<div style={SECTION}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: '#0A0A0A', margin: '0 0 20px' }}>
General
</h2>
<label style={LABEL} htmlFor="ws-name">Workspace name</label>
<div style={{ display: 'flex', gap: 10, marginBottom: 4 }}>
<input
id="ws-name"
style={INPUT}
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void saveName(); }}
disabled={!isOwner}
/>
<button
onClick={() => void saveName()}
disabled={!isOwner || renameStatus === 'saving' || name.trim() === workspaceName}
style={{
...BTN_PRIMARY,
opacity: (!isOwner || name.trim() === workspaceName) ? 0.4 : 1,
flexShrink: 0,
}}
>
{renameStatus === 'saving' ? 'Saving…' : renameStatus === 'saved' ? '✓ Saved' : 'Rename'}
</button>
</div>
{renameStatus === 'error' && (
<span style={{ fontSize: '0.75rem', color: '#EF4444' }}>Rename failed try again</span>
)}
{!isOwner && (
<span style={{ fontSize: '0.75rem', color: '#71717A' }}>Only workspace owners can rename.</span>
)}
</div>
{/* IDE Integration */}
<div style={SECTION}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: '#0A0A0A', margin: '0 0 6px' }}>
IDE Integration
</h2>
<p style={{ fontSize: '0.8125rem', color: '#71717A', margin: '0 0 20px', lineHeight: 1.6 }}>
Issue a signed workspace token and copy the config snippet into your editor. Tokens expire after 30 days.
</p>
<label style={LABEL}>Agent type</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
{(['CLAUDE_CODE', 'CURSOR', 'GENERIC'] as const).map(t => (
<button
key={t}
onClick={() => setAgentType(t)}
style={{
fontSize: '0.75rem', fontWeight: 600, padding: '6px 14px', borderRadius: 8,
border: `1px solid ${agentType === t ? '#0066FF' : 'rgba(0,0,0,0.12)'}`,
background: agentType === t ? 'rgba(0,102,255,0.08)' : '#FFFFFF',
color: agentType === t ? '#0066FF' : '#52525B',
cursor: 'pointer',
}}
>
{t === 'CLAUDE_CODE' ? 'Claude Code' : t === 'CURSOR' ? 'Cursor' : 'Generic'}
</button>
))}
</div>
<button
onClick={() => void issueToken()}
disabled={tokenLoading}
style={{ ...BTN_PRIMARY, opacity: tokenLoading ? 0.6 : 1, marginBottom: tokenResult ? 16 : 0 }}
>
{tokenLoading ? 'Generating…' : 'Generate token'}
</button>
{tokenError && (
<p style={{ fontSize: '0.75rem', color: '#EF4444', marginTop: 8 }}>{tokenError}</p>
)}
{tokenResult && (
<div style={{ marginTop: 16 }}>
{/* Token display */}
<label style={{ ...LABEL, marginBottom: 6 }}>Workspace token</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input
readOnly
value={tokenResult.token}
style={{ ...INPUT, fontFamily: 'monospace', fontSize: '0.75rem', color: '#0066FF' }}
/>
<button
onClick={copyToken}
style={{
...BTN_PRIMARY, background: copiedToken ? '#10B981' : '#0066FF', flexShrink: 0,
transition: 'background 0.2s',
}}
>
{copiedToken ? '✓ Copied' : 'Copy'}
</button>
</div>
{/* Config snippet */}
<label style={{ ...LABEL, marginBottom: 6 }}>
{agentType === 'CURSOR' ? '.cursorrules snippet' : 'CLAUDE.md snippet'}
</label>
<pre style={{
background: '#0A0A0A', color: 'rgba(255,255,255,0.75)',
borderRadius: 10, padding: '14px 16px', fontSize: '0.625rem',
fontFamily: 'monospace', overflowX: 'auto', lineHeight: 1.7,
margin: 0, maxHeight: 260, overflow: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{configText}
</pre>
<p style={{ fontSize: '0.75rem', color: '#71717A', marginTop: 8, lineHeight: 1.5 }}>
Paste this into your{' '}
{agentType === 'CURSOR' ? (
<code style={{ fontFamily: 'monospace', background: 'rgba(0,0,0,0.06)', padding: '1px 5px', borderRadius: 4 }}>.cursorrules</code>
) : (
<code style={{ fontFamily: 'monospace', background: 'rgba(0,0,0,0.06)', padding: '1px 5px', borderRadius: 4 }}>CLAUDE.md</code>
)}{' '}
file to enable the Originmain MCP server in your editor.
</p>
</div>
)}
</div>
{/* Danger zone */}
{isOwner && (
<div style={{ ...SECTION, borderColor: 'rgba(239,68,68,0.2)', background: 'rgba(239,68,68,0.02)' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: '#EF4444', margin: '0 0 8px' }}>
Danger zone
</h2>
<p style={{ fontSize: '0.8125rem', color: '#71717A', margin: '0 0 16px', lineHeight: 1.6 }}>
Deleting this workspace is permanent and cannot be undone. All projects and artboards will be lost.
</p>
<button
style={{
fontSize: '0.875rem', fontWeight: 600, padding: '9px 20px', borderRadius: 9,
border: '1px solid rgba(239,68,68,0.35)', background: 'transparent',
color: '#EF4444', cursor: 'pointer',
}}
onClick={() => {
if (window.confirm(`Delete workspace "${workspaceName}"? This cannot be undone.`)) {
// TODO: call DELETE /api/workspace/:id when implemented
window.alert('Delete endpoint not yet implemented — coming soon.');
}
}}
>
Delete workspace
</button>
</div>
)}
</>
);
}
+18 -8
View File
@@ -11,12 +11,6 @@ export interface CanvasArtboard {
renderUrl?: string; renderUrl?: string;
} }
const DEMO_ARTBOARDS: CanvasArtboard[] = [
{ id: 'dashboard-card', label: 'DashboardCard', x: 120, y: 100, width: 280, height: 200 },
{ id: 'user-profile', label: 'UserProfile', x: 460, y: 100, width: 200, height: 260 },
{ id: 'nav-sidebar', label: 'NavSidebar', x: 120, y: 360, width: 200, height: 340 },
{ id: 'data-table', label: 'DataTable', x: 380, y: 380, width: 420, height: 280 },
];
function toCanvasArtboard(ab: Artboard): CanvasArtboard | null { function toCanvasArtboard(ab: Artboard): CanvasArtboard | null {
const meta = ab.metadata_jsonb; const meta = ab.metadata_jsonb;
@@ -45,6 +39,23 @@ async function fetchArtboards(workspaceId: string, projectId?: string): Promise<
return { rows, canvas: rows.map(toCanvasArtboard).filter((ab): ab is CanvasArtboard => ab !== null) }; return { rows, canvas: rows.map(toCanvasArtboard).filter((ab): ab is CanvasArtboard => ab !== null) };
} }
/** PATCH an artboard (name and/or metadata_jsonb). */
export async function patchArtboard(
id: string,
patch: { name?: string; metadata_jsonb?: Record<string, unknown> },
): Promise<Artboard> {
const res = await fetch(`/api/artboards/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { error?: string }).error ?? `Patch failed: ${res.status}`);
}
return res.json() as Promise<Artboard>;
}
/** Create a new artboard via POST /api/artboards and invalidate the cache. */ /** Create a new artboard via POST /api/artboards and invalidate the cache. */
export async function createArtboardMutation( export async function createArtboardMutation(
body: InsertArtboard, body: InsertArtboard,
@@ -70,8 +81,7 @@ export function useArtboards(workspaceId: string | undefined, projectId?: string
}); });
const canvasArtboards = query.data?.canvas ?? []; const canvasArtboards = query.data?.canvas ?? [];
// Show demo artboards while loading or when workspace/project has no artboards yet. const artboards = canvasArtboards;
const artboards = canvasArtboards.length === 0 ? DEMO_ARTBOARDS : canvasArtboards;
return { return {
artboards, artboards,
+75
View File
@@ -0,0 +1,75 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { IntentDiff, InsertIntentDiff, DiffStatus } from '@originmain/origin-graph';
// ── Fetch ─────────────────────────────────────────────────────────────────────
async function fetchDiffs(artboardId: string): Promise<IntentDiff[]> {
const res = await fetch(`/api/diffs?artboardId=${encodeURIComponent(artboardId)}`);
if (!res.ok) throw new Error(`Diffs fetch failed: ${res.status}`);
return res.json() as Promise<IntentDiff[]>;
}
async function createDiffRequest(
body: Omit<InsertIntentDiff, 'author_id'>,
): Promise<IntentDiff> {
const res = await fetch('/api/diffs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({})) as { error?: string };
throw new Error(err.error ?? `Create diff failed: ${res.status}`);
}
return res.json() as Promise<IntentDiff>;
}
async function updateDiffStatusRequest(
id: string,
status: DiffStatus,
notes?: string,
): Promise<IntentDiff> {
const res = await fetch(`/api/diffs/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, notes }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({})) as { error?: string };
throw new Error(err.error ?? `Update diff failed: ${res.status}`);
}
return res.json() as Promise<IntentDiff>;
}
// ── Hook ──────────────────────────────────────────────────────────────────────
export function useDiffs(artboardId: string | null) {
const queryClient = useQueryClient();
const queryKey = ['diffs', artboardId];
const query = useQuery({
queryKey,
queryFn: () => fetchDiffs(artboardId!),
enabled: artboardId !== null,
staleTime: 15_000,
});
const createDiff = useMutation({
mutationFn: (body: Omit<InsertIntentDiff, 'author_id'>) => createDiffRequest(body),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
const updateStatus = useMutation({
mutationFn: ({ id, status, notes }: { id: string; status: DiffStatus; notes?: string }) =>
updateDiffStatusRequest(id, status, notes),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
return {
diffs: query.data ?? [],
isLoading: query.isLoading,
error: query.error,
createDiff,
updateStatus,
};
}
+35 -5
View File
@@ -1,24 +1,54 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { FiberNode } from '@originmain/renderer';
export type Tool = 'select' | 'pan' | 'artboard' | 'zone'; export type Tool = 'select' | 'pan' | 'artboard' | 'zone';
interface CanvasStore { interface CanvasStore {
// ── Tool state ──────────────────────────────────────────────────────────────
activeTool: Tool; activeTool: Tool;
setActiveTool: (tool: Tool) => void;
// ── Artboard selection ──────────────────────────────────────────────────────
selectedArtboardId: string | null; selectedArtboardId: string | null;
/** Workspace/project context set by AppChrome on mount. */ selectArtboard: (id: string | null) => void;
// ── Workspace / project context (set by AppChrome on mount) ───────────────
workspaceId: string | null; workspaceId: string | null;
projectId: string | null; projectId: string | null;
setActiveTool: (tool: Tool) => void;
selectArtboard: (id: string | null) => void;
setContext: (workspaceId: string, projectId: string) => void; setContext: (workspaceId: string, projectId: string) => void;
// ── Live artboard tracking ──────────────────────────────────────────────────
/** IDs of artboards that have an active LiveArtboard iframe connection */
liveArtboardIds: Set<string>;
setArtboardLive: (id: string, live: boolean) => void;
// ── Component selection (from SelectionOverlay / fiber tree) ───────────────
selectedComponentId: string | null;
selectedComponentData: FiberNode | null;
selectComponent: (id: string | null, data: FiberNode | null) => void;
} }
export const useCanvas = create<CanvasStore>((set) => ({ export const useCanvas = create<CanvasStore>((set) => ({
activeTool: 'select', activeTool: 'select',
setActiveTool: (tool) => set({ activeTool: tool }),
selectedArtboardId: null, selectedArtboardId: null,
selectArtboard: (id) =>
set({ selectedArtboardId: id, selectedComponentId: null, selectedComponentData: null }),
workspaceId: null, workspaceId: null,
projectId: null, projectId: null,
setActiveTool: (tool) => set({ activeTool: tool }),
selectArtboard: (id) => set({ selectedArtboardId: id }),
setContext: (workspaceId, projectId) => set({ workspaceId, projectId }), setContext: (workspaceId, projectId) => set({ workspaceId, projectId }),
liveArtboardIds: new Set<string>(),
setArtboardLive: (id, live) =>
set((state) => {
const next = new Set(state.liveArtboardIds);
if (live) next.add(id); else next.delete(id);
return { liveArtboardIds: next };
}),
selectedComponentId: null,
selectedComponentData: null,
selectComponent: (id, data) => set({ selectedComponentId: id, selectedComponentData: data }),
})); }));
+28 -17
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface ViewportState { interface ViewportState {
panX: number; panX: number;
@@ -9,23 +10,33 @@ interface ViewportState {
reset: () => void; reset: () => void;
} }
export const useViewport = create<ViewportState>((set, get) => ({ export const useViewport = create<ViewportState>()(
panX: 0, persist(
panY: 0, (set, get) => ({
zoom: 1, panX: 0,
panY: 0,
zoom: 1,
setPan: (x, y) => set({ panX: x, panY: y }), setPan: (x, y) => set({ panX: x, panY: y }),
setZoom: (zoom, originX = 0, originY = 0) => { setZoom: (zoom, originX = 0, originY = 0) => {
const prev = get(); const prev = get();
const clampedZoom = Math.min(Math.max(zoom, 0.1), 8); const clampedZoom = Math.min(Math.max(zoom, 0.1), 8);
const scale = clampedZoom / prev.zoom; const scale = clampedZoom / prev.zoom;
set({ set({
zoom: clampedZoom, zoom: clampedZoom,
panX: originX - (originX - prev.panX) * scale, panX: originX - (originX - prev.panX) * scale,
panY: originY - (originY - prev.panY) * scale, panY: originY - (originY - prev.panY) * scale,
}); });
}, },
reset: () => set({ panX: 0, panY: 0, zoom: 1 }), reset: () => set({ panX: 0, panY: 0, zoom: 1 }),
})); }),
{
name: 'originmain:viewport',
storage: createJSONStorage(() => localStorage),
// Only persist the numeric state, not the action functions
partialize: (state) => ({ panX: state.panX, panY: state.panY, zoom: state.zoom }),
},
),
);
File diff suppressed because one or more lines are too long
+109
View File
@@ -12,10 +12,13 @@ import type {
DesignLanguageFile, DesignLanguageFile,
AgentSession, AgentSession,
TeamMember, TeamMember,
Project,
InsertArtboard, InsertArtboard,
InsertOrigin, InsertOrigin,
InsertIntentDiff, InsertIntentDiff,
InsertAgentSession, InsertAgentSession,
InsertTeamMember,
InsertProject,
DiffStatus, DiffStatus,
ArtboardAncestry, ArtboardAncestry,
} from './types.js'; } from './types.js';
@@ -90,6 +93,29 @@ export async function createArtboard(db: DbClient, row: InsertArtboard): Promise
return data; return data;
} }
export async function updateArtboard(
db: DbClient,
id: string,
patch: Partial<InsertArtboard>,
): Promise<Artboard> {
const { data, error } = await (db
.from('artboards')
.update(patch)
.eq('id', id)
.select()
.single() as Promise<{ data: Artboard; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
export async function deleteArtboard(db: DbClient, id: string): Promise<void> {
const { error } = await (db
.from('artboards')
.delete()
.eq('id', id) as unknown as Promise<{ data: unknown; error: DbError | null }>);
if (error) throw new Error(error.message);
}
export async function getArtboardAncestors(db: DbClient, artboardId: string): Promise<ArtboardAncestry[]> { export async function getArtboardAncestors(db: DbClient, artboardId: string): Promise<ArtboardAncestry[]> {
const { data, error } = await (db const { data, error } = await (db
.from('artboard_ancestry') .from('artboard_ancestry')
@@ -217,3 +243,86 @@ export async function getTeamMembers(db: DbClient, workspaceId: string): Promise
if (error) throw new Error(error.message); if (error) throw new Error(error.message);
return data; return data;
} }
export async function addTeamMember(db: DbClient, row: InsertTeamMember): Promise<TeamMember> {
const { data, error } = await (db
.from('team_members')
.insert(row)
.select()
.single() as Promise<{ data: TeamMember; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
export async function removeTeamMember(
db: DbClient,
workspaceId: string,
userId: string,
): Promise<void> {
const { error } = await (db
.from('team_members')
.delete()
.eq('workspace_id', workspaceId)
.eq('user_id', userId) as unknown as Promise<{ data: unknown; error: DbError | null }>);
if (error) throw new Error(error.message);
}
export async function updateWorkspace(
db: DbClient,
id: string,
patch: { name?: string },
): Promise<Workspace> {
const { data, error } = await (db
.from('workspaces')
.update(patch)
.eq('id', id)
.select()
.single() as Promise<{ data: Workspace; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
// ── Project queries ───────────────────────────────────────────────────────────
export async function getProject(db: DbClient, id: string): Promise<Project> {
const { data, error } = await (db
.from('projects')
.select('*')
.eq('id', id)
.single() as Promise<{ data: Project; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
export async function createProject(db: DbClient, row: InsertProject): Promise<Project> {
const { data, error } = await (db
.from('projects')
.insert(row)
.select()
.single() as Promise<{ data: Project; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
export async function updateProject(
db: DbClient,
id: string,
patch: Partial<InsertProject>,
): Promise<Project> {
const { data, error } = await (db
.from('projects')
.update(patch)
.eq('id', id)
.select()
.single() as Promise<{ data: Project; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
export async function deleteProject(db: DbClient, id: string): Promise<void> {
const { error } = await (db
.from('projects')
.delete()
.eq('id', id) as unknown as Promise<{ data: unknown; error: DbError | null }>);
if (error) throw new Error(error.message);
}