458 lines
16 KiB
TypeScript
458 lines
16 KiB
TypeScript
"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 { toast } from "sonner";
|
|
import { TreeView, TreeNode } from "@/components/ui/tree-view";
|
|
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar";
|
|
|
|
interface WorkItem {
|
|
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;
|
|
}
|
|
|
|
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 [workItems, setWorkItems] = useState<WorkItem[]>([]);
|
|
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();
|
|
}, [projectId]);
|
|
|
|
const loadDesignItems = async () => {
|
|
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 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;
|
|
});
|
|
|
|
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}`);
|
|
}}
|
|
/>
|
|
|
|
{/* 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>
|
|
|
|
{/* Filters */}
|
|
<div className="flex items-center gap-2 mt-4">
|
|
<Button
|
|
variant={filterState === "all" ? "secondary" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setFilterState("all")}
|
|
>
|
|
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>
|
|
</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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* End Main Content */}
|
|
</div>
|
|
);
|
|
}
|
|
|