- New /api/projects/[projectId]/architecture (GET/POST/PATCH) — reads PRD + phases, calls AI to generate structured monorepo architecture JSON, persists to fs_projects.data.architecture; PATCH sets confirmed flag - Rebuilt Build tab to show the AI-generated recommendation: expandable app cards (tech stack, key screens), shared packages, infrastructure, integrations, and risk notes; confirm button + "adjustable later" note Made-with: Cursor
476 lines
18 KiB
TypeScript
476 lines
18 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { useParams } from "next/navigation";
|
||
import Link from "next/link";
|
||
|
||
interface App {
|
||
name: string;
|
||
type: string;
|
||
description: string;
|
||
tech: string[];
|
||
screens: string[];
|
||
}
|
||
|
||
interface Package {
|
||
name: string;
|
||
description: string;
|
||
}
|
||
|
||
interface Infra {
|
||
name: string;
|
||
reason: string;
|
||
}
|
||
|
||
interface Integration {
|
||
name: string;
|
||
required: boolean;
|
||
notes: string;
|
||
}
|
||
|
||
interface Architecture {
|
||
productName: string;
|
||
productType: string;
|
||
summary: string;
|
||
apps: App[];
|
||
packages: Package[];
|
||
infrastructure: Infra[];
|
||
integrations: Integration[];
|
||
designSurfaces: string[];
|
||
riskNotes: string[];
|
||
}
|
||
|
||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<div style={{
|
||
fontSize: "0.6rem", fontWeight: 700, color: "#a09a90",
|
||
letterSpacing: "0.12em", textTransform: "uppercase",
|
||
marginBottom: 10, marginTop: 28,
|
||
}}>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AppCard({ app }: { app: App }) {
|
||
const [open, setOpen] = useState(false);
|
||
const icons: Record<string, string> = {
|
||
web: "🌐", api: "⚡", simulator: "🎮", admin: "🔧",
|
||
mobile: "📱", worker: "⚙️", engine: "🎯",
|
||
};
|
||
const icon = Object.entries(icons).find(([k]) => app.name.toLowerCase().includes(k))?.[1] ?? "📦";
|
||
|
||
return (
|
||
<div style={{
|
||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||
marginBottom: 8, overflow: "hidden",
|
||
}}>
|
||
<button
|
||
onClick={() => setOpen(o => !o)}
|
||
style={{
|
||
width: "100%", textAlign: "left", background: "none", border: "none",
|
||
cursor: "pointer", padding: "14px 18px",
|
||
display: "flex", alignItems: "center", gap: 12,
|
||
fontFamily: "Outfit, sans-serif",
|
||
}}
|
||
>
|
||
<span style={{ fontSize: "1.2rem" }}>{icon}</span>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>
|
||
apps/{app.name}
|
||
</span>
|
||
<span style={{
|
||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||
color: "#6b6560", background: "#f0ece4",
|
||
padding: "2px 7px", borderRadius: 4,
|
||
}}>
|
||
{app.type}
|
||
</span>
|
||
</div>
|
||
<div style={{ fontSize: "0.78rem", color: "#8a8478", marginTop: 2 }}>
|
||
{app.description}
|
||
</div>
|
||
</div>
|
||
<span style={{ fontSize: "0.7rem", color: "#c5c0b8" }}>{open ? "▲" : "▼"}</span>
|
||
</button>
|
||
|
||
{open && (
|
||
<div style={{ padding: "0 18px 16px", borderTop: "1px solid #f0ece4" }}>
|
||
{app.tech.length > 0 && (
|
||
<div style={{ marginTop: 12 }}>
|
||
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Stack</div>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
|
||
{app.tech.map((t, i) => (
|
||
<span key={i} style={{
|
||
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
|
||
color: "#4a4640", background: "#f6f4f0",
|
||
border: "1px solid #e8e4dc", padding: "2px 8px", borderRadius: 4,
|
||
}}>{t}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{app.screens.length > 0 && (
|
||
<div style={{ marginTop: 12 }}>
|
||
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Key screens</div>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||
{app.screens.map((s, i) => (
|
||
<div key={i} style={{ fontSize: "0.78rem", color: "#4a4640", display: "flex", alignItems: "center", gap: 6 }}>
|
||
<span style={{ color: "#c5c0b8" }}>→</span> {s}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function BuildPage() {
|
||
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 [architectureConfirmed, setArchitectureConfirmed] = useState(false);
|
||
const [loading, setLoading] = useState(true);
|
||
const [generating, setGenerating] = useState(false);
|
||
const [confirming, setConfirming] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
fetch(`/api/projects/${projectId}/architecture`)
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
setPrd(d.prd);
|
||
setArchitecture(d.architecture ?? null);
|
||
setLoading(false);
|
||
})
|
||
.catch(() => setLoading(false));
|
||
|
||
// Also check confirmed flag
|
||
fetch(`/api/projects/${projectId}`)
|
||
.then(r => r.json())
|
||
.then(d => setArchitectureConfirmed(d.project?.architectureConfirmed === true))
|
||
.catch(() => {});
|
||
}, [projectId]);
|
||
|
||
const handleGenerate = async (force = false) => {
|
||
setGenerating(true);
|
||
setError(null);
|
||
try {
|
||
const res = await fetch(`/api/projects/${projectId}/architecture`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ forceRegenerate: force }),
|
||
});
|
||
const d = await res.json();
|
||
if (!res.ok) throw new Error(d.error || "Generation failed");
|
||
setArchitecture(d.architecture);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Something went wrong");
|
||
} finally {
|
||
setGenerating(false);
|
||
}
|
||
};
|
||
|
||
const handleConfirm = async () => {
|
||
setConfirming(true);
|
||
try {
|
||
await fetch(`/api/projects/${projectId}/architecture`, { method: "PATCH" });
|
||
setArchitectureConfirmed(true);
|
||
} catch { /* swallow */ } finally {
|
||
setConfirming(false);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
|
||
Loading…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// No PRD yet
|
||
if (!prd) {
|
||
return (
|
||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
|
||
<div style={{ textAlign: "center", maxWidth: 360 }}>
|
||
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🔒</div>
|
||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
|
||
Complete your PRD first
|
||
</h3>
|
||
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 20 }}>
|
||
Finish your discovery conversation with Atlas, then the architect will unlock automatically.
|
||
</p>
|
||
<Link href={`/${workspace}/project/${projectId}/overview`} style={{
|
||
display: "inline-block", padding: "9px 20px", borderRadius: 7,
|
||
background: "#1a1a1a", color: "#fff",
|
||
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
||
textDecoration: "none",
|
||
}}>
|
||
Continue with Atlas →
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// PRD exists but no architecture yet — prompt to generate
|
||
if (!architecture) {
|
||
return (
|
||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
|
||
<div style={{ textAlign: "center", maxWidth: 440 }}>
|
||
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🏗️</div>
|
||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.4rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 10 }}>
|
||
Ready to architect {architecture ? (architecture as Architecture).productName : "your product"}
|
||
</h3>
|
||
<p style={{ fontSize: "0.84rem", color: "#6b6560", lineHeight: 1.65, marginBottom: 28, maxWidth: 380, margin: "0 auto 28px" }}>
|
||
The AI will read your PRD and recommend the technical structure — apps, services, database, and integrations. You'll review it before anything gets built.
|
||
</p>
|
||
{error && (
|
||
<div style={{ marginBottom: 16, padding: "10px 14px", background: "#fff0f0", border: "1px solid #ffcdd2", borderRadius: 8, fontSize: "0.78rem", color: "#c62828" }}>
|
||
{error}
|
||
</div>
|
||
)}
|
||
<button
|
||
onClick={() => handleGenerate(false)}
|
||
disabled={generating}
|
||
style={{
|
||
padding: "11px 28px", borderRadius: 8,
|
||
background: generating ? "#8a8478" : "#1a1a1a",
|
||
color: "#fff", border: "none", cursor: generating ? "default" : "pointer",
|
||
fontSize: "0.84rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
||
transition: "background 0.15s",
|
||
}}
|
||
>
|
||
{generating ? "Analysing PRD…" : "Generate architecture →"}
|
||
</button>
|
||
{generating && (
|
||
<p style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 12 }}>
|
||
This takes about 15–30 seconds
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Architecture loaded — show full review UI
|
||
return (
|
||
<div style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif", maxWidth: 780 }}>
|
||
|
||
{/* Header */}
|
||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 4 }}>
|
||
<div>
|
||
<h2 style={{ fontFamily: "Newsreader, serif", fontSize: "1.35rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
||
Architecture
|
||
</h2>
|
||
<p style={{ fontSize: "0.75rem", color: "#a09a90", marginTop: 4 }}>
|
||
{architecture.productType}
|
||
</p>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||
{architectureConfirmed && (
|
||
<span style={{
|
||
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
|
||
color: "#2e7d32", background: "#2e7d3210",
|
||
border: "1px solid #a5d6a740", padding: "4px 10px", borderRadius: 5,
|
||
}}>
|
||
✓ Confirmed
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={() => handleGenerate(true)}
|
||
disabled={generating}
|
||
style={{
|
||
padding: "6px 14px", borderRadius: 6,
|
||
background: "none", border: "1px solid #e0dcd4",
|
||
fontSize: "0.72rem", color: "#8a8478", cursor: "pointer",
|
||
fontFamily: "Outfit, sans-serif",
|
||
}}
|
||
>
|
||
{generating ? "Regenerating…" : "Regenerate"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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>
|
||
);
|
||
}
|