improved a lot of things
This commit is contained in:
@@ -124,7 +124,9 @@
|
|||||||
"Bash(mkdir -p \"/Users/USER/Desktop/originmain/packages/app/src/app/workspace/[wid]\")",
|
"Bash(mkdir -p \"/Users/USER/Desktop/originmain/packages/app/src/app/workspace/[wid]\")",
|
||||||
"Bash(mkdir -p \"/Users/USER/Desktop/originmain/packages/app/src/app/workspace/[wid]/project/new\")",
|
"Bash(mkdir -p \"/Users/USER/Desktop/originmain/packages/app/src/app/workspace/[wid]/project/new\")",
|
||||||
"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 status --short)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// GET /api/artboards?workspaceId=<uuid> → list artboards for workspace
|
// GET /api/artboards?workspaceId=<uuid>[&projectId=<uuid>] → list artboards
|
||||||
// POST /api/artboards → create artboard
|
// POST /api/artboards → create 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';
|
||||||
@@ -14,9 +14,11 @@ export async function GET(req: NextRequest) {
|
|||||||
const workspaceId = req.nextUrl.searchParams.get('workspaceId');
|
const workspaceId = req.nextUrl.searchParams.get('workspaceId');
|
||||||
if (!workspaceId) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 });
|
if (!workspaceId) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 });
|
||||||
|
|
||||||
|
const projectId = req.nextUrl.searchParams.get('projectId') ?? undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = serverClient();
|
const db = serverClient();
|
||||||
const artboards = await getArtboards(db, workspaceId);
|
const artboards = await getArtboards(db, workspaceId, projectId);
|
||||||
return NextResponse.json(artboards);
|
return NextResponse.json(artboards);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export async function POST(
|
|||||||
|
|
||||||
const artboard = await createArtboard(db, {
|
const artboard = await createArtboard(db, {
|
||||||
workspace_id: workspaceId,
|
workspace_id: workspaceId,
|
||||||
|
project_id: null,
|
||||||
name: result.artboardTitle,
|
name: result.artboardTitle,
|
||||||
origin_id: origin.id,
|
origin_id: origin.id,
|
||||||
parent_artboard_id: null,
|
parent_artboard_id: null,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('[GlobalError]', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body style={{
|
||||||
|
margin: 0, minHeight: '100vh',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: '#FAFAFA',
|
||||||
|
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
||||||
|
padding: 24,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#FFFFFF', border: '1px solid rgba(220,38,38,0.15)',
|
||||||
|
borderRadius: 16, padding: '44px 48px', maxWidth: 480, width: '100%',
|
||||||
|
boxShadow: '0 4px 24px rgba(0,0,0,0.06)', textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 48, height: 48, borderRadius: 12,
|
||||||
|
background: 'rgba(220,38,38,0.07)', border: '1px solid rgba(220,38,38,0.15)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
margin: '0 auto 20px',
|
||||||
|
}}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M10 6v4M10 14h.01" stroke="#DC2626" strokeWidth="1.8" strokeLinecap="round"/>
|
||||||
|
<circle cx="10" cy="10" r="8.5" stroke="#DC2626" strokeWidth="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 style={{ fontSize: '1.125rem', fontWeight: 700, color: '#0A0A0A', margin: '0 0 8px', letterSpacing: '-0.02em' }}>
|
||||||
|
Something went wrong
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#71717A', margin: '0 0 28px', lineHeight: 1.6 }}>
|
||||||
|
{error.message || 'An unexpected error occurred.'}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
style={{
|
||||||
|
padding: '9px 20px', borderRadius: 8,
|
||||||
|
fontSize: '0.875rem', fontWeight: 600,
|
||||||
|
background: '#0A0A0A', color: '#FFFFFF', border: 'none',
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/workspaces"
|
||||||
|
style={{
|
||||||
|
padding: '9px 20px', borderRadius: 8,
|
||||||
|
fontSize: '0.875rem', fontWeight: 600,
|
||||||
|
background: '#F4F4F5', color: '#3F3F46',
|
||||||
|
border: 'none', textDecoration: 'none',
|
||||||
|
display: 'inline-flex', alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to workspaces
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { AppHeader } from '@/components/shell/AppHeader';
|
||||||
|
|
||||||
|
export default function WorkspaceError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('[WorkspaceError]', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||||
|
<AppHeader breadcrumbs={[{ label: 'Workspaces', href: '/workspaces' }]} />
|
||||||
|
<main style={{ maxWidth: 480, margin: '80px auto', padding: '0 24px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '2rem', marginBottom: 16 }}>⚠️</div>
|
||||||
|
<h1 style={{ fontSize: '1.125rem', fontWeight: 700, color: '#0A0A0A', margin: '0 0 8px' }}>
|
||||||
|
Failed to load workspace
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#71717A', margin: '0 0 24px', lineHeight: 1.6 }}>
|
||||||
|
{error.message}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
style={{
|
||||||
|
padding: '9px 20px', borderRadius: 8, fontSize: '0.875rem',
|
||||||
|
fontWeight: 600, background: '#0A0A0A', color: '#FFFFFF',
|
||||||
|
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/workspaces"
|
||||||
|
style={{
|
||||||
|
padding: '9px 20px', borderRadius: 8, fontSize: '0.875rem',
|
||||||
|
fontWeight: 600, background: '#F4F4F5', color: '#3F3F46',
|
||||||
|
textDecoration: 'none', display: 'inline-flex', alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
All workspaces
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import Link from 'next/link';
|
|||||||
import { serverClient } from '@/lib/supabase';
|
import { serverClient } from '@/lib/supabase';
|
||||||
import { AppHeader } from '@/components/shell/AppHeader';
|
import { AppHeader } from '@/components/shell/AppHeader';
|
||||||
import { ProjectCard } from '@/components/shell/ProjectCard';
|
import { ProjectCard } from '@/components/shell/ProjectCard';
|
||||||
|
import { DesignLanguageUpload } from '@/components/shell/DesignLanguageUpload';
|
||||||
|
import { getActiveDesignLanguageFile } from '@originmain/origin-graph';
|
||||||
import type { Workspace, Project } from '@originmain/origin-graph';
|
import type { Workspace, Project } from '@originmain/origin-graph';
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ wid: string }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ wid: string }> }) {
|
||||||
@@ -31,10 +33,11 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
|
|||||||
|
|
||||||
if (!member) redirect('/workspaces');
|
if (!member) redirect('/workspaces');
|
||||||
|
|
||||||
// Fetch workspace + projects in parallel
|
// Fetch workspace + projects + design language in parallel
|
||||||
const [{ data: wsData }, { data: projectsData }] = await Promise.all([
|
const [{ data: wsData }, { data: projectsData }, designLanguage] = await Promise.all([
|
||||||
db.from('workspaces').select('*').eq('id', wid).single(),
|
db.from('workspaces').select('*').eq('id', wid).single(),
|
||||||
db.from('projects').select('*').eq('workspace_id', wid).order('created_at', { ascending: true }),
|
db.from('projects').select('*').eq('workspace_id', wid).order('created_at', { ascending: true }),
|
||||||
|
getActiveDesignLanguageFile(db, wid).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!wsData) redirect('/workspaces');
|
if (!wsData) redirect('/workspaces');
|
||||||
@@ -126,6 +129,9 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Design Language */}
|
||||||
|
<DesignLanguageUpload workspaceId={wid} current={designLanguage} />
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function CanvasError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
const params = useParams<{ wid: string }>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('[CanvasError]', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: '#0C0C10',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontFamily: "'Inter', -apple-system, sans-serif",
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: 420,
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: 'rgba(220,38,38,0.08)',
|
||||||
|
border: '1px solid rgba(220,38,38,0.15)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
margin: '0 auto 20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M10 6v4M10 14h.01" stroke="#DC2626" strokeWidth="1.8" strokeLinecap="round"/>
|
||||||
|
<circle cx="10" cy="10" r="8.5" stroke="#DC2626" strokeWidth="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'rgba(255,255,255,0.88)',
|
||||||
|
margin: '0 0 8px',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Canvas failed to load
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'rgba(255,255,255,0.38)',
|
||||||
|
margin: '0 0 28px',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error.message || 'An unexpected error occurred.'}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
style={{
|
||||||
|
padding: '9px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
background: 'rgba(255,255,255,0.1)',
|
||||||
|
color: 'rgba(255,255,255,0.88)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={params?.wid ? `/workspace/${params.wid}` : '/workspaces'}
|
||||||
|
style={{
|
||||||
|
padding: '9px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'rgba(255,255,255,0.42)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.07)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to workspace
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { AppHeader } from '@/components/shell/AppHeader';
|
||||||
|
|
||||||
|
export default function WorkspacesError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('[WorkspacesError]', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||||
|
<AppHeader />
|
||||||
|
<main style={{ maxWidth: 480, margin: '80px auto', padding: '0 24px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '2rem', marginBottom: 16 }}>⚠️</div>
|
||||||
|
<h1 style={{ fontSize: '1.125rem', fontWeight: 700, color: '#0A0A0A', margin: '0 0 8px' }}>
|
||||||
|
Failed to load workspaces
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#71717A', margin: '0 0 24px', lineHeight: 1.6 }}>
|
||||||
|
{error.message}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
style={{
|
||||||
|
padding: '9px 20px', borderRadius: 8, fontSize: '0.875rem',
|
||||||
|
fontWeight: 600, background: '#0A0A0A', color: '#FFFFFF',
|
||||||
|
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, useEffect, useCallback } from 'react';
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
|
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';
|
||||||
import { useArtboards } from '@/hooks/useArtboards';
|
import { useArtboards, createArtboardMutation } from '@/hooks/useArtboards';
|
||||||
import { Artboard } from './Artboard';
|
import { Artboard } from './Artboard';
|
||||||
|
|
||||||
export function Canvas() {
|
export function Canvas() {
|
||||||
@@ -11,8 +12,9 @@ export function Canvas() {
|
|||||||
const panX = useViewport((s) => s.panX);
|
const panX = useViewport((s) => s.panX);
|
||||||
const panY = useViewport((s) => s.panY);
|
const panY = useViewport((s) => s.panY);
|
||||||
const zoom = useViewport((s) => s.zoom);
|
const zoom = useViewport((s) => s.zoom);
|
||||||
const { activeTool, selectArtboard, workspaceId } = useCanvas();
|
const { activeTool, setActiveTool, selectArtboard, workspaceId, projectId } = useCanvas();
|
||||||
const { artboards } = useArtboards(workspaceId ?? undefined);
|
const { artboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
|
||||||
|
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 });
|
||||||
@@ -47,14 +49,43 @@ export function Canvas() {
|
|||||||
|
|
||||||
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
const startPan = e.button === 1 || spaceDown.current || activeTool === 'pan';
|
const startPan = e.button === 1 || spaceDown.current || activeTool === 'pan';
|
||||||
|
|
||||||
if (startPan) {
|
if (startPan) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
isPanning.current = true;
|
isPanning.current = true;
|
||||||
lastPos.current = { x: e.clientX, y: e.clientY };
|
lastPos.current = { x: e.clientX, y: e.clientY };
|
||||||
} else if (e.target === e.currentTarget) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artboard creation tool: click on canvas to place a new artboard
|
||||||
|
if (activeTool === 'artboard' && e.target === e.currentTarget && workspaceId) {
|
||||||
|
const rect = containerRef.current!.getBoundingClientRect();
|
||||||
|
// Convert screen → canvas space (invert matrix(zoom,0,0,zoom,panX,panY))
|
||||||
|
const { panX, panY, zoom } = useViewport.getState();
|
||||||
|
const canvasX = Math.round((e.clientX - rect.left - panX) / zoom);
|
||||||
|
const canvasY = Math.round((e.clientY - rect.top - panY) / zoom);
|
||||||
|
|
||||||
|
const label = `Artboard ${Date.now().toString(36).slice(-4).toUpperCase()}`;
|
||||||
|
createArtboardMutation({
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
project_id: projectId ?? null,
|
||||||
|
name: label,
|
||||||
|
origin_id: null,
|
||||||
|
parent_artboard_id: null,
|
||||||
|
metadata_jsonb: { x: canvasX, y: canvasY, width: 360, height: 240 },
|
||||||
|
}).then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
||||||
|
setActiveTool('select');
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
console.error('[Canvas] Failed to create artboard', err);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
selectArtboard(null);
|
selectArtboard(null);
|
||||||
}
|
}
|
||||||
}, [activeTool, selectArtboard]);
|
}, [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) return;
|
||||||
@@ -69,7 +100,10 @@ export function Canvas() {
|
|||||||
|
|
||||||
// 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 = activeTool === 'pan' || isPanning.current ? 'grab' : 'default';
|
const cursor =
|
||||||
|
activeTool === 'pan' || isPanning.current ? 'grab' :
|
||||||
|
activeTool === 'artboard' ? 'crosshair' :
|
||||||
|
'default';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ArtboardNavigator } from '../navigator/ArtboardNavigator';
|
|||||||
import { Canvas } from '../canvas/Canvas';
|
import { Canvas } from '../canvas/Canvas';
|
||||||
import { Inspector } from '../inspector/Inspector';
|
import { Inspector } from '../inspector/Inspector';
|
||||||
import { useHistory } from '@/store/history';
|
import { useHistory } from '@/store/history';
|
||||||
import { useCanvas } from '@/store/canvas';
|
import { useCanvas, type Tool } from '@/store/canvas';
|
||||||
|
|
||||||
interface AppChromeProps {
|
interface AppChromeProps {
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
@@ -19,7 +19,8 @@ interface AppChromeProps {
|
|||||||
|
|
||||||
export function AppChrome({ workspaceId, projectId, workspaceName, projectName }: AppChromeProps) {
|
export function AppChrome({ workspaceId, projectId, workspaceName, projectName }: AppChromeProps) {
|
||||||
const selectedArtboardId = useCanvas((s) => s.selectedArtboardId);
|
const selectedArtboardId = useCanvas((s) => s.selectedArtboardId);
|
||||||
const setContext = useCanvas((s) => s.setContext);
|
const setContext = useCanvas((s) => s.setContext);
|
||||||
|
const setActiveTool = useCanvas((s) => s.setActiveTool);
|
||||||
|
|
||||||
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
|
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -28,6 +29,26 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
// Skip if user is typing in an input
|
||||||
|
if ((e.target as HTMLElement).matches('input, textarea, [contenteditable]')) return;
|
||||||
|
|
||||||
|
// Tool shortcuts (no modifier)
|
||||||
|
if (!e.metaKey && !e.ctrlKey) {
|
||||||
|
const toolKeys: Record<string, Tool> = { v: 'select', h: 'pan', a: 'artboard', z: 'zone' };
|
||||||
|
const mapped = toolKeys[e.key.toLowerCase()];
|
||||||
|
if (mapped !== undefined) {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTool(mapped);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Escape cancels active tool back to select
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setActiveTool('select');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo/Redo require a selected artboard
|
||||||
if (!(e.metaKey || e.ctrlKey)) return;
|
if (!(e.metaKey || e.ctrlKey)) return;
|
||||||
if (!selectedArtboardId) return;
|
if (!selectedArtboardId) return;
|
||||||
|
|
||||||
@@ -42,7 +63,7 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
|||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [selectedArtboardId]);
|
}, [selectedArtboardId, setActiveTool]);
|
||||||
|
|
||||||
const showBreadcrumb = Boolean(workspaceId && projectId);
|
const showBreadcrumb = Boolean(workspaceId && projectId);
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,11 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useCanvas } from '@/store/canvas';
|
import { useCanvas } from '@/store/canvas';
|
||||||
|
import { useArtboards } from '@/hooks/useArtboards';
|
||||||
import { useDiff } from '@/hooks/useDiff';
|
import { useDiff } from '@/hooks/useDiff';
|
||||||
import { ARTBOARD_SNAPSHOTS } from '@/data/artboard-snapshots';
|
import { ARTBOARD_SNAPSHOTS } from '@/data/artboard-snapshots';
|
||||||
import type { DiffResult, PropChange } from '@originmain/diff-engine';
|
import type { DiffResult, PropChange } from '@originmain/diff-engine';
|
||||||
|
import type { Artboard } from '@originmain/origin-graph';
|
||||||
const PROPS = [
|
|
||||||
{ key: 'title', val: '"Revenue Overview"', type: 's' },
|
|
||||||
{ key: 'value', val: '"$12,450"', type: 's' },
|
|
||||||
{ key: 'delta', val: '+2.4', type: 'n' },
|
|
||||||
{ key: 'period', val: '"monthly"', type: 's' },
|
|
||||||
{ key: 'loading', val: 'false', type: 'b' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TYPE_COLORS: Record<string, string> = {
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
s: '#7DD3A8',
|
s: '#7DD3A8',
|
||||||
@@ -36,9 +30,11 @@ const T = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function Inspector() {
|
export function Inspector() {
|
||||||
const { selectedArtboardId } = useCanvas();
|
const { selectedArtboardId, workspaceId, projectId } = useCanvas();
|
||||||
const [tab, setTab] = useState<TabId>('props');
|
const [tab, setTab] = useState<TabId>('props');
|
||||||
const diffResult = useDiff(selectedArtboardId);
|
const diffResult = useDiff(selectedArtboardId);
|
||||||
|
const { rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
|
||||||
|
const selectedArtboard = rawArtboards.find((ab) => ab.id === selectedArtboardId) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -113,19 +109,8 @@ export function Inspector() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : tab === 'props' ? (
|
) : tab === 'props' ? (
|
||||||
<>
|
<PropsTab artboard={selectedArtboard} />
|
||||||
<Section label="Component Props">
|
|
||||||
{PROPS.map(({ key, val, type }) => (
|
|
||||||
<PropRow key={key} label={key} value={val} color={TYPE_COLORS[type] ?? T.key} />
|
|
||||||
))}
|
|
||||||
</Section>
|
|
||||||
<HSep />
|
|
||||||
<Section label="Render Target">
|
|
||||||
<PropRow label="file" value="dashboard.tsx:42" color="#7EB8FF" />
|
|
||||||
<PropRow label="status" value="connected" color="#7DD3A8" />
|
|
||||||
<PropRow label="agent" value="claude-code" color="#7DD3A8" />
|
|
||||||
</Section>
|
|
||||||
</>
|
|
||||||
) : tab === 'diff' ? (
|
) : tab === 'diff' ? (
|
||||||
<DiffTab artboardId={selectedArtboardId} diffResult={diffResult} />
|
<DiffTab artboardId={selectedArtboardId} diffResult={diffResult} />
|
||||||
) : (
|
) : (
|
||||||
@@ -164,13 +149,74 @@ export function Inspector() {
|
|||||||
Live render connected
|
Live render connected
|
||||||
</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)' }}>
|
||||||
284 nodes
|
{selectedArtboard
|
||||||
|
? `${selectedArtboard.metadata_jsonb['width'] ?? '?'} × ${selectedArtboard.metadata_jsonb['height'] ?? '?'}`
|
||||||
|
: '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Props tab ────────────────────────────────────────────── */
|
||||||
|
function PropsTab({ artboard }: { artboard: Artboard | null }) {
|
||||||
|
if (!artboard) return null;
|
||||||
|
|
||||||
|
const meta = artboard.metadata_jsonb;
|
||||||
|
|
||||||
|
const N = TYPE_COLORS['n']!;
|
||||||
|
const B = TYPE_COLORS['b']!;
|
||||||
|
const S = TYPE_COLORS['s']!;
|
||||||
|
|
||||||
|
// Extract canvas geometry
|
||||||
|
const canvasProps: Array<{ key: string; val: string; color: string }> = [
|
||||||
|
{ key: 'x', val: String(meta['x'] ?? 0), color: N },
|
||||||
|
{ key: 'y', val: String(meta['y'] ?? 0), color: N },
|
||||||
|
{ key: 'width', val: String(meta['width'] ?? 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 extraProps = Object.entries(meta)
|
||||||
|
.filter(([k]) => !reservedKeys.has(k))
|
||||||
|
.map(([k, v]) => {
|
||||||
|
const t = typeof v;
|
||||||
|
const color = t === 'number' ? N : t === 'boolean' ? B : S;
|
||||||
|
const val = t === 'string' ? `"${v}"` : String(v);
|
||||||
|
return { key: k, val, color };
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderUrl = typeof meta['renderUrl'] === 'string' ? meta['renderUrl'] as string : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{extraProps.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Section label="Component Props">
|
||||||
|
{extraProps.map(({ key, val, color }) => (
|
||||||
|
<PropRow key={key} label={key} value={val} color={color} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
<HSep />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Section label="Canvas">
|
||||||
|
{canvasProps.map(({ key, val, color }) => (
|
||||||
|
<PropRow key={key} label={key} value={val} color={color} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
<HSep />
|
||||||
|
<Section label="Render Target">
|
||||||
|
<PropRow label="name" value={artboard.name} color="#7EB8FF" />
|
||||||
|
<PropRow label="status" value={renderUrl ? 'connected' : 'none'} color={renderUrl ? '#7DD3A8' : 'rgba(255,255,255,0.28)'} />
|
||||||
|
{renderUrl && <PropRow label="url" value={renderUrl} color="#7DD3A8" />}
|
||||||
|
<PropRow label="id" value={artboard.id.slice(0, 8) + '…'} color="rgba(255,255,255,0.28)" />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Section({ label, children }: { label: string; children: React.ReactNode }) {
|
function Section({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px 14px' }}>
|
<div style={{ padding: '12px 14px' }}>
|
||||||
|
|||||||
@@ -7,18 +7,6 @@ import { SquareRegular } from '@fluentui/react-icons';
|
|||||||
import { useCanvas } from '@/store/canvas';
|
import { useCanvas } from '@/store/canvas';
|
||||||
import { useArtboards } from '@/hooks/useArtboards';
|
import { useArtboards } from '@/hooks/useArtboards';
|
||||||
|
|
||||||
const FILE_PATHS = [
|
|
||||||
'src/components/DashboardCard.tsx',
|
|
||||||
'src/components/UserProfile.tsx',
|
|
||||||
'src/components/NavSidebar.tsx',
|
|
||||||
'src/components/DataTable.tsx',
|
|
||||||
'src/components/StatsCard.tsx',
|
|
||||||
'src/app/canvas/page.tsx',
|
|
||||||
'src/app/layout.tsx',
|
|
||||||
'src/store/canvas.ts',
|
|
||||||
'src/store/viewport.ts',
|
|
||||||
];
|
|
||||||
|
|
||||||
const T = {
|
const T = {
|
||||||
bg: '#111115',
|
bg: '#111115',
|
||||||
border: 'rgba(255,255,255,0.055)',
|
border: 'rgba(255,255,255,0.055)',
|
||||||
@@ -47,11 +35,16 @@ const treeThemeStyles = themeToTreeStyles({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function ArtboardNavigator() {
|
export function ArtboardNavigator() {
|
||||||
const { selectedArtboardId, selectArtboard, workspaceId } = useCanvas();
|
const { selectedArtboardId, selectArtboard, workspaceId, projectId } = useCanvas();
|
||||||
const { artboards } = useArtboards(workspaceId ?? undefined);
|
const { artboards, rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
|
||||||
|
|
||||||
|
// Build file tree paths from artboard names (strip .tsx suffix if present, else use name as path)
|
||||||
|
const filePaths = rawArtboards.length > 0
|
||||||
|
? rawArtboards.map((ab) => `artboards/${ab.name}`)
|
||||||
|
: ['artboards/(no artboards yet)'];
|
||||||
|
|
||||||
const { model } = useFileTree({
|
const { model } = useFileTree({
|
||||||
paths: FILE_PATHS,
|
paths: filePaths,
|
||||||
initialExpansion: 2,
|
initialExpansion: 2,
|
||||||
density: 'compact',
|
density: 'compact',
|
||||||
icons: 'minimal',
|
icons: 'minimal',
|
||||||
@@ -115,9 +108,9 @@ export function ArtboardNavigator() {
|
|||||||
{/* ── Graph stats ── */}
|
{/* ── Graph stats ── */}
|
||||||
<SectionLabel>Graph</SectionLabel>
|
<SectionLabel>Graph</SectionLabel>
|
||||||
<div style={{ padding: '4px 14px 14px', display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
<div style={{ padding: '4px 14px 14px', display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
||||||
<GraphStat label="nodes" value="284" color={T.accent} />
|
<GraphStat label="artboards" value={String(rawArtboards.length || artboards.length)} color={T.accent} />
|
||||||
<GraphStat label="components" value="47" color="rgba(255,255,255,0.45)" />
|
<GraphStat label="components" value="—" color="rgba(255,255,255,0.45)" />
|
||||||
<GraphStat label="tokens" value="112" color="rgba(255,255,255,0.45)" />
|
<GraphStat label="tokens" value="—" color="rgba(255,255,255,0.45)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import type { DesignLanguageFile } from '@originmain/origin-graph';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
workspaceId: string;
|
||||||
|
current: DesignLanguageFile | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesignLanguageUpload({ workspaceId, current }: Props) {
|
||||||
|
const [status, setStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
async function handleFile(file: File) {
|
||||||
|
setStatus('uploading');
|
||||||
|
setErrorMsg('');
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
let parsed: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
throw new Error('File must be valid JSON.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/design-language', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
name: file.name.replace(/\.[^/.]+$/, ''),
|
||||||
|
schema_jsonb: parsed,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({})) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `Upload failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
setStatus('success');
|
||||||
|
} catch (e) {
|
||||||
|
setErrorMsg(e instanceof Error ? e.message : 'Unknown error');
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) void handleFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: '1px solid #E4E4E7',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '24px 28px',
|
||||||
|
marginTop: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '0.9375rem', fontWeight: 700, color: '#0A0A0A', margin: '0 0 4px', letterSpacing: '-0.02em' }}>
|
||||||
|
Design Language
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '0.8125rem', color: '#71717A', margin: 0, lineHeight: 1.6 }}>
|
||||||
|
Upload a JSON schema describing your workspace’s design tokens and component rules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload button */}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={status === 'uploading'}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '8px 18px',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
background: status === 'uploading' ? '#E4E4E7' : '#0A0A0A',
|
||||||
|
color: status === 'uploading' ? '#71717A' : '#FFFFFF',
|
||||||
|
border: 'none',
|
||||||
|
cursor: status === 'uploading' ? 'default' : 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
transition: 'background 0.12s',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === 'uploading' ? 'Uploading…' : current ? 'Update' : 'Upload JSON'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current file info */}
|
||||||
|
{current && status !== 'success' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: '#F4F4F5',
|
||||||
|
borderRadius: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '0.8125rem', color: '#3F3F46', fontWeight: 500 }}>
|
||||||
|
{current.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.6875rem',
|
||||||
|
padding: '2px 7px',
|
||||||
|
background: '#E4E4E7',
|
||||||
|
borderRadius: 4,
|
||||||
|
color: '#71717A',
|
||||||
|
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
v{current.version}
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: '0.75rem', color: '#A1A1AA' }}>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success state */}
|
||||||
|
{status === 'success' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'rgba(16,185,129,0.06)',
|
||||||
|
border: '1px solid rgba(16,185,129,0.2)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: '#059669',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓ Design language uploaded successfully.{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => setStatus('idle')}
|
||||||
|
style={{ background: 'none', border: 'none', color: '#059669', cursor: 'pointer', padding: 0, fontSize: 'inherit', textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'rgba(220,38,38,0.05)',
|
||||||
|
border: '1px solid rgba(220,38,38,0.15)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: '#DC2626',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { Artboard } from '@originmain/origin-graph';
|
import type { Artboard, InsertArtboard } from '@originmain/origin-graph';
|
||||||
|
|
||||||
export interface CanvasArtboard {
|
export interface CanvasArtboard {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,24 +30,53 @@ function toCanvasArtboard(ab: Artboard): CanvasArtboard | null {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchArtboards(workspaceId: string): Promise<CanvasArtboard[]> {
|
interface ArtboardQueryResult {
|
||||||
const res = await fetch(`/api/artboards?workspaceId=${encodeURIComponent(workspaceId)}`);
|
rows: Artboard[];
|
||||||
if (!res.ok) throw new Error(`Artboard fetch failed: ${res.status}`);
|
canvas: CanvasArtboard[];
|
||||||
const rows = (await res.json()) as Artboard[];
|
|
||||||
return rows.map(toCanvasArtboard).filter((ab): ab is CanvasArtboard => ab !== null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useArtboards(workspaceId: string | undefined) {
|
async function fetchArtboards(workspaceId: string, projectId?: string): Promise<ArtboardQueryResult> {
|
||||||
|
const url = new URL('/api/artboards', window.location.origin);
|
||||||
|
url.searchParams.set('workspaceId', workspaceId);
|
||||||
|
if (projectId) url.searchParams.set('projectId', projectId);
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
if (!res.ok) throw new Error(`Artboard fetch failed: ${res.status}`);
|
||||||
|
const rows = (await res.json()) as Artboard[];
|
||||||
|
return { rows, canvas: rows.map(toCanvasArtboard).filter((ab): ab is CanvasArtboard => ab !== null) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new artboard via POST /api/artboards and invalidate the cache. */
|
||||||
|
export async function createArtboardMutation(
|
||||||
|
body: InsertArtboard,
|
||||||
|
): Promise<Artboard> {
|
||||||
|
const res = await fetch('/api/artboards', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as { error?: string }).error ?? `Create failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<Artboard>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArtboards(workspaceId: string | undefined, projectId?: string) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['artboards', workspaceId],
|
queryKey: ['artboards', workspaceId, projectId],
|
||||||
queryFn: () => fetchArtboards(workspaceId!),
|
queryFn: () => fetchArtboards(workspaceId!, projectId),
|
||||||
enabled: workspaceId !== undefined,
|
enabled: workspaceId !== undefined,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show demo artboards while loading or when workspace has no artboards yet.
|
const canvasArtboards = query.data?.canvas ?? [];
|
||||||
const artboards =
|
// Show demo artboards while loading or when workspace/project has no artboards yet.
|
||||||
!query.data || query.data.length === 0 ? DEMO_ARTBOARDS : query.data;
|
const artboards = canvasArtboards.length === 0 ? DEMO_ARTBOARDS : canvasArtboards;
|
||||||
|
|
||||||
return { artboards, isLoading: query.isLoading, error: query.error };
|
return {
|
||||||
|
artboards,
|
||||||
|
rawArtboards: query.data?.rows ?? [],
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
error: query.error,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -55,12 +55,17 @@ export interface DbError {
|
|||||||
|
|
||||||
// ── Artboard queries ──────────────────────────────────────────────────────────
|
// ── Artboard queries ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getArtboards(db: DbClient, workspaceId: string): Promise<Artboard[]> {
|
export async function getArtboards(
|
||||||
const { data, error } = await (db
|
db: DbClient,
|
||||||
|
workspaceId: string,
|
||||||
|
projectId?: string,
|
||||||
|
): Promise<Artboard[]> {
|
||||||
|
let q: DbQuery = db
|
||||||
.from('artboards')
|
.from('artboards')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('workspace_id', workspaceId)
|
.eq('workspace_id', workspaceId);
|
||||||
.order('created_at', { ascending: false }) as unknown as Promise<{ data: Artboard[]; error: DbError | null }>);
|
if (projectId) q = q.eq('project_id', projectId);
|
||||||
|
const { data, error } = await (q.order('created_at', { ascending: false }) as unknown as Promise<{ data: Artboard[]; error: DbError | null }>);
|
||||||
if (error) throw new Error(error.message);
|
if (error) throw new Error(error.message);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export type Workspace = z.infer<typeof WorkspaceSchema>;
|
|||||||
export const ArtboardSchema = z.object({
|
export const ArtboardSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
workspace_id: z.string().uuid(),
|
workspace_id: z.string().uuid(),
|
||||||
|
project_id: z.string().uuid().nullable(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
origin_id: z.string().uuid().nullable(),
|
origin_id: z.string().uuid().nullable(),
|
||||||
parent_artboard_id: z.string().uuid().nullable(),
|
parent_artboard_id: z.string().uuid().nullable(),
|
||||||
|
|||||||
Reference in New Issue
Block a user