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
This commit is contained in:
@@ -4,196 +4,536 @@ import { use, useState, useEffect } from "react";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { CheckCircle2, ExternalLink, Loader2, Package } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
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;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
bestFor: string[];
|
icon: React.ElementType;
|
||||||
url: string;
|
themes: Theme[];
|
||||||
tags: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = () => (
|
||||||
|
<div className="w-full h-full bg-white border border-zinc-200 rounded-lg p-2.5 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-3 h-3 rounded bg-zinc-900" />
|
||||||
|
<div className="h-2 w-14 bg-zinc-200 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-zinc-100" />
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<div className="h-1.5 w-full bg-zinc-100 rounded" />
|
||||||
|
<div className="h-1.5 w-2/3 bg-zinc-100 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<span className="h-5 px-2 bg-zinc-900 text-white rounded text-[8px] flex items-center font-medium">Save</span>
|
||||||
|
<span className="h-5 px-2 border border-zinc-200 rounded text-[8px] flex items-center text-zinc-500">Cancel</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MantiinePreview = () => (
|
||||||
|
<div className="w-full h-full bg-white border border-zinc-200 rounded-lg p-2.5 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-2 w-12 bg-zinc-800 rounded" />
|
||||||
|
<span className="h-4 px-1.5 bg-blue-600 text-white rounded text-[7px] flex items-center">New</span>
|
||||||
|
</div>
|
||||||
|
<div className="border border-zinc-200 rounded overflow-hidden">
|
||||||
|
<div className="h-4 bg-zinc-50 border-b border-zinc-200 flex items-center px-1.5 gap-1">
|
||||||
|
<div className="h-1.5 w-8 bg-zinc-300 rounded" />
|
||||||
|
<div className="h-1.5 w-10 bg-zinc-300 rounded ml-auto" />
|
||||||
|
</div>
|
||||||
|
{[1,2].map(i => (
|
||||||
|
<div key={i} className="h-4 border-b border-zinc-100 flex items-center px-1.5 gap-1 last:border-0">
|
||||||
|
<div className="h-1.5 w-12 bg-zinc-200 rounded" />
|
||||||
|
<div className="h-1.5 w-8 bg-zinc-200 rounded ml-auto" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 mt-auto">
|
||||||
|
<span className="h-5 px-2 bg-blue-600 text-white rounded text-[8px] flex items-center">Apply</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const HeroUIPreview = () => (
|
||||||
|
<div className="w-full h-full bg-white rounded-lg p-2.5 flex flex-col gap-2" style={{ background: 'linear-gradient(135deg, #f8faff 0%, #fff 100%)' }}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ background: 'linear-gradient(135deg, #7c3aed, #ec4899)' }} />
|
||||||
|
<div className="h-2 w-12 bg-zinc-200 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg p-2 flex-1" style={{ background: 'linear-gradient(135deg, rgba(124,58,237,0.05), rgba(236,72,153,0.05))' }}>
|
||||||
|
<div className="h-1.5 w-full bg-zinc-200 rounded mb-1" />
|
||||||
|
<div className="h-1.5 w-3/4 bg-zinc-200 rounded" />
|
||||||
|
</div>
|
||||||
|
<span className="h-5 px-2 text-white rounded-full text-[8px] flex items-center justify-center self-start font-medium" style={{ background: 'linear-gradient(135deg, #7c3aed, #ec4899)' }}>
|
||||||
|
Get started →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DaisyPreview = () => (
|
||||||
|
<div className="w-full h-full bg-[#1d232a] rounded-lg p-2.5 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-[#f59e0b]" />
|
||||||
|
<div className="w-2 h-2 rounded-full bg-[#10b981]" />
|
||||||
|
<div className="w-2 h-2 rounded-full bg-[#3b82f6]" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<div className="h-1.5 w-full bg-zinc-700 rounded" />
|
||||||
|
<div className="h-1.5 w-5/6 bg-zinc-700 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="h-5 px-2 bg-[#f59e0b] text-black rounded-full text-[8px] flex items-center font-bold">Primary</span>
|
||||||
|
<span className="h-5 px-2 bg-[#10b981] text-black rounded-full text-[8px] flex items-center font-bold">Success</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AcernityPreview = () => (
|
||||||
|
<div className="w-full h-full bg-zinc-950 rounded-lg p-2.5 flex flex-col gap-2">
|
||||||
|
<div className="h-2 w-20 rounded" style={{ background: 'linear-gradient(90deg, #a855f7, #3b82f6)' }} />
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<div className="h-1.5 w-full bg-zinc-800 rounded" />
|
||||||
|
<div className="h-1.5 w-2/3 bg-zinc-800 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-5 rounded border flex items-center justify-center" style={{ borderColor: 'rgba(168,85,247,0.4)', background: 'rgba(168,85,247,0.05)' }}>
|
||||||
|
<span className="text-[8px] font-medium" style={{ background: 'linear-gradient(90deg, #a855f7, #3b82f6)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>Get started</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TailwindPreview = () => (
|
||||||
|
<div className="w-full h-full bg-white rounded-lg p-2.5 flex flex-col gap-2 border border-zinc-100">
|
||||||
|
<div className="text-[8px] font-mono text-zinc-400">className="flex…"</div>
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<div className="h-5 w-full bg-gradient-to-r from-violet-100 to-blue-100 rounded flex items-center px-2">
|
||||||
|
<div className="h-1.5 w-10 bg-violet-300 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-5 w-full bg-zinc-100 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-5 bg-zinc-900 rounded flex items-center justify-center">
|
||||||
|
<span className="text-white text-[8px] font-medium">Custom →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TremorPreview = () => (
|
||||||
|
<div className="w-full h-full bg-white rounded-lg p-2.5 flex flex-col gap-2 border border-zinc-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-2 w-14 bg-zinc-800 rounded" />
|
||||||
|
<div className="h-2 w-6 bg-blue-500 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 flex-1 items-end">
|
||||||
|
{[60, 80, 45, 90, 70, 55].map((h, i) => (
|
||||||
|
<div key={i} className="flex-1 bg-blue-100 rounded-t" style={{ height: `${h}%` }}>
|
||||||
|
<div className="bg-blue-500 rounded-t w-full" style={{ height: '30%' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NativewindPreview = () => (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<div className="w-16 h-24 bg-zinc-900 rounded-xl border-2 border-zinc-700 flex flex-col overflow-hidden">
|
||||||
|
<div className="h-2 bg-zinc-800 flex items-center justify-center">
|
||||||
|
<div className="w-4 h-0.5 bg-zinc-600 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-zinc-950 p-1 flex flex-col gap-1">
|
||||||
|
<div className="h-2 w-full bg-zinc-800 rounded" />
|
||||||
|
<div className="h-6 w-full bg-zinc-800 rounded flex items-center justify-center">
|
||||||
|
<div className="h-1 w-6 bg-zinc-600 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-3 w-full bg-violet-600 rounded flex items-center justify-center">
|
||||||
|
<span className="text-white text-[5px]">Button</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const GluestackPreview = () => (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<div className="w-16 h-24 bg-white rounded-xl border border-zinc-200 flex flex-col overflow-hidden shadow-sm">
|
||||||
|
<div className="h-3 bg-blue-600 flex items-center px-1.5">
|
||||||
|
<div className="h-1 w-6 bg-blue-400 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-1 flex flex-col gap-1">
|
||||||
|
<div className="h-2 w-full bg-zinc-100 rounded" />
|
||||||
|
<div className="h-2 w-4/5 bg-zinc-100 rounded" />
|
||||||
|
<div className="mt-auto h-4 w-full bg-blue-600 rounded flex items-center justify-center">
|
||||||
|
<span className="text-white text-[5px] font-bold">Submit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ReactEmailPreview = () => (
|
||||||
|
<div className="w-full h-full bg-zinc-50 rounded-lg p-2.5 flex flex-col gap-1.5 border border-zinc-200">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded bg-blue-600" />
|
||||||
|
<div className="h-1.5 w-16 bg-zinc-300 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-zinc-200" />
|
||||||
|
<div className="h-1.5 w-2/3 bg-zinc-800 rounded" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-1.5 w-full bg-zinc-300 rounded" />
|
||||||
|
<div className="h-1.5 w-5/6 bg-zinc-300 rounded" />
|
||||||
|
<div className="h-1.5 w-4/5 bg-zinc-300 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-5 bg-blue-600 rounded flex items-center justify-center mt-1">
|
||||||
|
<span className="text-white text-[8px] font-medium">Open App →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NextraPreview = () => (
|
||||||
|
<div className="w-full h-full bg-white rounded-lg border border-zinc-200 overflow-hidden flex">
|
||||||
|
<div className="w-1/3 bg-zinc-50 border-r border-zinc-200 p-1.5 flex flex-col gap-1">
|
||||||
|
{['Getting started', 'Installation', 'API', 'Examples'].map(l => (
|
||||||
|
<div key={l} className="h-2 bg-zinc-200 rounded" style={{ width: `${60 + Math.random() * 30}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-2 flex flex-col gap-1.5">
|
||||||
|
<div className="h-2 w-3/4 bg-zinc-800 rounded" />
|
||||||
|
<div className="h-1.5 w-full bg-zinc-200 rounded" />
|
||||||
|
<div className="h-1.5 w-5/6 bg-zinc-200 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ALL_SURFACES: Surface[] = [
|
||||||
{
|
{
|
||||||
id: "shadcn",
|
id: "web-app",
|
||||||
name: "shadcn/ui",
|
name: "Web App",
|
||||||
description:
|
description: "The core product your users log into — dashboards, features, settings",
|
||||||
"Copy-paste components built on Radix UI primitives. Tailwind-styled, fully customisable — you own the code.",
|
icon: Monitor,
|
||||||
bestFor: ["App", "Admin", "Dashboard"],
|
themes: [
|
||||||
url: "https://ui.shadcn.com",
|
{ 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: <ShadcnPreview /> },
|
||||||
tags: ["Tailwind", "Radix", "Headless"],
|
{ 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: <MantiinePreview /> },
|
||||||
|
{ id: "hero-ui", name: "HeroUI", description: "Beautiful, accessible components with smooth animations and dark mode.", tags: ["Tailwind", "Animations", "Accessible"], url: "https://heroui.com", preview: <HeroUIPreview /> },
|
||||||
|
{ id: "tremor", name: "Tremor", description: "Dashboard components — charts, KPIs, tables — designed for analytics UIs.", tags: ["Charts", "Dashboard", "Analytics"], url: "https://tremor.so", preview: <TremorPreview /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "daisy-ui",
|
id: "marketing",
|
||||||
name: "DaisyUI",
|
name: "Marketing Site",
|
||||||
description:
|
description: "Public-facing landing page, blog, pricing — brand expression and conversion",
|
||||||
"Tailwind plugin that adds semantic class names (btn, badge, card). Fastest path from idea to styled UI.",
|
icon: Globe,
|
||||||
bestFor: ["Website", "Prototype"],
|
themes: [
|
||||||
url: "https://daisyui.com",
|
{ 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: <DaisyPreview /> },
|
||||||
tags: ["Tailwind", "Plugin", "Themes"],
|
{ id: "hero-ui", name: "HeroUI", description: "Beautiful components with gradients and smooth animations.", tags: ["Tailwind", "Animations", "Modern"], url: "https://heroui.com", preview: <HeroUIPreview /> },
|
||||||
|
{ id: "aceternity", name: "Aceternity UI", description: "Animated, visually striking components for premium landing pages.", tags: ["Animations", "Dark", "Premium"], url: "https://ui.aceternity.com", preview: <AcernityPreview /> },
|
||||||
|
{ 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: <TailwindPreview /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "hero-ui",
|
id: "admin",
|
||||||
name: "HeroUI",
|
name: "Admin Panel",
|
||||||
description:
|
description: "Internal tool for managing your business — users, support, billing, analytics",
|
||||||
"Beautiful, accessible React components with smooth animations and dark mode built-in. Formerly NextUI.",
|
icon: Settings,
|
||||||
bestFor: ["App", "Website", "Landing"],
|
themes: [
|
||||||
url: "https://heroui.com",
|
{ 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: <MantiinePreview /> },
|
||||||
tags: ["React", "Tailwind", "Animations"],
|
{ 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: <ShadcnPreview /> },
|
||||||
|
{ id: "tremor", name: "Tremor", description: "Analytics-first — built for KPI dashboards, charts, and data tables.", tags: ["Analytics", "Charts", "KPIs"], url: "https://tremor.so", preview: <TremorPreview /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mantine",
|
id: "mobile",
|
||||||
name: "Mantine",
|
name: "Mobile App",
|
||||||
description:
|
description: "iOS and Android companion app — touch-first, native feel",
|
||||||
"100+ fully-featured React components with hooks, forms, and charts. Best for data-heavy admin tools.",
|
icon: Smartphone,
|
||||||
bestFor: ["Admin", "Dashboard", "Complex Apps"],
|
themes: [
|
||||||
url: "https://mantine.dev",
|
{ 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: <NativewindPreview /> },
|
||||||
tags: ["React", "CSS-in-JS", "Hooks"],
|
{ id: "gluestack", name: "Gluestack UI", description: "Universal components for React Native — accessible, well-tested, comprehensive.", tags: ["Universal", "Accessible", "Expo"], url: "https://gluestack.io", preview: <GluestackPreview /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "headless-ui",
|
id: "email",
|
||||||
name: "Headless UI",
|
name: "Email",
|
||||||
description:
|
description: "Transactional and marketing emails — welcome, billing, notifications",
|
||||||
"Completely unstyled, accessible components by Tailwind Labs. Full creative control with Tailwind classes.",
|
icon: Mail,
|
||||||
bestFor: ["Custom Design", "Brand-specific UI"],
|
themes: [
|
||||||
url: "https://headlessui.com",
|
{ 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: <ReactEmailPreview /> },
|
||||||
tags: ["Tailwind", "Unstyled", "Accessible"],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tailwind-only",
|
id: "docs",
|
||||||
name: "Tailwind only",
|
name: "Docs / Content",
|
||||||
description:
|
description: "Documentation, knowledge base, or blog for your product",
|
||||||
"No component library — pure Tailwind CSS with your own components. Maximum flexibility, zero opinions.",
|
icon: BookOpen,
|
||||||
bestFor: ["Custom", "Marketing Site"],
|
themes: [
|
||||||
url: "https://tailwindcss.com",
|
{ 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: <NextraPreview /> },
|
||||||
tags: ["Tailwind", "Custom", "Minimal"],
|
{ 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: <ShadcnPreview /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Package card
|
// Theme option card
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function PackageCard({
|
function ThemeCard({
|
||||||
pkg,
|
theme,
|
||||||
selected,
|
selected,
|
||||||
saving,
|
locked,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
pkg: DesignPackage;
|
theme: Theme;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
saving: boolean;
|
locked: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
disabled={saving}
|
disabled={locked && !selected}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full text-left rounded-xl border p-4 transition-all",
|
"flex flex-col text-left rounded-xl border transition-all overflow-hidden",
|
||||||
selected
|
"w-52 shrink-0",
|
||||||
? "border-foreground bg-foreground/5 ring-1 ring-foreground"
|
selected && locked
|
||||||
: "border-border hover:border-foreground/40 hover:bg-muted/40"
|
? "border-foreground ring-2 ring-foreground shadow-sm"
|
||||||
|
: selected
|
||||||
|
? "border-foreground ring-1 ring-foreground"
|
||||||
|
: "border-border hover:border-foreground/40",
|
||||||
|
locked && !selected && "opacity-40 cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selected && (
|
{/* Preview */}
|
||||||
<CheckCircle2 className="absolute top-3 right-3 h-4 w-4 text-foreground" />
|
<div className="h-28 p-2 bg-zinc-50 border-b border-border relative">
|
||||||
|
{theme.preview}
|
||||||
|
{selected && locked && (
|
||||||
|
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-foreground flex items-center justify-center">
|
||||||
|
<Lock className="w-2.5 h-2.5 text-background" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{saving && selected && (
|
{selected && !locked && (
|
||||||
<Loader2 className="absolute top-3 right-3 h-4 w-4 animate-spin text-muted-foreground" />
|
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-foreground flex items-center justify-center">
|
||||||
|
<CheckCircle2 className="w-2.5 h-2.5 text-background" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="font-semibold text-sm text-foreground">{pkg.name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
|
||||||
{pkg.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1 mt-3">
|
|
||||||
{pkg.tags.map((tag) => (
|
|
||||||
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[10px] text-muted-foreground mt-2">
|
{/* Info */}
|
||||||
Best for: {pkg.bestFor.join(", ")}
|
<div className="p-3 flex flex-col gap-1.5">
|
||||||
</p>
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs font-semibold text-foreground">{theme.name}</p>
|
||||||
|
<a
|
||||||
|
href={theme.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
Docs ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-relaxed">{theme.description}</p>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||||
|
{theme.tags.map(t => (
|
||||||
|
<Badge key={t} variant="secondary" className="text-[9px] px-1.5 py-0">{t}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App section
|
// Surface section
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function AppSection({
|
function SurfaceSection({
|
||||||
appName,
|
surface,
|
||||||
selectedPackageId,
|
selectedThemeId,
|
||||||
|
lockedThemeId,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onLock,
|
||||||
|
onUnlock,
|
||||||
|
saving,
|
||||||
}: {
|
}: {
|
||||||
appName: string;
|
surface: Surface;
|
||||||
selectedPackageId: string | undefined;
|
selectedThemeId: string | null;
|
||||||
onSelect: (appName: string, pkgId: string) => Promise<void>;
|
lockedThemeId: string | null;
|
||||||
|
onSelect: (themeId: string) => void;
|
||||||
|
onLock: () => void;
|
||||||
|
onUnlock: () => void;
|
||||||
|
saving: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [saving, setSaving] = useState<string | null>(null);
|
const Icon = surface.icon;
|
||||||
|
const lockedTheme = surface.themes.find(t => t.id === lockedThemeId);
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
|
{/* Surface header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="w-7 h-7 rounded-lg bg-muted flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center">
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold capitalize">{appName}</p>
|
<div className="flex items-center gap-2">
|
||||||
{selectedPkg && (
|
<p className="text-sm font-semibold">{surface.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
{lockedTheme && (
|
||||||
Using {selectedPkg.name}
|
<Badge variant="secondary" className="text-[10px] gap-1 px-1.5">
|
||||||
</p>
|
<Lock className="w-2.5 h-2.5" />
|
||||||
|
{lockedTheme.name}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{surface.description}</p>
|
||||||
</div>
|
</div>
|
||||||
{selectedPkg && (
|
</div>
|
||||||
<a
|
|
||||||
href={selectedPkg.url}
|
{/* Lock / Unlock controls */}
|
||||||
target="_blank"
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
rel="noopener noreferrer"
|
{lockedThemeId ? (
|
||||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
<Button variant="outline" size="sm" onClick={onUnlock} className="gap-1.5 text-xs">
|
||||||
onClick={(e) => e.stopPropagation()}
|
<Pencil className="h-3 w-3" />
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onLock}
|
||||||
|
disabled={!selectedThemeId || saving}
|
||||||
|
className="gap-1.5 text-xs"
|
||||||
>
|
>
|
||||||
Docs
|
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Lock className="h-3 w-3" />}
|
||||||
<ExternalLink className="h-3 w-3" />
|
Lock in
|
||||||
</a>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme cards — horizontal scroll */}
|
||||||
|
{!lockedThemeId && (
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2 -mx-1 px-1">
|
||||||
|
{surface.themes.map(theme => (
|
||||||
|
<ThemeCard
|
||||||
|
key={theme.id}
|
||||||
|
theme={theme}
|
||||||
|
selected={selectedThemeId === theme.id}
|
||||||
|
locked={false}
|
||||||
|
onSelect={() => onSelect(theme.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Locked state — show only selected */}
|
||||||
|
{lockedThemeId && (
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2 -mx-1 px-1">
|
||||||
|
{surface.themes.map(theme => (
|
||||||
|
<ThemeCard
|
||||||
|
key={theme.id}
|
||||||
|
theme={theme}
|
||||||
|
selected={theme.id === lockedThemeId}
|
||||||
|
locked={true}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase 1 — Surface picker
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SurfacePicker({
|
||||||
|
onConfirm,
|
||||||
|
saving,
|
||||||
|
}: {
|
||||||
|
onConfirm: (ids: string[]) => void;
|
||||||
|
saving: boolean;
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Design surfaces</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
Which surfaces does your product need? Select all that apply — you can always add more later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{PACKAGES.map((pkg) => (
|
{ALL_SURFACES.map(surface => {
|
||||||
<PackageCard
|
const Icon = surface.icon;
|
||||||
key={pkg.id}
|
const isSelected = selected.has(surface.id);
|
||||||
pkg={pkg}
|
return (
|
||||||
selected={selectedPackageId === pkg.id}
|
<button
|
||||||
saving={saving === pkg.id}
|
key={surface.id}
|
||||||
onSelect={() => handleSelect(pkg.id)}
|
onClick={() => toggle(surface.id)}
|
||||||
/>
|
className={cn(
|
||||||
))}
|
"flex items-start gap-3 rounded-xl border p-4 text-left transition-all",
|
||||||
|
isSelected
|
||||||
|
? "border-foreground bg-foreground/5 ring-1 ring-foreground"
|
||||||
|
: "border-border hover:border-foreground/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 mt-0.5",
|
||||||
|
isSelected ? "bg-foreground text-background" : "bg-muted text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">{surface.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{surface.description}</p>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-foreground ml-auto shrink-0 mt-0.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => onConfirm(Array.from(selected))}
|
||||||
|
disabled={selected.size === 0 || saving}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
Confirm surfaces ({selected.size})
|
||||||
|
</Button>
|
||||||
|
{selected.size === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">Select at least one surface to continue</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -210,39 +550,73 @@ export default function DesignPage({
|
|||||||
}) {
|
}) {
|
||||||
const { projectId } = use(params);
|
const { projectId } = use(params);
|
||||||
|
|
||||||
const [apps, setApps] = useState<{ name: string; path: string }[]>([]);
|
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||||
const [designPackages, setDesignPackages] = useState<Record<string, string>>({});
|
const [surfaceThemes, setSurfaceThemes] = useState<Record<string, string>>({});
|
||||||
const [giteaRepo, setGiteaRepo] = useState<string | null>(null);
|
const [selectedThemes, setSelectedThemes] = useState<Record<string, string>>({});
|
||||||
|
const [savingLock, setSavingLock] = useState<string | null>(null);
|
||||||
|
const [savingSurfaces, setSavingSurfaces] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/projects/${projectId}/apps`)
|
fetch(`/api/projects/${projectId}/design-surfaces`)
|
||||||
.then((r) => r.json())
|
.then(r => r.json())
|
||||||
.then((d) => {
|
.then(d => {
|
||||||
setApps(d.apps ?? []);
|
setSurfaces(d.surfaces ?? []);
|
||||||
setDesignPackages(d.designPackages ?? {});
|
setSurfaceThemes(d.surfaceThemes ?? {});
|
||||||
setGiteaRepo(d.giteaRepo ?? null);
|
setSelectedThemes(d.surfaceThemes ?? {});
|
||||||
})
|
})
|
||||||
.catch(() => toast.error("Failed to load apps"))
|
.catch(() => toast.error("Failed to load design data"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
const handleSelect = async (appName: string, packageId: string) => {
|
const handleConfirmSurfaces = async (ids: string[]) => {
|
||||||
|
setSavingSurfaces(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/projects/${projectId}/apps`, {
|
const res = await fetch(`/api/projects/${projectId}/design-surfaces`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ appName, packageId }),
|
body: JSON.stringify({ surfaces: ids }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Save failed");
|
if (!res.ok) throw new Error();
|
||||||
setDesignPackages((prev) => ({ ...prev, [appName]: packageId }));
|
setSurfaces(ids);
|
||||||
const pkg = PACKAGES.find((p) => p.id === packageId);
|
toast.success("Surfaces saved");
|
||||||
toast.success(`${appName} → ${pkg?.name ?? packageId}`);
|
|
||||||
} catch {
|
} 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-32">
|
<div className="flex items-center justify-center py-32">
|
||||||
@@ -251,54 +625,61 @@ export default function DesignPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 1 — no surfaces set yet
|
||||||
|
if (surfaces.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-4xl mx-auto">
|
||||||
|
<SurfacePicker onConfirm={handleConfirmSurfaces} saving={savingSurfaces} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2 — surfaces set, pick themes
|
||||||
|
const activeSurfaces = ALL_SURFACES.filter(s => surfaces.includes(s.id));
|
||||||
|
const lockedCount = Object.keys(surfaceThemes).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10 p-6 max-w-5xl mx-auto">
|
<div className="space-y-10 p-6 max-w-5xl mx-auto">
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold">Design packages</h1>
|
<h1 className="text-xl font-bold">Design</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{giteaRepo && (
|
<div className="flex items-center gap-2">
|
||||||
<a
|
{lockedCount === activeSurfaces.length && lockedCount > 0 && (
|
||||||
href={`https://git.vibnai.com/${giteaRepo}/src/branch/main/apps`}
|
<Badge variant="default" className="gap-1.5">
|
||||||
target="_blank"
|
<Lock className="w-3 h-3" />
|
||||||
rel="noopener noreferrer"
|
All locked in
|
||||||
>
|
</Badge>
|
||||||
<Button variant="outline" size="sm" className="gap-1.5">
|
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
|
||||||
View apps in Gitea
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSurfaces([])}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Change surfaces
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* App sections */}
|
<div className="space-y-10 divide-y divide-border">
|
||||||
{apps.length === 0 ? (
|
{activeSurfaces.map((surface, i) => (
|
||||||
<div className="rounded-xl border border-dashed p-12 text-center">
|
<div key={surface.id} className={i > 0 ? "pt-10" : ""}>
|
||||||
<Package className="h-8 w-8 text-muted-foreground mx-auto mb-3" />
|
<SurfaceSection
|
||||||
<p className="text-sm font-medium">No apps found</p>
|
surface={surface}
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
selectedThemeId={selectedThemes[surface.id] ?? null}
|
||||||
Push a Turborepo scaffold to your Gitea repo — apps in the{" "}
|
lockedThemeId={surfaceThemes[surface.id] ?? null}
|
||||||
<code className="font-mono">apps/</code> directory will appear here.
|
onSelect={themeId => setSelectedThemes(prev => ({ ...prev, [surface.id]: themeId }))}
|
||||||
</p>
|
onLock={() => handleLock(surface.id)}
|
||||||
</div>
|
onUnlock={() => handleUnlock(surface.id)}
|
||||||
) : (
|
saving={savingLock === surface.id}
|
||||||
<div className="space-y-12">
|
|
||||||
{apps.map((app) => (
|
|
||||||
<div key={app.name} className="space-y-1">
|
|
||||||
<AppSection
|
|
||||||
appName={app.name}
|
|
||||||
selectedPackageId={designPackages[app.name]}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
84
app/api/projects/[projectId]/design-surfaces/route.ts
Normal file
84
app/api/projects/[projectId]/design-surfaces/route.ts
Normal file
@@ -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<string, unknown> }>(
|
||||||
|
`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<surfaceId, themeId> — 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<string, string>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user