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/<codebase>/ 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
This commit is contained in:
2026-04-28 16:37:38 -07:00
parent 305516c7e4
commit 69c3a1258c
9 changed files with 958 additions and 709 deletions

View File

@@ -0,0 +1,39 @@
import { SectionScaffold, StatusPanel, EmptyState } from "@/components/project/section-scaffold";
export default function HostingTab() {
return (
<SectionScaffold
subAreas={[
{ label: "Production", hint: "The Coolify app serving real users." },
{ label: "Preview URLs", hint: "Per-port previews from your dev container." },
{ label: "Domains & DNS", hint: "Custom domains, SSL, cert renewal." },
{ label: "CDN & cache", hint: "Edge caching, asset delivery." },
{ label: "Observability", hint: "Uptime, logs, metrics, alerts." },
{ label: "Backups", hint: "Database snapshots + restore points." },
{ label: "Cost", hint: "Monthly $$ across all hosting." },
]}
rightPanel={
<>
<StatusPanel title="Production">
<EmptyState
message="No production app linked yet"
hint="Phase 2 will list Coolify apps wired to this project repo."
/>
</StatusPanel>
<StatusPanel title="Preview URLs">
<EmptyState
message="No active dev servers"
hint="Start a dev server from chat to get a *.preview.vibnai.com URL here."
/>
</StatusPanel>
<StatusPanel title="Domains">
<EmptyState
message="No custom domains"
hint="Phase 2 will let you attach + verify a domain in one place."
/>
</StatusPanel>
</>
}
/>
);
}

View File

@@ -0,0 +1,34 @@
import { SectionScaffold, StatusPanel, EmptyState } from "@/components/project/section-scaffold";
export default function InfrastructureTab() {
return (
<SectionScaffold
subAreas={[
{ label: "Database", hint: "Postgres, Redis, vector DBs." },
{ label: "Auth", hint: "Identity, sessions, SSO providers." },
{ label: "File storage", hint: "Uploads, attachments, CDNs." },
{ label: "Email & SMS", hint: "Transactional + outbound messaging." },
{ label: "Payments", hint: "Stripe, billing, subscriptions." },
{ label: "Analytics", hint: "Product analytics + event pipelines." },
{ label: "LLM providers", hint: "Model APIs, embeddings, search." },
{ label: "Secrets", hint: "API keys + environment variables." },
]}
rightPanel={
<>
<StatusPanel title="Connected providers">
<EmptyState
message="No providers connected yet"
hint="Phase 2 will inventory Coolify databases and any third-party services tied to this project."
/>
</StatusPanel>
<StatusPanel title="Secrets vault">
<EmptyState
message="Not wired yet"
hint="Phase 2 will surface env vars + API keys with a single source of truth."
/>
</StatusPanel>
</>
}
/>
);
}

View File

@@ -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<ProjectMeta> {
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 (
<>
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
<div style={pageWrap}>
<VIBNSidebar workspace={workspace} />
<main style={{ flex: 1, overflow: "auto" }}>
{children}
<main style={mainCol}>
<header style={headerWrap}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 24 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={eyebrow}>Project</div>
<h1 style={projectTitle}>{project.name}</h1>
{project.vision && <p style={projectVisionText}>{project.vision}</p>}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
<StagePill stage={project.stage} />
<Link
href={`/${workspace}/project/${projectId}/settings`}
style={settingsBtn}
aria-label="Project settings"
title="Project settings"
>
<Settings size={15} />
</Link>
</div>
</div>
<ProjectTabBar workspace={workspace} projectId={projectId} />
</header>
<div style={contentWrap}>{children}</div>
</main>
</div>
<ProjectAssociationPrompt workspace={workspace} />
@@ -32,3 +98,107 @@ export default function ProjectHomeLayout({ children }: { children: ReactNode })
</>
);
}
function StagePill({ stage }: { stage: string }) {
const map: Record<string, { label: string; color: string; bg: string }> = {
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 (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "4px 10px", borderRadius: 4,
fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.02em",
color: s.color, background: s.bg,
whiteSpace: "nowrap",
}}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: s.color }} />
{s.label}
</span>
);
}
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,
};

View File

@@ -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<ProjectSummary | null>(null);
const [projectLoading, setProjectLoading] = useState(true);
const [files, setFiles] = useState<FileTreeItem[] | null>(null);
const [filesLoading, setFilesLoading] = useState(true);
const [apps, setApps] = useState<PreviewApp[]>([]);
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 (
<div style={pageWrap}>
<div style={centeredFiller}>
<Loader2 className="animate-spin" size={22} style={{ color: INK.stone }} />
</div>
</div>
);
}
if (!project) {
return (
<div style={pageWrap}>
<div style={{ ...centeredFiller, color: INK.muted, fontSize: 14, fontFamily: INK.fontSans }}>
Project not found.
</div>
</div>
);
}
return (
<div style={pageWrap}>
<div style={pageInner}>
{/* ── Hero ─────────────────────────────────────────────── */}
<header style={heroStyle}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={eyebrow}>Project</div>
<h1 style={heroTitle}>{projectName}</h1>
{projectDesc && <p style={heroDesc}>{projectDesc}</p>}
</div>
<StagePill stage={stage} />
</header>
{/* ── Continue setup link (quiet, only when in discovery) ── */}
{interviewIncomplete && (
<Link
href={`/${workspace}/project/${projectId}/overview`}
style={continueRow}
>
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
<span style={continueDot} />
<div style={{ minWidth: 0 }}>
<div style={continueTitle}>Continue setup</div>
<div style={continueSub}>
Pick up the AI interview where you left off.
</div>
</div>
</div>
<ArrowRight size={16} style={{ color: INK.ink, flexShrink: 0 }} />
</Link>
)}
{/* ── Two big tiles ────────────────────────────────────── */}
<div style={tileGrid}>
<CodeTile
workspace={workspace}
projectId={projectId}
files={files}
loading={filesLoading}
giteaRepo={project.giteaRepo}
/>
<InfraTile
workspace={workspace}
projectId={projectId}
app={liveApp}
loading={appsLoading}
/>
</div>
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────────────
// 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 (
<Link href={`/${workspace}/project/${projectId}/code`} style={tileLink}>
<article
style={tileCard}
onMouseEnter={hoverEnter}
onMouseLeave={hoverLeave}
>
<header style={tileHeader}>
<span style={tileIconWrap}>
<Code2 size={16} />
</span>
<div style={{ flex: 1 }}>
<h2 style={tileTitle}>Code</h2>
<p style={tileSubtitle}>What the AI is building, file by file.</p>
</div>
<ArrowRight size={14} style={{ color: INK.muted }} />
</header>
<div style={tileBody}>
{loading ? (
<TileLoader label="Reading repository…" />
) : items.length === 0 ? (
<TileEmpty
icon={<Folder size={18} />}
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."
}
/>
) : (
<>
<div style={tileMetaRow}>
<Metric label="Folders" value={dirCount} />
<Metric label="Files" value={fileCount} />
</div>
<ul style={fileList}>
{previewItems.map(item => (
<li key={item.path} style={fileRow}>
<span style={fileIconWrap}>
{item.type === "dir" ? (
<Folder size={13} />
) : (
<FileText size={13} />
)}
</span>
<span style={fileName}>{item.name}</span>
<span style={fileType}>
{item.type === "dir" ? "folder" : ext(item.name)}
</span>
</li>
))}
</ul>
{items.length > previewItems.length && (
<div style={tileMore}>
+{items.length - previewItems.length} more
</div>
)}
</>
)}
</div>
</article>
</Link>
);
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 (
<Link href={`/${workspace}/project/${projectId}/infrastructure`} style={tileLink}>
<article
style={tileCard}
onMouseEnter={hoverEnter}
onMouseLeave={hoverLeave}
>
<header style={tileHeader}>
<span style={tileIconWrap}>
<Rocket size={16} />
</span>
<div style={{ flex: 1 }}>
<h2 style={tileTitle}>Infrastructure</h2>
<p style={tileSubtitle}>What's live and how it's running.</p>
</div>
<ArrowRight size={14} style={{ color: INK.muted }} />
</header>
<div style={tileBody}>
{loading ? (
<TileLoader label="Checking deployment…" />
) : !app ? (
<TileEmpty
icon={<Rocket size={18} />}
title="Nothing is live yet"
subtitle="The AI will deploy your project here once the build is ready."
/>
) : (
<>
<div style={tileMetaRow}>
<StatusBlock
color={isLive ? "#2e7d32" : isBuilding ? "#3d5afe" : "#9a7b3a"}
label={isLive ? "Live" : isBuilding ? "Building" : statusFriendly(status)}
/>
<Metric label="App" value={app.name} />
</div>
{app.url ? (
<div style={liveUrlRow}>
<span style={liveUrlLabel}>Live URL</span>
<span style={liveUrlValue}>{shortUrl(app.url)}</span>
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
style={liveUrlOpen}
aria-label="Open live site"
>
<ExternalLink size={12} />
</a>
</div>
) : (
<div style={liveUrlRow}>
<span style={liveUrlLabel}>Status</span>
<span style={liveUrlValue}>{statusFriendly(status)}</span>
</div>
)}
</>
)}
</div>
</article>
</Link>
);
}
// ──────────────────────────────────────────────────────────────────────
// Small bits
// ──────────────────────────────────────────────────────────────────────
function StagePill({ stage }: { stage: string }) {
const map: Record<string, { label: string; color: string; bg: string }> = {
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 (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "3px 9px", borderRadius: 4,
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
color: s.color, background: s.bg, fontFamily: INK.fontSans,
whiteSpace: "nowrap", flexShrink: 0,
}}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: s.color }} />
{s.label}
</span>
);
}
function StatusBlock({ color, label }: { color: string; label: string }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={metricLabel}>Status</span>
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
fontSize: 13, color: INK.ink, fontFamily: INK.fontSans, fontWeight: 500,
}}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color }} />
{label}
</span>
</div>
);
}
function Metric({ label, value }: { label: string; value: string | number }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
<span style={metricLabel}>{label}</span>
<span style={{
fontSize: 13, color: INK.ink, fontFamily: INK.fontSans, fontWeight: 500,
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}>
{value}
</span>
</div>
);
}
function TileLoader({ label }: { label: string }) {
return (
<div style={{
display: "flex", alignItems: "center", justifyContent: "center",
gap: 8, padding: "32px 0", color: INK.muted, fontSize: 13,
fontFamily: INK.fontSans,
}}>
<Loader2 className="animate-spin" size={14} /> {label}
</div>
);
}
function TileEmpty({
icon,
title,
subtitle,
}: {
icon: React.ReactNode;
title: string;
subtitle: string;
}) {
return (
<div style={{
padding: "28px 8px",
textAlign: "center",
display: "flex", flexDirection: "column", alignItems: "center", gap: 10,
fontFamily: INK.fontSans,
}}>
<span style={{ ...tileIconWrap, width: 38, height: 38 }}>{icon}</span>
<div style={{ fontSize: 13.5, fontWeight: 600, color: INK.ink }}>{title}</div>
<div style={{ fontSize: 12.5, color: INK.muted, lineHeight: 1.55, maxWidth: 280 }}>
{subtitle}
</div>
</div>
);
}
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<HTMLElement>) {
const el = e.currentTarget;
el.style.borderColor = INK.borderHover;
el.style.boxShadow = INK.shadowHover;
}
function hoverLeave(e: React.MouseEvent<HTMLElement>) {
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",
};

View File

@@ -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<string>(CODEBASES[0]?.id ?? "");
const selected = CODEBASES.find(c => c.id === selectedId) ?? CODEBASES[0];
return (
<SectionScaffold
subAreasHeading="Codebases"
rightHeading={selected ? `Preview · ${selected.label}` : "Preview"}
subAreas={CODEBASES.map(cb => ({
label: cb.label,
hint: cb.hint,
onClick: () => setSelectedId(cb.id),
active: cb.id === selected?.id,
}))}
rightPanel={
selected ? (
<StatusPanel>
<GiteaFileTree projectId={projectId} rootPath={`apps/${selected.id}`} />
</StatusPanel>
) : null
}
/>
);
}

View File

@@ -1,7 +0,0 @@
import { ProjectInfraPanel } from "@/components/project-main/ProjectInfraPanel";
export default function InfrastructurePage() {
return (
<ProjectInfraPanel routeBase="infrastructure" navGroupLabel="Infrastructure" />
);
}

View File

@@ -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<TreeItem[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [childrenByPath, setChildrenByPath] = useState<Record<string, TreeItem[]>>({});
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
const fetchPath = useCallback(
async (path: string): Promise<TreeItem[]> => {
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 (
<div style={msgWrap}>
<Loader2 size={14} className="animate-spin" style={{ color: INK.muted }} />
<span style={msgText}>Loading</span>
</div>
);
}
if (error) {
const isMissingRepo = /no gitea repo/i.test(error);
return (
<div style={msgWrap}>
<AlertCircle size={14} style={{ color: INK.muted }} />
<span style={msgText}>
{isMissingRepo ? "No Gitea repo connected to this project." : error}
</span>
</div>
);
}
if (!rootItems || rootItems.length === 0) {
return (
<div style={msgWrap}>
<span style={msgText}>
<code style={inlineCode}>{rootPath}</code> is empty (or doesn't exist yet).
</span>
</div>
);
}
return (
<div style={treeWrap}>
{rootItems.map(item => (
<Node
key={item.path}
item={item}
depth={0}
expanded={expanded}
loadingPaths={loadingPaths}
childrenByPath={childrenByPath}
onToggle={toggleDir}
/>
))}
</div>
);
}
interface NodeProps {
item: TreeItem;
depth: number;
expanded: Set<string>;
loadingPaths: Set<string>;
childrenByPath: Record<string, TreeItem[]>;
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 (
<>
<div
style={{ ...rowStyle, paddingLeft: 6 + indent, cursor: isDir ? "pointer" : "default" }}
onClick={isDir ? () => onToggle(item.path) : undefined}
role={isDir ? "button" : undefined}
aria-expanded={isDir ? isOpen : undefined}
>
<span style={chevronCell}>
{Icon && <Icon size={12} style={{ color: INK.mid }} />}
</span>
{isDir ? (
<Folder size={13} style={{ color: INK.stone, flexShrink: 0 }} />
) : (
<FileText size={13} style={{ color: INK.muted, flexShrink: 0 }} />
)}
<span style={nameStyle}>{item.name}</span>
{isLoading && (
<Loader2 size={11} className="animate-spin" style={{ color: INK.muted, marginLeft: "auto" }} />
)}
</div>
{isDir && isOpen && children?.map(child => (
<Node
key={child.path}
item={child}
depth={depth + 1}
expanded={expanded}
loadingPaths={loadingPaths}
childrenByPath={childrenByPath}
onToggle={onToggle}
/>
))}
</>
);
}
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,
};

View File

@@ -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 (
<nav style={tabBar} aria-label="Project sections">
{TABS.map(tab => {
const isActive = tab.id === activeTab;
const Icon = tab.icon;
return (
<Link
key={tab.id}
href={`/${workspace}/project/${projectId}/${tab.id}`}
style={{
...tabLink,
color: isActive ? "#1a1a1a" : "#5f5e5a",
borderBottomColor: isActive ? "#1a1a1a" : "transparent",
fontWeight: isActive ? 600 : 500,
}}
title={tab.blurb}
>
<Icon size={14} style={{ opacity: isActive ? 1 : 0.7 }} />
{tab.label}
</Link>
);
})}
</nav>
);
}
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',
};

View File

@@ -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 (
<div style={pageWrap}>
<div style={grid}>
<section style={leftCol}>
<h3 style={subHeading}>{subAreasHeading}</h3>
<ul style={subList}>
{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 = (
<>
<span
style={{
...subItemDot,
background: area.active ? INK.ink : INK.stone,
}}
/>
<div style={{ minWidth: 0 }}>
<div style={subItemLabel}>{area.label}</div>
<div style={subItemHint}>{area.hint}</div>
</div>
</>
);
return interactive ? (
<li key={area.label} style={{ listStyle: "none" }}>
<button
type="button"
onClick={area.onClick}
style={{
...style,
width: "100%",
textAlign: "left",
font: "inherit",
color: "inherit",
}}
aria-pressed={area.active}
>
{content}
</button>
</li>
) : (
<li key={area.label} style={style}>
{content}
</li>
);
})}
</ul>
</section>
<aside style={rightCol}>
<h3 style={{ ...subHeading, visibility: rightHeading ? "visible" : "hidden" }}>
{rightHeading ?? "\u00A0"}
</h3>
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
{rightPanel}
</div>
</aside>
</div>
</div>
);
}
export function StatusPanel({
title,
children,
cta,
}: {
title?: string;
children: ReactNode;
cta?: ReactNode;
}) {
return (
<div style={panel}>
{(title || cta) && (
<div style={panelHeader}>
{title && <span style={panelTitle}>{title}</span>}
{cta}
</div>
)}
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
{children}
</div>
</div>
);
}
export function EmptyState({
message,
hint,
}: {
message: string;
hint?: string;
}) {
return (
<div style={emptyWrap}>
<div style={emptyMsg}>{message}</div>
{hint && <div style={emptyHint}>{hint}</div>}
</div>
);
}
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",
};