feat: VIBN-branded file browser on Build tab + sidebar status dot

- Build page: full file tree (lazy-load dirs) + code preview panel
  with line numbers and token-level syntax colouring (VS Code dark theme)
- New API route /api/projects/[id]/file proxies Gitea contents API
  returning directory listings or decoded file content
- Sidebar Apps section now links to /build instead of raw Gitea URL
- Status indicator replaced with a proper coloured dot (amber/blue/green)
  alongside the status label text

Made-with: Cursor
This commit is contained in:
2026-03-06 13:37:38 -08:00
parent bb021be088
commit e08fcf674b
3 changed files with 486 additions and 428 deletions

View File

@@ -1,475 +1,416 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { useSession } from "next-auth/react";
interface App {
// ── Types ─────────────────────────────────────────────────────────────────────
interface FileItem {
name: string;
type: string;
description: string;
tech: string[];
screens: string[];
path: string;
type: "file" | "dir" | "symlink";
size?: number;
}
interface Package {
interface TreeNode {
name: string;
description: string;
path: string;
type: "file" | "dir";
children?: TreeNode[];
expanded?: boolean;
loaded?: boolean;
}
interface Infra {
name: string;
reason: string;
}
// ── Language detection for syntax colouring hint ─────────────────────────────
interface Integration {
name: string;
required: boolean;
notes: string;
}
interface Architecture {
productName: string;
productType: string;
summary: string;
apps: App[];
packages: Package[];
infrastructure: Infra[];
integrations: Integration[];
designSurfaces: string[];
riskNotes: string[];
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{
fontSize: "0.6rem", fontWeight: 700, color: "#a09a90",
letterSpacing: "0.12em", textTransform: "uppercase",
marginBottom: 10, marginTop: 28,
}}>
{children}
</div>
);
}
function AppCard({ app }: { app: App }) {
const [open, setOpen] = useState(false);
const icons: Record<string, string> = {
web: "🌐", api: "⚡", simulator: "🎮", admin: "🔧",
mobile: "📱", worker: "⚙️", engine: "🎯",
function langFromName(name: string): string {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
json: "json", md: "markdown", mdx: "markdown",
css: "css", scss: "css", html: "html",
py: "python", sh: "shell", yaml: "yaml", yml: "yaml",
toml: "toml", prisma: "prisma", sql: "sql",
env: "dotenv", gitignore: "shell", dockerfile: "dockerfile",
};
const icon = Object.entries(icons).find(([k]) => app.name.toLowerCase().includes(k))?.[1] ?? "📦";
return map[ext] ?? "text";
}
// ── Icons ─────────────────────────────────────────────────────────────────────
function FileIcon({ name, type }: { name: string; type: "file" | "dir" }) {
if (type === "dir") return <span style={{ color: "#d4a04a", fontSize: "0.78rem" }}></span>;
const ext = name.split(".").pop()?.toLowerCase() ?? "";
const color =
ext === "tsx" || ext === "ts" ? "#3178c6"
: ext === "jsx" || ext === "js" ? "#f0db4f"
: ext === "json" ? "#a09a90"
: ext === "css" || ext === "scss" ? "#e879f9"
: ext === "md" || ext === "mdx" ? "#6b6560"
: ext === "prisma" ? "#5a67d8"
: "#b5b0a6";
return <span style={{ color, fontSize: "0.7rem" }}></span>;
}
// ── Simple token-based highlighter ────────────────────────────────────────────
function highlightCode(code: string, lang: string): React.ReactNode[] {
if (lang === "text" || lang === "dotenv" || lang === "dockerfile") {
return code.split("\n").map((line, i) => (
<div key={i}>{line || " "}</div>
));
}
// Split into lines and apply simple token colouring per line
return code.split("\n").map((line, i) => {
const tokens: React.ReactNode[] = [];
let remaining = line;
let ki = 0;
// Comment
const commentPrefixes = ["//", "#", "--"];
for (const p of commentPrefixes) {
if (remaining.trimStart().startsWith(p)) {
tokens.push(<span key={ki++} style={{ color: "#6a9955" }}>{remaining}</span>);
remaining = "";
break;
}
}
if (remaining) {
// Keywords
const kwRe = /\b(import|export|from|const|let|var|function|return|if|else|async|await|type|interface|class|extends|implements|new|default|null|undefined|true|false|void|string|number|boolean|object|Promise|React)\b/g;
const parts = remaining.split(kwRe);
for (let j = 0; j < parts.length; j++) {
const part = parts[j];
if (!part) continue;
if (kwRe.test(part) || /^(import|export|from|const|let|var|function|return|if|else|async|await|type|interface|class|extends|implements|new|default|null|undefined|true|false|void|string|number|boolean|object|Promise|React)$/.test(part)) {
tokens.push(<span key={ki++} style={{ color: "#569cd6" }}>{part}</span>);
} else if (/^(['"`]).*\1$/.test(part.trim())) {
tokens.push(<span key={ki++} style={{ color: "#ce9178" }}>{part}</span>);
} else {
tokens.push(<span key={ki++}>{part}</span>);
}
}
}
return (
<div key={i} style={{ minHeight: "1.4em" }}>
{tokens.length > 0 ? tokens : " "}
</div>
);
});
}
// ── File tree node ────────────────────────────────────────────────────────────
function TreeNodeRow({
node,
depth,
selectedPath,
onSelect,
onToggle,
projectId,
}: {
node: TreeNode;
depth: number;
selectedPath: string | null;
onSelect: (path: string, type: "file" | "dir") => void;
onToggle: (path: string) => void;
projectId: string;
}) {
const isSelected = selectedPath === node.path;
const isDir = node.type === "dir";
return (
<div style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
marginBottom: 8, overflow: "hidden",
}}>
<>
<button
onClick={() => setOpen(o => !o)}
onClick={() => isDir ? onToggle(node.path) : onSelect(node.path, "file")}
style={{
width: "100%", textAlign: "left", background: "none", border: "none",
cursor: "pointer", padding: "14px 18px",
display: "flex", alignItems: "center", gap: 12,
fontFamily: "Outfit, sans-serif",
display: "flex", alignItems: "center",
gap: 6, width: "100%", textAlign: "left",
background: isSelected ? "#f0ece4" : "transparent",
border: "none", cursor: "pointer",
padding: `5px 10px 5px ${14 + depth * 14}px`,
borderRadius: 4,
transition: "background 0.1s",
fontFamily: "IBM Plex Mono, monospace",
fontSize: "0.75rem",
color: isSelected ? "#1a1a1a" : "#4a4640",
}}
onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<span style={{ fontSize: "1.2rem" }}>{icon}</span>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>
apps/{app.name}
</span>
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#6b6560", background: "#f0ece4",
padding: "2px 7px", borderRadius: 4,
}}>
{app.type}
</span>
</div>
<div style={{ fontSize: "0.78rem", color: "#8a8478", marginTop: 2 }}>
{app.description}
</div>
</div>
<span style={{ fontSize: "0.7rem", color: "#c5c0b8" }}>{open ? "▲" : "▼"}</span>
{isDir ? (
<span style={{ fontSize: "0.55rem", color: "#a09a90", transform: node.expanded ? "rotate(90deg)" : "none", display: "inline-block", transition: "transform 0.12s", flexShrink: 0 }}></span>
) : (
<FileIcon name={node.name} type="file" />
)}
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{node.name}
</span>
</button>
{open && (
<div style={{ padding: "0 18px 16px", borderTop: "1px solid #f0ece4" }}>
{app.tech.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Stack</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
{app.tech.map((t, i) => (
<span key={i} style={{
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
color: "#4a4640", background: "#f6f4f0",
border: "1px solid #e8e4dc", padding: "2px 8px", borderRadius: 4,
}}>{t}</span>
))}
</div>
</div>
)}
{app.screens.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Key screens</div>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{app.screens.map((s, i) => (
<div key={i} style={{ fontSize: "0.78rem", color: "#4a4640", display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ color: "#c5c0b8" }}></span> {s}
</div>
))}
</div>
</div>
)}
</div>
{isDir && node.expanded && node.children && (
node.children.map(child => (
<TreeNodeRow
key={child.path}
node={child}
depth={depth + 1}
selectedPath={selectedPath}
onSelect={onSelect}
onToggle={onToggle}
projectId={projectId}
/>
))
)}
</div>
</>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function BuildPage() {
const params = useParams();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const { status: authStatus } = useSession();
const [prd, setPrd] = useState<string | null>(null);
const [architecture, setArchitecture] = useState<Architecture | null>(null);
const [architectureConfirmed, setArchitectureConfirmed] = useState(false);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
// File tree state
const [tree, setTree] = useState<TreeNode[]>([]);
const [treeLoading, setTreeLoading] = useState(true);
const [treeError, setTreeError] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/projects/${projectId}/architecture`)
.then(r => r.json())
.then(d => {
setPrd(d.prd);
setArchitecture(d.architecture ?? null);
setLoading(false);
})
.catch(() => setLoading(false));
// File content state
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string | null>(null);
const [fileLoading, setFileLoading] = useState(false);
const [fileName, setFileName] = useState<string | null>(null);
// Also check confirmed flag
fetch(`/api/projects/${projectId}`)
.then(r => r.json())
.then(d => setArchitectureConfirmed(d.project?.architectureConfirmed === true))
.catch(() => {});
// Fetch a directory listing and return items
const fetchDir = useCallback(async (path: string): Promise<TreeNode[]> => {
const res = await fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Failed to load");
const items: FileItem[] = data.items ?? [];
return items.map(item => ({
name: item.name,
path: item.path,
type: item.type === "dir" ? "dir" : "file",
expanded: false,
loaded: item.type !== "dir",
children: item.type === "dir" ? [] : undefined,
}));
}, [projectId]);
const handleGenerate = async (force = false) => {
setGenerating(true);
setError(null);
// Load root tree on mount
useEffect(() => {
if (authStatus !== "authenticated") return;
setTreeLoading(true);
fetchDir("")
.then(nodes => { setTree(nodes); setTreeLoading(false); })
.catch(e => { setTreeError(e.message); setTreeLoading(false); });
}, [authStatus, fetchDir]);
// Toggle dir expand/collapse
const handleToggle = useCallback(async (path: string) => {
setTree(prev => {
const toggle = (nodes: TreeNode[]): TreeNode[] =>
nodes.map(n => {
if (n.path === path) {
return { ...n, expanded: !n.expanded };
}
if (n.children) return { ...n, children: toggle(n.children) };
return n;
});
return toggle(prev);
});
// Lazy-load children if not yet fetched
const findNode = (nodes: TreeNode[], p: string): TreeNode | null => {
for (const n of nodes) {
if (n.path === p) return n;
if (n.children) { const found = findNode(n.children, p); if (found) return found; }
}
return null;
};
const node = findNode(tree, path);
if (node && !node.loaded) {
try {
const children = await fetchDir(path);
setTree(prev => {
const update = (nodes: TreeNode[]): TreeNode[] =>
nodes.map(n => {
if (n.path === path) return { ...n, children, loaded: true };
if (n.children) return { ...n, children: update(n.children) };
return n;
});
return update(prev);
});
} catch { /* silently fail */ }
}
}, [tree, fetchDir]);
// Select file and load content
const handleSelectFile = useCallback(async (path: string) => {
setSelectedPath(path);
setFileContent(null);
setFileName(path.split("/").pop() ?? null);
setFileLoading(true);
try {
const res = await fetch(`/api/projects/${projectId}/architecture`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ forceRegenerate: force }),
});
const d = await res.json();
if (!res.ok) throw new Error(d.error || "Generation failed");
setArchitecture(d.architecture);
} catch (e) {
setError(e instanceof Error ? e.message : "Something went wrong");
const res = await fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`);
const data = await res.json();
setFileContent(data.content ?? "");
} catch {
setFileContent("// Failed to load file content");
} finally {
setGenerating(false);
setFileLoading(false);
}
};
}, [projectId]);
const handleConfirm = async () => {
setConfirming(true);
try {
await fetch(`/api/projects/${projectId}/architecture`, { method: "PATCH" });
setArchitectureConfirmed(true);
} catch { /* swallow */ } finally {
setConfirming(false);
}
};
const lang = fileName ? langFromName(fileName) : "text";
const lines = (fileContent ?? "").split("\n");
if (loading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
Loading
</div>
);
}
return (
<div style={{ display: "flex", height: "100%", fontFamily: "Outfit, sans-serif", overflow: "hidden" }}>
// No PRD yet
if (!prd) {
return (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
<div style={{ textAlign: "center", maxWidth: 360 }}>
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🔒</div>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
Complete your PRD first
</h3>
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 20 }}>
Finish your discovery conversation with Atlas, then the architect will unlock automatically.
</p>
<Link href={`/${workspace}/project/${projectId}/overview`} style={{
display: "inline-block", padding: "9px 20px", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
textDecoration: "none",
}}>
Continue with Atlas
</Link>
{/* ── File tree panel ── */}
<div style={{
width: 240, flexShrink: 0,
borderRight: "1px solid #e8e4dc",
background: "#faf8f5",
display: "flex", flexDirection: "column",
overflow: "hidden",
}}>
{/* Tree header */}
<div style={{
padding: "12px 14px 10px",
borderBottom: "1px solid #e8e4dc",
fontSize: "0.65rem", fontWeight: 700,
color: "#a09a90", letterSpacing: "0.08em",
textTransform: "uppercase", flexShrink: 0,
}}>
Files
</div>
</div>
);
}
// PRD exists but no architecture yet — prompt to generate
if (!architecture) {
return (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
<div style={{ textAlign: "center", maxWidth: 440 }}>
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🏗</div>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.4rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 10 }}>
Ready to architect {architecture ? (architecture as Architecture).productName : "your product"}
</h3>
<p style={{ fontSize: "0.84rem", color: "#6b6560", lineHeight: 1.65, marginBottom: 28, maxWidth: 380, margin: "0 auto 28px" }}>
The AI will read your PRD and recommend the technical structure apps, services, database, and integrations. You'll review it before anything gets built.
</p>
{error && (
<div style={{ marginBottom: 16, padding: "10px 14px", background: "#fff0f0", border: "1px solid #ffcdd2", borderRadius: 8, fontSize: "0.78rem", color: "#c62828" }}>
{error}
{/* Tree content */}
<div style={{ flex: 1, overflow: "auto", padding: "6px 4px" }}>
{treeLoading && (
<div style={{ padding: "16px 14px", fontSize: "0.75rem", color: "#b5b0a6" }}>
Loading
</div>
)}
<button
onClick={() => handleGenerate(false)}
disabled={generating}
style={{
padding: "11px 28px", borderRadius: 8,
background: generating ? "#8a8478" : "#1a1a1a",
color: "#fff", border: "none", cursor: generating ? "default" : "pointer",
fontSize: "0.84rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
transition: "background 0.15s",
}}
>
{generating ? "Analysing PRD…" : "Generate architecture →"}
</button>
{generating && (
<p style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 12 }}>
This takes about 1530 seconds
</p>
{treeError && (
<div style={{ padding: "16px 14px", fontSize: "0.75rem", color: "#e53e3e" }}>
{treeError === "No Gitea repo connected"
? "No repository connected yet. Create a project to get started."
: treeError}
</div>
)}
{!treeLoading && !treeError && tree.length === 0 && (
<div style={{ padding: "16px 14px", fontSize: "0.75rem", color: "#b5b0a6" }}>
Repository is empty.
</div>
)}
{tree.map(node => (
<TreeNodeRow
key={node.path}
node={node}
depth={0}
selectedPath={selectedPath}
onSelect={handleSelectFile}
onToggle={handleToggle}
projectId={projectId}
/>
))}
</div>
</div>
);
}
// Architecture loaded — show full review UI
return (
<div style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif", maxWidth: 780 }}>
{/* ── Code preview panel ── */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0, background: "#1e1e1e", overflow: "hidden" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 4 }}>
<div>
<h2 style={{ fontFamily: "Newsreader, serif", fontSize: "1.35rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
Architecture
</h2>
<p style={{ fontSize: "0.75rem", color: "#a09a90", marginTop: 4 }}>
{architecture.productType}
</p>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{architectureConfirmed && (
<span style={{
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
color: "#2e7d32", background: "#2e7d3210",
border: "1px solid #a5d6a740", padding: "4px 10px", borderRadius: 5,
}}>
✓ Confirmed
{/* File path breadcrumb */}
<div style={{
padding: "10px 20px",
borderBottom: "1px solid #333",
background: "#252526",
display: "flex", alignItems: "center", gap: 8,
flexShrink: 0,
}}>
{selectedPath ? (
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#a09a90" }}>
{selectedPath.split("/").map((seg, i, arr) => (
<span key={i}>
{i > 0 && <span style={{ color: "#555", margin: "0 4px" }}>/</span>}
<span style={{ color: i === arr.length - 1 ? "#d4d4d4" : "#a09a90" }}>{seg}</span>
</span>
))}
</span>
) : (
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#555" }}>
Select a file to view
</span>
)}
<button
onClick={() => handleGenerate(true)}
disabled={generating}
style={{
padding: "6px 14px", borderRadius: 6,
background: "none", border: "1px solid #e0dcd4",
fontSize: "0.72rem", color: "#8a8478", cursor: "pointer",
fontFamily: "Outfit, sans-serif",
}}
>
{generating ? "Regenerating…" : "Regenerate"}
</button>
{selectedPath && (
<span style={{ marginLeft: "auto", fontFamily: "IBM Plex Mono, monospace", fontSize: "0.65rem", color: "#555", textTransform: "uppercase" }}>
{lang}
</span>
)}
</div>
{/* Code area */}
<div style={{ flex: 1, overflow: "auto", display: "flex" }}>
{!selectedPath && !fileLoading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%", color: "#555", fontSize: "0.82rem", fontFamily: "IBM Plex Mono, monospace" }}>
Select a file from the tree
</div>
)}
{fileLoading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%", color: "#555", fontSize: "0.82rem", fontFamily: "IBM Plex Mono, monospace" }}>
Loading
</div>
)}
{!fileLoading && fileContent !== null && (
<div style={{ display: "flex", width: "100%", overflow: "auto" }}>
{/* Line numbers */}
<div style={{
padding: "16px 0",
background: "#1e1e1e",
borderRight: "1px solid #2d2d2d",
textAlign: "right",
userSelect: "none",
flexShrink: 0,
minWidth: 44,
}}>
{lines.map((_, i) => (
<div key={i} style={{
fontFamily: "IBM Plex Mono, monospace",
fontSize: "0.73rem",
lineHeight: "1.4em",
color: "#555",
padding: "0 12px 0 8px",
}}>
{i + 1}
</div>
))}
</div>
{/* Code content */}
<div style={{
padding: "16px 24px",
fontFamily: "IBM Plex Mono, monospace",
fontSize: "0.73rem",
lineHeight: "1.4em",
color: "#d4d4d4",
flex: 1,
whiteSpace: "pre",
overflow: "auto",
}}>
{highlightCode(fileContent, lang)}
</div>
</div>
)}
</div>
</div>
{/* Summary */}
<div style={{
marginTop: 18, padding: "16px 20px",
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
fontSize: "0.84rem", color: "#2a2824", lineHeight: 1.7,
}}>
{architecture.summary}
</div>
{/* Apps */}
<SectionLabel>Apps — monorepo/apps/</SectionLabel>
{architecture.apps.map((app, i) => <AppCard key={i} app={app} />)}
{/* Packages */}
<SectionLabel>Shared packages — monorepo/packages/</SectionLabel>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{architecture.packages.map((pkg, i) => (
<div key={i} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "12px 16px",
}}>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>
packages/{pkg.name}
</div>
<div style={{ fontSize: "0.76rem", color: "#8a8478", marginTop: 4, lineHeight: 1.5 }}>
{pkg.description}
</div>
</div>
))}
</div>
{/* Infrastructure */}
{architecture.infrastructure.length > 0 && (
<>
<SectionLabel>Infrastructure</SectionLabel>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{architecture.infrastructure.map((infra, i) => (
<div key={i} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 8,
padding: "10px 16px", display: "flex", gap: 12, alignItems: "flex-start",
}}>
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#3d5afe", background: "#3d5afe0d",
border: "1px solid #3d5afe20", padding: "2px 7px", borderRadius: 4,
flexShrink: 0, marginTop: 1,
}}>
{infra.name}
</span>
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>
{infra.reason}
</span>
</div>
))}
</div>
</>
)}
{/* Integrations */}
{architecture.integrations.length > 0 && (
<>
<SectionLabel>External integrations</SectionLabel>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{architecture.integrations.map((intg, i) => (
<div key={i} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 8,
padding: "10px 16px", display: "flex", gap: 12, alignItems: "flex-start",
}}>
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: intg.required ? "#9a7b3a" : "#8a8478",
background: intg.required ? "#d4a04a12" : "#f6f4f0",
border: `1px solid ${intg.required ? "#d4a04a30" : "#e8e4dc"}`,
padding: "2px 7px", borderRadius: 4, flexShrink: 0, marginTop: 1,
}}>
{intg.required ? "required" : "optional"}
</span>
<div>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{intg.name}</div>
<div style={{ fontSize: "0.75rem", color: "#8a8478", marginTop: 2 }}>{intg.notes}</div>
</div>
</div>
))}
</div>
</>
)}
{/* Risk notes */}
{architecture.riskNotes.length > 0 && (
<>
<SectionLabel>Architecture risks</SectionLabel>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{architecture.riskNotes.map((risk, i) => (
<div key={i} style={{
background: "#fff8f0", border: "1px solid #ffe0b2",
borderRadius: 8, padding: "10px 16px",
fontSize: "0.78rem", color: "#6d4c00", lineHeight: 1.55,
display: "flex", gap: 8,
}}>
<span>⚠️</span><span>{risk}</span>
</div>
))}
</div>
</>
)}
{/* Confirm section */}
<div style={{
marginTop: 32, padding: "20px 24px",
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 12,
borderLeft: "3px solid #1a1a1a",
}}>
{architectureConfirmed ? (
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>
✓ Architecture confirmed
</div>
<p style={{ fontSize: "0.78rem", color: "#6b6560", margin: "0 0 14px" }}>
You can still regenerate or adjust the architecture before scaffolding begins. Nothing has been built yet.
</p>
<Link href={`/${workspace}/project/${projectId}/design`} style={{
display: "inline-block", padding: "9px 20px", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
textDecoration: "none",
}}>
Choose your design →
</Link>
</div>
) : (
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>
Does this look right?
</div>
<p style={{ fontSize: "0.78rem", color: "#6b6560", margin: "0 0 16px", lineHeight: 1.6 }}>
Review the structure above. You can regenerate if something's off, or confirm to move to design.
You can always come back and adjust before the build starts nothing gets scaffolded yet.
</p>
<div style={{ display: "flex", gap: 10 }}>
<button
onClick={handleConfirm}
disabled={confirming}
style={{
padding: "9px 22px", borderRadius: 7,
background: confirming ? "#8a8478" : "#1a1a1a",
color: "#fff", border: "none",
fontSize: "0.78rem", fontWeight: 600,
fontFamily: "Outfit, sans-serif", cursor: confirming ? "default" : "pointer",
}}
>
{confirming ? "Confirming…" : "Confirm architecture →"}
</button>
<button
onClick={() => handleGenerate(true)}
disabled={generating}
style={{
padding: "9px 18px", borderRadius: 7,
background: "none", border: "1px solid #e0dcd4",
fontSize: "0.78rem", color: "#8a8478",
fontFamily: "Outfit, sans-serif", cursor: "pointer",
}}
>
Regenerate
</button>
</div>
</div>
)}
</div>
<div style={{ height: 40 }} />
</div>
);
}

View File

@@ -0,0 +1,108 @@
/**
* GET /api/projects/[projectId]/file?path=apps/admin
*
* Returns directory listing or file content from the project's Gitea repo.
* Response for directory: { type: "dir", items: [{ name, path, type }] }
* Response for file: { type: "file", content: string, encoding: "utf8" | "base64" }
*/
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? '';
async function giteaGet(path: string) {
const res = await fetch(`${GITEA_API_URL}/api/v1${path}`, {
headers: { Authorization: `token ${GITEA_API_TOKEN}` },
next: { revalidate: 10 },
});
if (!res.ok) throw new Error(`Gitea ${res.status}: ${path}`);
return res.json();
}
const BINARY_EXTENSIONS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico',
'woff', 'woff2', 'ttf', 'eot',
'zip', 'tar', 'gz', 'pdf',
]);
function isBinary(name: string): boolean {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
return BINARY_EXTENSIONS.has(ext);
}
export async function GET(
req: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const filePath = searchParams.get('path') ?? '';
// Verify ownership + get giteaRepo
const rows = await query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
[projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const giteaRepo = rows[0].data?.giteaRepo as string | undefined;
if (!giteaRepo) {
return NextResponse.json({ error: 'No Gitea repo connected' }, { status: 404 });
}
const encodedPath = filePath ? encodeURIComponent(filePath).replace(/%2F/g, '/') : '';
const apiPath = `/repos/${giteaRepo}/contents/${encodedPath}`;
const data = await giteaGet(apiPath);
// Directory listing
if (Array.isArray(data)) {
const items = data
.map((item: { name: string; path: string; type: string; size?: number }) => ({
name: item.name,
path: item.path,
type: item.type, // "file" | "dir" | "symlink"
size: item.size,
}))
.sort((a, b) => {
// Dirs first
if (a.type === 'dir' && b.type !== 'dir') return -1;
if (a.type !== 'dir' && b.type === 'dir') return 1;
return a.name.localeCompare(b.name);
});
return NextResponse.json({ type: 'dir', items });
}
// Single file
const item = data as { name: string; content?: string; encoding?: string; size?: number };
if (isBinary(item.name)) {
return NextResponse.json({ type: 'file', content: '(binary file)', encoding: 'utf8' });
}
// Gitea returns base64-encoded content
const raw = item.content ?? '';
let content: string;
try {
content = Buffer.from(raw.replace(/\n/g, ''), 'base64').toString('utf8');
} catch {
content = raw;
}
return NextResponse.json({ type: 'file', content, encoding: 'utf8', name: item.name });
} catch (err) {
console.error('[file API]', err);
return NextResponse.json({ error: 'Failed to fetch file' }, { status: 500 });
}
}