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"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import Link from "next/link"; import { useSession } from "next-auth/react";
interface App { // ── Types ─────────────────────────────────────────────────────────────────────
interface FileItem {
name: string; name: string;
type: string; path: string;
description: string; type: "file" | "dir" | "symlink";
tech: string[]; size?: number;
screens: string[];
} }
interface Package { interface TreeNode {
name: string; name: string;
description: string; path: string;
type: "file" | "dir";
children?: TreeNode[];
expanded?: boolean;
loaded?: boolean;
} }
interface Infra { // ── Language detection for syntax colouring hint ─────────────────────────────
name: string;
reason: string;
}
interface Integration { function langFromName(name: string): string {
name: string; const ext = name.split(".").pop()?.toLowerCase() ?? "";
required: boolean; const map: Record<string, string> = {
notes: string; ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
} json: "json", md: "markdown", mdx: "markdown",
css: "css", scss: "css", html: "html",
interface Architecture { py: "python", sh: "shell", yaml: "yaml", yml: "yaml",
productName: string; toml: "toml", prisma: "prisma", sql: "sql",
productType: string; env: "dotenv", gitignore: "shell", dockerfile: "dockerfile",
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: "🎯",
}; };
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 ( return (
<div style={{ <div key={i} style={{ minHeight: "1.4em" }}>
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10, {tokens.length > 0 ? tokens : " "}
marginBottom: 8, overflow: "hidden",
}}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: "100%", textAlign: "left", background: "none", border: "none",
cursor: "pointer", padding: "14px 18px",
display: "flex", alignItems: "center", gap: 12,
fontFamily: "Outfit, sans-serif",
}}
>
<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>
</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>
)}
</div> </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 (
<>
<button
onClick={() => isDir ? onToggle(node.path) : onSelect(node.path, "file")}
style={{
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"; }}
>
{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>
{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}
/>
))
)}
</>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function BuildPage() { export default function BuildPage() {
const params = useParams(); const params = useParams();
const projectId = params.projectId as string; const projectId = params.projectId as string;
const workspace = params.workspace as string; const { status: authStatus } = useSession();
const [prd, setPrd] = useState<string | null>(null); // File tree state
const [architecture, setArchitecture] = useState<Architecture | null>(null); const [tree, setTree] = useState<TreeNode[]>([]);
const [architectureConfirmed, setArchitectureConfirmed] = useState(false); const [treeLoading, setTreeLoading] = useState(true);
const [loading, setLoading] = useState(true); const [treeError, setTreeError] = useState<string | null>(null);
const [generating, setGenerating] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { // File content state
fetch(`/api/projects/${projectId}/architecture`) const [selectedPath, setSelectedPath] = useState<string | null>(null);
.then(r => r.json()) const [fileContent, setFileContent] = useState<string | null>(null);
.then(d => { const [fileLoading, setFileLoading] = useState(false);
setPrd(d.prd); const [fileName, setFileName] = useState<string | null>(null);
setArchitecture(d.architecture ?? null);
setLoading(false);
})
.catch(() => setLoading(false));
// Also check confirmed flag // Fetch a directory listing and return items
fetch(`/api/projects/${projectId}`) const fetchDir = useCallback(async (path: string): Promise<TreeNode[]> => {
.then(r => r.json()) const res = await fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`);
.then(d => setArchitectureConfirmed(d.project?.architectureConfirmed === true)) const data = await res.json();
.catch(() => {}); 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]); }, [projectId]);
const handleGenerate = async (force = false) => { // Load root tree on mount
setGenerating(true); useEffect(() => {
setError(null); if (authStatus !== "authenticated") return;
try { setTreeLoading(true);
const res = await fetch(`/api/projects/${projectId}/architecture`, { fetchDir("")
method: "POST", .then(nodes => { setTree(nodes); setTreeLoading(false); })
headers: { "Content-Type": "application/json" }, .catch(e => { setTreeError(e.message); setTreeLoading(false); });
body: JSON.stringify({ forceRegenerate: force }), }, [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;
}); });
const d = await res.json(); return toggle(prev);
if (!res.ok) throw new Error(d.error || "Generation failed"); });
setArchitecture(d.architecture);
} catch (e) { // Lazy-load children if not yet fetched
setError(e instanceof Error ? e.message : "Something went wrong"); const findNode = (nodes: TreeNode[], p: string): TreeNode | null => {
} finally { for (const n of nodes) {
setGenerating(false); if (n.path === p) return n;
if (n.children) { const found = findNode(n.children, p); if (found) return found; }
} }
return null;
}; };
const handleConfirm = async () => { const node = findNode(tree, path);
setConfirming(true); if (node && !node.loaded) {
try { try {
await fetch(`/api/projects/${projectId}/architecture`, { method: "PATCH" }); const children = await fetchDir(path);
setArchitectureConfirmed(true); setTree(prev => {
} catch { /* swallow */ } finally { const update = (nodes: TreeNode[]): TreeNode[] =>
setConfirming(false); 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}/file?path=${encodeURIComponent(path)}`);
const data = await res.json();
setFileContent(data.content ?? "");
} catch {
setFileContent("// Failed to load file content");
} finally {
setFileLoading(false);
}
}, [projectId]);
const lang = fileName ? langFromName(fileName) : "text";
const lines = (fileContent ?? "").split("\n");
if (loading) {
return ( return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}> <div style={{ display: "flex", height: "100%", fontFamily: "Outfit, sans-serif", overflow: "hidden" }}>
{/* ── 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>
{/* Tree content */}
<div style={{ flex: 1, overflow: "auto", padding: "6px 4px" }}>
{treeLoading && (
<div style={{ padding: "16px 14px", fontSize: "0.75rem", color: "#b5b0a6" }}>
Loading Loading
</div> </div>
); )}
} {treeError && (
<div style={{ padding: "16px 14px", fontSize: "0.75rem", color: "#e53e3e" }}>
// No PRD yet {treeError === "No Gitea repo connected"
if (!prd) { ? "No repository connected yet. Create a project to get started."
return ( : treeError}
<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>
</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}
</div> </div>
)} )}
<button {!treeLoading && !treeError && tree.length === 0 && (
onClick={() => handleGenerate(false)} <div style={{ padding: "16px 14px", fontSize: "0.75rem", color: "#b5b0a6" }}>
disabled={generating} Repository is empty.
style={{ </div>
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>
)} )}
</div> {tree.map(node => (
</div> <TreeNodeRow
); key={node.path}
} node={node}
depth={0}
// Architecture loaded — show full review UI selectedPath={selectedPath}
return ( onSelect={handleSelectFile}
<div style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif", maxWidth: 780 }}> onToggle={handleToggle}
projectId={projectId}
{/* 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
</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>
</div> </div>
</div> </div>
{/* Summary */} {/* ── Code preview panel ── */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0, background: "#1e1e1e", overflow: "hidden" }}>
{/* File path breadcrumb */}
<div style={{ <div style={{
marginTop: 18, padding: "16px 20px", padding: "10px 20px",
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10, borderBottom: "1px solid #333",
fontSize: "0.84rem", color: "#2a2824", lineHeight: 1.7, background: "#252526",
display: "flex", alignItems: "center", gap: 8,
flexShrink: 0,
}}> }}>
{architecture.summary} {selectedPath ? (
</div> <span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#a09a90" }}>
{selectedPath.split("/").map((seg, i, arr) => (
{/* Apps */} <span key={i}>
<SectionLabel>Apps — monorepo/apps/</SectionLabel> {i > 0 && <span style={{ color: "#555", margin: "0 4px" }}>/</span>}
{architecture.apps.map((app, i) => <AppCard key={i} app={app} />)} <span style={{ color: i === arr.length - 1 ? "#d4d4d4" : "#a09a90" }}>{seg}</span>
{/* 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>
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}> ))}
{infra.reason}
</span> </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> <span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#555" }}>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}> Select a file to view
Does this look right? </span>
)}
{selectedPath && (
<span style={{ marginLeft: "auto", fontFamily: "IBM Plex Mono, monospace", fontSize: "0.65rem", color: "#555", textTransform: "uppercase" }}>
{lang}
</span>
)}
</div> </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. {/* Code area */}
You can always come back and adjust before the build starts nothing gets scaffolded yet. <div style={{ flex: 1, overflow: "auto", display: "flex" }}>
</p> {!selectedPath && !fileLoading && (
<div style={{ display: "flex", gap: 10 }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%", color: "#555", fontSize: "0.82rem", fontFamily: "IBM Plex Mono, monospace" }}>
<button Select a file from the tree
onClick={handleConfirm} </div>
disabled={confirming} )}
style={{ {fileLoading && (
padding: "9px 22px", borderRadius: 7, <div style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%", color: "#555", fontSize: "0.82rem", fontFamily: "IBM Plex Mono, monospace" }}>
background: confirming ? "#8a8478" : "#1a1a1a", Loading
color: "#fff", border: "none", </div>
fontSize: "0.78rem", fontWeight: 600, )}
fontFamily: "Outfit, sans-serif", cursor: confirming ? "default" : "pointer", {!fileLoading && fileContent !== null && (
}} <div style={{ display: "flex", width: "100%", overflow: "auto" }}>
> {/* Line numbers */}
{confirming ? "Confirming…" : "Confirm architecture →"} <div style={{
</button> padding: "16px 0",
<button background: "#1e1e1e",
onClick={() => handleGenerate(true)} borderRight: "1px solid #2d2d2d",
disabled={generating} textAlign: "right",
style={{ userSelect: "none",
padding: "9px 18px", borderRadius: 7, flexShrink: 0,
background: "none", border: "1px solid #e0dcd4", minWidth: 44,
fontSize: "0.78rem", color: "#8a8478", }}>
fontFamily: "Outfit, sans-serif", cursor: "pointer", {lines.map((_, i) => (
}} <div key={i} style={{
> fontFamily: "IBM Plex Mono, monospace",
Regenerate fontSize: "0.73rem",
</button> 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>
)} )}
</div> </div>
</div>
<div style={{ height: 40 }} />
</div> </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 });
}
}

View File

@@ -295,10 +295,19 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
<div style={{ fontSize: "0.82rem", fontWeight: 700, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <div style={{ fontSize: "0.82rem", fontWeight: 700, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{project.productName || project.name || "Project"} {project.productName || project.name || "Project"}
</div> </div>
<div style={{ fontSize: "0.68rem", color: "#a09a90", marginTop: 1 }}> <div style={{ display: "flex", alignItems: "center", gap: 5, marginTop: 3 }}>
{project.status === "live" ? "● Live" <span style={{
: project.status === "building" ? "● Building" width: 6, height: 6, borderRadius: "50%", flexShrink: 0,
: "● Defining"} background: project.status === "live" ? "#2e7d32"
: project.status === "building" ? "#3d5afe"
: "#d4a04a",
display: "inline-block",
}} />
<span style={{ fontSize: "0.68rem", color: "#8a8478" }}>
{project.status === "live" ? "Live"
: project.status === "building" ? "Building"
: "Defining"}
</span>
</div> </div>
</div> </div>
)} )}
@@ -311,12 +320,12 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
key={app.name} key={app.name}
icon="▢" icon="▢"
label={app.name} label={app.name}
href={project.giteaRepoUrl ? `${project.giteaRepoUrl}/src/branch/main/${app.path}` : undefined} href={`${base}/build`}
collapsed={collapsed} collapsed={collapsed}
/> />
)) ))
) : ( ) : (
<SectionRow icon="▢" label="No apps yet" dim collapsed={collapsed} /> <SectionRow icon="▢" label="No apps yet" dim href={`${base}/build`} collapsed={collapsed} />
)} )}
<SectionDivider /> <SectionDivider />