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:
@@ -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 15–30 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>
|
||||
);
|
||||
}
|
||||
|
||||
108
app/api/projects/[projectId]/file/route.ts
Normal file
108
app/api/projects/[projectId]/file/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user