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";
|
"use client";
|
||||||
|
|
||||||
import { use, useState, useEffect } from "react";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Button } from "@/components/ui/button";
|
||||||
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 { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { TreeView, TreeNode } from "@/components/ui/tree-view";
|
import { CheckCircle2, ExternalLink, Loader2, Package } from "lucide-react";
|
||||||
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface WorkItem {
|
// ---------------------------------------------------------------------------
|
||||||
|
// Design package catalogue
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface DesignPackage {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
name: string;
|
||||||
path: string;
|
description: string;
|
||||||
status: "built" | "in_progress" | "missing";
|
bestFor: string[];
|
||||||
state?: "draft" | "final";
|
url: string;
|
||||||
category: string;
|
tags: 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
export default function DesignPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ workspace: string; projectId: string }>;
|
params: Promise<{ workspace: string; projectId: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { workspace, projectId } = use(params);
|
const { projectId } = use(params);
|
||||||
|
|
||||||
// Helper function to count nodes by status recursively
|
const [apps, setApps] = useState<{ name: string; path: string }[]>([]);
|
||||||
const countNodesByStatus = (node: TreeNode, status: string): number => {
|
const [designPackages, setDesignPackages] = useState<Record<string, string>>({});
|
||||||
let count = node.status === status ? 1 : 0;
|
const [giteaRepo, setGiteaRepo] = useState<string | null>(null);
|
||||||
if (node.children) {
|
|
||||||
count += node.children.reduce((acc, child) => acc + countNodesByStatus(child, status), 0);
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
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]);
|
}, [projectId]);
|
||||||
|
|
||||||
const loadDesignItems = async () => {
|
const handleSelect = async (appName: string, packageId: string) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
const res = await fetch(`/api/projects/${projectId}/apps`, {
|
||||||
const response = await fetch(`/api/projects/${projectId}/timeline-view`);
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
if (response.ok) {
|
body: JSON.stringify({ appName, packageId }),
|
||||||
const data = await response.json();
|
});
|
||||||
// Filter for design/user-facing items only
|
if (!res.ok) throw new Error("Save failed");
|
||||||
const designItems = data.workItems.filter((item: WorkItem) =>
|
setDesignPackages((prev) => ({ ...prev, [appName]: packageId }));
|
||||||
isTouchpoint(item)
|
const pkg = PACKAGES.find((p) => p.id === packageId);
|
||||||
);
|
toast.success(`${appName} → ${pkg?.name ?? packageId}`);
|
||||||
setWorkItems(designItems);
|
} catch {
|
||||||
}
|
toast.error("Failed to save selection");
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading design items:", error);
|
|
||||||
toast.error("Failed to load design items");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isTouchpoint = (item: WorkItem): boolean => {
|
if (loading) {
|
||||||
const path = item.path.toLowerCase();
|
return (
|
||||||
const title = item.title.toLowerCase();
|
<div className="flex items-center justify-center py-32">
|
||||||
|
<Loader2 className="h-7 w-7 animate-spin text-muted-foreground" />
|
||||||
// Exclude APIs and backend systems
|
</div>
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full bg-background overflow-hidden flex">
|
<div className="space-y-10 p-6 max-w-5xl mx-auto">
|
||||||
{/* 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}`);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 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 */}
|
{/* Header */}
|
||||||
<div className="border-b bg-background p-4">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<div>
|
||||||
<div className="flex items-center gap-4">
|
<h1 className="text-xl font-bold">Design packages</h1>
|
||||||
<Palette className="h-6 w-6" />
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
<div>
|
Each app in your Turborepo can use a different UI library — they never conflict.
|
||||||
<h1 className="text-xl font-bold">Design</h1>
|
</p>
|
||||||
<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>
|
</div>
|
||||||
|
{giteaRepo && (
|
||||||
{/* Filters */}
|
<a
|
||||||
<div className="flex items-center gap-2 mt-4">
|
href={`https://git.vibnai.com/${giteaRepo}/src/branch/main/apps`}
|
||||||
<Button
|
target="_blank"
|
||||||
variant={filterState === "all" ? "secondary" : "ghost"}
|
rel="noopener noreferrer"
|
||||||
size="sm"
|
|
||||||
onClick={() => setFilterState("all")}
|
|
||||||
>
|
>
|
||||||
All
|
<Button variant="outline" size="sm" className="gap-1.5">
|
||||||
</Button>
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
<Button
|
View apps in Gitea
|
||||||
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>
|
</Button>
|
||||||
</div>
|
</a>
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</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