"use client"; import { useEffect, useState } from "react"; import { useSession } from "next-auth/react"; import { Card, CardContent, CardHeader, CardTitle, CardDescription, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Plus, Sparkles, Loader2, MoreVertical, Trash2, GitBranch, GitCommit, Rocket, Terminal, CheckCircle2, XCircle, Clock, } from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ProjectCreationModal } from "@/components/project-creation-modal"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { toast } from "sonner"; interface ContextSnapshot { lastCommit?: { sha: string; message: string; author?: string; timestamp?: string }; currentBranch?: string; openPRs?: { number: number; title: string }[]; openIssues?: { number: number; title: string }[]; lastDeployment?: { status: string; url?: string }; } interface ProjectWithStats { id: string; name: string; slug: string; productName: string; productVision?: string; workspacePath?: string; status?: string; createdAt: string | null; updatedAt: string | null; giteaRepo?: string; giteaRepoUrl?: string; theiaWorkspaceUrl?: string; contextSnapshot?: ContextSnapshot; stats: { sessions: number; costs: number; }; } function timeAgo(dateStr?: string | null): string { if (!dateStr) return "—"; const date = new Date(dateStr); if (isNaN(date.getTime())) return "—"; const diff = (Date.now() - date.getTime()) / 1000; if (diff < 60) return "just now"; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; const days = Math.floor(diff / 86400); if (days === 1) return "Yesterday"; if (days < 7) return `${days}d ago`; if (days < 30) return `${Math.floor(days / 7)}w ago`; return `${Math.floor(days / 30)}mo ago`; } function DeployDot({ status }: { status?: string }) { if (!status) return null; const map: Record = { finished: "bg-green-500", in_progress: "bg-blue-500 animate-pulse", queued: "bg-yellow-400", failed: "bg-red-500", }; return ( ); } export default function ProjectsPage() { const params = useParams(); const workspace = params.workspace as string; const { data: session, status } = useSession(); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showCreationModal, setShowCreationModal] = useState(false); const [projectToDelete, setProjectToDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const fetchProjects = async () => { try { setLoading(true); const res = await fetch("/api/projects"); if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Failed to fetch projects"); } const data = await res.json(); const loaded: ProjectWithStats[] = data.projects || []; setProjects(loaded); setError(null); // Fire-and-forget: prewarm all provisioned IDE workspaces so containers // are already running by the time the user clicks "Open IDE" const warmUrls = loaded .map((p) => p.theiaWorkspaceUrl) .filter((u): u is string => Boolean(u)); if (warmUrls.length > 0) { fetch("/api/projects/prewarm", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ urls: warmUrls }), }).catch(() => {}); // ignore errors — this is best-effort } } catch (err: unknown) { setError(err instanceof Error ? err.message : "Unknown error"); } finally { setLoading(false); } }; useEffect(() => { if (status === "authenticated") fetchProjects(); else if (status === "unauthenticated") setLoading(false); }, [status]); const handleDeleteProject = async () => { if (!projectToDelete) return; setIsDeleting(true); try { const res = await fetch("/api/projects/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId: projectToDelete.id }), }); if (res.ok) { toast.success("Project deleted"); setProjectToDelete(null); fetchProjects(); } else { const err = await res.json(); toast.error(err.error || "Failed to delete project"); } } catch { toast.error("An error occurred"); } finally { setIsDeleting(false); } }; if (status === "loading") { return (
); } return ( <>
{/* Header */}

Projects

{session?.user?.email}

{/* States */} {loading && (
)} {error && (

Error: {error}

)} {/* Projects Grid */} {!loading && !error && projects.length > 0 && (
{projects.map((project) => { const href = `/${workspace}/project/${project.id}/overview`; const snap = project.contextSnapshot; const deployStatus = snap?.lastDeployment?.status; return (
{project.productName} {timeAgo(project.updatedAt)}
{project.status ?? "active"} e.preventDefault()}> { e.preventDefault(); setProjectToDelete(project); }} > Delete
{/* Vision */} {project.productVision && (

{project.productVision}

)} {/* Gitea repo + last commit */} {project.giteaRepo && (
{project.giteaRepo} {snap?.currentBranch && ( {snap.currentBranch} )}
{snap?.lastCommit ? (
{snap.lastCommit.sha.slice(0, 7)} {snap.lastCommit.message} {timeAgo(snap.lastCommit.timestamp)}
) : (

No commits yet

)}
)} {/* Footer row: deploy + stats + IDE */}
{deployStatus && ( {deployStatus === "finished" ? "Live" : deployStatus} )} {project.stats.sessions} sessions ${project.stats.costs.toFixed(2)}
{project.theiaWorkspaceUrl && ( e.stopPropagation()} className="flex items-center gap-1 text-[10px] text-primary hover:underline" > IDE )}
); })} {/* Create card */} setShowCreationModal(true)} >

New Project

Auto-provisions a Gitea repo and workspace

)} {/* Empty state */} {!loading && !error && projects.length === 0 && (

No projects yet

Create your first project. Vibn will automatically provision a Gitea repo, register webhooks, and prepare your IDE workspace.

)}
{ setShowCreationModal(open); if (!open) fetchProjects(); }} workspace={workspace} /> !open && setProjectToDelete(null)}> Delete "{projectToDelete?.productName}"? This will remove the project record. Sessions will be preserved but unlinked. The Gitea repo will not be deleted automatically. Cancel {isDeleting ? ( ) : ( )} Delete Project ); }