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:
39
app/[workspace]/project/[projectId]/(home)/hosting/page.tsx
Normal file
39
app/[workspace]/project/[projectId]/(home)/hosting/page.tsx
Normal 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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
53
app/[workspace]/project/[projectId]/(home)/product/page.tsx
Normal file
53
app/[workspace]/project/[projectId]/(home)/product/page.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { ProjectInfraPanel } from "@/components/project-main/ProjectInfraPanel";
|
||||
|
||||
export default function InfrastructurePage() {
|
||||
return (
|
||||
<ProjectInfraPanel routeBase="infrastructure" navGroupLabel="Infrastructure" />
|
||||
);
|
||||
}
|
||||
281
components/project/gitea-file-tree.tsx
Normal file
281
components/project/gitea-file-tree.tsx
Normal 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,
|
||||
};
|
||||
76
components/project/project-tab-bar.tsx
Normal file
76
components/project/project-tab-bar.tsx
Normal 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',
|
||||
};
|
||||
277
components/project/section-scaffold.tsx
Normal file
277
components/project/section-scaffold.tsx
Normal 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",
|
||||
};
|
||||
Reference in New Issue
Block a user