fix(gitea-bot): add write:organization scope so bot can create repos

Without this the bot PAT 403s on POST /orgs/{org}/repos, which is
the single most important operation — creating new project repos
inside the workspace's Gitea org.

Made-with: Cursor
This commit is contained in:
2026-04-21 11:05:55 -07:00
parent d9d3514647
commit 6f79a88abd
66 changed files with 2088 additions and 1713 deletions

View File

@@ -3,7 +3,15 @@
import { Suspense, useState, useEffect, useCallback, useRef } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
import Link from "next/link";
import { JM } from "@/components/project-creation/modal-theme";
import { AtlasChat } from "@/components/AtlasChat";
import { PRD_PLAN_SECTIONS } from "@/lib/prd-sections";
import {
type ChatContextRef,
contextRefKey,
} from "@/lib/chat-context-refs";
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -17,15 +25,6 @@ interface TreeNode {
// ── Constants ─────────────────────────────────────────────────────────────────
const INFRA_ITEMS = [
{ id: "builds", label: "Builds", icon: "⬡" },
{ id: "databases", label: "Databases", icon: "◫" },
{ id: "services", label: "Services", icon: "◎" },
{ id: "environment", label: "Environment", icon: "≡" },
{ id: "domains", label: "Domains", icon: "◬" },
{ id: "logs", label: "Logs", icon: "≈" },
];
const SURFACE_LABELS: Record<string, string> = {
webapp: "Web App", marketing: "Marketing Site", admin: "Admin Panel",
};
@@ -33,6 +32,74 @@ const SURFACE_ICONS: Record<string, string> = {
webapp: "◈", marketing: "◌", admin: "◫",
};
/** Growth pagestyle left rail: cream panel, icon + label rows */
const BUILD_LEFT_BG = "#faf8f5";
const BUILD_LEFT_BORDER = "#e8e4dc";
const BUILD_NAV_GROUP: React.CSSProperties = {
fontSize: "0.6rem",
fontWeight: 700,
color: "#b5b0a6",
letterSpacing: "0.09em",
textTransform: "uppercase",
padding: "14px 12px 6px",
fontFamily: JM.fontSans,
};
const BUILD_PRIMARY = [
{ id: "chat", label: "Chat", icon: "◆" },
{ id: "code", label: "Code", icon: "◇" },
{ id: "layouts", label: "Layouts", icon: "◈" },
{ id: "tasks", label: "Agent", icon: "◎" },
{ id: "preview", label: "Preview", icon: "▢" },
] as const;
function BuildGrowthNavRow({
icon,
label,
active,
onClick,
}: {
icon: string;
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
textAlign: "left",
background: active ? "#f0ece4" : "transparent",
border: "none",
cursor: "pointer",
padding: "6px 12px",
borderRadius: 5,
fontSize: "0.78rem",
fontWeight: active ? 600 : 440,
color: active ? "#1a1a1a" : "#5a5550",
fontFamily: JM.fontSans,
}}
onMouseEnter={e => {
if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0";
}}
onMouseLeave={e => {
if (!active) (e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center", flexShrink: 0 }}>
{icon}
</span>
{label}
</button>
);
}
// ── Language / syntax helpers ─────────────────────────────────────────────────
function langFromName(name: string): string {
@@ -103,28 +170,11 @@ function TreeRow({ node, depth, selectedPath, onSelect, onToggle }: {
// ── Left nav shared styles ────────────────────────────────────────────────────
const NAV_GROUP_LABEL: React.CSSProperties = {
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
fontSize: "0.6rem", fontWeight: 700, color: JM.muted,
letterSpacing: "0.09em", textTransform: "uppercase",
padding: "12px 12px 5px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
padding: "12px 12px 5px", fontFamily: JM.fontSans,
};
function NavItem({ label, active, onClick, indent = false }: { label: string; active: boolean; onClick: () => void; indent?: boolean }) {
return (
<button onClick={onClick} style={{
display: "flex", alignItems: "center", gap: 7, width: "100%", textAlign: "left",
background: active ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
padding: `5px 12px 5px ${indent ? 22 : 12}px`, borderRadius: 5,
fontSize: "0.78rem", fontWeight: active ? 600 : 440,
color: active ? "#1a1a1a" : "#5a5550", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{label}
</button>
);
}
// ── Placeholder panel ─────────────────────────────────────────────────────────
function Placeholder({ icon, title, desc }: { icon: string; title: string; desc: string }) {
@@ -140,30 +190,6 @@ function Placeholder({ icon, title, desc }: { icon: string; title: string; desc:
);
}
// ── Infra content ─────────────────────────────────────────────────────────────
function InfraContent({ tab, projectId, workspace }: { tab: string; projectId: string; workspace: string }) {
const base = `/${workspace}/project/${projectId}/infrastructure`;
const descriptions: Record<string, { icon: string; title: string; desc: string }> = {
databases: { icon: "◫", title: "Databases", desc: "PostgreSQL, Redis, and other databases — provisioned and managed with connection strings auto-injected." },
services: { icon: "◎", title: "Services", desc: "Background workers, queues, email delivery, file storage, and third-party integrations." },
environment: { icon: "≡", title: "Environment", desc: "Environment variables and secrets, encrypted at rest and auto-injected into your containers." },
domains: { icon: "◬", title: "Domains", desc: "Custom domains and SSL certificates for all your deployed services." },
logs: { icon: "≈", title: "Logs", desc: "Runtime logs, request traces, and error reports streaming from deployed services." },
builds: { icon: "⬡", title: "Builds", desc: "Deployment history, build logs, and rollback controls for all your apps." },
};
const d = descriptions[tab];
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
<div style={{ padding: "20px 28px 0", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ fontSize: "0.68rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>{tab}</div>
<Link href={`${base}?tab=${tab}`} style={{ fontSize: "0.72rem", color: "#a09a90", textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Open full view </Link>
</div>
{d && <Placeholder icon={d.icon} title={d.title} desc={d.desc} />}
</div>
);
}
// ── Layouts content ───────────────────────────────────────────────────────────
function LayoutsContent({ surfaces, projectId, workspace, activeSurfaceId, onSelectSurface }: {
@@ -956,7 +982,8 @@ function FileTree({ projectId, rootPath, selectedPath, onSelectFile }: {
}, [projectId]);
useEffect(() => {
if (!rootPath || status !== "authenticated") return;
if (!rootPath) return;
if (!isClientDevProjectBypass() && status !== "authenticated") return;
setTree([]); setTreeLoading(true);
fetchDir(rootPath).then(nodes => { setTree(nodes); setTreeLoading(false); }).catch(() => setTreeLoading(false));
}, [rootPath, status, fetchDir]);
@@ -1046,21 +1073,6 @@ interface PreviewApp { name: string; url: string | null; status: string; }
// ── PRD Content ───────────────────────────────────────────────────────────────
const PRD_SECTIONS = [
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: null },
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
];
interface SavedPhase { phase: string; title: string; summary: string; data: Record<string, unknown>; saved_at: string; }
function PrdContent({ projectId }: { projectId: string }) {
@@ -1086,7 +1098,7 @@ function PrdContent({ projectId }: { projectId: string }) {
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
const sections = PRD_SECTIONS.map(s => ({
const sections = PRD_PLAN_SECTIONS.map(s => ({
...s,
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
@@ -1272,10 +1284,11 @@ function BuildHubInner() {
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const section = searchParams.get("section") ?? "code";
const section = searchParams.get("section") ?? "chat";
const tasksSubTabRaw = searchParams.get("tab") ?? "tasks";
const tasksSubTab = tasksSubTabRaw === "prd" ? "requirements" : tasksSubTabRaw;
const activeApp = searchParams.get("app") ?? "";
const activeRoot = searchParams.get("root") ?? "";
const activeInfra = searchParams.get("tab") ?? "builds";
const activeSurfaceParam = searchParams.get("surface") ?? "";
const [apps, setApps] = useState<AppEntry[]>([]);
@@ -1292,6 +1305,32 @@ function BuildHubInner() {
const [fileLoading, setFileLoading] = useState(false);
const [fileName, setFileName] = useState<string | null>(null);
const [chatContextRefs, setChatContextRefs] = useState<ChatContextRef[]>([]);
const addAppToChat = useCallback((app: AppEntry) => {
setChatContextRefs(prev => {
const next: ChatContextRef = { kind: "app", label: app.name, path: app.path };
const k = contextRefKey(next);
if (prev.some(r => contextRefKey(r) === k)) return prev;
return [...prev, next];
});
}, []);
const removeChatContextRef = useCallback((key: string) => {
setChatContextRefs(prev => prev.filter(r => contextRefKey(r) !== key));
}, []);
useEffect(() => {
if (searchParams.get("section") !== "infrastructure") return;
const t = searchParams.get("tab") ?? "builds";
router.replace(`/${workspace}/project/${projectId}/run?tab=${encodeURIComponent(t)}`, { scroll: false });
}, [searchParams, workspace, projectId, router]);
useEffect(() => {
if (searchParams.get("section") !== "mvp") return;
router.replace(`/${workspace}/project/${projectId}/mvp-setup/launch`, { scroll: false });
}, [searchParams, workspace, projectId, router]);
useEffect(() => {
fetch(`/api/projects/${projectId}/preview-url`)
.then(r => r.json())
@@ -1336,82 +1375,114 @@ function BuildHubInner() {
router.push(`/${workspace}/project/${projectId}/build?${sp.toString()}`, { scroll: false });
};
const workspaceAppActive = (app: AppEntry) => {
if (section === "chat") {
return chatContextRefs.some(r => r.kind === "app" && r.path === app.path);
}
if (section === "code" || (section === "tasks" && tasksSubTab !== "requirements")) {
return activeApp === app.name;
}
return false;
};
const onWorkspaceApp = (app: AppEntry) => {
if (section === "chat") addAppToChat(app);
else if (section === "code") navigate({ section: "code", app: app.name, root: app.path });
else if (section === "tasks" && tasksSubTab !== "requirements") {
navigate({ section: "tasks", tab: "tasks", app: app.name, root: app.path });
}
else navigate({ section: "code", app: app.name, root: app.path });
};
return (
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
<div style={{ display: "flex", height: "100%", fontFamily: JM.fontSans, overflow: "hidden", background: JM.inputBg }}>
{/* Growth-style left rail */}
<div style={{
width: 200,
flexShrink: 0,
borderRight: `1px solid ${BUILD_LEFT_BORDER}`,
background: BUILD_LEFT_BG,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}>
<div style={BUILD_NAV_GROUP}>Build</div>
{BUILD_PRIMARY.map(p => (
<BuildGrowthNavRow
key={p.id}
icon={p.icon}
label={p.label}
active={section === p.id}
onClick={() => {
if (p.id === "tasks") navigate({ section: "tasks", tab: "tasks" });
else navigate({ section: p.id });
}}
/>
))}
{/* ── Build content ── */}
<div style={{ flex: 1, display: "flex", overflow: "hidden", minWidth: 0 }}>
{/* Inner nav — contextual items driven by top-bar tool icon */}
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Code: app list + file tree */}
{section === "code" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ flexShrink: 0 }}>
<div style={NAV_GROUP_LABEL}>Apps</div>
{apps.length > 0 ? apps.map(app => (
<NavItem key={app.name} label={app.name} indent
active={activeApp === app.name}
onClick={() => navigate({ section: "code", app: app.name, root: app.path })}
/>
)) : (
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>No apps yet</div>
)}
</div>
{activeApp && activeRoot && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", borderTop: "1px solid #e8e4dc", marginTop: 6 }}>
<div style={{ padding: "7px 12px 4px", flexShrink: 0, display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: "0.57rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.1em", textTransform: "uppercase", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Files</span>
<span style={{ fontSize: "0.62rem", color: "#a09a90", fontFamily: "IBM Plex Mono, monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{activeApp}</span>
</div>
<FileTree projectId={projectId} rootPath={activeRoot} selectedPath={selectedFilePath} onSelectFile={handleSelectFile} />
</div>
)}
<div style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
borderTop: `1px solid ${BUILD_LEFT_BORDER}`,
marginTop: 6,
paddingTop: 4,
}}>
{section === "chat" && (
<div style={{ padding: "8px 12px 12px", fontSize: 11, color: JM.mid, lineHeight: 1.45, fontFamily: JM.fontSans }}>
Attach monorepo apps from <strong style={{ fontWeight: 600 }}>Workspace</strong> below. Live preview stays on the right when deployed.
</div>
)}
{section === "code" && activeApp && activeRoot && (
<div style={{ display: "flex", flexDirection: "column", paddingBottom: 10, borderBottom: `1px solid ${BUILD_LEFT_BORDER}`, marginBottom: 8 }}>
<div style={NAV_GROUP_LABEL}>Files</div>
<div style={{
padding: "2px 12px 6px",
fontSize: "0.62rem",
color: JM.mid,
fontFamily: "IBM Plex Mono, monospace",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{activeApp}
</div>
<div style={{ flex: 1, minHeight: 0, maxHeight: 280, overflow: "auto" }}>
<FileTree projectId={projectId} rootPath={activeRoot} selectedPath={selectedFilePath} onSelectFile={handleSelectFile} />
</div>
</div>
)}
{/* Layouts: surface list */}
{section === "layouts" && (
<div style={{ overflow: "auto", flex: 1 }}>
<div style={{ paddingBottom: 8 }}>
<div style={NAV_GROUP_LABEL}>Surfaces</div>
{surfaces.length > 0 ? surfaces.map(s => (
<NavItem key={s.id} label={SURFACE_LABELS[s.id] ?? s.id} indent
<BuildGrowthNavRow
key={s.id}
icon={SURFACE_ICONS[s.id] ?? "◈"}
label={SURFACE_LABELS[s.id] ?? s.id}
active={activeSurfaceId === s.id}
onClick={() => { setActiveSurfaceId(s.id); navigate({ section: "layouts", surface: s.id }); }}
/>
)) : (
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Not configured</div>
<div style={{ padding: "8px 12px", fontSize: "0.74rem", color: JM.muted, fontFamily: JM.fontSans }}>Not configured</div>
)}
</div>
)}
{/* Infrastructure: item list */}
{section === "infrastructure" && (
<div style={{ overflow: "auto", flex: 1 }}>
<div style={NAV_GROUP_LABEL}>Infrastructure</div>
{INFRA_ITEMS.map(item => (
<NavItem key={item.id} label={item.label} indent
active={activeInfra === item.id}
onClick={() => navigate({ section: "infrastructure", tab: item.id })}
/>
))}
</div>
)}
{/* Tasks: sub-nav + app list */}
{section === "tasks" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Tasks | PRD sub-nav */}
<div style={{ flexShrink: 0, padding: "8px 10px", borderBottom: "1px solid #e8e4dc", display: "flex", gap: 4 }}>
{[{ id: "tasks", label: "Tasks" }, { id: "prd", label: "PRD" }].map(item => {
const isActive = (searchParams.get("tab") ?? "tasks") === item.id;
<div style={{ paddingBottom: 8 }}>
<div style={{ padding: "8px 10px", borderBottom: `1px solid ${BUILD_LEFT_BORDER}`, display: "flex", gap: 4, marginBottom: 8 }}>
{[{ id: "tasks", label: "Runs" }, { id: "requirements", label: "Requirements" }].map(item => {
const isActive = tasksSubTab === item.id;
return (
<button key={item.id} onClick={() => navigate({ section: "tasks", tab: item.id })} style={{
<button key={item.id} type="button" onClick={() => navigate({ section: "tasks", tab: item.id })} style={{
flex: 1, padding: "5px 0", border: "none", borderRadius: 6, cursor: "pointer",
fontSize: "0.72rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
background: isActive ? "#1a1a1a" : "transparent",
color: isActive ? "#fff" : "#a09a90",
fontSize: "0.72rem", fontWeight: 600, fontFamily: JM.fontSans,
background: isActive ? JM.primaryGradient : "transparent",
color: isActive ? "#fff" : JM.mid,
boxShadow: isActive ? JM.primaryShadow : "none",
transition: "all 0.12s",
}}>
{item.label}
@@ -1419,40 +1490,106 @@ function BuildHubInner() {
);
})}
</div>
{/* App list (only in tasks tab) */}
{(searchParams.get("tab") ?? "tasks") !== "prd" && (
<>
<div style={NAV_GROUP_LABEL}>Apps</div>
{apps.length > 0 ? apps.map(app => (
<NavItem key={app.name} label={app.name} indent
active={activeApp === app.name}
onClick={() => navigate({ section: "tasks", tab: "tasks", app: app.name, root: app.path })}
/>
)) : (
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>No apps yet</div>
)}
</>
)}
</div>
)}
{/* Preview: deployed apps list */}
{section === "preview" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={NAV_GROUP_LABEL}>Apps</div>
<div style={{ paddingBottom: 8 }}>
<div style={NAV_GROUP_LABEL}>Deployed</div>
{previewApps.length > 0 ? previewApps.map(app => (
<NavItem key={app.name} label={app.name} indent
<BuildGrowthNavRow
key={app.name}
icon="▢"
label={app.name}
active={activePreviewApp?.name === app.name}
onClick={() => setActivePreviewApp(app)}
/>
)) : (
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>No deployments yet</div>
<div style={{ padding: "8px 12px", fontSize: "0.74rem", color: JM.muted, fontFamily: JM.fontSans }}>No deployments yet</div>
)}
</div>
)}
</div>
{/* Main content panel */}
<div style={{ flexShrink: 0, borderTop: `1px solid ${BUILD_LEFT_BORDER}`, paddingBottom: 10, background: BUILD_LEFT_BG }}>
{apps.length > 0 && (
<>
<div style={BUILD_NAV_GROUP}>Workspace</div>
{apps.map(app => (
<BuildGrowthNavRow
key={app.name}
icon="◈"
label={app.name}
active={workspaceAppActive(app)}
onClick={() => onWorkspaceApp(app)}
/>
))}
</>
)}
{section === "chat" && previewApps.length > 0 && (
<>
<div style={BUILD_NAV_GROUP}>Live</div>
{previewApps.map(app => (
<BuildGrowthNavRow
key={`live-${app.name}`}
icon="▢"
label={app.name}
active={activePreviewApp?.name === app.name}
onClick={() => setActivePreviewApp(app)}
/>
))}
</>
)}
<Link
href={`/${workspace}/project/${projectId}/tasks`}
style={{
display: "block",
margin: "10px 12px 0",
fontSize: 12,
fontWeight: 600,
color: JM.indigo,
textDecoration: "none",
fontFamily: JM.fontSans,
}}
>
Open Tasks
</Link>
</div>
</div>
{/* Main content */}
<div style={{ flex: 1, display: "flex", overflow: "hidden", minWidth: 0 }}>
{section === "chat" && (
<div style={{ flex: 1, display: "flex", minWidth: 0, overflow: "hidden" }}>
<div style={{ flex: 1, minWidth: 0, minHeight: 0 }}>
<AtlasChat
projectId={projectId}
conversationScope="build"
contextEmptyLabel="Workspace"
emptyStateHint="Plan and implement in your monorepo. Attach apps from the left, preview on the right when deployed."
chatContextRefs={chatContextRefs}
onRemoveChatContextRef={removeChatContextRef}
/>
</div>
<div style={{
width: 400,
flexShrink: 0,
minWidth: 280,
borderLeft: `1px solid ${JM.border}`,
display: "flex",
flexDirection: "column",
overflow: "hidden",
background: JM.inputBg,
}}>
<PreviewContent
projectId={projectId}
apps={previewApps}
activePreviewApp={activePreviewApp}
onSelectApp={setActivePreviewApp}
/>
</div>
</div>
)}
{section === "code" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<FileViewer
@@ -1468,13 +1605,10 @@ function BuildHubInner() {
{section === "layouts" && (
<LayoutsContent surfaces={surfaces} projectId={projectId} workspace={workspace} activeSurfaceId={activeSurfaceId} onSelectSurface={id => { setActiveSurfaceId(id); navigate({ section: "layouts", surface: id }); }} />
)}
{section === "infrastructure" && (
<InfraContent tab={activeInfra} projectId={projectId} workspace={workspace} />
)}
{section === "tasks" && (searchParams.get("tab") ?? "tasks") !== "prd" && (
{section === "tasks" && tasksSubTab !== "requirements" && (
<AgentMode projectId={projectId} appName={activeApp} appPath={activeRoot} />
)}
{section === "tasks" && searchParams.get("tab") === "prd" && (
{section === "tasks" && tasksSubTab === "requirements" && (
<PrdContent projectId={projectId} />
)}
{section === "preview" && (
@@ -1492,7 +1626,7 @@ function BuildHubInner() {
export default function BuildPage() {
return (
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading</div>}>
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: JM.muted, fontFamily: JM.fontSans, fontSize: "0.85rem" }}>Loading</div>}>
<BuildHubInner />
</Suspense>
);

View File

@@ -1,353 +1,7 @@
"use client";
import { Suspense, useState, useEffect } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
// ── Types ─────────────────────────────────────────────────────────────────────
interface InfraApp {
name: string;
domain?: string | null;
coolifyServiceUuid?: string | null;
}
interface ProjectData {
giteaRepo?: string;
giteaRepoUrl?: string;
apps?: InfraApp[];
}
// ── Tab definitions ───────────────────────────────────────────────────────────
const TABS = [
{ id: "builds", label: "Builds", icon: "⬡" },
{ id: "databases", label: "Databases", icon: "◫" },
{ id: "services", label: "Services", icon: "◎" },
{ id: "environment", label: "Environment", icon: "≡" },
{ id: "domains", label: "Domains", icon: "◬" },
{ id: "logs", label: "Logs", icon: "≈" },
] as const;
type TabId = typeof TABS[number]["id"];
// ── Shared empty state ────────────────────────────────────────────────────────
function ComingSoonPanel({ icon, title, description }: { icon: string; title: string; description: string }) {
return (
<div style={{
flex: 1, display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
padding: 60, textAlign: "center", gap: 16,
}}>
<div style={{
width: 56, height: 56, borderRadius: 14, background: "#f0ece4",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "1.5rem", color: "#b5b0a6",
}}>
{icon}
</div>
<div>
<div style={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>{title}</div>
<div style={{ fontSize: "0.82rem", color: "#a09a90", maxWidth: 340, lineHeight: 1.6 }}>{description}</div>
</div>
<div style={{
marginTop: 8, padding: "8px 18px",
background: "#1a1a1a", color: "#fff",
borderRadius: 7, fontSize: "0.78rem", fontWeight: 500,
opacity: 0.4, cursor: "default",
}}>
Coming soon
</div>
</div>
);
}
// ── Builds tab ────────────────────────────────────────────────────────────────
function BuildsTab({ project }: { project: ProjectData | null }) {
const apps = project?.apps ?? [];
if (apps.length === 0) {
return (
<ComingSoonPanel
icon="⬡"
title="No deployments yet"
description="Once your apps are deployed via Coolify, build history and deployment logs will appear here."
/>
);
}
return (
<div style={{ padding: 32, maxWidth: 720 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Deployed Apps
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{apps.map(app => (
<div key={app.name} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span style={{ fontSize: "0.85rem", color: "#a09a90" }}></span>
<div>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{app.name}</div>
{app.domain && (
<div style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 2 }}>{app.domain}</div>
)}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>Running</span>
</div>
</div>
))}
</div>
</div>
);
}
// ── Databases tab ─────────────────────────────────────────────────────────────
function DatabasesTab() {
return (
<ComingSoonPanel
icon="◫"
title="Databases"
description="Provision and manage PostgreSQL, Redis, and other databases for your project. Connection strings and credentials will be auto-injected into your environment."
/>
);
}
// ── Services tab ──────────────────────────────────────────────────────────────
function ServicesTab() {
return (
<ComingSoonPanel
icon="◎"
title="Services"
description="Background workers, email delivery, queues, file storage, and third-party integrations will be configured and monitored here."
/>
);
}
// ── Environment tab ───────────────────────────────────────────────────────────
function EnvironmentTab({ project }: { project: ProjectData | null }) {
return (
<div style={{ padding: 32, maxWidth: 720 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Environment Variables & Secrets
</div>
<div style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
overflow: "hidden", marginBottom: 20,
}}>
{/* Header row */}
<div style={{
display: "grid", gridTemplateColumns: "1fr 1fr auto",
padding: "10px 18px", background: "#faf8f5",
borderBottom: "1px solid #e8e4dc",
fontSize: "0.68rem", fontWeight: 700, color: "#a09a90",
letterSpacing: "0.06em", textTransform: "uppercase",
}}>
<span>Key</span><span>Value</span><span />
</div>
{/* Placeholder rows */}
{["DATABASE_URL", "NEXTAUTH_SECRET", "GITEA_API_TOKEN"].map(k => (
<div key={k} style={{
display: "grid", gridTemplateColumns: "1fr 1fr auto",
padding: "11px 18px", borderBottom: "1px solid #f0ece4",
alignItems: "center",
}}>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#1a1a1a" }}>{k}</span>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#b5b0a6", letterSpacing: 2 }}></span>
<button style={{ background: "none", border: "none", cursor: "pointer", color: "#a09a90", fontSize: "0.72rem", padding: "2px 6px" }}>Edit</button>
</div>
))}
<div style={{ padding: "11px 18px", borderTop: "1px solid #f0ece4" }}>
<button style={{
background: "none", border: "1px dashed #d4cfc8", borderRadius: 6,
padding: "6px 14px", fontSize: "0.75rem", color: "#a09a90",
cursor: "pointer", width: "100%",
}}>
+ Add variable
</button>
</div>
</div>
<div style={{ fontSize: "0.75rem", color: "#b5b0a6", lineHeight: 1.6 }}>
Variables are encrypted at rest and auto-injected into deployed containers. Secrets are never exposed in logs.
</div>
</div>
);
}
// ── Domains tab ───────────────────────────────────────────────────────────────
function DomainsTab({ project }: { project: ProjectData | null }) {
const apps = (project?.apps ?? []).filter(a => a.domain);
return (
<div style={{ padding: 32, maxWidth: 720 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Domains & SSL
</div>
{apps.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 20 }}>
{apps.map(app => (
<div key={app.name} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<div>
<div style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.8rem", color: "#1a1a1a", fontWeight: 500 }}>
{app.domain}
</div>
<div style={{ fontSize: "0.7rem", color: "#a09a90", marginTop: 3 }}>{app.name}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>SSL active</span>
</div>
</div>
))}
</div>
) : (
<div style={{
background: "#fff", border: "1px dashed #d4cfc8", borderRadius: 10,
padding: "32px 24px", textAlign: "center", marginBottom: 20,
}}>
<div style={{ fontSize: "0.82rem", color: "#a09a90" }}>No custom domains configured</div>
<div style={{ fontSize: "0.73rem", color: "#b5b0a6", marginTop: 6 }}>Deploy an app first, then point a domain here.</div>
</div>
)}
<button style={{
background: "#1a1a1a", color: "#fff", border: "none",
borderRadius: 8, padding: "9px 20px",
fontSize: "0.78rem", fontWeight: 500, cursor: "pointer",
opacity: 0.5,
}}>
+ Add domain
</button>
</div>
);
}
// ── Logs tab ──────────────────────────────────────────────────────────────────
function LogsTab({ project }: { project: ProjectData | null }) {
const apps = project?.apps ?? [];
if (apps.length === 0) {
return (
<ComingSoonPanel
icon="≈"
title="No logs yet"
description="Runtime logs, request traces, and error reports from your deployed services will stream here."
/>
);
}
return (
<div style={{ padding: 32, maxWidth: 900 }}>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
Runtime Logs
</div>
<div style={{
background: "#1e1e1e", borderRadius: 10, padding: "20px 24px",
fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#d4d4d4",
lineHeight: 1.6, minHeight: 200,
}}>
<div style={{ color: "#6a9955" }}>{"# Logs will stream here once connected to Coolify"}</div>
<div style={{ color: "#569cd6", marginTop: 8 }}>{"→ Select a service to tail its log output"}</div>
</div>
</div>
);
}
// ── Inner page ────────────────────────────────────────────────────────────────
function InfrastructurePageInner() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const activeTab = (searchParams.get("tab") ?? "builds") as TabId;
const [project, setProject] = useState<ProjectData | null>(null);
useEffect(() => {
fetch(`/api/projects/${projectId}/apps`)
.then(r => r.json())
.then(d => setProject({ apps: d.apps ?? [], giteaRepo: d.giteaRepo, giteaRepoUrl: d.giteaRepoUrl }))
.catch(() => {});
}, [projectId]);
const setTab = (id: TabId) => {
router.push(`/${workspace}/project/${projectId}/infrastructure?tab=${id}`, { scroll: false });
};
return (
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
{/* ── Left sub-nav ── */}
<div style={{
width: 190, flexShrink: 0,
borderRight: "1px solid #e8e4dc",
background: "#faf8f5",
display: "flex", flexDirection: "column",
padding: "16px 8px",
gap: 2,
overflow: "auto",
}}>
<div style={{
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
letterSpacing: "0.1em", textTransform: "uppercase",
padding: "0 8px 10px",
}}>
Infrastructure
</div>
{TABS.map(tab => {
const active = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setTab(tab.id)}
style={{
display: "flex", alignItems: "center", gap: 9,
padding: "7px 10px", borderRadius: 6,
background: active ? "#f0ece4" : "transparent",
border: "none", cursor: "pointer", width: "100%", textAlign: "left",
color: active ? "#1a1a1a" : "#6b6560",
fontSize: "0.8rem", fontWeight: active ? 600 : 450,
transition: "background 0.1s",
}}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<span style={{ fontSize: "0.75rem", opacity: 0.65, width: 16, textAlign: "center" }}>{tab.icon}</span>
{tab.label}
</button>
);
})}
</div>
{/* ── Content ── */}
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
{activeTab === "builds" && <BuildsTab project={project} />}
{activeTab === "databases" && <DatabasesTab />}
{activeTab === "services" && <ServicesTab />}
{activeTab === "environment" && <EnvironmentTab project={project} />}
{activeTab === "domains" && <DomainsTab project={project} />}
{activeTab === "logs" && <LogsTab project={project} />}
</div>
</div>
);
}
// ── Export ────────────────────────────────────────────────────────────────────
import { ProjectInfraPanel } from "@/components/project-main/ProjectInfraPanel";
export default function InfrastructurePage() {
return (
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading</div>}>
<InfrastructurePageInner />
</Suspense>
<ProjectInfraPanel routeBase="infrastructure" navGroupLabel="Infrastructure" />
);
}

View File

@@ -3,7 +3,9 @@
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
import { Loader2 } from "lucide-react";
import { JM } from "@/components/project-creation/modal-theme";
import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain";
import { ChatImportMain } from "@/components/project-main/ChatImportMain";
import { CodeImportMain } from "@/components/project-main/CodeImportMain";
@@ -35,10 +37,12 @@ export default function ProjectOverviewPage() {
const [loading, setLoading] = useState(true);
useEffect(() => {
if (authStatus !== "authenticated") {
const bypass = isClientDevProjectBypass();
if (!bypass && authStatus !== "authenticated") {
if (authStatus === "unauthenticated") setLoading(false);
return;
}
if (!bypass && authStatus === "loading") return;
fetch(`/api/projects/${projectId}`)
.then(r => r.json())
.then(d => setProject(d.project))
@@ -48,15 +52,23 @@ export default function ProjectOverviewPage() {
if (loading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
<div style={{
display: "flex", alignItems: "center", justifyContent: "center",
height: "100%", fontFamily: JM.fontSans,
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
}}>
<Loader2 style={{ width: 24, height: 24, color: JM.indigo }} className="animate-spin" />
</div>
);
}
if (!project) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90", fontSize: "0.88rem" }}>
<div style={{
display: "flex", alignItems: "center", justifyContent: "center",
height: "100%", fontFamily: JM.fontSans, color: JM.muted, fontSize: 14,
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
}}>
Project not found.
</div>
);

View File

@@ -1,459 +1,11 @@
"use client";
import { redirect } from "next/navigation";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
// Maps each PRD section to the discovery phase that populates it
const PRD_SECTIONS = [
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" },
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
];
interface SavedPhase {
phase: string;
title: string;
summary: string;
data: Record<string, unknown>;
saved_at: string;
}
function formatValue(v: unknown): string {
if (v === null || v === undefined) return "—";
if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", ");
return String(v);
}
function PhaseDataCard({ phase }: { phase: SavedPhase }) {
const [expanded, setExpanded] = useState(false);
const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== "");
return (
<div style={{
marginTop: 10, background: "#f6f4f0", borderRadius: 8,
border: "1px solid #e8e4dc", overflow: "hidden",
}}>
<button
onClick={() => setExpanded(e => !e)}
style={{
width: "100%", textAlign: "left", padding: "10px 14px",
background: "none", border: "none", cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "space-between",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}
>
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
{phase.summary}
</span>
<span style={{ fontSize: "0.7rem", color: "#a09a90", marginLeft: 8, flexShrink: 0 }}>
{expanded ? "▲" : "▼"}
</span>
</button>
{expanded && entries.length > 0 && (
<div style={{ padding: "4px 14px 14px", borderTop: "1px solid #e8e4dc" }}>
{entries.map(([k, v]) => (
<div key={k} style={{ marginTop: 10 }}>
<div style={{
fontSize: "0.6rem", color: "#b5b0a6", textTransform: "uppercase",
letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2,
}}>
{k.replace(/_/g, " ")}
</div>
<div style={{ fontSize: "0.78rem", color: "#2a2824", lineHeight: 1.5 }}>
{formatValue(v)}
</div>
</div>
))}
</div>
)}
</div>
);
}
interface ArchApp { name: string; type: string; description: string; tech?: string[]; screens?: string[] }
interface ArchInfra { name: string; reason: string }
interface ArchPackage { name: string; description: string }
interface ArchIntegration { name: string; required?: boolean; notes?: string }
interface Architecture {
productName?: string;
productType?: string;
summary?: string;
apps?: ArchApp[];
packages?: ArchPackage[];
infrastructure?: ArchInfra[];
integrations?: ArchIntegration[];
designSurfaces?: string[];
riskNotes?: string[];
}
function ArchitectureView({ arch }: { arch: Architecture }) {
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 10 }}>{title}</div>
{children}
</div>
);
const Card = ({ children }: { children: React.ReactNode }) => (
<div style={{ background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9, padding: "14px 16px", marginBottom: 8 }}>{children}</div>
);
const Tag = ({ label }: { label: string }) => (
<span style={{ background: "#f0ece4", borderRadius: 4, padding: "2px 7px", fontSize: "0.68rem", color: "#6b6560", fontFamily: "IBM Plex Mono, monospace", marginRight: 4, display: "inline-block", marginBottom: 3 }}>{label}</span>
);
return (
<div style={{ maxWidth: 760 }}>
{arch.summary && (
<div style={{ background: "#1a1a1a", borderRadius: 10, padding: "18px 22px", marginBottom: 24, color: "#e8e4dc", fontSize: "0.88rem", lineHeight: 1.7 }}>
{arch.summary}
</div>
)}
{(arch.apps ?? []).length > 0 && (
<Section title="Applications">
{arch.apps!.map(a => (
<Card key={a.name}>
<div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>{a.name}</span>
<span style={{ fontSize: "0.72rem", color: "#9a9490" }}>{a.type}</span>
</div>
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.55, marginBottom: a.tech?.length ? 8 : 0 }}>{a.description}</div>
{a.tech?.map(t => <Tag key={t} label={t} />)}
{a.screens && a.screens.length > 0 && (
<div style={{ marginTop: 6, fontSize: "0.72rem", color: "#a09a90" }}>Screens: {a.screens.join(", ")}</div>
)}
</Card>
))}
</Section>
)}
{(arch.packages ?? []).length > 0 && (
<Section title="Shared Packages">
{arch.packages!.map(p => (
<Card key={p.name}>
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>{p.name}</span>
<span style={{ fontSize: "0.78rem", color: "#4a4640" }}>{p.description}</span>
</div>
</Card>
))}
</Section>
)}
{(arch.infrastructure ?? []).length > 0 && (
<Section title="Infrastructure">
{arch.infrastructure!.map(i => (
<Card key={i.name}>
<div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 3 }}>{i.name}</div>
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>{i.reason}</div>
</Card>
))}
</Section>
)}
{(arch.integrations ?? []).length > 0 && (
<Section title="Integrations">
{arch.integrations!.map(i => (
<Card key={i.name}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: i.notes ? 4 : 0 }}>
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a" }}>{i.name}</span>
{i.required && <span style={{ fontSize: "0.62rem", background: "#fef3c7", color: "#92400e", padding: "1px 6px", borderRadius: 4 }}>required</span>}
</div>
{i.notes && <div style={{ fontSize: "0.78rem", color: "#4a4640" }}>{i.notes}</div>}
</Card>
))}
</Section>
)}
{(arch.riskNotes ?? []).length > 0 && (
<Section title="Architectural Risks">
{arch.riskNotes!.map((r, i) => (
<div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start", marginBottom: 8 }}>
<span style={{ fontSize: "0.72rem", color: "#d97706", marginTop: 2, flexShrink: 0 }}></span>
<span style={{ fontSize: "0.82rem", color: "#4a4640", lineHeight: 1.5 }}>{r}</span>
</div>
))}
</Section>
)}
</div>
);
}
export default function PRDPage() {
const params = useParams();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const [prd, setPrd] = useState<string | null>(null);
const [architecture, setArchitecture] = useState<Architecture | null>(null);
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<"prd" | "architecture">("prd");
const [archGenerating, setArchGenerating] = useState(false);
const [archError, setArchError] = useState<string | null>(null);
useEffect(() => {
Promise.all([
fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})),
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
]).then(([projectData, phaseData]) => {
setPrd(projectData?.project?.prd ?? null);
setArchitecture(projectData?.project?.architecture ?? null);
setSavedPhases(phaseData?.phases ?? []);
setLoading(false);
});
}, [projectId]);
const router = useRouter();
const handleGenerateArchitecture = async () => {
setArchGenerating(true);
setArchError(null);
try {
const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Generation failed");
setArchitecture(data.architecture);
setActiveTab("architecture");
} catch (e) {
setArchError(e instanceof Error ? e.message : "Something went wrong");
} finally {
setArchGenerating(false);
}
};
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
const sections = PRD_SECTIONS.map(s => ({
...s,
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
}));
const doneCount = sections.filter(s => s.isDone).length;
const totalPct = Math.round((doneCount / sections.length) * 100);
if (loading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
Loading
</div>
);
}
const tabs = [
{ id: "prd" as const, label: "PRD", available: true },
{ id: "architecture" as const, label: "Architecture", available: !!architecture },
];
return (
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
{/* Tab bar — only when at least one doc exists */}
{(prd || architecture) && (
<div style={{ display: "flex", gap: 4, marginBottom: 24 }}>
{tabs.map(t => {
const isActive = activeTab === t.id;
return (
<button
key={t.id}
onClick={() => t.available && setActiveTab(t.id)}
disabled={!t.available}
style={{
padding: "6px 14px", borderRadius: 8, border: "none", cursor: t.available ? "pointer" : "default",
background: isActive ? "#1a1a1a" : "transparent",
color: isActive ? "#fff" : t.available ? "#6b6560" : "#c5c0b8",
fontSize: "0.8rem", fontWeight: isActive ? 600 : 400,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
transition: "all 0.1s",
}}
>
{t.label}
{!t.available && <span style={{ marginLeft: 5, fontSize: "0.65rem", opacity: 0.6 }}></span>}
</button>
);
})}
</div>
)}
{/* Next step banner — PRD done but no architecture yet */}
{prd && !architecture && activeTab === "prd" && (
<div style={{
marginBottom: 24, padding: "18px 22px",
background: "#1a1a1a", borderRadius: 10,
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 16, flexWrap: "wrap",
}}>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 4 }}>
Next: Generate technical architecture
</div>
<div style={{ fontSize: "0.76rem", color: "#a09a90", lineHeight: 1.5 }}>
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds.
</div>
{archError && (
<div style={{ fontSize: "0.74rem", color: "#f87171", marginTop: 6 }}> {archError}</div>
)}
</div>
<button
onClick={handleGenerateArchitecture}
disabled={archGenerating}
style={{
padding: "10px 20px", borderRadius: 8, border: "none",
background: archGenerating ? "#4a4640" : "#fff",
color: archGenerating ? "#a09a90" : "#1a1a1a",
fontSize: "0.82rem", fontWeight: 700,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: archGenerating ? "default" : "pointer",
flexShrink: 0, display: "flex", alignItems: "center", gap: 8,
transition: "opacity 0.15s",
}}
>
{archGenerating && (
<span style={{
width: 12, height: 12, borderRadius: "50%",
border: "2px solid #60606040", borderTopColor: "#a09a90",
animation: "spin 0.7s linear infinite", display: "inline-block",
}} />
)}
{archGenerating ? "Analysing PRD…" : "Generate architecture →"}
</button>
</div>
)}
{/* Architecture tab */}
{activeTab === "architecture" && architecture && (
<ArchitectureView arch={architecture} />
)}
{/* PRD tab — finalized */}
{activeTab === "prd" && prd && (
<div style={{ maxWidth: 760 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
Product Requirements
</h3>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
PRD complete
</span>
</div>
<div style={{
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
padding: "28px 32px", lineHeight: 1.8,
fontSize: "0.88rem", color: "#2a2824",
whiteSpace: "pre-wrap", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}>
{prd}
</div>
</div>
)}
{/* PRD tab — section progress (no finalized PRD yet) */}
{activeTab === "prd" && !prd && (
/* ── Section progress view ── */
<div style={{ maxWidth: 680 }}>
{/* Progress bar */}
<div style={{
display: "flex", alignItems: "center", gap: 16,
padding: "16px 20px", background: "#fff",
border: "1px solid #e8e4dc", borderRadius: 10,
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
}}>
<div style={{
fontFamily: "IBM Plex Mono, monospace",
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
}}>
{totalPct}%
</div>
<div style={{ flex: 1 }}>
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
<div style={{
height: "100%", borderRadius: 2,
width: `${totalPct}%`, background: "#1a1a1a",
transition: "width 0.6s ease",
}} />
</div>
</div>
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
{doneCount}/{sections.length} sections
</span>
</div>
{/* Sections */}
{sections.map((s, i) => (
<div
key={s.id}
style={{
padding: "14px 18px", marginBottom: 6,
background: "#fff", borderRadius: 10,
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
animationDelay: `${i * 0.04}s`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{/* Status icon */}
<div style={{
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.65rem", fontWeight: 700,
color: s.isDone ? "#2e7d32" : "#c5c0b8",
}}>
{s.isDone ? "✓" : "○"}
</div>
<span style={{
flex: 1, fontSize: "0.84rem",
color: s.isDone ? "#1a1a1a" : "#a09a90",
fontWeight: s.isDone ? 500 : 400,
}}>
{s.label}
</span>
{s.isDone && s.savedPhase && (
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#2e7d32", background: "#2e7d3210",
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
}}>
saved
</span>
)}
{!s.isDone && !s.phaseId && (
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#b5b0a6", padding: "2px 7px",
}}>
generated
</span>
)}
</div>
{/* Expandable phase data */}
{s.isDone && s.savedPhase && (
<PhaseDataCard phase={s.savedPhase} />
)}
{/* Pending hint */}
{!s.isDone && (
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
{s.phaseId
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
: "Will be generated when PRD is finalized"}
</div>
)}
</div>
))}
{doneCount === 0 && (
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
Continue chatting with Vibn saved phases will appear here automatically.
</p>
)}
</div>
)}
</div>
);
/** Legacy URL — project work now lives under Tasks (PRD is the first task). */
export default async function PrdRedirectPage({
params,
}: {
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
redirect(`/${workspace}/project/${projectId}/tasks`);
}

View File

@@ -1,72 +1,12 @@
import { ProjectShell } from "@/components/layout/project-shell";
import { query } from "@/lib/db-postgres";
/**
* Passthrough layout for the project route.
*
* Two sibling route groups provide their own scaffolds:
* - (home)/ — VIBNSidebar scaffold for the project home page.
* - (workspace)/ — ProjectShell (top tab nav) for overview/build/run/etc.
*/
import { ReactNode } from "react";
interface ProjectData {
name: string;
description?: string;
status?: string;
progress?: number;
discoveryPhase?: number;
capturedData?: Record<string, string>;
createdAt?: string;
updatedAt?: string;
featureCount?: number;
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
}
async function getProjectData(projectId: string): Promise<ProjectData> {
try {
const rows = await query<{ data: any; created_at?: string; updated_at?: string }>(
`SELECT data, created_at, updated_at FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
if (rows.length > 0) {
const { data, created_at, updated_at } = rows[0];
return {
name: data?.productName || data?.name || "Project",
description: data?.productVision || data?.description,
status: data?.status,
progress: data?.progress ?? 0,
discoveryPhase: data?.discoveryPhase ?? 0,
capturedData: data?.capturedData ?? {},
createdAt: created_at,
updatedAt: updated_at,
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
creationMode: data?.creationMode ?? "fresh",
};
}
} catch (error) {
console.error("Error fetching project:", error);
}
return { name: "Project" };
}
export default async function ProjectLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace, projectId } = await params;
const project = await getProjectData(projectId);
return (
<ProjectShell
workspace={workspace}
projectId={projectId}
projectName={project.name}
projectDescription={project.description}
projectStatus={project.status}
projectProgress={project.progress}
discoveryPhase={project.discoveryPhase}
capturedData={project.capturedData}
createdAt={project.createdAt}
updatedAt={project.updatedAt}
featureCount={project.featureCount}
creationMode={project.creationMode}
>
{children}
</ProjectShell>
);
export default function ProjectRootLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}