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