improved a lot of things

This commit is contained in:
SinachPat
2026-04-26 18:11:59 +01:00
parent ca4c0ee09a
commit 4166ffd3c1
17 changed files with 1134 additions and 19 deletions
+2 -2
View File
@@ -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
+9 -9
View File
@@ -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 });
}
+4 -4
View File
@@ -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');
}
+145
View File
@@ -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>
);
}
+149
View File
@@ -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);
+15
View File
@@ -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'>;