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";
|
"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 (
|
||||||
|
<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 (
|
return (
|
||||||
<div style={{
|
<>
|
||||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
|
||||||
marginBottom: 8, overflow: "hidden",
|
|
||||||
}}>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(o => !o)}
|
onClick={() => isDir ? onToggle(node.path) : onSelect(node.path, "file")}
|
||||||
style={{
|
style={{
|
||||||
width: "100%", textAlign: "left", background: "none", border: "none",
|
display: "flex", alignItems: "center",
|
||||||
cursor: "pointer", padding: "14px 18px",
|
gap: 6, width: "100%", textAlign: "left",
|
||||||
display: "flex", alignItems: "center", gap: 12,
|
background: isSelected ? "#f0ece4" : "transparent",
|
||||||
fontFamily: "Outfit, sans-serif",
|
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>
|
{isDir ? (
|
||||||
<div style={{ flex: 1 }}>
|
<span style={{ fontSize: "0.55rem", color: "#a09a90", transform: node.expanded ? "rotate(90deg)" : "none", display: "inline-block", transition: "transform 0.12s", flexShrink: 0 }}>▶</span>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
) : (
|
||||||
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>
|
<FileIcon name={node.name} type="file" />
|
||||||
apps/{app.name}
|
)}
|
||||||
</span>
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
<span style={{
|
{node.name}
|
||||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
</span>
|
||||||
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>
|
</button>
|
||||||
|
{isDir && node.expanded && node.children && (
|
||||||
{open && (
|
node.children.map(child => (
|
||||||
<div style={{ padding: "0 18px 16px", borderTop: "1px solid #f0ece4" }}>
|
<TreeNodeRow
|
||||||
{app.tech.length > 0 && (
|
key={child.path}
|
||||||
<div style={{ marginTop: 12 }}>
|
node={child}
|
||||||
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Stack</div>
|
depth={depth + 1}
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
|
selectedPath={selectedPath}
|
||||||
{app.tech.map((t, i) => (
|
onSelect={onSelect}
|
||||||
<span key={i} style={{
|
onToggle={onToggle}
|
||||||
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
|
projectId={projectId}
|
||||||
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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/projects/${projectId}/architecture`, {
|
const res = await fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`);
|
||||||
method: "POST",
|
const data = await res.json();
|
||||||
headers: { "Content-Type": "application/json" },
|
setFileContent(data.content ?? "");
|
||||||
body: JSON.stringify({ forceRegenerate: force }),
|
} catch {
|
||||||
});
|
setFileContent("// Failed to load file content");
|
||||||
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");
|
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false);
|
setFileLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [projectId]);
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const lang = fileName ? langFromName(fileName) : "text";
|
||||||
setConfirming(true);
|
const lines = (fileContent ?? "").split("\n");
|
||||||
try {
|
|
||||||
await fetch(`/api/projects/${projectId}/architecture`, { method: "PATCH" });
|
|
||||||
setArchitectureConfirmed(true);
|
|
||||||
} catch { /* swallow */ } finally {
|
|
||||||
setConfirming(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
return (
|
||||||
return (
|
<div style={{ display: "flex", height: "100%", fontFamily: "Outfit, sans-serif", overflow: "hidden" }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
|
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No PRD yet
|
{/* ── File tree panel ── */}
|
||||||
if (!prd) {
|
<div style={{
|
||||||
return (
|
width: 240, flexShrink: 0,
|
||||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
|
borderRight: "1px solid #e8e4dc",
|
||||||
<div style={{ textAlign: "center", maxWidth: 360 }}>
|
background: "#faf8f5",
|
||||||
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🔒</div>
|
display: "flex", flexDirection: "column",
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
|
overflow: "hidden",
|
||||||
Complete your PRD first
|
}}>
|
||||||
</h3>
|
{/* Tree header */}
|
||||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 20 }}>
|
<div style={{
|
||||||
Finish your discovery conversation with Atlas, then the architect will unlock automatically.
|
padding: "12px 14px 10px",
|
||||||
</p>
|
borderBottom: "1px solid #e8e4dc",
|
||||||
<Link href={`/${workspace}/project/${projectId}/overview`} style={{
|
fontSize: "0.65rem", fontWeight: 700,
|
||||||
display: "inline-block", padding: "9px 20px", borderRadius: 7,
|
color: "#a09a90", letterSpacing: "0.08em",
|
||||||
background: "#1a1a1a", color: "#fff",
|
textTransform: "uppercase", flexShrink: 0,
|
||||||
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
}}>
|
||||||
textDecoration: "none",
|
Files
|
||||||
}}>
|
|
||||||
Continue with Atlas →
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PRD exists but no architecture yet — prompt to generate
|
{/* Tree content */}
|
||||||
if (!architecture) {
|
<div style={{ flex: 1, overflow: "auto", padding: "6px 4px" }}>
|
||||||
return (
|
{treeLoading && (
|
||||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
|
<div style={{ padding: "16px 14px", fontSize: "0.75rem", color: "#b5b0a6" }}>
|
||||||
<div style={{ textAlign: "center", maxWidth: 440 }}>
|
Loading…
|
||||||
<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
|
{treeError && (
|
||||||
onClick={() => handleGenerate(false)}
|
<div style={{ padding: "16px 14px", fontSize: "0.75rem", color: "#e53e3e" }}>
|
||||||
disabled={generating}
|
{treeError === "No Gitea repo connected"
|
||||||
style={{
|
? "No repository connected yet. Create a project to get started."
|
||||||
padding: "11px 28px", borderRadius: 8,
|
: treeError}
|
||||||
background: generating ? "#8a8478" : "#1a1a1a",
|
</div>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
{!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>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Architecture loaded — show full review UI
|
{/* ── Code preview panel ── */}
|
||||||
return (
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0, background: "#1e1e1e", overflow: "hidden" }}>
|
||||||
<div style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif", maxWidth: 780 }}>
|
|
||||||
|
|
||||||
{/* Header */}
|
{/* File path breadcrumb */}
|
||||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 4 }}>
|
<div style={{
|
||||||
<div>
|
padding: "10px 20px",
|
||||||
<h2 style={{ fontFamily: "Newsreader, serif", fontSize: "1.35rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
borderBottom: "1px solid #333",
|
||||||
Architecture
|
background: "#252526",
|
||||||
</h2>
|
display: "flex", alignItems: "center", gap: 8,
|
||||||
<p style={{ fontSize: "0.75rem", color: "#a09a90", marginTop: 4 }}>
|
flexShrink: 0,
|
||||||
{architecture.productType}
|
}}>
|
||||||
</p>
|
{selectedPath ? (
|
||||||
</div>
|
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#a09a90" }}>
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
{selectedPath.split("/").map((seg, i, arr) => (
|
||||||
{architectureConfirmed && (
|
<span key={i}>
|
||||||
<span style={{
|
{i > 0 && <span style={{ color: "#555", margin: "0 4px" }}>/</span>}
|
||||||
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
|
<span style={{ color: i === arr.length - 1 ? "#d4d4d4" : "#a09a90" }}>{seg}</span>
|
||||||
color: "#2e7d32", background: "#2e7d3210",
|
</span>
|
||||||
border: "1px solid #a5d6a740", padding: "4px 10px", borderRadius: 5,
|
))}
|
||||||
}}>
|
</span>
|
||||||
✓ Confirmed
|
) : (
|
||||||
|
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#555" }}>
|
||||||
|
Select a file to view
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
{selectedPath && (
|
||||||
onClick={() => handleGenerate(true)}
|
<span style={{ marginLeft: "auto", fontFamily: "IBM Plex Mono, monospace", fontSize: "0.65rem", color: "#555", textTransform: "uppercase" }}>
|
||||||
disabled={generating}
|
{lang}
|
||||||
style={{
|
</span>
|
||||||
padding: "6px 14px", borderRadius: 6,
|
)}
|
||||||
background: "none", border: "1px solid #e0dcd4",
|
</div>
|
||||||
fontSize: "0.72rem", color: "#8a8478", cursor: "pointer",
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
{/* Code area */}
|
||||||
}}
|
<div style={{ flex: 1, overflow: "auto", display: "flex" }}>
|
||||||
>
|
{!selectedPath && !fileLoading && (
|
||||||
{generating ? "Regenerating…" : "Regenerate"}
|
<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
|
||||||
|
</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>
|
||||||
</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>
|
</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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user