From 287bc96fac696770b2cb68bb1cbf0f32b2a4e6bd Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Sun, 1 Mar 2026 20:33:39 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20design=20packages=20page=20=E2=80=94=20?= =?UTF-8?q?pick=20UI=20library=20per=20Turborepo=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the old design page with a per-app package selector. Fetches real apps/ from the project's Gitea repo and lets users assign a UI library (shadcn, DaisyUI, HeroUI, Mantine, Headless UI, or Tailwind only) independently per app. Selections saved to fs_projects.data.designPackages. Made-with: Cursor --- .../project/[projectId]/design/page.tsx | 689 +++++++----------- app/api/projects/[projectId]/apps/route.ts | 98 +++ 2 files changed, 366 insertions(+), 421 deletions(-) create mode 100644 app/api/projects/[projectId]/apps/route.ts diff --git a/app/[workspace]/project/[projectId]/design/page.tsx b/app/[workspace]/project/[projectId]/design/page.tsx index 593da1c..ecf3e87 100644 --- a/app/[workspace]/project/[projectId]/design/page.tsx +++ b/app/[workspace]/project/[projectId]/design/page.tsx @@ -1,457 +1,304 @@ "use client"; import { use, useState, useEffect } from "react"; -import { Card } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { - Palette, - Plus, - MessageSquare, - History, - Loader2, - CheckCircle2, - Circle, - Clock, - Sparkles, - ExternalLink, -} from "lucide-react"; -import { Separator } from "@/components/ui/separator"; -import Link from "next/link"; +import { Button } from "@/components/ui/button"; import { toast } from "sonner"; -import { TreeView, TreeNode } from "@/components/ui/tree-view"; -import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar"; +import { CheckCircle2, ExternalLink, Loader2, Package } from "lucide-react"; +import { cn } from "@/lib/utils"; -interface WorkItem { +// --------------------------------------------------------------------------- +// Design package catalogue +// --------------------------------------------------------------------------- + +interface DesignPackage { id: string; - title: string; - path: string; - status: "built" | "in_progress" | "missing"; - state?: "draft" | "final"; - category: string; - priority: string; - startDate: string | null; - endDate: string | null; - sessionsCount: number; - commitsCount: number; - estimatedCost?: number; - requirements: Array<{ - id: number; - text: string; - status: string; - }>; - versionCount?: number; - messageCount?: number; + name: string; + description: string; + bestFor: string[]; + url: string; + tags: string[]; } +const PACKAGES: DesignPackage[] = [ + { + 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: "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: "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: "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: "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: "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"], + }, +]; + +// --------------------------------------------------------------------------- +// Package card +// --------------------------------------------------------------------------- + +function PackageCard({ + pkg, + selected, + saving, + onSelect, +}: { + pkg: DesignPackage; + selected: boolean; + saving: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// App section +// --------------------------------------------------------------------------- + +function AppSection({ + appName, + selectedPackageId, + onSelect, +}: { + appName: string; + selectedPackageId: string | undefined; + onSelect: (appName: string, pkgId: string) => Promise; +}) { + 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); + + return ( +
+
+
+
+ +
+
+

{appName}

+ {selectedPkg && ( +

+ Using {selectedPkg.name} +

+ )} +
+
+ {selectedPkg && ( + e.stopPropagation()} + > + Docs + + + )} +
+ +
+ {PACKAGES.map((pkg) => ( + handleSelect(pkg.id)} + /> + ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + export default function DesignPage({ params, }: { params: Promise<{ workspace: string; projectId: string }>; }) { - const { workspace, projectId } = use(params); - - // Helper function to count nodes by status recursively - const countNodesByStatus = (node: TreeNode, status: string): number => { - let count = node.status === status ? 1 : 0; - if (node.children) { - count += node.children.reduce((acc, child) => acc + countNodesByStatus(child, status), 0); - } - return count; - }; + const { projectId } = use(params); - const [workItems, setWorkItems] = useState([]); + const [apps, setApps] = useState<{ name: string; path: string }[]>([]); + const [designPackages, setDesignPackages] = useState>({}); + const [giteaRepo, setGiteaRepo] = useState(null); const [loading, setLoading] = useState(true); - const [filterState, setFilterState] = useState<"all" | "draft" | "final">("all"); - const [selectedItem, setSelectedItem] = useState(null); - const [selectedTreeNodeId, setSelectedTreeNodeId] = useState(null); - - // Sample tree structure - will be populated from AI-generated data - const [treeData] = useState([ - { - id: "navigation", - label: "Navigation", - children: [ - { - id: "sidebar", - label: "Sidebar", - status: "in_progress", - children: [ - { id: "dashboard", label: "Dashboard", status: "built", metadata: { sessionsCount: 12, commitsCount: 5 } }, - { id: "projects", label: "Projects", status: "in_progress", metadata: { sessionsCount: 8, commitsCount: 3 } }, - { id: "account", label: "Account", status: "missing" }, - ], - }, - { - id: "topnav", - label: "Top Nav", - status: "built", - children: [ - { id: "search", label: "Search", status: "built", metadata: { sessionsCount: 6, commitsCount: 2 } }, - { id: "notifications", label: "Notifications", status: "built", metadata: { sessionsCount: 4, commitsCount: 1 } }, - ], - }, - ], - }, - { - id: "site", - label: "Site", - children: [ - { - id: "home", - label: "Home", - status: "built", - children: [ - { id: "hero", label: "Hero", status: "built", metadata: { sessionsCount: 10, commitsCount: 4 } }, - { id: "features", label: "Features", status: "built", metadata: { sessionsCount: 15, commitsCount: 6 } }, - { id: "testimonials", label: "Testimonials", status: "in_progress", metadata: { sessionsCount: 3, commitsCount: 1 } }, - { id: "cta", label: "CTA", status: "built", metadata: { sessionsCount: 5, commitsCount: 2 } }, - ], - }, - { - id: "blog", - label: "Blog", - status: "missing", - children: [ - { id: "post-list", label: "Post List", status: "missing" }, - { id: "post-page", label: "Post Page", status: "missing" }, - ], - }, - { - id: "pricing", - label: "Pricing", - status: "built", - metadata: { sessionsCount: 7, commitsCount: 3 }, - }, - ], - }, - { - id: "onboarding", - label: "Onboarding", - children: [ - { id: "signup", label: "Signup", status: "built", metadata: { sessionsCount: 9, commitsCount: 4 } }, - { id: "magic-link", label: "Magic Link Confirmation", status: "in_progress", metadata: { sessionsCount: 2, commitsCount: 1 } }, - { id: "welcome", label: "Welcome Tour", status: "missing" }, - ], - }, - ]); useEffect(() => { - loadDesignItems(); + fetch(`/api/projects/${projectId}/apps`) + .then((r) => r.json()) + .then((d) => { + setApps(d.apps ?? []); + setDesignPackages(d.designPackages ?? {}); + setGiteaRepo(d.giteaRepo ?? null); + }) + .catch(() => toast.error("Failed to load apps")) + .finally(() => setLoading(false)); }, [projectId]); - const loadDesignItems = async () => { + const handleSelect = async (appName: string, packageId: string) => { try { - setLoading(true); - const response = await fetch(`/api/projects/${projectId}/timeline-view`); - - if (response.ok) { - const data = await response.json(); - // Filter for design/user-facing items only - const designItems = data.workItems.filter((item: WorkItem) => - isTouchpoint(item) - ); - setWorkItems(designItems); - } - } catch (error) { - console.error("Error loading design items:", error); - toast.error("Failed to load design items"); - } finally { - setLoading(false); + const res = await fetch(`/api/projects/${projectId}/apps`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ appName, packageId }), + }); + 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}`); + } catch { + toast.error("Failed to save selection"); } }; - const isTouchpoint = (item: WorkItem): boolean => { - const path = item.path.toLowerCase(); - const title = item.title.toLowerCase(); - - // Exclude APIs and backend systems - if (path.startsWith('/api/')) return false; - if (title.includes(' api') || title.includes('api ')) return false; - - // Exclude pure auth infrastructure (OAuth endpoints) - if (path.includes('oauth') && !path.includes('button') && !path.includes('signin')) return false; - - // Include everything else - screens, pages, flows, etc. - return true; - }; - - const toggleState = async (itemId: string, newState: "draft" | "final") => { - try { - // TODO: Implement API call to update state - setWorkItems(items => - items.map(item => - item.id === itemId ? { ...item, state: newState } : item - ) - ); - toast.success(`Marked as ${newState}`); - } catch (error) { - toast.error("Failed to update state"); - } - }; - - const openInV0 = (item: WorkItem) => { - // TODO: Integrate with v0 API - toast.info("Opening in v0 designer..."); - }; - - const getStatusIcon = (status: string) => { - if (status === "built") return ; - if (status === "in_progress") return ; - return ; - }; - - const getStatusLabel = (status: string) => { - if (status === "built") return "Done"; - if (status === "in_progress") return "Started"; - return "To-do"; - }; - - const filteredItems = workItems.filter(item => { - if (filterState === "all") return true; - return item.state === filterState; - }); + if (loading) { + return ( +
+ +
+ ); + } return ( -
- {/* Left Sidebar */} - -
-
-

- Design Assets -

- - {workItems.length} - -
- - {/* Tree View */} - { - setSelectedTreeNodeId(node.id); - toast.info(`Selected: ${node.label}`); - }} - /> +
- {/* Quick Stats at Bottom */} -
-
- Built - - {treeData.reduce((acc, node) => acc + countNodesByStatus(node, "built"), 0)} - -
-
- In Progress - - {treeData.reduce((acc, node) => acc + countNodesByStatus(node, "in_progress"), 0)} - -
-
- To Build - - {treeData.reduce((acc, node) => acc + countNodesByStatus(node, "missing"), 0)} - -
-
-
- - - {/* Main Content */} -
{/* Header */} -
-
-
- -
-

Design

-

- User-facing screens, features, and design assets -

-
-
- +
+
+

Design packages

+

+ Each app in your Turborepo can use a different UI library — they never conflict. +

- - {/* Filters */} - -
- - {/* Work Items List */} -
- {loading ? ( -
- -
- ) : filteredItems.length === 0 ? ( -
- -

No design items yet

-

- Design items are user-facing elements like screens and features -

- -
- ) : ( -
- {filteredItems.map((item) => ( - -
-
- {getStatusIcon(item.status)} - -
- {/* Title and Status */} -
-

{item.title}

- - {getStatusLabel(item.status)} - - {item.state && ( - - {item.state === "final" ? "Final" : "Draft"} - - )} -
- - {/* Path */} -

- {item.path} -

- - {/* Stats */} -
- {item.sessionsCount} sessions - - {item.commitsCount} commits - {item.estimatedCost && ( - <> - - ${item.estimatedCost.toFixed(2)} - - )} - {item.versionCount && ( - <> - - {item.versionCount} versions - - )} - {item.messageCount && ( - <> - - {item.messageCount} messages - - )} -
- - {/* Requirements Preview */} - {item.requirements.length > 0 && ( -
-

- {item.requirements.filter(r => r.status === "built").length} of{" "} - {item.requirements.length} requirements complete -

-
- )} -
-
- - {/* Actions */} -
- - - - - - - {/* State Toggle */} - {item.state !== "final" && ( - - )} - {item.state === "final" && ( - - )} -
-
-
- ))} -
+
)}
-
- {/* End Main Content */} + + {/* 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) => ( +
+ +
+ ))} +
+ )}
); } - diff --git a/app/api/projects/[projectId]/apps/route.ts b/app/api/projects/[projectId]/apps/route.ts new file mode 100644 index 0000000..9b9dc5b --- /dev/null +++ b/app/api/projects/[projectId]/apps/route.ts @@ -0,0 +1,98 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth/authOptions'; +import { query } from '@/lib/db-postgres'; + +const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; +const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? ''; + +async function giteaGet(path: string) { + const res = await fetch(`${GITEA_API_URL}/api/v1${path}`, { + headers: { Authorization: `token ${GITEA_API_TOKEN}` }, + next: { revalidate: 30 }, + }); + if (!res.ok) throw new Error(`Gitea ${res.status}: ${path}`); + return res.json(); +} + +/** + * GET — returns the project's apps/ directories from Gitea + saved designPackages. + * Response: { apps: [{ name, path, type }], designPackages: { appName: packageId } } + */ +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 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, session.user.email] + ); + + if (rows.length === 0) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + const data = rows[0].data ?? {}; + const giteaRepo = data.giteaRepo as string | undefined; // e.g. "mark/sportsy" + const designPackages = (data.designPackages ?? {}) as Record; + + let apps: { name: string; path: string }[] = []; + + if (giteaRepo) { + try { + const contents: Array<{ name: string; path: string; type: string }> = + await giteaGet(`/repos/${giteaRepo}/contents/apps`); + apps = contents + .filter((item) => item.type === 'dir') + .map(({ name, path }) => ({ name, path })); + } catch { + // Repo may not have an apps/ dir yet — return empty list gracefully + } + } + + return NextResponse.json({ apps, designPackages, giteaRepo }); +} + +/** + * PATCH — saves { appName, packageId } → stored in fs_projects.data.designPackages + */ +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 { appName, packageId } = await req.json() as { appName: string; packageId: string }; + + if (!appName || !packageId) { + return NextResponse.json({ error: 'appName and packageId are required' }, { status: 400 }); + } + + await query( + `UPDATE fs_projects p + SET data = data || jsonb_build_object( + 'designPackages', + COALESCE(data->'designPackages', '{}'::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, appName, packageId] + ); + + return NextResponse.json({ success: true }); +}