improved a lot of things
This commit is contained in:
@@ -126,7 +126,10 @@
|
||||
"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 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
@@ -8,21 +8,24 @@ SUPABASE_SERVICE_ROLE_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# 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=
|
||||
|
||||
# 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_SECRET_KEY=
|
||||
|
||||
# Linear integration
|
||||
# Webhook secrets — set these in each integration's dashboard
|
||||
LINEAR_WEBHOOK_SECRET=
|
||||
|
||||
# Slack integration
|
||||
SLACK_BOT_TOKEN=
|
||||
SLACK_SIGNING_SECRET=
|
||||
|
||||
# Agent Bridge WebSocket server port
|
||||
AGENT_BRIDGE_PORT=3001
|
||||
GITHUB_WEBHOOK_SECRET=
|
||||
INTERCOM_WEBHOOK_SECRET=
|
||||
|
||||
# App base URL
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
+81
-1
@@ -248,4 +248,84 @@ Actual `createClient` / `createRoomContext` calls go in `packages/app/src/lib/li
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-04-26 after Layers 9–10 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
|
||||
// PATCH /api/artboards/:id → update name / metadata_jsonb
|
||||
// DELETE /api/artboards/:id → permanently remove artboard
|
||||
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
const db = serverClient();
|
||||
const artboard = await getArtboard(db, id);
|
||||
const artboard = await getArtboard(serverClient(), id);
|
||||
return NextResponse.json(artboard);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
const status = message.includes('not found') || message.includes('0 rows') ? 404 : 500;
|
||||
const status = message.includes('0 rows') ? 404 : 500;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,20 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
|
||||
: `${projects.length} project${projects.length !== 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<Link href={`/workspace/${wid}/settings`} 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 14 14" fill="none">
|
||||
<circle cx="7" cy="7" r="2" stroke="currentColor" strokeWidth="1.4"/>
|
||||
<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"/>
|
||||
</svg>
|
||||
Settings
|
||||
</Link>
|
||||
<Link href={`/workspace/${wid}/project/new`} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
background: '#0A0A0A', color: '#FFFFFF',
|
||||
@@ -79,6 +93,7 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
|
||||
New project
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{projects.length === 0 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
'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 { useViewport } from '@/store/viewport';
|
||||
import { LiveArtboard } from './LiveArtboard';
|
||||
import { SelectionOverlay } from './SelectionOverlay';
|
||||
import type { FiberNode } from '@originmain/renderer';
|
||||
@@ -17,38 +19,172 @@ interface 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 queryClient = useQueryClient();
|
||||
|
||||
// ── Fiber tree (from LiveArtboard) ─────────────────────────────────────────
|
||||
const [fiberRoot, setFiberRoot] = useState<FiberNode | undefined>(undefined);
|
||||
|
||||
const handleFiberUpdate = useCallback((root: FiberNode) => setFiberRoot(root), []);
|
||||
const handleComponentSelected = useCallback(
|
||||
(nodeId: string) => { void nodeId; /* future: highlight in inspector */ },
|
||||
[]
|
||||
);
|
||||
const handleFiberUpdate = useCallback((root: FiberNode) => {
|
||||
setFiberRoot(root);
|
||||
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 (
|
||||
<div
|
||||
style={{ position: 'absolute', top: y, left: x }}
|
||||
style={{ position: 'absolute', top: effectiveY, left: effectiveX }}
|
||||
onClick={(e) => { e.stopPropagation(); selectArtboard(id); }}
|
||||
>
|
||||
{/* Label */}
|
||||
{/* Label / drag handle */}
|
||||
<div
|
||||
onMouseDown={onLabelMouseDown}
|
||||
onDoubleClick={startRename}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -24,
|
||||
top: -26,
|
||||
left: 0,
|
||||
height: 22,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: isDragging.current ? 'grabbing' : 'grab',
|
||||
userSelect: 'none',
|
||||
minWidth: 80,
|
||||
}}
|
||||
>
|
||||
{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)',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
letterSpacing: '-0.01em',
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Frame */}
|
||||
@@ -67,7 +203,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
|
||||
transition: 'box-shadow 0.15s',
|
||||
}}
|
||||
>
|
||||
{/* Selection handles */}
|
||||
{/* Selection corner handles */}
|
||||
{selected && (
|
||||
<>
|
||||
<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 ? (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 }) {
|
||||
switch (id) {
|
||||
case 'dashboard-card': return <DashboardCard />;
|
||||
case 'user-profile': return <UserProfile />;
|
||||
case 'nav-sidebar': return <NavSidebar />;
|
||||
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() {
|
||||
return (
|
||||
<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>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<Tag>Q4 2024</Tag>
|
||||
<Tag>MRR</Tag>
|
||||
<Tag>SaaS</Tag>
|
||||
{['Q4 2024', 'MRR', 'SaaS'].map(t => (
|
||||
<span key={t} style={{ fontSize: 9, background: '#F4F4F5', color: '#71717A', padding: '3px 8px', borderRadius: 99, fontWeight: 500 }}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── UserProfile ────────────────────────────────────────────
|
||||
function UserProfile() {
|
||||
return (
|
||||
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%' }}>
|
||||
@@ -152,14 +423,10 @@ function UserProfile() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── NavSidebar ─────────────────────────────────────────────
|
||||
function NavSidebar() {
|
||||
const items = [
|
||||
{ icon: '⊞', label: 'Dashboard', active: true },
|
||||
@@ -175,40 +442,20 @@ function NavSidebar() {
|
||||
</div>
|
||||
<div style={{ height: 1, background: '#EBEBEB', margin: '0 0 8px' }} />
|
||||
{items.map(({ icon, label, active }) => (
|
||||
<div
|
||||
key={label}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '7px 12px',
|
||||
margin: '1px 6px',
|
||||
borderRadius: 5,
|
||||
<div key={label} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 12px', margin: '1px 6px', 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}
|
||||
fontSize: 10, fontWeight: active ? 600 : 400,
|
||||
color: active ? '#0066FF' : '#52525B', cursor: 'default',
|
||||
}}>
|
||||
<span style={{ fontSize: 11 }}>{icon}</span>{label}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── DataTable ──────────────────────────────────────────────
|
||||
function DataTable() {
|
||||
const rows = [
|
||||
{ name: 'DashboardCard', status: 'Live', nodes: 12, tokens: 8 },
|
||||
@@ -226,32 +473,19 @@ function DataTable() {
|
||||
<span key={h} style={{ fontFamily: 'monospace', fontSize: 8, color: '#A1A1AA', letterSpacing: '0.05em', textTransform: 'uppercase' }}>{h}</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ height: 1, background: '#F0F0F0' }} />
|
||||
{rows.map((row, i) => (
|
||||
<div
|
||||
key={row.name}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 60px 50px 50px',
|
||||
padding: '8px 16px',
|
||||
gap: 4,
|
||||
background: i % 2 === 0 ? 'transparent' : '#FAFAFA',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10, fontWeight: 500, color: '#0A0A0A', letterSpacing: '-0.01em' }}>{row.name}</span>
|
||||
<div key={row.name} style={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 60px 50px 50px',
|
||||
padding: '8px 16px', gap: 4,
|
||||
background: i % 2 === 0 ? 'transparent' : '#FAFAFA', alignItems: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: 10, fontWeight: 500, color: '#0A0A0A' }}>{row.name}</span>
|
||||
<span style={{
|
||||
fontSize: 8,
|
||||
fontWeight: 600,
|
||||
fontSize: 8, fontWeight: 600, fontFamily: 'monospace',
|
||||
color: row.status === 'Live' ? '#059669' : row.status === 'Draft' ? '#6B7280' : '#D97706',
|
||||
background: row.status === 'Live' ? '#ECFDF5' : row.status === 'Draft' ? '#F9FAFB' : '#FFFBEB',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 99,
|
||||
width: 'fit-content',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
{row.status}
|
||||
</span>
|
||||
padding: '2px 6px', borderRadius: 99, width: 'fit-content',
|
||||
}}>{row.status}</span>
|
||||
<span style={{ fontSize: 10, color: '#52525B', fontFamily: 'monospace' }}>{row.nodes}</span>
|
||||
<span style={{ fontSize: 10, color: '#52525B', fontFamily: 'monospace' }}>{row.tokens}</span>
|
||||
</div>
|
||||
@@ -260,35 +494,5 @@ function DataTable() {
|
||||
);
|
||||
}
|
||||
|
||||
function Placeholder({ label }: { label: string }) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
// Suppress unused warning — kept for demo IDs in ArtboardContent
|
||||
void ArtboardContent;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useViewport } from '@/store/viewport';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
@@ -20,6 +20,10 @@ export function Canvas() {
|
||||
const lastPos = useRef({ x: 0, y: 0 });
|
||||
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
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
@@ -57,14 +61,20 @@ export function Canvas() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Artboard creation tool: click on canvas to place a new artboard
|
||||
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);
|
||||
|
||||
// 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
|
||||
if (activeTool === 'artboard' && e.target === e.currentTarget && workspaceId) {
|
||||
const label = `Artboard ${Date.now().toString(36).slice(-4).toUpperCase()}`;
|
||||
createArtboardMutation({
|
||||
workspace_id: workspaceId,
|
||||
@@ -88,21 +98,46 @@ export function Canvas() {
|
||||
}, [activeTool, setActiveTool, selectArtboard, workspaceId, projectId, queryClient]);
|
||||
|
||||
const onMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isPanning.current) return;
|
||||
if (isPanning.current) {
|
||||
const dx = e.clientX - lastPos.current.x;
|
||||
const dy = e.clientY - lastPos.current.y;
|
||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||
const { panX, panY, setPan } = useViewport.getState();
|
||||
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
|
||||
const gridSpacing = Math.max(6, 20 * zoom);
|
||||
const cursor =
|
||||
activeTool === 'pan' || isPanning.current ? 'grab' :
|
||||
activeTool === 'artboard' ? 'crosshair' :
|
||||
activeTool === 'artboard' || activeTool === 'zone' ? 'crosshair' :
|
||||
'default';
|
||||
|
||||
return (
|
||||
@@ -156,7 +191,57 @@ export function Canvas() {
|
||||
{artboards.map((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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
import { useArtboards } from '@/hooks/useArtboards';
|
||||
import { useDiff } from '@/hooks/useDiff';
|
||||
import { ARTBOARD_SNAPSHOTS } from '@/data/artboard-snapshots';
|
||||
import type { DiffResult, PropChange } from '@originmain/diff-engine';
|
||||
import type { Artboard } from '@originmain/origin-graph';
|
||||
import { useHistory } from '@/store/history';
|
||||
import { useArtboards, patchArtboard } from '@/hooks/useArtboards';
|
||||
import { useDiffs } from '@/hooks/useDiffs';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { PropChange } from '@originmain/diff-engine';
|
||||
import type { Artboard, IntentDiff } from '@originmain/origin-graph';
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
s: '#7DD3A8',
|
||||
@@ -30,11 +31,11 @@ const T = {
|
||||
};
|
||||
|
||||
export function Inspector() {
|
||||
const { selectedArtboardId, workspaceId, projectId } = useCanvas();
|
||||
const { selectedArtboardId, liveArtboardIds, workspaceId, projectId } = useCanvas();
|
||||
const [tab, setTab] = useState<TabId>('props');
|
||||
const diffResult = useDiff(selectedArtboardId);
|
||||
const { rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
|
||||
const selectedArtboard = rawArtboards.find((ab) => ab.id === selectedArtboardId) ?? null;
|
||||
const isLive = selectedArtboardId ? liveArtboardIds.has(selectedArtboardId) : false;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -50,13 +51,7 @@ export function Inspector() {
|
||||
}}
|
||||
>
|
||||
{/* Tab bar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
borderBottom: `1px solid ${T.border}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', borderBottom: `1px solid ${T.border}`, flexShrink: 0 }}>
|
||||
{(['props', 'diff', 'graph'] as TabId[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
@@ -109,10 +104,9 @@ export function Inspector() {
|
||||
</span>
|
||||
</div>
|
||||
) : tab === 'props' ? (
|
||||
<PropsTab artboard={selectedArtboard} />
|
||||
|
||||
<PropsTab artboard={selectedArtboard} workspaceId={workspaceId} projectId={projectId} />
|
||||
) : tab === 'diff' ? (
|
||||
<DiffTab artboardId={selectedArtboardId} diffResult={diffResult} />
|
||||
<DiffTab artboardId={selectedArtboardId} />
|
||||
) : (
|
||||
<Section label="Origin Graph">
|
||||
<GraphNode label="DashboardCard" depth={0} isRoot />
|
||||
@@ -144,9 +138,14 @@ export function Inspector() {
|
||||
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)' }}>
|
||||
Live render connected
|
||||
{isLive ? 'Live render connected' : selectedArtboardId ? 'No render — set URL in Props' : 'No artboard selected'}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.22)' }}>
|
||||
{selectedArtboard
|
||||
@@ -159,16 +158,41 @@ export function Inspector() {
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
|
||||
const meta = artboard.metadata_jsonb;
|
||||
|
||||
const N = TYPE_COLORS['n']!;
|
||||
const B = TYPE_COLORS['b']!;
|
||||
const S = TYPE_COLORS['s']!;
|
||||
|
||||
// Extract canvas geometry
|
||||
const canvasProps: Array<{ key: string; val: string; color: string }> = [
|
||||
{ key: 'x', val: String(meta['x'] ?? 0), color: N },
|
||||
{ key: 'y', val: String(meta['y'] ?? 0), color: N },
|
||||
@@ -176,7 +200,6 @@ function PropsTab({ artboard }: { artboard: Artboard | null }) {
|
||||
{ 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 extraProps = Object.entries(meta)
|
||||
.filter(([k]) => !reservedKeys.has(k))
|
||||
@@ -187,7 +210,7 @@ function PropsTab({ artboard }: { artboard: Artboard | null }) {
|
||||
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 (
|
||||
<>
|
||||
@@ -201,17 +224,80 @@ function PropsTab({ artboard }: { artboard: Artboard | null }) {
|
||||
<HSep />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Section label="Canvas">
|
||||
{canvasProps.map(({ key, val, color }) => (
|
||||
<PropRow key={key} label={key} value={val} color={color} />
|
||||
))}
|
||||
</Section>
|
||||
<HSep />
|
||||
|
||||
<Section label="Render Target">
|
||||
<PropRow label="name" value={artboard.name} color="#7EB8FF" />
|
||||
<PropRow label="status" value={renderUrl ? 'connected' : 'none'} color={renderUrl ? '#7DD3A8' : '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>
|
||||
</>
|
||||
);
|
||||
@@ -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 }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', 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}
|
||||
</span>
|
||||
</div>
|
||||
@@ -309,51 +372,119 @@ function HSep() {
|
||||
}
|
||||
|
||||
/* ── Diff tab ─────────────────────────────────────────────── */
|
||||
function DiffTab({
|
||||
artboardId,
|
||||
diffResult,
|
||||
}: {
|
||||
artboardId: string | null;
|
||||
diffResult: DiffResult | null;
|
||||
}) {
|
||||
if (!artboardId || !diffResult) {
|
||||
function DiffTab({ artboardId }: { artboardId: string | null }) {
|
||||
const { stacks } = useHistory();
|
||||
const { diffs, createDiff, isLoading } = useDiffs(artboardId);
|
||||
|
||||
const artboardHistory = artboardId ? (stacks[artboardId] ?? { past: [], future: [] }) : { past: [], future: [] };
|
||||
const pendingChanges: PropChange[] = artboardHistory.past.flatMap(e => e.changes);
|
||||
const hasChanges = pendingChanges.length > 0;
|
||||
|
||||
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 (
|
||||
<Section label="Intent Diff">
|
||||
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
|
||||
No diff available
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
|
||||
No artboard selected
|
||||
</span>
|
||||
</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 (
|
||||
<Section label="Intent Diff">
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
fontSize: '0.5875rem',
|
||||
color: 'rgba(255,255,255,0.28)',
|
||||
marginBottom: 10,
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
{filename} · {hunkCount} change{hunkCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
{allChanges.map((change, i) => (
|
||||
<>
|
||||
{/* Pending local changes */}
|
||||
<Section label={`Pending · ${hasChanges ? pendingChanges.filter(c => c.changeType !== 'unchanged').length : 0} changes`}>
|
||||
{hasChanges ? (
|
||||
<>
|
||||
{pendingChanges
|
||||
.filter(c => c.changeType !== 'unchanged')
|
||||
.map((change, i) => (
|
||||
<DiffChangeRow key={i} change={change} />
|
||||
))}
|
||||
{allChanges.length === 0 && (
|
||||
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
|
||||
No changes detected
|
||||
<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>
|
||||
{diff.summary && (
|
||||
<span style={{ fontFamily: 'sans-serif', fontSize: '0.625rem', color: 'rgba(255,255,255,0.45)', lineHeight: 1.4, display: 'block' }}>
|
||||
{diff.summary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -363,7 +494,6 @@ function DiffChangeRow({ change }: { change: PropChange }) {
|
||||
const isModified = change.changeType === 'modified';
|
||||
|
||||
const rows: Array<{ op: 'del' | 'add'; text: string }> = [];
|
||||
|
||||
if (isModified) {
|
||||
rows.push({ op: 'del', text: `− ${change.key}: ${change.before}` });
|
||||
rows.push({ op: 'add', text: `+ ${change.key}: ${change.after}` });
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,6 @@ export interface CanvasArtboard {
|
||||
renderUrl?: string;
|
||||
}
|
||||
|
||||
const DEMO_ARTBOARDS: CanvasArtboard[] = [
|
||||
{ id: 'dashboard-card', label: 'DashboardCard', x: 120, y: 100, width: 280, height: 200 },
|
||||
{ id: 'user-profile', label: 'UserProfile', x: 460, y: 100, width: 200, height: 260 },
|
||||
{ id: 'nav-sidebar', label: 'NavSidebar', x: 120, y: 360, width: 200, height: 340 },
|
||||
{ id: 'data-table', label: 'DataTable', x: 380, y: 380, width: 420, height: 280 },
|
||||
];
|
||||
|
||||
function toCanvasArtboard(ab: Artboard): CanvasArtboard | null {
|
||||
const meta = ab.metadata_jsonb;
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export async function createArtboardMutation(
|
||||
body: InsertArtboard,
|
||||
@@ -70,8 +81,7 @@ export function useArtboards(workspaceId: string | undefined, projectId?: string
|
||||
});
|
||||
|
||||
const canvasArtboards = query.data?.canvas ?? [];
|
||||
// Show demo artboards while loading or when workspace/project has no artboards yet.
|
||||
const artboards = canvasArtboards.length === 0 ? DEMO_ARTBOARDS : canvasArtboards;
|
||||
const artboards = canvasArtboards;
|
||||
|
||||
return {
|
||||
artboards,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,24 +1,54 @@
|
||||
import { create } from 'zustand';
|
||||
import type { FiberNode } from '@originmain/renderer';
|
||||
|
||||
export type Tool = 'select' | 'pan' | 'artboard' | 'zone';
|
||||
|
||||
interface CanvasStore {
|
||||
// ── Tool state ──────────────────────────────────────────────────────────────
|
||||
activeTool: Tool;
|
||||
setActiveTool: (tool: Tool) => void;
|
||||
|
||||
// ── Artboard selection ──────────────────────────────────────────────────────
|
||||
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;
|
||||
projectId: string | null;
|
||||
setActiveTool: (tool: Tool) => void;
|
||||
selectArtboard: (id: string | null) => 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) => ({
|
||||
activeTool: 'select',
|
||||
setActiveTool: (tool) => set({ activeTool: tool }),
|
||||
|
||||
selectedArtboardId: null,
|
||||
selectArtboard: (id) =>
|
||||
set({ selectedArtboardId: id, selectedComponentId: null, selectedComponentData: null }),
|
||||
|
||||
workspaceId: null,
|
||||
projectId: null,
|
||||
setActiveTool: (tool) => set({ activeTool: tool }),
|
||||
selectArtboard: (id) => set({ selectedArtboardId: id }),
|
||||
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 }),
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
interface ViewportState {
|
||||
panX: number;
|
||||
@@ -9,7 +10,9 @@ interface ViewportState {
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useViewport = create<ViewportState>((set, get) => ({
|
||||
export const useViewport = create<ViewportState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
zoom: 1,
|
||||
@@ -28,4 +31,12 @@ export const useViewport = create<ViewportState>((set, get) => ({
|
||||
},
|
||||
|
||||
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
@@ -12,10 +12,13 @@ import type {
|
||||
DesignLanguageFile,
|
||||
AgentSession,
|
||||
TeamMember,
|
||||
Project,
|
||||
InsertArtboard,
|
||||
InsertOrigin,
|
||||
InsertIntentDiff,
|
||||
InsertAgentSession,
|
||||
InsertTeamMember,
|
||||
InsertProject,
|
||||
DiffStatus,
|
||||
ArtboardAncestry,
|
||||
} from './types.js';
|
||||
@@ -90,6 +93,29 @@ export async function createArtboard(db: DbClient, row: InsertArtboard): Promise
|
||||
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[]> {
|
||||
const { data, error } = await (db
|
||||
.from('artboard_ancestry')
|
||||
@@ -217,3 +243,86 @@ export async function getTeamMembers(db: DbClient, workspaceId: string): Promise
|
||||
if (error) throw new Error(error.message);
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user