From 69c3a1258c68633bfea8c48f6955ed63ea6ed1a4 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Tue, 28 Apr 2026 16:37:38 -0700 Subject: [PATCH] feat(project): Product/Infrastructure/Hosting tab shell with live Gitea preview Replaces the old two-tile project landing with a tabbed shell anchored on three sections: Product (codebases), Infrastructure (swappable services), Hosting (runtime + reachability). Bare project URL redirects to /product so the founder always lands on the most actionable surface. Product tab is the only one wired with real data so far: each codebase tile is selectable and renders a lazy-loading Gitea file tree for apps// in the right column. Both columns share height + a heading slot so panels stay visually aligned even when the right side is sparse. Infrastructure and Hosting are stubs ready for Phase 2 wiring (no behavioural change vs today). The old (workspace)/infrastructure route is removed in favour of the new tab; the other 15 sidebar routes are untouched and still reachable for the migration window. Made-with: Cursor --- .../[projectId]/(home)/hosting/page.tsx | 39 + .../(home)/infrastructure/page.tsx | 34 + .../project/[projectId]/(home)/layout.tsx | 198 ++++- .../project/[projectId]/(home)/page.tsx | 702 +----------------- .../[projectId]/(home)/product/page.tsx | 53 ++ .../(workspace)/infrastructure/page.tsx | 7 - components/project/gitea-file-tree.tsx | 281 +++++++ components/project/project-tab-bar.tsx | 76 ++ components/project/section-scaffold.tsx | 277 +++++++ 9 files changed, 958 insertions(+), 709 deletions(-) create mode 100644 app/[workspace]/project/[projectId]/(home)/hosting/page.tsx create mode 100644 app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx create mode 100644 app/[workspace]/project/[projectId]/(home)/product/page.tsx delete mode 100644 app/[workspace]/project/[projectId]/(workspace)/infrastructure/page.tsx create mode 100644 components/project/gitea-file-tree.tsx create mode 100644 components/project/project-tab-bar.tsx create mode 100644 components/project/section-scaffold.tsx diff --git a/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx b/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx new file mode 100644 index 00000000..f3fafdbc --- /dev/null +++ b/app/[workspace]/project/[projectId]/(home)/hosting/page.tsx @@ -0,0 +1,39 @@ +import { SectionScaffold, StatusPanel, EmptyState } from "@/components/project/section-scaffold"; + +export default function HostingTab() { + return ( + + + + + + + + + + + + } + /> + ); +} diff --git a/app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx b/app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx new file mode 100644 index 00000000..3d56675a --- /dev/null +++ b/app/[workspace]/project/[projectId]/(home)/infrastructure/page.tsx @@ -0,0 +1,34 @@ +import { SectionScaffold, StatusPanel, EmptyState } from "@/components/project/section-scaffold"; + +export default function InfrastructureTab() { + return ( + + + + + + + + + } + /> + ); +} diff --git a/app/[workspace]/project/[projectId]/(home)/layout.tsx b/app/[workspace]/project/[projectId]/(home)/layout.tsx index f9a34ab8..5a9b4310 100644 --- a/app/[workspace]/project/[projectId]/(home)/layout.tsx +++ b/app/[workspace]/project/[projectId]/(home)/layout.tsx @@ -1,30 +1,96 @@ -"use client"; - /** - * Project home scaffold. + * Project tab shell (server layout). * - * Mirrors the /[workspace]/projects scaffold: VIBNSidebar on the left, - * cream main area on the right. Used only for the project home page - * (`/{workspace}/project/{id}`) — sub-routes use the (workspace) group - * with the ProjectShell tab nav instead. + * Wraps the three top-level project tabs (Product / Infrastructure / + * Hosting) with: + * - the global VIBNSidebar (so workspace navigation still works) + * - a project header showing name + vision + stage pill + * - a tab bar across the top of the cream main area + * + * Each tab is its own route under this layout — see + * `(home)/product/page.tsx`, etc. The bare project URL + * (`(home)/page.tsx`) redirects to the Product tab. + * + * Boundary rule (see PROJECT_PAGE_ARCHITECTURE.md): + * - Product = custom code/content built FOR this vision + * - Infrastructure = swappable third-party providers + * - Hosting = where it runs + how people reach it */ import { ReactNode } from "react"; -import { useParams } from "next/navigation"; +import Link from "next/link"; import { Toaster } from "sonner"; +import { Settings } from "lucide-react"; import { VIBNSidebar } from "@/components/layout/vibn-sidebar"; import { ProjectAssociationPrompt } from "@/components/project-association-prompt"; +import { ProjectTabBar } from "@/components/project/project-tab-bar"; +import { query } from "@/lib/db-postgres"; -export default function ProjectHomeLayout({ children }: { children: ReactNode }) { - const params = useParams(); - const workspace = params.workspace as string; +interface ProjectMeta { + name: string; + vision?: string; + stage: "discovery" | "architecture" | "building" | "active"; +} + +async function getProjectMeta(projectId: string): Promise { + try { + const rows = await query<{ data: any }>( + `SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`, + [projectId] + ); + if (rows.length > 0) { + const d = rows[0].data ?? {}; + return { + name: d.productName || d.name || "Project", + vision: d.productVision || d.description, + stage: (d.status as ProjectMeta["stage"]) || "discovery", + }; + } + } catch (err) { + console.error("[project-tabs-layout] failed to load project meta:", err); + } + return { name: "Project", stage: "discovery" }; +} + +export default async function ProjectTabsLayout({ + children, + params, +}: { + children: ReactNode; + params: Promise<{ workspace: string; projectId: string }>; +}) { + const { workspace, projectId } = await params; + const project = await getProjectMeta(projectId); return ( <> -
+
-
- {children} +
+
+
+
+
Project
+

{project.name}

+ {project.vision &&

{project.vision}

} +
+
+ + + + +
+
+ + +
+ +
{children}
@@ -32,3 +98,107 @@ export default function ProjectHomeLayout({ children }: { children: ReactNode }) ); } + +function StagePill({ stage }: { stage: string }) { + const map: Record = { + discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a14" }, + 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} + + ); +} + +const INK = { + ink: "#1a1a1a", + mid: "#5f5e5a", + muted: "#a09a90", + border: "#e8e4dc", + pageBg: "#f7f4ee", + cardBg: "#fff", + fontSerif: '"Newsreader", "Lora", Georgia, serif', + fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', +} as const; + +const pageWrap: React.CSSProperties = { + display: "flex", + height: "100vh", + background: INK.pageBg, + overflow: "hidden", +}; + +const mainCol: React.CSSProperties = { + flex: 1, + display: "flex", + flexDirection: "column", + minWidth: 0, + overflow: "hidden", +}; + +const headerWrap: React.CSSProperties = { + flexShrink: 0, + background: INK.pageBg, + padding: "32px 48px 0", + borderBottom: `1px solid ${INK.border}`, + fontFamily: INK.fontSans, +}; + +const eyebrow: React.CSSProperties = { + fontSize: "0.66rem", + fontWeight: 600, + letterSpacing: "0.12em", + textTransform: "uppercase", + color: INK.muted, + marginBottom: 6, +}; + +const projectTitle: React.CSSProperties = { + fontFamily: INK.fontSerif, + fontSize: "1.7rem", + fontWeight: 400, + color: INK.ink, + letterSpacing: "-0.025em", + lineHeight: 1.2, + margin: 0, +}; + +const projectVisionText: React.CSSProperties = { + fontSize: "0.85rem", + color: INK.mid, + marginTop: 8, + maxWidth: 720, + lineHeight: 1.55, +}; + +const settingsBtn: React.CSSProperties = { + width: 32, + height: 32, + borderRadius: 7, + border: `1px solid ${INK.border}`, + background: INK.cardBg, + color: INK.mid, + display: "flex", + alignItems: "center", + justifyContent: "center", + textDecoration: "none", + transition: "color 0.15s, border-color 0.15s", +}; + +const contentWrap: React.CSSProperties = { + flex: 1, + minHeight: 0, + overflow: "auto", + background: INK.pageBg, +}; diff --git a/app/[workspace]/project/[projectId]/(home)/page.tsx b/app/[workspace]/project/[projectId]/(home)/page.tsx index 2ce293ec..0c4df90e 100644 --- a/app/[workspace]/project/[projectId]/(home)/page.tsx +++ b/app/[workspace]/project/[projectId]/(home)/page.tsx @@ -1,696 +1,22 @@ -"use client"; +import { redirect } from "next/navigation"; /** - * Project home page. + * /[workspace]/project/[projectId] * - * 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. + * Bare project URL is a server-side redirect into the default tab + * (Product). The actual landing experience lives under + * `/[workspace]/project/[projectId]/product` with the shared tab + * shell rendered by `(home)/layout.tsx`. * - * Styled to match the production "ink & parchment" design: - * Newsreader serif headings, Outfit sans body, warm beige borders, - * solid black CTAs. No indigo. No gradients. + * Why redirect rather than render: keeping every tab as its own URL + * means refresh / back / share always lands the user on the right + * surface, and Next.js can prefetch each tab independently. */ - -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, +export default async function ProjectIndexPage({ + params, }: { - workspace: string; - projectId: string; - files: FileTreeItem[] | null; - loading: boolean; - giteaRepo?: string; + params: Promise<{ workspace: string; projectId: 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 -
- )} - - )} -
-
- - ); + const { workspace, projectId } = await params; + redirect(`/${workspace}/project/${projectId}/product`); } - -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 ( - -
-
- - - -
-

Infrastructure

-

What's live and how it's running.

-
- -
- -
- {loading ? ( - - ) : !app ? ( - } - title="Nothing is live yet" - subtitle="The AI will deploy your project here once the build is ready." - /> - ) : ( - <> -
- - -
- {app.url ? ( - - ) : ( -
- Status - {statusFriendly(status)} -
- )} - - )} -
-
- - ); -} - -// ────────────────────────────────────────────────────────────────────── -// 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", -}; diff --git a/app/[workspace]/project/[projectId]/(home)/product/page.tsx b/app/[workspace]/project/[projectId]/(home)/product/page.tsx new file mode 100644 index 00000000..39b1adf7 --- /dev/null +++ b/app/[workspace]/project/[projectId]/(home)/product/page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useState } from "react"; +import { useParams } from "next/navigation"; +import { SectionScaffold, StatusPanel } from "@/components/project/section-scaffold"; +import { GiteaFileTree } from "@/components/project/gitea-file-tree"; + +/** + * Product tab. + * + * Each tile is a CODEBASE that lives in this project's monorepo + * (Turborepo `apps/*`). Selecting a tile renders that codebase's + * Gitea file tree in the right column. Phase 2 will discover the + * `apps/*` list from the dev container instead of hard-coding it. + */ + +interface Codebase { + id: string; + label: string; + hint: string; +} + +const CODEBASES: Codebase[] = [ + { id: "web", label: "web", hint: "The main customer-facing app." }, + { id: "marketing", label: "marketing", hint: "Public landing page + marketing site." }, +]; + +export default function ProductTab() { + const params = useParams(); + const projectId = params.projectId as string; + const [selectedId, setSelectedId] = useState(CODEBASES[0]?.id ?? ""); + const selected = CODEBASES.find(c => c.id === selectedId) ?? CODEBASES[0]; + + return ( + ({ + label: cb.label, + hint: cb.hint, + onClick: () => setSelectedId(cb.id), + active: cb.id === selected?.id, + }))} + rightPanel={ + selected ? ( + + + + ) : null + } + /> + ); +} diff --git a/app/[workspace]/project/[projectId]/(workspace)/infrastructure/page.tsx b/app/[workspace]/project/[projectId]/(workspace)/infrastructure/page.tsx deleted file mode 100644 index fffb9977..00000000 --- a/app/[workspace]/project/[projectId]/(workspace)/infrastructure/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { ProjectInfraPanel } from "@/components/project-main/ProjectInfraPanel"; - -export default function InfrastructurePage() { - return ( - - ); -} diff --git a/components/project/gitea-file-tree.tsx b/components/project/gitea-file-tree.tsx new file mode 100644 index 00000000..a4a033f9 --- /dev/null +++ b/components/project/gitea-file-tree.tsx @@ -0,0 +1,281 @@ +"use client"; + +/** + * Lazy-expanding file tree for a Gitea repo path. + * + * Wraps `GET /api/projects/[projectId]/file?path=…` which returns a + * directory listing. Directories expand inline on click and lazy-load + * their children; files render as leaves and (for now) link out to + * Gitea's web UI on click. + */ + +import { useEffect, useState, useCallback } from "react"; +import { ChevronRight, ChevronDown, Folder, FileText, Loader2, AlertCircle } from "lucide-react"; + +interface TreeItem { + name: string; + path: string; + type: "file" | "dir" | "symlink"; + size?: number; +} + +interface ApiOk { + type: "dir"; + items: TreeItem[]; +} + +interface GiteaFileTreeProps { + projectId: string; + /** Repo path to root the tree at, e.g. "apps/web" */ + rootPath: string; +} + +export function GiteaFileTree({ projectId, rootPath }: GiteaFileTreeProps) { + const [rootItems, setRootItems] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); + const [childrenByPath, setChildrenByPath] = useState>({}); + const [loadingPaths, setLoadingPaths] = useState>(new Set()); + + const fetchPath = useCallback( + async (path: string): Promise => { + const res = await fetch( + `/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`, + { credentials: "include" } + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + const data = (await res.json()) as ApiOk; + return data.items ?? []; + }, + [projectId] + ); + + // Load root whenever projectId or rootPath changes + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + setRootItems(null); + setExpanded(new Set()); + setChildrenByPath({}); + + fetchPath(rootPath) + .then(items => { + if (!cancelled) setRootItems(items); + }) + .catch(err => { + if (!cancelled) setError(err.message || "Failed to load"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [projectId, rootPath, fetchPath]); + + const toggleDir = useCallback( + async (path: string) => { + const isOpen = expanded.has(path); + const next = new Set(expanded); + if (isOpen) { + next.delete(path); + setExpanded(next); + return; + } + next.add(path); + setExpanded(next); + if (childrenByPath[path]) return; + + setLoadingPaths(prev => new Set(prev).add(path)); + try { + const items = await fetchPath(path); + setChildrenByPath(prev => ({ ...prev, [path]: items })); + } catch (err) { + console.warn(`[gitea-file-tree] failed to load ${path}:`, err); + } finally { + setLoadingPaths(prev => { + const n = new Set(prev); + n.delete(path); + return n; + }); + } + }, + [expanded, childrenByPath, fetchPath] + ); + + if (loading) { + return ( +
+ + Loading… +
+ ); + } + + if (error) { + const isMissingRepo = /no gitea repo/i.test(error); + return ( +
+ + + {isMissingRepo ? "No Gitea repo connected to this project." : error} + +
+ ); + } + + if (!rootItems || rootItems.length === 0) { + return ( +
+ + {rootPath} is empty (or doesn't exist yet). + +
+ ); + } + + return ( +
+ {rootItems.map(item => ( + + ))} +
+ ); +} + +interface NodeProps { + item: TreeItem; + depth: number; + expanded: Set; + loadingPaths: Set; + childrenByPath: Record; + onToggle: (path: string) => void; +} + +function Node({ item, depth, expanded, loadingPaths, childrenByPath, onToggle }: NodeProps) { + const isDir = item.type === "dir"; + const isOpen = expanded.has(item.path); + const isLoading = loadingPaths.has(item.path); + const children = childrenByPath[item.path]; + + const Icon = isDir + ? isOpen + ? ChevronDown + : ChevronRight + : null; + + const indent = depth * 14; + + return ( + <> +
onToggle(item.path) : undefined} + role={isDir ? "button" : undefined} + aria-expanded={isDir ? isOpen : undefined} + > + + {Icon && } + + {isDir ? ( + + ) : ( + + )} + {item.name} + {isLoading && ( + + )} +
+ {isDir && isOpen && children?.map(child => ( + + ))} + + ); +} + +const INK = { + ink: "#1a1a1a", + mid: "#5f5e5a", + muted: "#a09a90", + stone: "#b5b0a6", + border: "#e8e4dc", +} as const; + +const treeWrap: React.CSSProperties = { + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: "0.78rem", + color: INK.ink, + flex: 1, + minHeight: 0, + overflowY: "auto", + margin: "-4px -8px", +}; + +const rowStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 6, + padding: "3px 8px", + lineHeight: 1.4, + borderRadius: 4, + userSelect: "none", +}; + +const chevronCell: React.CSSProperties = { + width: 12, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, +}; + +const nameStyle: React.CSSProperties = { + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + minWidth: 0, +}; + +const msgWrap: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + padding: "16px 4px", +}; + +const msgText: React.CSSProperties = { + fontSize: "0.82rem", + color: INK.mid, + lineHeight: 1.5, +}; + +const inlineCode: React.CSSProperties = { + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: "0.78rem", + background: "rgba(0,0,0,0.04)", + padding: "1px 6px", + borderRadius: 4, +}; diff --git a/components/project/project-tab-bar.tsx b/components/project/project-tab-bar.tsx new file mode 100644 index 00000000..7ee61722 --- /dev/null +++ b/components/project/project-tab-bar.tsx @@ -0,0 +1,76 @@ +"use client"; + +/** + * Project tab bar — Product · Infrastructure · Hosting. + * + * Lives at the top of the cream main area, right below the project + * header. The active tab is determined by the URL pathname so back / + * forward / refresh always highlight the right one. + */ + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Box, Cloud, Server } from "lucide-react"; + +const TABS = [ + { id: "product", label: "Product", icon: Box, blurb: "Custom code, design, and content built for this vision." }, + { id: "infrastructure", label: "Infrastructure", icon: Server, blurb: "Swappable services this product depends on." }, + { id: "hosting", label: "Hosting", icon: Cloud, blurb: "Where it runs and how people reach it." }, +] as const; + +export function ProjectTabBar({ + workspace, + projectId, +}: { + workspace: string; + projectId: string; +}) { + const pathname = usePathname() ?? ""; + const activeTab = + TABS.find(t => pathname.includes(`/project/${projectId}/${t.id}`))?.id ?? + "product"; + + return ( + + ); +} + +const tabBar: React.CSSProperties = { + display: "flex", + gap: 4, + marginTop: 22, + marginBottom: -1, +}; + +const tabLink: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: 8, + padding: "10px 14px", + fontSize: "0.82rem", + textDecoration: "none", + borderBottom: "2px solid transparent", + transition: "color 0.15s, border-color 0.15s", + fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif', +}; diff --git a/components/project/section-scaffold.tsx b/components/project/section-scaffold.tsx new file mode 100644 index 00000000..24edb9fe --- /dev/null +++ b/components/project/section-scaffold.tsx @@ -0,0 +1,277 @@ +/** + * Shared layout for the Product / Infrastructure / Hosting tabs. + * + * The tab bar in the page header already names the section, so the + * page itself is just two columns: + * - left: a "what lives here" grid of sub-areas + * - right: live status panels (counts, empty states, CTAs) + */ + +import { ReactNode } from "react"; + +export interface SubArea { + label: string; + hint: string; + /** When provided, the tile renders as a button; pair with `active`. */ + onClick?: () => void; + /** Visually mark this tile as the current selection. */ + active?: boolean; +} + +interface SectionScaffoldProps { + subAreas: SubArea[]; + rightPanel: ReactNode; + /** Defaults to "What lives here". Pass e.g. "Codebases" for the Product tab. */ + subAreasHeading?: string; + /** Optional heading above the right panel — keeps both columns + * vertically aligned. If omitted, an invisible spacer is rendered + * with the same height so panels still line up with tiles. */ + rightHeading?: string; +} + +export function SectionScaffold({ + subAreas, + rightPanel, + subAreasHeading = "What lives here", + rightHeading, +}: SectionScaffoldProps) { + return ( +
+
+
+

{subAreasHeading}

+
    + {subAreas.map(area => { + const interactive = typeof area.onClick === "function"; + const style: React.CSSProperties = { + ...subItem, + cursor: interactive ? "pointer" : "default", + borderColor: area.active ? INK.ink : INK.borderSoft, + boxShadow: area.active ? "0 0 0 1px " + INK.ink : "none", + transition: "border-color 0.12s, box-shadow 0.12s, background 0.12s", + background: area.active ? "#fffdf8" : INK.cardBg, + }; + const content = ( + <> + +
    +
    {area.label}
    +
    {area.hint}
    +
    + + ); + return interactive ? ( +
  • + +
  • + ) : ( +
  • + {content} +
  • + ); + })} +
+
+ + +
+
+ ); +} + +export function StatusPanel({ + title, + children, + cta, +}: { + title?: string; + children: ReactNode; + cta?: ReactNode; +}) { + return ( +
+ {(title || cta) && ( +
+ {title && {title}} + {cta} +
+ )} +
+ {children} +
+
+ ); +} + +export function EmptyState({ + message, + hint, +}: { + message: string; + hint?: string; +}) { + return ( +
+
{message}
+ {hint &&
{hint}
} +
+ ); +} + +const INK = { + ink: "#1a1a1a", + mid: "#5f5e5a", + muted: "#a09a90", + stone: "#b5b0a6", + border: "#e8e4dc", + borderSoft: "#efebe1", + cardBg: "#fff", + fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif', +} as const; + +const pageWrap: React.CSSProperties = { + padding: "28px 48px 48px", + fontFamily: INK.fontSans, + color: INK.ink, +}; + +const grid: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "minmax(220px, 280px) minmax(0, 1fr)", + gap: 28, + maxWidth: 1280, + margin: "0 auto", + alignItems: "stretch", +}; + +const leftCol: React.CSSProperties = { + minWidth: 0, + display: "flex", + flexDirection: "column", +}; + +const rightCol: React.CSSProperties = { + minWidth: 0, + display: "flex", + flexDirection: "column", +}; + +const subHeading: React.CSSProperties = { + fontSize: "0.72rem", + fontWeight: 600, + letterSpacing: "0.12em", + textTransform: "uppercase", + color: INK.muted, + margin: "0 0 14px", +}; + +const subList: React.CSSProperties = { + listStyle: "none", + padding: 0, + margin: 0, + display: "flex", + flexDirection: "column", + gap: 8, +}; + +const subItem: React.CSSProperties = { + display: "flex", + gap: 10, + alignItems: "flex-start", + padding: "12px 14px", + background: INK.cardBg, + border: `1px solid ${INK.borderSoft}`, + borderRadius: 8, +}; + +const subItemDot: React.CSSProperties = { + width: 6, + height: 6, + borderRadius: "50%", + background: INK.stone, + marginTop: 7, + flexShrink: 0, +}; + +const subItemLabel: React.CSSProperties = { + fontSize: "0.85rem", + fontWeight: 600, + color: INK.ink, + marginBottom: 2, +}; + +const subItemHint: React.CSSProperties = { + fontSize: "0.75rem", + color: INK.mid, + lineHeight: 1.4, +}; + +const panel: React.CSSProperties = { + background: INK.cardBg, + border: `1px solid ${INK.border}`, + borderRadius: 10, + padding: 18, + marginBottom: 16, + display: "flex", + flexDirection: "column", + flex: 1, + minHeight: 0, +}; + +const panelHeader: React.CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 14, + gap: 12, +}; + +const panelTitle: React.CSSProperties = { + fontSize: "0.78rem", + fontWeight: 600, + letterSpacing: "0.06em", + textTransform: "uppercase", + color: INK.ink, +}; + +const emptyWrap: React.CSSProperties = { + padding: "20px 0 4px", + textAlign: "center", +}; + +const emptyMsg: React.CSSProperties = { + fontSize: "0.85rem", + color: INK.mid, + marginBottom: 4, +}; + +const emptyHint: React.CSSProperties = { + fontSize: "0.74rem", + color: INK.muted, + fontStyle: "italic", +};