"use client"; /** * Project home page. * * Sits between the projects list and the AI interview. Gives users two * simplified entry tiles — Code (their Gitea repo) and Infrastructure * (their Coolify deployment) — plus a quiet "Continue setup" link if * the discovery interview isn't done. * * Styled to match the production "ink & parchment" design: * Newsreader serif headings, Outfit sans body, warm beige borders, * solid black CTAs. No indigo. No gradients. */ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { useSession } from "next-auth/react"; import { isClientDevProjectBypass } from "@/lib/dev-bypass"; import { ArrowRight, Code2, ExternalLink, FileText, Folder, Loader2, Rocket, } from "lucide-react"; // ── Design tokens (mirrors the prod ink & parchment palette) ───────── const INK = { fontSerif: '"Newsreader", "Lora", Georgia, serif', fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', fontMono: '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace', ink: "#1a1a1a", ink2: "#2c2c2a", mid: "#5f5e5a", muted: "#a09a90", stone: "#b5b0a6", border: "#e8e4dc", borderHover: "#d0ccc4", cardBg: "#fff", pageBg: "#f7f4ee", shadow: "0 1px 2px #1a1a1a05", shadowHover: "0 2px 8px #1a1a1a0a", iconWrapBg: "#1a1a1a08", } as const; interface ProjectSummary { id: string; productName?: string; name?: string; productVision?: string; description?: string; giteaRepo?: string; giteaRepoUrl?: string; stage?: "discovery" | "architecture" | "building" | "active"; creationMode?: "fresh" | "chat-import" | "code-import" | "migration"; discoveryPhase?: number; progress?: number; } interface FileTreeItem { name: string; path: string; type: "file" | "dir"; } interface PreviewApp { name: string; url: string | null; status: string; } export default function ProjectHomePage() { const params = useParams(); const workspace = params.workspace as string; const projectId = params.projectId as string; const { status: authStatus } = useSession(); const [project, setProject] = useState(null); const [projectLoading, setProjectLoading] = useState(true); const [files, setFiles] = useState(null); const [filesLoading, setFilesLoading] = useState(true); const [apps, setApps] = useState([]); const [appsLoading, setAppsLoading] = useState(true); const ready = useMemo( () => isClientDevProjectBypass() || authStatus === "authenticated", [authStatus] ); useEffect(() => { if (!ready) { if (authStatus === "unauthenticated") setProjectLoading(false); return; } fetch(`/api/projects/${projectId}`, { credentials: "include" }) .then(r => r.json()) .then(d => setProject(d.project ?? null)) .catch(() => {}) .finally(() => setProjectLoading(false)); }, [ready, authStatus, projectId]); useEffect(() => { if (!ready) return; fetch(`/api/projects/${projectId}/file?path=`, { credentials: "include" }) .then(r => (r.ok ? r.json() : null)) .then(d => { if (d?.type === "dir" && Array.isArray(d.items)) { setFiles(d.items as FileTreeItem[]); } else { setFiles([]); } }) .catch(() => setFiles([])) .finally(() => setFilesLoading(false)); }, [ready, projectId]); useEffect(() => { if (!ready) return; fetch(`/api/projects/${projectId}/preview-url`, { credentials: "include" }) .then(r => (r.ok ? r.json() : null)) .then(d => setApps(Array.isArray(d?.apps) ? d.apps : [])) .catch(() => {}) .finally(() => setAppsLoading(false)); }, [ready, projectId]); const projectName = project?.productName || project?.name || "Untitled project"; const projectDesc = project?.productVision || project?.description; const stage = project?.stage ?? "discovery"; const interviewIncomplete = stage === "discovery"; const liveApp = apps.find(a => a.url) ?? apps[0] ?? null; if (projectLoading) { return (
); } if (!project) { return (
Project not found.
); } return (
{/* ── Hero ─────────────────────────────────────────────── */}
Project

{projectName}

{projectDesc &&

{projectDesc}

}
{/* ── Continue setup link (quiet, only when in discovery) ── */} {interviewIncomplete && (
Continue setup
Pick up the AI interview where you left off.
)} {/* ── Two big tiles ────────────────────────────────────── */}
); } // ────────────────────────────────────────────────────────────────────── // Tiles // ────────────────────────────────────────────────────────────────────── function CodeTile({ workspace, projectId, files, loading, giteaRepo, }: { workspace: string; projectId: string; files: FileTreeItem[] | null; loading: boolean; giteaRepo?: string; }) { const items = files ?? []; const dirCount = items.filter(i => i.type === "dir").length; const fileCount = items.filter(i => i.type === "file").length; const previewItems = items.slice(0, 6); return (

Code

What the AI is building, file by file.

{loading ? ( ) : items.length === 0 ? ( } title="No files yet" subtitle={ giteaRepo ? "Your repository is empty. The AI will commit the first files when you start building." : "This project doesn't have a repository yet." } /> ) : ( <>
    {previewItems.map(item => (
  • {item.type === "dir" ? ( ) : ( )} {item.name} {item.type === "dir" ? "folder" : ext(item.name)}
  • ))}
{items.length > previewItems.length && (
+{items.length - previewItems.length} more
)} )}
); } function InfraTile({ workspace, projectId, app, loading, }: { workspace: string; projectId: string; app: PreviewApp | null; loading: boolean; }) { const status = app?.status?.toLowerCase() ?? "unknown"; const isLive = !!app?.url && (status.includes("running") || status.includes("healthy")); const isBuilding = status.includes("queued") || status.includes("in_progress") || status.includes("starting"); return ( ); } // ────────────────────────────────────────────────────────────────────── // Small bits // ────────────────────────────────────────────────────────────────────── function StagePill({ stage }: { stage: string }) { const map: Record = { discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a12" }, architecture: { label: "Planning", color: "#3d5afe", bg: "#3d5afe10" }, building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" }, active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" }, }; const s = map[stage] ?? map.discovery; return ( {s.label} ); } function StatusBlock({ color, label }: { color: string; label: string }) { return (
Status {label}
); } function Metric({ label, value }: { label: string; value: string | number }) { return (
{label} {value}
); } function TileLoader({ label }: { label: string }) { return (
{label}
); } function TileEmpty({ icon, title, subtitle, }: { icon: React.ReactNode; title: string; subtitle: string; }) { return (
{icon}
{title}
{subtitle}
); } function statusFriendly(status: string): string { if (!status || status === "unknown") return "Unknown"; return status.replace(/[:_-]+/g, " ").replace(/\b\w/g, c => c.toUpperCase()); } function ext(name: string): string { const dot = name.lastIndexOf("."); return dot > 0 ? name.slice(dot + 1) : "file"; } function shortUrl(url: string): string { try { const u = new URL(url); return u.host + (u.pathname === "/" ? "" : u.pathname); } catch { return url; } } function hoverEnter(e: React.MouseEvent) { const el = e.currentTarget; el.style.borderColor = INK.borderHover; el.style.boxShadow = INK.shadowHover; } function hoverLeave(e: React.MouseEvent) { const el = e.currentTarget; el.style.borderColor = INK.border; el.style.boxShadow = INK.shadow; } // ────────────────────────────────────────────────────────────────────── // Styles // ────────────────────────────────────────────────────────────────────── const pageWrap: React.CSSProperties = { flex: 1, minHeight: 0, overflow: "auto", background: INK.pageBg, fontFamily: INK.fontSans, }; const pageInner: React.CSSProperties = { maxWidth: 900, margin: "0 auto", padding: "44px 52px 64px", display: "flex", flexDirection: "column", gap: 28, }; const centeredFiller: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", height: "100%", padding: 64, }; const heroStyle: React.CSSProperties = { display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 24, }; const eyebrow: React.CSSProperties = { fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: INK.muted, fontFamily: INK.fontSans, marginBottom: 8, }; const heroTitle: React.CSSProperties = { fontFamily: INK.fontSerif, fontSize: "1.9rem", fontWeight: 400, color: INK.ink, letterSpacing: "-0.03em", lineHeight: 1.15, margin: 0, }; const heroDesc: React.CSSProperties = { fontSize: "0.88rem", color: INK.mid, marginTop: 10, maxWidth: 620, lineHeight: 1.6, fontFamily: INK.fontSans, }; const continueRow: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10, padding: "14px 18px", textDecoration: "none", color: "inherit", boxShadow: INK.shadow, fontFamily: INK.fontSans, transition: "border-color 0.15s, box-shadow 0.15s", }; const continueDot: React.CSSProperties = { width: 7, height: 7, borderRadius: "50%", background: "#d4a04a", flexShrink: 0, }; const continueTitle: React.CSSProperties = { fontSize: 13, fontWeight: 600, color: INK.ink, }; const continueSub: React.CSSProperties = { fontSize: 12, color: INK.muted, marginTop: 2, }; const tileGrid: React.CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))", gap: 14, }; const tileLink: React.CSSProperties = { textDecoration: "none", color: "inherit", }; const tileCard: React.CSSProperties = { background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10, padding: 22, display: "flex", flexDirection: "column", gap: 18, minHeight: 280, boxShadow: INK.shadow, transition: "border-color 0.15s, box-shadow 0.15s", fontFamily: INK.fontSans, }; const tileHeader: React.CSSProperties = { display: "flex", alignItems: "center", gap: 12, }; const tileIconWrap: React.CSSProperties = { width: 32, height: 32, borderRadius: 8, background: INK.iconWrapBg, color: INK.ink, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, }; const tileTitle: React.CSSProperties = { fontFamily: INK.fontSerif, fontSize: "1.05rem", fontWeight: 400, color: INK.ink, letterSpacing: "-0.02em", margin: 0, lineHeight: 1.2, }; const tileSubtitle: React.CSSProperties = { fontSize: 12, color: INK.muted, marginTop: 3, fontFamily: INK.fontSans, }; const tileBody: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 14, flex: 1, minHeight: 0, }; const tileMetaRow: React.CSSProperties = { display: "flex", gap: 28, }; const metricLabel: React.CSSProperties = { fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: INK.muted, fontFamily: INK.fontSans, }; const fileList: React.CSSProperties = { listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", border: `1px solid ${INK.border}`, borderRadius: 8, overflow: "hidden", background: "#fdfcfa", }; const fileRow: React.CSSProperties = { display: "flex", alignItems: "center", gap: 10, padding: "8px 12px", borderTop: `1px solid ${INK.border}`, fontSize: 12.5, color: INK.ink, }; const fileIconWrap: React.CSSProperties = { color: INK.stone, display: "flex", alignItems: "center", }; const fileName: React.CSSProperties = { flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontFamily: INK.fontMono, fontSize: 12, }; const fileType: React.CSSProperties = { fontSize: 10, color: INK.stone, fontWeight: 500, textTransform: "uppercase", letterSpacing: "0.08em", flexShrink: 0, fontFamily: INK.fontSans, }; const tileMore: React.CSSProperties = { fontSize: 11.5, color: INK.muted, paddingLeft: 4, fontFamily: INK.fontSans, }; const liveUrlRow: React.CSSProperties = { display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", background: "#fdfcfa", border: `1px solid ${INK.border}`, borderRadius: 8, }; const liveUrlLabel: React.CSSProperties = { fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: INK.muted, flexShrink: 0, fontFamily: INK.fontSans, }; const liveUrlValue: React.CSSProperties = { flex: 1, minWidth: 0, fontSize: 12, color: INK.ink, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontFamily: INK.fontMono, }; const liveUrlOpen: React.CSSProperties = { width: 24, height: 24, borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", color: INK.ink, background: INK.cardBg, border: `1px solid ${INK.border}`, flexShrink: 0, textDecoration: "none", };