From 62731af91f50ba4c919861f2c1d4aa452de78f84 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Sun, 1 Mar 2026 21:14:20 -0800 Subject: [PATCH] feat: design surfaces page with two-phase theme picker Phase 1: user picks which surfaces their product needs (Web App, Marketing Site, Admin, Mobile, Email, Docs). Phase 2: per-surface horizontal card gallery with mini visual previews of each UI library. Lock in confirms the choice; locked themes are saved to DB and shown to the AI coder. Surfaces and themes stored in fs_projects.data. Made-with: Cursor --- .../project/[projectId]/design/page.tsx | 751 +++++++++++++----- .../[projectId]/design-surfaces/route.ts | 84 ++ 2 files changed, 650 insertions(+), 185 deletions(-) create mode 100644 app/api/projects/[projectId]/design-surfaces/route.ts diff --git a/app/[workspace]/project/[projectId]/design/page.tsx b/app/[workspace]/project/[projectId]/design/page.tsx index ecf3e87..b17c38c 100644 --- a/app/[workspace]/project/[projectId]/design/page.tsx +++ b/app/[workspace]/project/[projectId]/design/page.tsx @@ -4,196 +4,536 @@ import { use, useState, useEffect } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; -import { CheckCircle2, ExternalLink, Loader2, Package } from "lucide-react"; import { cn } from "@/lib/utils"; +import { + Monitor, Globe, Settings, Smartphone, Mail, BookOpen, + Lock, CheckCircle2, Loader2, ChevronRight, Pencil, +} from "lucide-react"; // --------------------------------------------------------------------------- -// Design package catalogue +// Surface definitions // --------------------------------------------------------------------------- -interface DesignPackage { +interface Surface { id: string; name: string; description: string; - bestFor: string[]; - url: string; - tags: string[]; + icon: React.ElementType; + themes: Theme[]; } -const PACKAGES: DesignPackage[] = [ +interface Theme { + id: string; + name: string; + description: string; + tags: string[]; + url: string; + preview: React.ReactNode; +} + +// Mini UI mockups — each styled to feel like the library +const ShadcnPreview = () => ( +
+
+
+
+
+
+
+
+
+
+
+ Save + Cancel +
+
+); + +const MantiinePreview = () => ( +
+
+
+ New +
+
+
+
+
+
+ {[1,2].map(i => ( +
+
+
+
+ ))} +
+
+ Apply +
+
+); + +const HeroUIPreview = () => ( +
+
+
+
+
+
+
+
+
+ + Get started → + +
+); + +const DaisyPreview = () => ( +
+
+
+
+
+
+
+
+
+
+
+ Primary + Success +
+
+); + +const AcernityPreview = () => ( +
+
+
+
+
+
+
+ Get started +
+
+); + +const TailwindPreview = () => ( +
+
className="flex…"
+
+
+
+
+
+
+
+ Custom → +
+
+); + +const TremorPreview = () => ( +
+
+
+
+
+
+ {[60, 80, 45, 90, 70, 55].map((h, i) => ( +
+
+
+ ))} +
+
+); + +const NativewindPreview = () => ( +
+
+
+
+
+
+
+
+
+
+
+ Button +
+
+
+
+); + +const GluestackPreview = () => ( +
+
+
+
+
+
+
+
+
+ Submit +
+
+
+
+); + +const ReactEmailPreview = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ Open App → +
+
+); + +const NextraPreview = () => ( +
+
+ {['Getting started', 'Installation', 'API', 'Examples'].map(l => ( +
+ ))} +
+
+
+
+
+
+
+); + +const ALL_SURFACES: Surface[] = [ { - id: "shadcn", - name: "shadcn/ui", - description: - "Copy-paste components built on Radix UI primitives. Tailwind-styled, fully customisable — you own the code.", - bestFor: ["App", "Admin", "Dashboard"], - url: "https://ui.shadcn.com", - tags: ["Tailwind", "Radix", "Headless"], + id: "web-app", + name: "Web App", + description: "The core product your users log into — dashboards, features, settings", + icon: Monitor, + themes: [ + { id: "shadcn", name: "shadcn/ui", description: "Copy-paste components on Radix primitives. You own the code, fully customisable.", tags: ["Tailwind", "Radix", "Copy-paste"], url: "https://ui.shadcn.com", preview: }, + { id: "mantine", name: "Mantine", description: "100+ components with hooks, forms, charts. Best for data-heavy apps.", tags: ["React", "Charts", "Forms"], url: "https://mantine.dev", preview: }, + { id: "hero-ui", name: "HeroUI", description: "Beautiful, accessible components with smooth animations and dark mode.", tags: ["Tailwind", "Animations", "Accessible"], url: "https://heroui.com", preview: }, + { id: "tremor", name: "Tremor", description: "Dashboard components — charts, KPIs, tables — designed for analytics UIs.", tags: ["Charts", "Dashboard", "Analytics"], url: "https://tremor.so", preview: }, + ], }, { - id: "daisy-ui", - name: "DaisyUI", - description: - "Tailwind plugin that adds semantic class names (btn, badge, card). Fastest path from idea to styled UI.", - bestFor: ["Website", "Prototype"], - url: "https://daisyui.com", - tags: ["Tailwind", "Plugin", "Themes"], + id: "marketing", + name: "Marketing Site", + description: "Public-facing landing page, blog, pricing — brand expression and conversion", + icon: Globe, + themes: [ + { id: "daisy-ui", name: "DaisyUI", description: "Tailwind plugin with 48 built-in themes. Fastest path to a beautiful site.", tags: ["Tailwind", "Themes", "Plugin"], url: "https://daisyui.com", preview: }, + { id: "hero-ui", name: "HeroUI", description: "Beautiful components with gradients and smooth animations.", tags: ["Tailwind", "Animations", "Modern"], url: "https://heroui.com", preview: }, + { id: "aceternity", name: "Aceternity UI", description: "Animated, visually striking components for premium landing pages.", tags: ["Animations", "Dark", "Premium"], url: "https://ui.aceternity.com", preview: }, + { id: "tailwind-only", name: "Tailwind only", description: "No component library — full creative control with pure Tailwind CSS.", tags: ["Custom", "Flexible", "Minimal"], url: "https://tailwindcss.com", preview: }, + ], }, { - id: "hero-ui", - name: "HeroUI", - description: - "Beautiful, accessible React components with smooth animations and dark mode built-in. Formerly NextUI.", - bestFor: ["App", "Website", "Landing"], - url: "https://heroui.com", - tags: ["React", "Tailwind", "Animations"], + id: "admin", + name: "Admin Panel", + description: "Internal tool for managing your business — users, support, billing, analytics", + icon: Settings, + themes: [ + { id: "mantine", name: "Mantine", description: "The best choice for admin — comprehensive tables, forms, and data components.", tags: ["Tables", "Forms", "Charts"], url: "https://mantine.dev", preview: }, + { id: "shadcn", name: "shadcn/ui", description: "Clean, neutral components. Great if you want the admin to match the main app.", tags: ["Tailwind", "Consistent", "Clean"], url: "https://ui.shadcn.com", preview: }, + { id: "tremor", name: "Tremor", description: "Analytics-first — built for KPI dashboards, charts, and data tables.", tags: ["Analytics", "Charts", "KPIs"], url: "https://tremor.so", preview: }, + ], }, { - id: "mantine", - name: "Mantine", - description: - "100+ fully-featured React components with hooks, forms, and charts. Best for data-heavy admin tools.", - bestFor: ["Admin", "Dashboard", "Complex Apps"], - url: "https://mantine.dev", - tags: ["React", "CSS-in-JS", "Hooks"], + id: "mobile", + name: "Mobile App", + description: "iOS and Android companion app — touch-first, native feel", + icon: Smartphone, + themes: [ + { id: "nativewind", name: "NativeWind", description: "Use Tailwind CSS in React Native. Consistent style across web and mobile.", tags: ["Tailwind", "React Native", "Expo"], url: "https://nativewind.dev", preview: }, + { id: "gluestack", name: "Gluestack UI", description: "Universal components for React Native — accessible, well-tested, comprehensive.", tags: ["Universal", "Accessible", "Expo"], url: "https://gluestack.io", preview: }, + ], }, { - id: "headless-ui", - name: "Headless UI", - description: - "Completely unstyled, accessible components by Tailwind Labs. Full creative control with Tailwind classes.", - bestFor: ["Custom Design", "Brand-specific UI"], - url: "https://headlessui.com", - tags: ["Tailwind", "Unstyled", "Accessible"], + id: "email", + name: "Email", + description: "Transactional and marketing emails — welcome, billing, notifications", + icon: Mail, + themes: [ + { id: "react-email", name: "React Email", description: "Build emails with React components. Works with any email provider.", tags: ["React", "Resend", "Cross-client"], url: "https://react.email", preview: }, + ], }, { - id: "tailwind-only", - name: "Tailwind only", - description: - "No component library — pure Tailwind CSS with your own components. Maximum flexibility, zero opinions.", - bestFor: ["Custom", "Marketing Site"], - url: "https://tailwindcss.com", - tags: ["Tailwind", "Custom", "Minimal"], + id: "docs", + name: "Docs / Content", + description: "Documentation, knowledge base, or blog for your product", + icon: BookOpen, + themes: [ + { id: "nextra", name: "Nextra", description: "Next.js-based docs site. Markdown-first, fast, with great search.", tags: ["Next.js", "Markdown", "Search"], url: "https://nextra.site", preview: }, + { id: "shadcn", name: "shadcn/ui + custom", description: "Build a fully custom docs site that matches your product exactly.", tags: ["Custom", "Tailwind", "Flexible"], url: "https://ui.shadcn.com", preview: }, + ], }, ]; // --------------------------------------------------------------------------- -// Package card +// Theme option card // --------------------------------------------------------------------------- -function PackageCard({ - pkg, +function ThemeCard({ + theme, selected, - saving, + locked, onSelect, }: { - pkg: DesignPackage; + theme: Theme; selected: boolean; - saving: boolean; + locked: boolean; onSelect: () => void; }) { return ( ); } // --------------------------------------------------------------------------- -// App section +// Surface section // --------------------------------------------------------------------------- -function AppSection({ - appName, - selectedPackageId, +function SurfaceSection({ + surface, + selectedThemeId, + lockedThemeId, onSelect, + onLock, + onUnlock, + saving, }: { - appName: string; - selectedPackageId: string | undefined; - onSelect: (appName: string, pkgId: string) => Promise; + surface: Surface; + selectedThemeId: string | null; + lockedThemeId: string | null; + onSelect: (themeId: string) => void; + onLock: () => void; + onUnlock: () => void; + saving: boolean; }) { - const [saving, setSaving] = useState(null); - - const handleSelect = async (pkgId: string) => { - if (pkgId === selectedPackageId) return; - setSaving(pkgId); - await onSelect(appName, pkgId); - setSaving(null); - }; - - const selectedPkg = PACKAGES.find((p) => p.id === selectedPackageId); + const Icon = surface.icon; + const lockedTheme = surface.themes.find(t => t.id === lockedThemeId); return ( -
+
+ {/* Surface header */}
-
-
- +
+
+
-

{appName}

- {selectedPkg && ( -

- Using {selectedPkg.name} -

- )} +
+

{surface.name}

+ {lockedTheme && ( + + + {lockedTheme.name} + + )} +
+

{surface.description}

- {selectedPkg && ( - e.stopPropagation()} - > - Docs - - - )} + + {/* Lock / Unlock controls */} +
+ {lockedThemeId ? ( + + ) : ( + + )} +
+
+ + {/* Theme cards — horizontal scroll */} + {!lockedThemeId && ( +
+ {surface.themes.map(theme => ( + onSelect(theme.id)} + /> + ))} +
+ )} + + {/* Locked state — show only selected */} + {lockedThemeId && ( +
+ {surface.themes.map(theme => ( + {}} + /> + ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Phase 1 — Surface picker +// --------------------------------------------------------------------------- + +function SurfacePicker({ + onConfirm, + saving, +}: { + onConfirm: (ids: string[]) => void; + saving: boolean; +}) { + const [selected, setSelected] = useState>(new Set()); + + const toggle = (id: string) => { + setSelected(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + + return ( +
+
+

Design surfaces

+

+ Which surfaces does your product need? Select all that apply — you can always add more later. +

- {PACKAGES.map((pkg) => ( - handleSelect(pkg.id)} - /> - ))} + {ALL_SURFACES.map(surface => { + const Icon = surface.icon; + const isSelected = selected.has(surface.id); + return ( + + ); + })} +
+ +
+ + {selected.size === 0 && ( +

Select at least one surface to continue

+ )}
); @@ -210,39 +550,73 @@ export default function DesignPage({ }) { const { projectId } = use(params); - const [apps, setApps] = useState<{ name: string; path: string }[]>([]); - const [designPackages, setDesignPackages] = useState>({}); - const [giteaRepo, setGiteaRepo] = useState(null); + const [surfaces, setSurfaces] = useState([]); + const [surfaceThemes, setSurfaceThemes] = useState>({}); + const [selectedThemes, setSelectedThemes] = useState>({}); + const [savingLock, setSavingLock] = useState(null); + const [savingSurfaces, setSavingSurfaces] = useState(false); const [loading, setLoading] = useState(true); useEffect(() => { - fetch(`/api/projects/${projectId}/apps`) - .then((r) => r.json()) - .then((d) => { - setApps(d.apps ?? []); - setDesignPackages(d.designPackages ?? {}); - setGiteaRepo(d.giteaRepo ?? null); + fetch(`/api/projects/${projectId}/design-surfaces`) + .then(r => r.json()) + .then(d => { + setSurfaces(d.surfaces ?? []); + setSurfaceThemes(d.surfaceThemes ?? {}); + setSelectedThemes(d.surfaceThemes ?? {}); }) - .catch(() => toast.error("Failed to load apps")) + .catch(() => toast.error("Failed to load design data")) .finally(() => setLoading(false)); }, [projectId]); - const handleSelect = async (appName: string, packageId: string) => { + const handleConfirmSurfaces = async (ids: string[]) => { + setSavingSurfaces(true); try { - const res = await fetch(`/api/projects/${projectId}/apps`, { + const res = await fetch(`/api/projects/${projectId}/design-surfaces`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ appName, packageId }), + body: JSON.stringify({ surfaces: ids }), }); - if (!res.ok) throw new Error("Save failed"); - setDesignPackages((prev) => ({ ...prev, [appName]: packageId })); - const pkg = PACKAGES.find((p) => p.id === packageId); - toast.success(`${appName} → ${pkg?.name ?? packageId}`); + if (!res.ok) throw new Error(); + setSurfaces(ids); + toast.success("Surfaces saved"); } catch { - toast.error("Failed to save selection"); + toast.error("Failed to save surfaces"); + } finally { + setSavingSurfaces(false); } }; + const handleLock = async (surfaceId: string) => { + const themeId = selectedThemes[surfaceId]; + if (!themeId) return; + setSavingLock(surfaceId); + try { + const res = await fetch(`/api/projects/${projectId}/design-surfaces`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ surface: surfaceId, theme: themeId }), + }); + if (!res.ok) throw new Error(); + setSurfaceThemes(prev => ({ ...prev, [surfaceId]: themeId })); + const surface = ALL_SURFACES.find(s => s.id === surfaceId); + const theme = surface?.themes.find(t => t.id === themeId); + toast.success(`${surface?.name} → ${theme?.name} locked in`); + } catch { + toast.error("Failed to lock in theme"); + } finally { + setSavingLock(null); + } + }; + + const handleUnlock = (surfaceId: string) => { + setSurfaceThemes(prev => { + const next = { ...prev }; + delete next[surfaceId]; + return next; + }); + }; + if (loading) { return (
@@ -251,54 +625,61 @@ export default function DesignPage({ ); } + // Phase 1 — no surfaces set yet + if (surfaces.length === 0) { + return ( +
+ +
+ ); + } + + // Phase 2 — surfaces set, pick themes + const activeSurfaces = ALL_SURFACES.filter(s => surfaces.includes(s.id)); + const lockedCount = Object.keys(surfaceThemes).length; + return (
- - {/* Header */}
-

Design packages

+

Design

- Each app in your Turborepo can use a different UI library — they never conflict. + Choose a UI library for each surface — the AI coder will reference these when building.

- {giteaRepo && ( - + {lockedCount === activeSurfaces.length && lockedCount > 0 && ( + + + All locked in + + )} + - - )} + Change surfaces + +
- {/* App sections */} - {apps.length === 0 ? ( -
- -

No apps found

-

- Push a Turborepo scaffold to your Gitea repo — apps in the{" "} - apps/ directory will appear here. -

-
- ) : ( -
- {apps.map((app) => ( -
- -
- ))} -
- )} +
+ {activeSurfaces.map((surface, i) => ( +
0 ? "pt-10" : ""}> + setSelectedThemes(prev => ({ ...prev, [surface.id]: themeId }))} + onLock={() => handleLock(surface.id)} + onUnlock={() => handleUnlock(surface.id)} + saving={savingLock === surface.id} + /> +
+ ))} +
); } diff --git a/app/api/projects/[projectId]/design-surfaces/route.ts b/app/api/projects/[projectId]/design-surfaces/route.ts new file mode 100644 index 0000000..8ef7b81 --- /dev/null +++ b/app/api/projects/[projectId]/design-surfaces/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth/authOptions'; +import { query } from '@/lib/db-postgres'; + +async function getProject(projectId: string, email: string) { + const rows = await query<{ data: Record }>( + `SELECT p.data FROM fs_projects p + JOIN fs_users u ON u.id = p.user_id + WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`, + [projectId, email] + ); + return rows[0] ?? null; +} + +/** + * GET — returns surfaces[] and surfaceThemes{} for the project. + * surfaces: string[] — which design surfaces are active (set by Atlas or manually) + * surfaceThemes: Record — locked-in theme per surface + */ +export async function GET( + _req: Request, + { params }: { params: Promise<{ projectId: string }> } +) { + const { projectId } = await params; + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const row = await getProject(projectId, session.user.email); + if (!row) return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + + const data = row.data ?? {}; + return NextResponse.json({ + surfaces: (data.surfaces ?? []) as string[], + surfaceThemes: (data.surfaceThemes ?? {}) as Record, + }); +} + +/** + * PATCH — two operations: + * { surfaces: string[] } — save the active surface list + * { surface: string, theme: string } — lock in a theme for one surface + */ +export async function PATCH( + req: Request, + { params }: { params: Promise<{ projectId: string }> } +) { + const { projectId } = await params; + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await req.json() as + | { surfaces: string[] } + | { surface: string; theme: string }; + + if ('surfaces' in body) { + // Save the surface list + await query( + `UPDATE fs_projects p + SET data = data || jsonb_build_object('surfaces', $3::jsonb), + updated_at = NOW() + FROM fs_users u + WHERE p.id = $1 AND p.user_id = u.id AND u.data->>'email' = $2`, + [projectId, session.user.email, JSON.stringify(body.surfaces)] + ); + } else if ('surface' in body && 'theme' in body) { + // Lock in a theme for one surface + await query( + `UPDATE fs_projects p + SET data = data || jsonb_build_object( + 'surfaceThemes', + COALESCE(data->'surfaceThemes', '{}'::jsonb) || jsonb_build_object($3, $4) + ), + updated_at = NOW() + FROM fs_users u + WHERE p.id = $1 AND p.user_id = u.id AND u.data->>'email' = $2`, + [projectId, session.user.email, body.surface, body.theme] + ); + } else { + return NextResponse.json({ error: 'Invalid body' }, { status: 400 }); + } + + return NextResponse.json({ success: true }); +}