From 4166ffd3c159398e09e7b09f0d4b6e46c0bc6b7e Mon Sep 17 00:00:00 2001 From: SinachPat Date: Sun, 26 Apr 2026 18:11:59 +0100 Subject: [PATCH] improved a lot of things --- .claude/settings.local.json | 7 +- packages/app/.env.local.example | 4 +- packages/app/public/marketing.html | 18 +- .../app/api/workspace/[id]/projects/route.ts | 78 ++++++++ packages/app/src/app/api/workspaces/route.ts | 75 +++++++ packages/app/src/app/canvas/page.tsx | 8 +- packages/app/src/app/onboarding/page.tsx | 145 ++++++++++++++ packages/app/src/app/workspace/[wid]/page.tsx | 187 ++++++++++++++++++ .../workspace/[wid]/project/[pid]/page.tsx | 52 +++++ .../app/workspace/[wid]/project/new/page.tsx | 168 ++++++++++++++++ packages/app/src/app/workspaces/new/page.tsx | 102 ++++++++++ packages/app/src/app/workspaces/page.tsx | 149 ++++++++++++++ .../app/src/components/chrome/AppChrome.tsx | 58 +++++- .../app/src/components/shell/AppHeader.tsx | 59 ++++++ packages/app/tsconfig.tsbuildinfo | 2 +- .../origin-graph/migrations/005_projects.sql | 26 +++ packages/origin-graph/src/types.ts | 15 ++ 17 files changed, 1134 insertions(+), 19 deletions(-) create mode 100644 packages/app/src/app/api/workspace/[id]/projects/route.ts create mode 100644 packages/app/src/app/api/workspaces/route.ts create mode 100644 packages/app/src/app/onboarding/page.tsx create mode 100644 packages/app/src/app/workspace/[wid]/page.tsx create mode 100644 packages/app/src/app/workspace/[wid]/project/[pid]/page.tsx create mode 100644 packages/app/src/app/workspace/[wid]/project/new/page.tsx create mode 100644 packages/app/src/app/workspaces/new/page.tsx create mode 100644 packages/app/src/app/workspaces/page.tsx create mode 100644 packages/app/src/components/shell/AppHeader.tsx create mode 100644 packages/origin-graph/migrations/005_projects.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0d54abd..ee69c17 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/packages/app/.env.local.example b/packages/app/.env.local.example index d43c1e1..4d090ab 100644 --- a/packages/app/.env.local.example +++ b/packages/app/.env.local.example @@ -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 diff --git a/packages/app/public/marketing.html b/packages/app/public/marketing.html index 54fa44c..3ec4972 100644 --- a/packages/app/public/marketing.html +++ b/packages/app/public/marketing.html @@ -1625,8 +1625,8 @@ footer {
  • Docs
  • @@ -1682,7 +1682,7 @@ footer {

    - Request Early Access + Get Started Free Watch the demo @@ -2382,7 +2382,7 @@ footer {
  • 3 AI completions / day
  • GitHub integration
  • -
    Get started free + Get started free
    Enterprise
    @@ -2413,7 +2413,7 @@ footer {
  • Custom integrations
  • Design system audit
  • - Talk to us + Talk to us
    @@ -2425,7 +2425,7 @@ footer {

    Start building
    the right way.

    Join the engineers who are done shipping pixel-perfect mockups that no one implements correctly.

    - Request Early Access + Get Started Free Read the docs →
    diff --git a/packages/app/src/app/api/workspace/[id]/projects/route.ts b/packages/app/src/app/api/workspace/[id]/projects/route.ts new file mode 100644 index 0000000..494f446 --- /dev/null +++ b/packages/app/src/app/api/workspace/[id]/projects/route.ts @@ -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, 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 }); +} diff --git a/packages/app/src/app/api/workspaces/route.ts b/packages/app/src/app/api/workspaces/route.ts new file mode 100644 index 0000000..1856b85 --- /dev/null +++ b/packages/app/src/app/api/workspaces/route.ts @@ -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 }); +} diff --git a/packages/app/src/app/canvas/page.tsx b/packages/app/src/app/canvas/page.tsx index 8cb1de5..33d6836 100644 --- a/packages/app/src/app/canvas/page.tsx +++ b/packages/app/src/app/canvas/page.tsx @@ -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 ; + redirect('/workspaces'); } diff --git a/packages/app/src/app/onboarding/page.tsx b/packages/app/src/app/onboarding/page.tsx new file mode 100644 index 0000000..a605b16 --- /dev/null +++ b/packages/app/src/app/onboarding/page.tsx @@ -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 ( +
    + {/* Logo */} +
    + Originmain +
    + + {/* Card */} +
    +
    + + + + + + +
    + +

    + Welcome, {firstName}! +

    +

    + Let's create your workspace. A workspace holds your projects and team — usually named after your company or product. +

    + +
    + + 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)')} + /> +

    + Leave blank to use "{defaultName}" +

    + + {error &&

    {error}

    } + + +
    +
    + +

    + Already have a workspace?{' '} + View all workspaces +

    +
    + ); +} diff --git a/packages/app/src/app/workspace/[wid]/page.tsx b/packages/app/src/app/workspace/[wid]/page.tsx new file mode 100644 index 0000000..19ab9f4 --- /dev/null +++ b/packages/app/src/app/workspace/[wid]/page.tsx @@ -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 = { + 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 ( +
    + + +
    + + {/* Title row */} +
    +
    +

    + Projects +

    +

    + {projects.length === 0 + ? 'No projects yet — connect your first app to get started' + : `${projects.length} project${projects.length !== 1 ? 's' : ''}`} +

    +
    + + + + + New project + +
    + + {/* Empty state */} + {projects.length === 0 && ( +
    +
    + + + + +
    +

    + Connect your first app +

    +

    + A project connects to your running application — local or staging — so you can inspect components and ship intent-level diffs. +

    + + Create a project → + +
    + )} + + {/* Project cards */} + {projects.length > 0 && ( +
    + {projects.map(project => { + const icon = project.framework ? (FRAMEWORK_ICON[project.framework.toLowerCase()] ?? '◻') : '◻'; + return ( + +
    { + (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)'; + }} + > +
    +
    + {icon} +
    + + {project.name} + +
    + + {project.app_url && ( +

    + {project.app_url} +

    + )} + + {project.description && ( +

    + {project.description} +

    + )} + +
    + + Open canvas → + +
    +
    + + ); + })} +
    + )} +
    +
    + ); +} diff --git a/packages/app/src/app/workspace/[wid]/project/[pid]/page.tsx b/packages/app/src/app/workspace/[wid]/project/[pid]/page.tsx new file mode 100644 index 0000000..1ebded1 --- /dev/null +++ b/packages/app/src/app/workspace/[wid]/project/[pid]/page.tsx @@ -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, + db.from('projects').select('name').eq('id', pid).single() as unknown as Promise, + ]); + + if (!projResult.data) redirect(`/workspace/${wid}`); + + return ( + + ); +} diff --git a/packages/app/src/app/workspace/[wid]/project/new/page.tsx b/packages/app/src/app/workspace/[wid]/project/new/page.tsx new file mode 100644 index 0000000..2b2fd9c --- /dev/null +++ b/packages/app/src/app/workspace/[wid]/project/new/page.tsx @@ -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 ( +
    + + +
    +
    +

    + New project +

    +

    + A project connects to your running app so you can inspect components live, create artboards, and ship intent diffs. +

    + +
    + + {/* Project name */} +
    + + 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)')} + /> +
    + + {/* App URL */} +
    + + 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)')} + /> +

    + The address where your app is running — local or remote. +

    +
    + + {/* Framework */} +
    + + +
    + + {/* Description */} +
    + +