improved a lot of things
This commit is contained in:
@@ -119,7 +119,12 @@
|
||||
"WebFetch(domain:worldvectorlogo.com)",
|
||||
"Bash(pnpm --filter @originmain/app exec tsc --noEmit)",
|
||||
"Bash(mkdir -p /Users/USER/Desktop/originmain/packages/app/src/app/sign-in/\\\\[\\\\[...sign-in\\\\]\\\\])",
|
||||
"Bash(mkdir -p /Users/USER/Desktop/originmain/packages/app/src/app/sign-up/\\\\[\\\\[...sign-up\\\\]\\\\])"
|
||||
"Bash(mkdir -p /Users/USER/Desktop/originmain/packages/app/src/app/sign-up/\\\\[\\\\[...sign-up\\\\]\\\\])",
|
||||
"Bash(python3)",
|
||||
"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/[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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
|
||||
CLERK_SECRET_KEY=sk_test_...
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/canvas
|
||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/canvas
|
||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/workspaces
|
||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding
|
||||
|
||||
# ── Supabase ──────────────────────────────────────────────────────────────────
|
||||
# https://supabase.com/dashboard → Project Settings → API
|
||||
|
||||
@@ -1625,8 +1625,8 @@ footer {
|
||||
<li><a href="#">Docs</a></li>
|
||||
</ul>
|
||||
<div class="nav-actions">
|
||||
<a href="#" class="btn btn-secondary" style="padding:7px 16px;font-size:.875rem">Log in</a>
|
||||
<a href="#" class="btn btn-primary" style="padding:8px 18px;font-size:.875rem">Request Access</a>
|
||||
<a href="/sign-in" class="btn btn-secondary" style="padding:7px 16px;font-size:.875rem">Log in</a>
|
||||
<a href="/sign-up" class="btn btn-primary" style="padding:8px 18px;font-size:.875rem">Get Started</a>
|
||||
<button class="nav-burger" id="nav-burger" aria-label="Toggle navigation" aria-expanded="false">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
@@ -1655,8 +1655,8 @@ footer {
|
||||
<li><a href="#">Docs</a></li>
|
||||
</ul>
|
||||
<div class="nav-mobile-ctas">
|
||||
<a href="#" class="btn btn-secondary">Log in</a>
|
||||
<a href="#" class="btn btn-primary">Request Access</a>
|
||||
<a href="/sign-in" class="btn btn-secondary">Log in</a>
|
||||
<a href="/sign-up" class="btn btn-primary">Get Started</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -1682,7 +1682,7 @@ footer {
|
||||
</p>
|
||||
|
||||
<div class="hero-ctas reveal">
|
||||
<a href="#" class="btn btn-primary btn-lg">Request Early Access</a>
|
||||
<a href="/sign-up" class="btn btn-primary btn-lg">Get Started Free</a>
|
||||
<a href="#" class="btn btn-secondary btn-lg">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
Watch the demo
|
||||
@@ -2382,7 +2382,7 @@ footer {
|
||||
<li><span class="px-check">✓</span> 3 AI completions / day</li>
|
||||
<li><span class="px-check">✓</span> GitHub integration</li>
|
||||
</ul>
|
||||
<a href="#" class="btn btn-secondary btn-full">Get started free</a>
|
||||
<a href="/sign-up" class="btn btn-secondary btn-full">Get started free</a>
|
||||
</div>
|
||||
<div class="px-card featured">
|
||||
<div class="px-tag">Most popular</div>
|
||||
@@ -2398,7 +2398,7 @@ footer {
|
||||
<li><span class="px-check">✓</span> Linear + Slack integration</li>
|
||||
<li><span class="px-check">✓</span> Multiplayer canvas</li>
|
||||
</ul>
|
||||
<a href="#" class="btn btn-blue btn-full">Request early access</a>
|
||||
<a href="/sign-up" class="btn btn-blue btn-full">Request early access</a>
|
||||
</div>
|
||||
<div class="px-card">
|
||||
<div class="px-tier">Enterprise</div>
|
||||
@@ -2413,7 +2413,7 @@ footer {
|
||||
<li><span class="px-check">✓</span> Custom integrations</li>
|
||||
<li><span class="px-check">✓</span> Design system audit</li>
|
||||
</ul>
|
||||
<a href="#" class="btn btn-secondary btn-full">Talk to us</a>
|
||||
<a href="mailto:hello@originmain.com" class="btn btn-secondary btn-full">Talk to us</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2425,7 +2425,7 @@ footer {
|
||||
<h2 class="cta-headline">Start building<br>the right way.</h2>
|
||||
<p class="cta-sub">Join the engineers who are done shipping pixel-perfect mockups that no one implements correctly.</p>
|
||||
<div class="cta-actions">
|
||||
<a href="#" class="btn btn-white btn-xl">Request Early Access</a>
|
||||
<a href="/sign-up" class="btn btn-white btn-xl">Get Started Free</a>
|
||||
<a href="#" class="btn btn-outline-white btn-xl">Read the docs →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// GET /api/workspace/[id]/projects — list projects in a workspace
|
||||
// POST /api/workspace/[id]/projects — create a new project
|
||||
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import type { Project, InsertProject } from '@originmain/origin-graph';
|
||||
|
||||
async function assertMember(db: ReturnType<typeof serverClient>, workspaceId: string, userId: string) {
|
||||
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 }: { 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();
|
||||
|
||||
if (!(await assertMember(db, workspaceId, userId)))
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const { data, error } = await db
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.eq('workspace_id', workspaceId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json(data as Project[]);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (!(await assertMember(db, workspaceId, userId)))
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : null;
|
||||
if (!name) return NextResponse.json({ error: 'name is required' }, { status: 400 });
|
||||
|
||||
const insert: InsertProject = {
|
||||
workspace_id: workspaceId,
|
||||
name,
|
||||
description: typeof body.description === 'string' ? body.description.trim() || null : null,
|
||||
app_url: typeof body.app_url === 'string' ? body.app_url.trim() || null : null,
|
||||
framework: typeof body.framework === 'string' ? body.framework.trim() || null : null,
|
||||
};
|
||||
|
||||
const { data, error } = await db
|
||||
.from('projects')
|
||||
.insert(insert)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json(data as Project, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// GET /api/workspaces — list every workspace the signed-in user belongs to
|
||||
// POST /api/workspaces — create a new workspace + add creator as OWNER
|
||||
|
||||
import { auth, currentUser } from '@clerk/nextjs/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import type { Workspace, InsertWorkspace } from '@originmain/origin-graph';
|
||||
|
||||
export async function GET() {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const db = serverClient();
|
||||
|
||||
// Fetch all workspace IDs the user is a member of, then join workspaces.
|
||||
const { data: memberships, error: mErr } = await db
|
||||
.from('team_members')
|
||||
.select('workspace_id')
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (mErr) return NextResponse.json({ error: mErr.message }, { status: 500 });
|
||||
|
||||
const ids = (memberships ?? []).map((m) => (m as { workspace_id: string }).workspace_id);
|
||||
if (ids.length === 0) return NextResponse.json([]);
|
||||
|
||||
const { data: workspaces, error: wErr } = await db
|
||||
.from('workspaces')
|
||||
.select('*')
|
||||
.in('id', ids)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (wErr) return NextResponse.json({ error: wErr.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json(workspaces as Workspace[]);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const name = typeof body.name === 'string' && body.name.trim()
|
||||
? body.name.trim()
|
||||
: null;
|
||||
|
||||
if (!name) return NextResponse.json({ error: 'name is required' }, { status: 400 });
|
||||
|
||||
const db = serverClient();
|
||||
|
||||
const insert: InsertWorkspace = {
|
||||
owner_id: userId,
|
||||
name,
|
||||
plan: 'FREE',
|
||||
settings_jsonb: {},
|
||||
};
|
||||
|
||||
const { data: created, error: insertErr } = await db
|
||||
.from('workspaces')
|
||||
.insert(insert)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertErr) return NextResponse.json({ error: insertErr.message }, { status: 500 });
|
||||
|
||||
const workspace = created as Workspace;
|
||||
|
||||
// Add creator as OWNER team member so GET /api/workspaces can find it.
|
||||
await db.from('team_members').insert({
|
||||
workspace_id: workspace.id,
|
||||
user_id: userId,
|
||||
role: 'OWNER',
|
||||
});
|
||||
|
||||
return NextResponse.json(workspace, { status: 201 });
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AppChrome } from '@/components/chrome/AppChrome';
|
||||
|
||||
export const metadata = { title: 'Canvas — Originmain' };
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
// The canvas now lives at /workspace/[wid]/project/[pid].
|
||||
// Redirect anyone hitting the old /canvas route to their workspace list.
|
||||
export default function CanvasPage() {
|
||||
return <AppChrome />;
|
||||
redirect('/workspaces');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useUser } from '@clerk/nextjs';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const { user, isLoaded } = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
const defaultName = isLoaded
|
||||
? `${user?.firstName ?? user?.emailAddresses[0]?.emailAddress?.split('@')[0] ?? 'My'}'s Workspace`
|
||||
: '';
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const workspaceName = name.trim() || defaultName;
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!workspaceName) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const res = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: workspaceName }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error ?? 'Something went wrong');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = await res.json();
|
||||
router.push(`/workspace/${ws.id}`);
|
||||
}
|
||||
|
||||
if (!isLoaded) return null;
|
||||
|
||||
const firstName = user?.firstName ?? user?.emailAddresses[0]?.emailAddress?.split('@')[0] ?? 'there';
|
||||
|
||||
return (
|
||||
<main style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
background: '#FAFAFA',
|
||||
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
||||
padding: '24px',
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<div style={{ marginBottom: 40, fontSize: '1.125rem', fontWeight: 700, letterSpacing: '-0.02em', color: '#0A0A0A' }}>
|
||||
Origin<span style={{ color: '#0066FF' }}>main</span>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div style={{
|
||||
background: '#FFFFFF', border: '1px solid rgba(0,0,0,0.07)',
|
||||
borderRadius: 20, padding: '44px 48px', maxWidth: 460, width: '100%',
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: 13,
|
||||
background: 'rgba(0,102,255,0.07)', border: '1px solid rgba(0,102,255,0.15)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 22,
|
||||
}}>
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
|
||||
<rect x="1.5" y="1.5" width="8.5" height="8.5" rx="2" fill="#0066FF" opacity="0.7"/>
|
||||
<rect x="12" y="1.5" width="8.5" height="8.5" rx="2" fill="#0066FF" opacity="0.3"/>
|
||||
<rect x="1.5" y="12" width="8.5" height="8.5" rx="2" fill="#0066FF" opacity="0.3"/>
|
||||
<rect x="12" y="12" width="8.5" height="8.5" rx="2" fill="#0066FF" opacity="0.15"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 style={{ fontSize: '1.375rem', fontWeight: 700, letterSpacing: '-0.03em', color: '#0A0A0A', margin: '0 0 8px' }}>
|
||||
Welcome, {firstName}!
|
||||
</h1>
|
||||
<p style={{ fontSize: '0.9rem', color: '#71717A', lineHeight: 1.6, margin: '0 0 28px' }}>
|
||||
Let's create your workspace. A workspace holds your projects and team — usually named after your company or product.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleCreate}>
|
||||
<label style={{ display: 'block', fontSize: '0.8125rem', fontWeight: 600, color: '#3F3F46', marginBottom: 6 }}>
|
||||
Workspace name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={defaultName}
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
padding: '11px 13px', fontSize: '0.9375rem',
|
||||
border: '1px solid rgba(0,0,0,0.12)', borderRadius: 9,
|
||||
outline: 'none', color: '#0A0A0A', background: '#FFFFFF',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = '#0066FF')}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = 'rgba(0,0,0,0.12)')}
|
||||
/>
|
||||
<p style={{ margin: '6px 0 0', fontSize: '0.75rem', color: '#A1A1AA' }}>
|
||||
Leave blank to use "{defaultName}"
|
||||
</p>
|
||||
|
||||
{error && <p style={{ margin: '10px 0 0', fontSize: '0.8125rem', color: '#DC2626' }}>{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
marginTop: 24, width: '100%',
|
||||
padding: '12px', borderRadius: 9,
|
||||
fontSize: '0.9375rem', fontWeight: 600,
|
||||
background: loading ? '#D4D4D8' : '#0A0A0A',
|
||||
color: '#FFFFFF', border: 'none',
|
||||
cursor: loading ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', letterSpacing: '-0.01em',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
}}
|
||||
>
|
||||
{loading ? 'Creating workspace…' : (
|
||||
<>
|
||||
Create workspace
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p style={{ marginTop: 24, fontSize: '0.8125rem', color: '#A1A1AA' }}>
|
||||
Already have a workspace?{' '}
|
||||
<a href="/workspaces" style={{ color: '#0066FF', textDecoration: 'none' }}>View all workspaces</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import { AppHeader } from '@/components/shell/AppHeader';
|
||||
import type { Workspace, Project } 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'} — Originmain` };
|
||||
}
|
||||
|
||||
const FRAMEWORK_ICON: Record<string, string> = {
|
||||
next: '▲',
|
||||
react: '⚛',
|
||||
vue: 'V',
|
||||
svelte: 'S',
|
||||
angular: 'A',
|
||||
nuxt: 'N',
|
||||
};
|
||||
|
||||
export default async function WorkspacePage({ params }: { params: Promise<{ wid: string }> }) {
|
||||
const { wid } = await params;
|
||||
const { userId } = await auth();
|
||||
if (!userId) redirect('/sign-in');
|
||||
|
||||
const db = serverClient();
|
||||
|
||||
// Verify membership
|
||||
const { data: member } = await db
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', wid)
|
||||
.eq('user_id', userId)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (!member) redirect('/workspaces');
|
||||
|
||||
// Fetch workspace + projects in parallel
|
||||
const [{ data: wsData }, { data: projectsData }] = await Promise.all([
|
||||
db.from('workspaces').select('*').eq('id', wid).single(),
|
||||
db.from('projects').select('*').eq('workspace_id', wid).order('created_at', { ascending: true }),
|
||||
]);
|
||||
|
||||
if (!wsData) redirect('/workspaces');
|
||||
|
||||
const workspace = wsData as Workspace;
|
||||
const projects = (projectsData ?? []) as Project[];
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<AppHeader breadcrumbs={[
|
||||
{ label: 'Workspaces', href: '/workspaces' },
|
||||
{ label: workspace.name },
|
||||
]} />
|
||||
|
||||
<main style={{ maxWidth: 960, margin: '0 auto', padding: '48px 24px' }}>
|
||||
|
||||
{/* Title row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 32 }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, letterSpacing: '-0.03em', color: '#0A0A0A', margin: 0 }}>
|
||||
Projects
|
||||
</h1>
|
||||
<p style={{ margin: '4px 0 0', fontSize: '0.875rem', color: '#71717A' }}>
|
||||
{projects.length === 0
|
||||
? 'No projects yet — connect your first app to get started'
|
||||
: `${projects.length} project${projects.length !== 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Empty state */}
|
||||
{projects.length === 0 && (
|
||||
<div style={{
|
||||
background: '#FFFFFF', border: '1px dashed rgba(0,0,0,0.12)',
|
||||
borderRadius: 16, padding: '56px 40px', textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: 14,
|
||||
background: 'rgba(0,102,255,0.07)', border: '1px solid rgba(0,102,255,0.15)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px',
|
||||
}}>
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
|
||||
<rect x="2" y="4" width="18" height="14" rx="2.5" stroke="#0066FF" strokeWidth="1.5"/>
|
||||
<path d="M7 9.5l2.5 2.5L15 7" stroke="#0066FF" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 600, color: '#0A0A0A', margin: '0 0 8px' }}>
|
||||
Connect your first app
|
||||
</h3>
|
||||
<p style={{ fontSize: '0.875rem', color: '#71717A', margin: '0 0 24px', maxWidth: 360, marginLeft: 'auto', marginRight: 'auto', lineHeight: 1.6 }}>
|
||||
A project connects to your running application — local or staging — so you can inspect components and ship intent-level diffs.
|
||||
</p>
|
||||
<Link href={`/workspace/${wid}/project/new`} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
background: '#0066FF', color: '#FFFFFF',
|
||||
fontSize: '0.875rem', fontWeight: 600,
|
||||
padding: '10px 20px', borderRadius: 9, textDecoration: 'none',
|
||||
}}>
|
||||
Create a project →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project cards */}
|
||||
{projects.length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
||||
{projects.map(project => {
|
||||
const icon = project.framework ? (FRAMEWORK_ICON[project.framework.toLowerCase()] ?? '◻') : '◻';
|
||||
return (
|
||||
<Link key={project.id} href={`/workspace/${wid}/project/${project.id}`} style={{ textDecoration: 'none' }}>
|
||||
<div style={{
|
||||
background: '#FFFFFF',
|
||||
border: '1px solid rgba(0,0,0,0.07)',
|
||||
borderRadius: 14, padding: '20px 22px', cursor: 'pointer',
|
||||
transition: 'box-shadow 0.15s, border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
(e.currentTarget as HTMLElement).style.boxShadow = '0 4px 16px rgba(0,0,0,0.08)';
|
||||
(e.currentTarget as HTMLElement).style.borderColor = 'rgba(0,0,0,0.12)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.currentTarget as HTMLElement).style.boxShadow = 'none';
|
||||
(e.currentTarget as HTMLElement).style.borderColor = 'rgba(0,0,0,0.07)';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
|
||||
<div style={{
|
||||
width: 38, height: 38, borderRadius: 9,
|
||||
background: '#0A0A0A',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '0.875rem', color: '#FFFFFF', fontWeight: 700, flexShrink: 0,
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
<span style={{ fontSize: '0.9375rem', fontWeight: 600, color: '#0A0A0A', letterSpacing: '-0.01em' }}>
|
||||
{project.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{project.app_url && (
|
||||
<p style={{
|
||||
margin: '0 0 6px',
|
||||
fontSize: '0.75rem', fontFamily: 'monospace',
|
||||
color: '#71717A', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{project.app_url}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{project.description && (
|
||||
<p style={{ margin: '0 0 12px', fontSize: '0.8125rem', color: '#71717A', lineHeight: 1.5 }}>
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 16, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: '0.8125rem', color: '#0066FF', fontWeight: 500 }}>
|
||||
Open canvas →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import { AppChrome } from '@/components/chrome/AppChrome';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ wid: string; pid: string }> }) {
|
||||
const { pid } = await params;
|
||||
const db = serverClient();
|
||||
const { data } = await db.from('projects').select('name').eq('id', pid).single() as unknown as Promise<{ data: { name: string } | null }> as unknown as { data: { name: string } | null };
|
||||
return { title: `${data?.name ?? 'Canvas'} — Originmain` };
|
||||
}
|
||||
|
||||
export default async function ProjectCanvasPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ wid: string; pid: string }>;
|
||||
}) {
|
||||
const { wid, pid } = await params;
|
||||
const { userId } = await auth();
|
||||
if (!userId) redirect('/sign-in');
|
||||
|
||||
const db = serverClient();
|
||||
|
||||
// Verify membership
|
||||
const { data: member } = await db
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', wid)
|
||||
.eq('user_id', userId)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (!member) redirect('/workspaces');
|
||||
|
||||
// Fetch workspace name + project name for the breadcrumb
|
||||
type NameRow = { data: { name: string } | null };
|
||||
const [wsResult, projResult] = await Promise.all([
|
||||
db.from('workspaces').select('name').eq('id', wid).single() as unknown as Promise<NameRow>,
|
||||
db.from('projects').select('name').eq('id', pid).single() as unknown as Promise<NameRow>,
|
||||
]);
|
||||
|
||||
if (!projResult.data) redirect(`/workspace/${wid}`);
|
||||
|
||||
return (
|
||||
<AppChrome
|
||||
workspaceId={wid}
|
||||
projectId={pid}
|
||||
workspaceName={wsResult.data?.name ?? 'Workspace'}
|
||||
projectName={projResult.data.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import { useState, use } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { AppHeader } from '@/components/shell/AppHeader';
|
||||
|
||||
const FRAMEWORKS = ['React', 'Next.js', 'Vue', 'Nuxt', 'Svelte', 'SvelteKit', 'Angular', 'Remix', 'Astro', 'Other'];
|
||||
|
||||
export default function NewProjectPage({ params }: { params: Promise<{ wid: string }> }) {
|
||||
const { wid } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [appUrl, setAppUrl] = useState('');
|
||||
const [description, setDesc] = useState('');
|
||||
const [framework, setFramework] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const res = await fetch(`/api/workspace/${wid}/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
app_url: appUrl.trim() || null,
|
||||
description: description.trim() || null,
|
||||
framework: framework || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error ?? 'Something went wrong');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const project = await res.json();
|
||||
router.push(`/workspace/${wid}/project/${project.id}`);
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', boxSizing: 'border-box' as const,
|
||||
padding: '10px 12px', fontSize: '0.9375rem',
|
||||
border: '1px solid rgba(0,0,0,0.12)', borderRadius: 8,
|
||||
outline: 'none', color: '#0A0A0A', background: '#FFFFFF',
|
||||
fontFamily: 'inherit',
|
||||
};
|
||||
|
||||
const labelStyle = {
|
||||
display: 'block' as const,
|
||||
fontSize: '0.8125rem', fontWeight: 600 as const,
|
||||
color: '#3F3F46', marginBottom: 6,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<AppHeader breadcrumbs={[
|
||||
{ label: 'Workspaces', href: '/workspaces' },
|
||||
{ label: 'Workspace', href: `/workspace/${wid}` },
|
||||
{ label: 'New project' },
|
||||
]} />
|
||||
|
||||
<main style={{ maxWidth: 540, margin: '64px auto', padding: '0 24px' }}>
|
||||
<div style={{ background: '#FFFFFF', border: '1px solid rgba(0,0,0,0.07)', borderRadius: 16, padding: '36px 40px', boxShadow: '0 4px 24px rgba(0,0,0,0.05)' }}>
|
||||
<h1 style={{ fontSize: '1.25rem', fontWeight: 700, letterSpacing: '-0.02em', color: '#0A0A0A', margin: '0 0 6px' }}>
|
||||
New project
|
||||
</h1>
|
||||
<p style={{ fontSize: '0.875rem', color: '#71717A', margin: '0 0 28px', lineHeight: 1.5 }}>
|
||||
A project connects to your running app so you can inspect components live, create artboards, and ship intent diffs.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
|
||||
{/* Project name */}
|
||||
<div>
|
||||
<label style={labelStyle}>Project name <span style={{ color: '#DC2626' }}>*</span></label>
|
||||
<input
|
||||
type="text" value={name} onChange={e => setName(e.target.value)}
|
||||
placeholder="Dashboard App"
|
||||
autoFocus style={inputStyle}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = '#0066FF')}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = 'rgba(0,0,0,0.12)')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* App URL */}
|
||||
<div>
|
||||
<label style={labelStyle}>App URL</label>
|
||||
<input
|
||||
type="url" value={appUrl} onChange={e => setAppUrl(e.target.value)}
|
||||
placeholder="http://localhost:3000 or https://staging.myapp.com"
|
||||
style={inputStyle}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = '#0066FF')}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = 'rgba(0,0,0,0.12)')}
|
||||
/>
|
||||
<p style={{ margin: '5px 0 0', fontSize: '0.75rem', color: '#A1A1AA' }}>
|
||||
The address where your app is running — local or remote.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Framework */}
|
||||
<div>
|
||||
<label style={labelStyle}>Framework</label>
|
||||
<select
|
||||
value={framework} onChange={e => setFramework(e.target.value)}
|
||||
style={{ ...inputStyle, color: framework ? '#0A0A0A' : '#A1A1AA', cursor: 'pointer' }}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = '#0066FF')}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = 'rgba(0,0,0,0.12)')}
|
||||
>
|
||||
<option value="">Select framework (optional)</option>
|
||||
{FRAMEWORKS.map(f => <option key={f} value={f.toLowerCase().replace('.', '')}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label style={labelStyle}>Description <span style={{ color: '#A1A1AA', fontWeight: 400 }}>(optional)</span></label>
|
||||
<textarea
|
||||
value={description} onChange={e => setDesc(e.target.value)}
|
||||
placeholder="Short description of what this project is…"
|
||||
rows={3}
|
||||
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.5 }}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = '#0066FF')}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = 'rgba(0,0,0,0.12)')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ margin: 0, fontSize: '0.8125rem', color: '#DC2626' }}>{error}</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 4 }}>
|
||||
<Link href={`/workspace/${wid}`} style={{
|
||||
flex: 1, textAlign: 'center', padding: '10px', borderRadius: 8,
|
||||
fontSize: '0.875rem', fontWeight: 600, color: '#3F3F46',
|
||||
background: '#F4F4F5', textDecoration: 'none',
|
||||
}}>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || loading}
|
||||
style={{
|
||||
flex: 2, padding: '10px', borderRadius: 8,
|
||||
fontSize: '0.875rem', fontWeight: 600,
|
||||
background: name.trim() && !loading ? '#0A0A0A' : '#D4D4D8',
|
||||
color: '#FFFFFF', border: 'none',
|
||||
cursor: name.trim() && !loading ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
{loading ? 'Creating…' : 'Create project & open canvas →'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { AppHeader } from '@/components/shell/AppHeader';
|
||||
|
||||
export default function NewWorkspacePage() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const res = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim() }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error ?? 'Something went wrong');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = await res.json();
|
||||
router.push(`/workspace/${ws.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<AppHeader breadcrumbs={[{ label: 'Workspaces', href: '/workspaces' }, { label: 'New workspace' }]} />
|
||||
|
||||
<main style={{ maxWidth: 480, margin: '64px auto', padding: '0 24px' }}>
|
||||
<div style={{ background: '#FFFFFF', border: '1px solid rgba(0,0,0,0.07)', borderRadius: 16, padding: '36px 40px', boxShadow: '0 4px 24px rgba(0,0,0,0.05)' }}>
|
||||
<h1 style={{ fontSize: '1.25rem', fontWeight: 700, letterSpacing: '-0.02em', color: '#0A0A0A', margin: '0 0 6px' }}>
|
||||
New workspace
|
||||
</h1>
|
||||
<p style={{ fontSize: '0.875rem', color: '#71717A', margin: '0 0 28px', lineHeight: 1.5 }}>
|
||||
A workspace holds your projects and team. Give it a name — usually your company or team name.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleCreate}>
|
||||
<label style={{ display: 'block', fontSize: '0.8125rem', fontWeight: 600, color: '#3F3F46', marginBottom: 6 }}>
|
||||
Workspace name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="Acme Inc."
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
padding: '10px 12px', fontSize: '0.9375rem',
|
||||
border: '1px solid rgba(0,0,0,0.12)', borderRadius: 8,
|
||||
outline: 'none', color: '#0A0A0A', background: '#FFFFFF',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = '#0066FF')}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = 'rgba(0,0,0,0.12)')}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p style={{ margin: '8px 0 0', fontSize: '0.8125rem', color: '#DC2626' }}>{error}</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 24 }}>
|
||||
<Link href="/workspaces" style={{
|
||||
flex: 1, textAlign: 'center',
|
||||
padding: '10px', borderRadius: 8, fontSize: '0.875rem', fontWeight: 600,
|
||||
color: '#3F3F46', background: '#F4F4F5', textDecoration: 'none',
|
||||
}}>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || loading}
|
||||
style={{
|
||||
flex: 2, padding: '10px', borderRadius: 8,
|
||||
fontSize: '0.875rem', fontWeight: 600,
|
||||
background: name.trim() && !loading ? '#0A0A0A' : '#D4D4D8',
|
||||
color: '#FFFFFF', border: 'none', cursor: name.trim() && !loading ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
{loading ? 'Creating…' : 'Create workspace →'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import { AppHeader } from '@/components/shell/AppHeader';
|
||||
import type { Workspace } from '@originmain/origin-graph';
|
||||
|
||||
export const metadata = { title: 'Workspaces — Originmain' };
|
||||
|
||||
const PLAN_BADGE: Record<string, { label: string; bg: string; color: string }> = {
|
||||
FREE: { label: 'Free', bg: 'rgba(0,0,0,0.05)', color: '#52525B' },
|
||||
TEAM: { label: 'Team', bg: 'rgba(0,102,255,0.08)', color: '#0066FF' },
|
||||
ENTERPRISE: { label: 'Enterprise', bg: 'rgba(124,58,237,0.08)', color: '#7C3AED' },
|
||||
};
|
||||
|
||||
async function getWorkspaces(userId: string): Promise<Workspace[]> {
|
||||
const db = serverClient();
|
||||
const { data: memberships } = await db
|
||||
.from('team_members')
|
||||
.select('workspace_id')
|
||||
.eq('user_id', userId);
|
||||
|
||||
const ids = (memberships ?? []).map((m) => (m as { workspace_id: string }).workspace_id);
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const { data } = await db
|
||||
.from('workspaces')
|
||||
.select('*')
|
||||
.in('id', ids)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
return (data ?? []) as Workspace[];
|
||||
}
|
||||
|
||||
export default async function WorkspacesPage() {
|
||||
const { userId } = await auth();
|
||||
if (!userId) redirect('/sign-in');
|
||||
|
||||
const workspaces = await getWorkspaces(userId);
|
||||
|
||||
// New user with no workspaces yet → send to onboarding
|
||||
if (workspaces.length === 0) redirect('/onboarding');
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
<AppHeader />
|
||||
|
||||
<main style={{ maxWidth: 960, margin: '0 auto', padding: '48px 24px' }}>
|
||||
|
||||
{/* Page title row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 32 }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, letterSpacing: '-0.03em', color: '#0A0A0A', margin: 0 }}>
|
||||
Workspaces
|
||||
</h1>
|
||||
<p style={{ margin: '4px 0 0', fontSize: '0.875rem', color: '#71717A' }}>
|
||||
{workspaces.length} workspace{workspaces.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<NewWorkspaceButton />
|
||||
</div>
|
||||
|
||||
{/* Workspace cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
||||
{workspaces.map(ws => {
|
||||
const badge = PLAN_BADGE[ws.plan] ?? PLAN_BADGE['FREE']!;
|
||||
return (
|
||||
<Link key={ws.id} href={`/workspace/${ws.id}`} style={{ textDecoration: 'none' }}>
|
||||
<div style={{
|
||||
background: '#FFFFFF',
|
||||
border: '1px solid rgba(0,0,0,0.07)',
|
||||
borderRadius: 14,
|
||||
padding: '20px 22px',
|
||||
cursor: 'pointer',
|
||||
transition: 'box-shadow 0.15s, border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
(e.currentTarget as HTMLElement).style.boxShadow = '0 4px 16px rgba(0,0,0,0.08)';
|
||||
(e.currentTarget as HTMLElement).style.borderColor = 'rgba(0,0,0,0.12)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.currentTarget as HTMLElement).style.boxShadow = 'none';
|
||||
(e.currentTarget as HTMLElement).style.borderColor = 'rgba(0,0,0,0.07)';
|
||||
}}
|
||||
>
|
||||
{/* Workspace 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',
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<rect x="1.5" y="1.5" width="6.5" height="6.5" rx="1.5" fill="#0066FF" opacity="0.7"/>
|
||||
<rect x="10" y="1.5" width="6.5" height="6.5" rx="1.5" fill="#0066FF" opacity="0.4"/>
|
||||
<rect x="1.5" y="10" width="6.5" height="6.5" rx="1.5" fill="#0066FF" opacity="0.4"/>
|
||||
<rect x="10" y="10" width="6.5" height="6.5" rx="1.5" fill="#0066FF" opacity="0.2"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontSize: '0.9375rem', fontWeight: 600, color: '#0A0A0A', letterSpacing: '-0.01em', lineHeight: 1.3 }}>
|
||||
{ws.name}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '0.6875rem', fontWeight: 600, letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase', padding: '2px 8px', borderRadius: 99,
|
||||
background: badge.bg, color: badge.color, flexShrink: 0,
|
||||
}}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: '6px 0 0', fontSize: '0.8125rem', color: '#A1A1AA' }}>
|
||||
Created {new Date(ws.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 16, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: '0.8125rem', color: '#0066FF', fontWeight: 500 }}>
|
||||
Open workspace →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Client button for creating a new workspace (needs interactivity)
|
||||
function NewWorkspaceButton() {
|
||||
return (
|
||||
<Link href="/workspaces/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 workspace
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { UserButton } from '@clerk/nextjs';
|
||||
import { Toolbar } from './Toolbar';
|
||||
import { ArtboardNavigator } from '../navigator/ArtboardNavigator';
|
||||
import { Canvas } from '../canvas/Canvas';
|
||||
@@ -8,7 +10,14 @@ import { Inspector } from '../inspector/Inspector';
|
||||
import { useHistory } from '@/store/history';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
|
||||
export function AppChrome() {
|
||||
interface AppChromeProps {
|
||||
workspaceId?: string;
|
||||
projectId?: string;
|
||||
workspaceName?: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export function AppChrome({ workspaceId, projectId, workspaceName, projectName }: AppChromeProps) {
|
||||
const selectedArtboardId = useCanvas((s) => s.selectedArtboardId);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,11 +37,13 @@ export function AppChrome() {
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [selectedArtboardId]);
|
||||
const showBreadcrumb = workspaceId && projectId;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateRows: '44px 1fr',
|
||||
gridTemplateRows: showBreadcrumb ? '36px 44px 1fr' : '44px 1fr',
|
||||
gridTemplateColumns: '220px 1fr 272px',
|
||||
height: '100dvh',
|
||||
overflow: 'hidden',
|
||||
@@ -40,6 +51,49 @@ export function AppChrome() {
|
||||
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
{/* Breadcrumb bar — spans all 3 columns, only shown when project context is set */}
|
||||
{showBreadcrumb && (
|
||||
<div style={{
|
||||
gridColumn: '1 / -1',
|
||||
background: '#0A0A0E',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: '0 14px', gap: 0,
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<Link href="/workspaces" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', marginRight: 4 }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 700, letterSpacing: '-0.01em', color: 'rgba(255,255,255,0.6)' }}>
|
||||
Origin<span style={{ color: '#3385FF' }}>main</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<span style={{ color: 'rgba(255,255,255,0.2)', margin: '0 6px', fontSize: '0.75rem' }}>/</span>
|
||||
|
||||
<Link href="/workspaces" style={{ textDecoration: 'none', fontSize: '0.75rem', color: 'rgba(255,255,255,0.4)', fontWeight: 500 }}>
|
||||
Workspaces
|
||||
</Link>
|
||||
|
||||
<span style={{ color: 'rgba(255,255,255,0.2)', margin: '0 6px', fontSize: '0.75rem' }}>/</span>
|
||||
|
||||
<Link href={`/workspace/${workspaceId}`} style={{ textDecoration: 'none', fontSize: '0.75rem', color: 'rgba(255,255,255,0.4)', fontWeight: 500 }}>
|
||||
{workspaceName ?? 'Workspace'}
|
||||
</Link>
|
||||
|
||||
<span style={{ color: 'rgba(255,255,255,0.2)', margin: '0 6px', fontSize: '0.75rem' }}>/</span>
|
||||
|
||||
<span style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.75)', fontWeight: 600 }}>
|
||||
{projectName ?? 'Canvas'}
|
||||
</span>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* User avatar in the top-right corner of canvas */}
|
||||
<div style={{ transform: 'scale(0.8)', transformOrigin: 'right center' }}>
|
||||
<UserButton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Toolbar />
|
||||
<ArtboardNavigator />
|
||||
<Canvas />
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { UserButton } from '@clerk/nextjs';
|
||||
|
||||
interface Crumb { label: string; href?: string }
|
||||
|
||||
interface AppHeaderProps {
|
||||
breadcrumbs?: Crumb[];
|
||||
}
|
||||
|
||||
export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
|
||||
return (
|
||||
<header style={{
|
||||
position: 'sticky', top: 0, zIndex: 100,
|
||||
height: 56,
|
||||
background: 'rgba(255,255,255,0.92)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.07)',
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: '0 24px',
|
||||
gap: 0,
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<Link href="/workspaces" style={{ textDecoration: 'none', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: '1rem', fontWeight: 700, letterSpacing: '-0.02em', color: '#0A0A0A' }}>
|
||||
Origin<span style={{ color: '#0066FF' }}>main</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<span key={i} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ margin: '0 8px', color: '#D4D4D8', fontSize: '0.875rem' }}>/</span>
|
||||
{crumb.href ? (
|
||||
<Link href={crumb.href} style={{
|
||||
fontSize: '0.875rem', fontWeight: 500,
|
||||
color: '#71717A', textDecoration: 'none',
|
||||
transition: 'color 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = '#0A0A0A')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = '#71717A')}
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#0A0A0A' }}>
|
||||
{crumb.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<UserButton />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
|
||||
-- Migration: 005 — projects
|
||||
-- A Project groups artboards for a specific application inside a workspace.
|
||||
-- Users connect their running app to a project (via app_url) and work
|
||||
-- on its artboards in the canvas.
|
||||
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
app_url TEXT, -- e.g. https://localhost:3000 or https://staging.myapp.com
|
||||
framework TEXT, -- e.g. 'react', 'next', 'vue', 'svelte'
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX projects_workspace_idx ON projects(workspace_id);
|
||||
|
||||
CREATE TRIGGER trg_projects_updated_at
|
||||
BEFORE UPDATE ON projects
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- Add optional project_id to artboards so artboards can be scoped to a project.
|
||||
-- Nullable: existing artboards without a project remain workspace-level.
|
||||
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS artboards_project_idx ON artboards(project_id);
|
||||
@@ -116,6 +116,20 @@ export const TeamMemberSchema = z.object({
|
||||
});
|
||||
export type TeamMember = z.infer<typeof TeamMemberSchema>;
|
||||
|
||||
// ── Project ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ProjectSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
workspace_id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
app_url: z.string().nullable(),
|
||||
framework: z.string().nullable(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
});
|
||||
export type Project = z.infer<typeof ProjectSchema>;
|
||||
|
||||
// ── Ancestry (from materialized view) ────────────────────────────────────────
|
||||
|
||||
export interface ArtboardAncestry {
|
||||
@@ -133,3 +147,4 @@ export type InsertIntentDiff = Omit<IntentDiff, 'id' | 'created_at' | 'updated_a
|
||||
export type InsertAgentSession = Omit<AgentSession, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type InsertDesignLanguageFile = Omit<DesignLanguageFile, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type InsertTeamMember = Omit<TeamMember, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type InsertProject = Omit<Project, 'id' | 'created_at' | 'updated_at'>;
|
||||
|
||||
Reference in New Issue
Block a user