feat: design packages page — pick UI library per Turborepo app
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
This commit is contained in:
@@ -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 (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
disabled={saving}
|
||||
className={cn(
|
||||
"relative w-full text-left rounded-xl border p-4 transition-all",
|
||||
selected
|
||||
? "border-foreground bg-foreground/5 ring-1 ring-foreground"
|
||||
: "border-border hover:border-foreground/40 hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
{selected && (
|
||||
<CheckCircle2 className="absolute top-3 right-3 h-4 w-4 text-foreground" />
|
||||
)}
|
||||
{saving && selected && (
|
||||
<Loader2 className="absolute top-3 right-3 h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground mt-2">
|
||||
Best for: {pkg.bestFor.join(", ")}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AppSection({
|
||||
appName,
|
||||
selectedPackageId,
|
||||
onSelect,
|
||||
}: {
|
||||
appName: string;
|
||||
selectedPackageId: string | undefined;
|
||||
onSelect: (appName: string, pkgId: string) => Promise<void>;
|
||||
}) {
|
||||
const [saving, setSaving] = useState<string | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold capitalize">{appName}</p>
|
||||
{selectedPkg && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Using {selectedPkg.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedPkg && (
|
||||
<a
|
||||
href={selectedPkg.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Docs
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{PACKAGES.map((pkg) => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
selected={selectedPackageId === pkg.id}
|
||||
saving={saving === pkg.id}
|
||||
onSelect={() => handleSelect(pkg.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<WorkItem[]>([]);
|
||||
const [apps, setApps] = useState<{ name: string; path: string }[]>([]);
|
||||
const [designPackages, setDesignPackages] = useState<Record<string, string>>({});
|
||||
const [giteaRepo, setGiteaRepo] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterState, setFilterState] = useState<"all" | "draft" | "final">("all");
|
||||
const [selectedItem, setSelectedItem] = useState<WorkItem | null>(null);
|
||||
const [selectedTreeNodeId, setSelectedTreeNodeId] = useState<string | null>(null);
|
||||
|
||||
// Sample tree structure - will be populated from AI-generated data
|
||||
const [treeData] = useState<TreeNode[]>([
|
||||
{
|
||||
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 <CheckCircle2 className="h-4 w-4 text-green-600" />;
|
||||
if (status === "in_progress") return <Clock className="h-4 w-4 text-blue-600" />;
|
||||
return <Circle className="h-4 w-4 text-gray-400" />;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<Loader2 className="h-7 w-7 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-background overflow-hidden flex">
|
||||
{/* Left Sidebar */}
|
||||
<CollapsibleSidebar>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Design Assets
|
||||
</h3>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{workItems.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Tree View */}
|
||||
<TreeView
|
||||
data={treeData}
|
||||
selectedId={selectedTreeNodeId}
|
||||
onSelect={(node) => {
|
||||
setSelectedTreeNodeId(node.id);
|
||||
toast.info(`Selected: ${node.label}`);
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-10 p-6 max-w-5xl mx-auto">
|
||||
|
||||
{/* Quick Stats at Bottom */}
|
||||
<div className="pt-3 mt-3 border-t space-y-1.5 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Built</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{treeData.reduce((acc, node) => acc + countNodesByStatus(node, "built"), 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">In Progress</span>
|
||||
<span className="font-medium text-blue-600">
|
||||
{treeData.reduce((acc, node) => acc + countNodesByStatus(node, "in_progress"), 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">To Build</span>
|
||||
<span className="font-medium text-gray-600">
|
||||
{treeData.reduce((acc, node) => acc + countNodesByStatus(node, "missing"), 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSidebar>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-background p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Palette className="h-6 w-6" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Design</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
User-facing screens, features, and design assets
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
New Design
|
||||
</Button>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Design packages</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Each app in your Turborepo can use a different UI library — they never conflict.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Button
|
||||
variant={filterState === "all" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setFilterState("all")}
|
||||
{giteaRepo && (
|
||||
<a
|
||||
href={`https://git.vibnai.com/${giteaRepo}/src/branch/main/apps`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant={filterState === "draft" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setFilterState("draft")}
|
||||
>
|
||||
Draft
|
||||
</Button>
|
||||
<Button
|
||||
variant={filterState === "final" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setFilterState("final")}
|
||||
>
|
||||
Final
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-6 mx-2" />
|
||||
<Badge variant="secondary">
|
||||
{filteredItems.length} items
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Work Items List */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<Palette className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No design items yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Design items are user-facing elements like screens and features
|
||||
</p>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create First Design
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
View apps in Gitea
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredItems.map((item) => (
|
||||
<Card key={item.id} className="p-4 hover:bg-accent/30 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
{getStatusIcon(item.status)}
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
{/* Title and Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{item.title}</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getStatusLabel(item.status)}
|
||||
</Badge>
|
||||
{item.state && (
|
||||
<Badge
|
||||
variant={item.state === "final" ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{item.state === "final" ? "Final" : "Draft"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Path */}
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{item.path}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{item.sessionsCount} sessions</span>
|
||||
<span>•</span>
|
||||
<span>{item.commitsCount} commits</span>
|
||||
{item.estimatedCost && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>${item.estimatedCost.toFixed(2)}</span>
|
||||
</>
|
||||
)}
|
||||
{item.versionCount && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{item.versionCount} versions</span>
|
||||
</>
|
||||
)}
|
||||
{item.messageCount && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{item.messageCount} messages</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Requirements Preview */}
|
||||
{item.requirements.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{item.requirements.filter(r => r.status === "built").length} of{" "}
|
||||
{item.requirements.length} requirements complete
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => openInV0(item)}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Design
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toast.info("Version history coming soon")}
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toast.info("Messages coming soon")}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* State Toggle */}
|
||||
{item.state !== "final" && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => toggleState(item.id, "final")}
|
||||
>
|
||||
Mark Final
|
||||
</Button>
|
||||
)}
|
||||
{item.state === "final" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleState(item.id, "draft")}
|
||||
>
|
||||
Back to Draft
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* End Main Content */}
|
||||
|
||||
{/* App sections */}
|
||||
{apps.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-12 text-center">
|
||||
<Package className="h-8 w-8 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-sm font-medium">No apps found</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Push a Turborepo scaffold to your Gitea repo — apps in the{" "}
|
||||
<code className="font-mono">apps/</code> directory will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
98
app/api/projects/[projectId]/apps/route.ts
Normal file
98
app/api/projects/[projectId]/apps/route.ts
Normal file
@@ -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<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, 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<string, string>;
|
||||
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user