chore: convert submodules to standard directories for true monorepo structure

This commit is contained in:
2026-05-13 14:54:23 -07:00
parent 4339da259c
commit abf9bf89c2
761 changed files with 133928 additions and 2 deletions

View File

@@ -0,0 +1,286 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { JM, JV } from "@/components/project-creation/modal-theme";
import { PRD_PLAN_SECTIONS, isSectionFilled } from "@/lib/prd-sections";
import {
type ChatContextRef,
contextRefKey,
} from "@/lib/chat-context-refs";
interface SavedPhase {
phase: string;
title: string;
summary: string;
data: Record<string, unknown>;
saved_at: string;
}
export function BuildLivePlanPanel({
projectId,
workspace,
chatContextRefs,
onAddSectionRef,
compactHeader,
}: {
projectId: string;
workspace: string;
chatContextRefs: ChatContextRef[];
onAddSectionRef: (label: string, phaseId: string | null) => void;
/** When true, hide subtitle to save space in narrow tabs */
compactHeader?: boolean;
}) {
const [prdText, setPrdText] = useState<string | null>(null);
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(() => {
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]) => {
setPrdText(projectData?.project?.prd ?? null);
setSavedPhases(phaseData?.phases ?? []);
setLoading(false);
});
}, [projectId]);
useEffect(() => {
refresh();
const t = setInterval(refresh, 8000);
return () => clearInterval(t);
}, [refresh]);
const savedPhaseIds = useMemo(() => new Set(savedPhases.map(p => p.phase)), [savedPhases]);
const phaseMap = useMemo(() => new Map(savedPhases.map(p => [p.phase, p])), [savedPhases]);
const rows = useMemo(() => {
let firstOpenIndex = -1;
const list = PRD_PLAN_SECTIONS.map((s, index) => {
const done = isSectionFilled(s.phaseId, savedPhaseIds);
if (!done && firstOpenIndex < 0) firstOpenIndex = index;
return {
...s,
done,
active: !done && index === firstOpenIndex,
pending: !done && index > firstOpenIndex,
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
};
});
return list;
}, [savedPhaseIds, phaseMap]);
const doneCount = rows.filter(r => r.done).length;
const tasksHref = `/${workspace}/project/${projectId}/tasks`;
const attached = useCallback(
(label: string) => chatContextRefs.some(r => r.kind === "section" && r.label === label),
[chatContextRefs]
);
if (loading) {
return (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: JV.prdPanelBg,
color: JM.muted,
fontSize: 13,
fontFamily: JM.fontSans,
}}
>
Loading plan
</div>
);
}
if (prdText) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
background: JV.prdPanelBg,
borderLeft: `1px solid ${JM.border}`,
fontFamily: JM.fontSans,
}}
>
<div style={{ padding: "16px 16px 12px", borderBottom: `1px solid ${JM.border}`, flexShrink: 0 }}>
<div style={{ fontSize: 10.5, fontWeight: 700, color: JM.muted, textTransform: "uppercase", letterSpacing: "0.06em" }}>
Your plan
</div>
<div style={{ fontSize: 15, fontWeight: 700, color: JM.ink, marginTop: 4 }}>PRD ready</div>
{!compactHeader && (
<div style={{ fontSize: 12, color: JM.muted, marginTop: 4, lineHeight: 1.45 }}>
Full document saved open Task to edit or keep refining in chat.
</div>
)}
</div>
<div style={{ flex: 1, padding: 16, overflow: "auto" }}>
<Link
href={tasksHref}
style={{
display: "block",
textAlign: "center",
padding: "11px 14px",
borderRadius: 10,
background: JM.primaryGradient,
color: "#fff",
fontSize: 13,
fontWeight: 600,
textDecoration: "none",
boxShadow: JM.primaryShadow,
}}
>
View full PRD
</Link>
</div>
</div>
);
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
background: JV.prdPanelBg,
borderLeft: `1px solid ${JM.border}`,
fontFamily: JM.fontSans,
minWidth: 0,
}}
>
<style>{`
@keyframes buildPlanFadeUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
<div style={{ padding: "16px 16px 10px", borderBottom: `1px solid ${JM.border}`, flexShrink: 0 }}>
<div style={{ fontSize: 10.5, fontWeight: 700, color: JM.muted, textTransform: "uppercase", letterSpacing: "0.06em" }}>
Your plan
</div>
<div style={{ fontSize: 15, fontWeight: 700, color: JM.ink, marginTop: 4 }}>Fills as you chat</div>
{!compactHeader && (
<div style={{ fontSize: 12, color: JM.muted, marginTop: 4, lineHeight: 1.45 }}>
Tap a section to attach it your next message prioritizes it.
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 12 }}>
<div style={{ fontSize: 22, fontWeight: 700, color: JM.indigo, minWidth: 40 }}>{Math.round((doneCount / rows.length) * 100)}%</div>
<div style={{ flex: 1 }}>
<div style={{ height: 4, borderRadius: 2, background: "#e0e7ff" }}>
<div
style={{
height: "100%",
borderRadius: 2,
width: `${(doneCount / rows.length) * 100}%`,
background: JM.primaryGradient,
transition: "width 0.5s ease",
}}
/>
</div>
</div>
<span style={{ fontSize: 11, color: JM.muted, whiteSpace: "nowrap" }}>
{doneCount}/{rows.length}
</span>
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "12px 14px 8px" }}>
{rows.map((r, i) => {
const isAttached = attached(r.label);
const hint = r.done && r.savedPhase?.summary
? r.savedPhase.summary.slice(0, 120) + (r.savedPhase.summary.length > 120 ? "…" : "")
: r.active
? "Answer in chat — this block updates when the phase saves."
: "Tap to attach — chat uses this section as context.";
return (
<button
key={r.id}
type="button"
onClick={() => onAddSectionRef(r.label, r.phaseId)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "11px 12px",
marginBottom: 8,
borderRadius: 9,
border: r.active ? `1px solid ${JV.bubbleAiBorder}` : `1px solid ${JM.border}`,
borderLeftWidth: r.active ? 3 : 1,
borderLeftColor: r.active ? JM.indigo : JM.border,
background: r.active ? "#fafaff" : r.done ? "#fff" : "#fff",
opacity: r.pending && !r.done ? 0.55 : 1,
borderStyle: r.pending && !r.done ? "dashed" : "solid",
cursor: "pointer",
boxShadow: r.active ? "0 0 0 3px rgba(99,102,241,0.08), 0 2px 12px rgba(99,102,241,0.07)" : "0 1px 8px rgba(99,102,241,0.05)",
animation: `buildPlanFadeUp 0.35s ease ${i * 0.02}s both`,
fontFamily: JM.fontSans,
}}
>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: r.done ? JM.mid : JM.muted }}>
{r.label}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
{r.done && <span style={{ fontSize: 10, color: JM.indigo, fontWeight: 700 }}></span>}
{isAttached && <span style={{ fontSize: 9, fontWeight: 600, color: JM.indigo, background: JV.violetTint, padding: "2px 6px", borderRadius: 4 }}>Attached</span>}
</div>
</div>
<div style={{ fontSize: 12, lineHeight: 1.5, color: r.done ? JM.ink : JM.muted, marginTop: 4 }}>{hint}</div>
</button>
);
})}
</div>
<div
style={{
flexShrink: 0,
padding: "10px 14px 14px",
borderTop: `1px solid ${JM.border}`,
display: "flex",
flexDirection: "column",
gap: 8,
alignItems: "stretch",
}}
>
<Link
href={tasksHref}
style={{
textAlign: "center",
padding: "10px 14px",
borderRadius: 9,
fontSize: 13,
fontWeight: 600,
color: JM.indigo,
background: "#eef2ff",
border: `1px solid ${JV.bubbleAiBorder}`,
textDecoration: "none",
}}
>
Open requirements view
</Link>
</div>
</div>
);
}
export function addSectionContextRef(
prev: ChatContextRef[],
label: string,
phaseId: string | null
): ChatContextRef[] {
const next: ChatContextRef = { kind: "section", label, phaseId };
const k = contextRefKey(next);
if (prev.some(r => contextRefKey(r) === k)) return prev;
return [...prev, next];
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,330 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useParams } from "next/navigation";
interface AnalysisResult {
decisions: string[];
ideas: string[];
openQuestions: string[];
architecture: string[];
targetUsers: string[];
}
interface ChatImportMainProps {
projectId: string;
projectName: string;
sourceData?: { chatText?: string };
analysisResult?: AnalysisResult;
}
type Stage = "intake" | "extracting" | "review";
function EditableList({
label,
items,
accent,
onChange,
}: {
label: string;
items: string[];
accent: string;
onChange: (items: string[]) => void;
}) {
const handleEdit = (i: number, value: string) => {
const next = [...items];
next[i] = value;
onChange(next);
};
const handleDelete = (i: number) => {
onChange(items.filter((_, idx) => idx !== i));
};
const handleAdd = () => {
onChange([...items, ""]);
};
return (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: "0.68rem", fontWeight: 700, color: accent, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 8 }}>
{label}
</div>
{items.length === 0 && (
<p style={{ fontSize: "0.75rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", margin: "0 0 6px" }}>
Nothing captured.
</p>
)}
{items.map((item, i) => (
<div key={i} style={{ display: "flex", gap: 6, marginBottom: 5 }}>
<input
type="text"
value={item}
onChange={e => handleEdit(i, e.target.value)}
style={{
flex: 1, padding: "7px 10px", borderRadius: 6,
border: "1px solid #e0dcd4", background: "#faf8f5",
fontSize: "0.81rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
color: "#1a1a1a", outline: "none",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
<button
onClick={() => handleDelete(i)}
style={{ background: "none", border: "none", cursor: "pointer", color: "#c5c0b8", fontSize: "0.85rem", padding: "4px 6px" }}
onMouseEnter={e => (e.currentTarget.style.color = "#e53e3e")}
onMouseLeave={e => (e.currentTarget.style.color = "#c5c0b8")}
>
×
</button>
</div>
))}
<button
onClick={handleAdd}
style={{
background: "none", border: "1px dashed #e0dcd4", cursor: "pointer",
borderRadius: 6, padding: "5px 10px", fontSize: "0.72rem", color: "#a09a90",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", width: "100%",
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = "#b5b0a6")}
onMouseLeave={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
>
+ Add
</button>
</div>
);
}
export function ChatImportMain({
projectId,
projectName,
sourceData,
analysisResult: initialResult,
}: ChatImportMainProps) {
const router = useRouter();
const params = useParams();
const workspace = params?.workspace as string;
const hasChatText = !!sourceData?.chatText;
const [stage, setStage] = useState<Stage>(
initialResult ? "review" : hasChatText ? "extracting" : "intake"
);
const [chatText, setChatText] = useState(sourceData?.chatText ?? "");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<AnalysisResult>(
initialResult ?? { decisions: [], ideas: [], openQuestions: [], architecture: [], targetUsers: [] }
);
// Kick off extraction automatically if chatText is ready
useEffect(() => {
if (stage === "extracting") {
runExtraction();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stage]);
const runExtraction = async () => {
setError(null);
try {
const res = await fetch(`/api/projects/${projectId}/analyze-chats`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chatText }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Extraction failed");
setResult(data.analysisResult);
setStage("review");
} catch (e) {
setError(e instanceof Error ? e.message : "Something went wrong");
setStage("intake");
}
};
const handlePRD = () => router.push(`/${workspace}/project/${projectId}/tasks`);
const handleMVP = () => router.push(`/${workspace}/project/${projectId}/mvp-setup/launch`);
// ── Stage: intake ─────────────────────────────────────────────────────────
if (stage === "intake") {
return (
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
<div style={{ width: "100%", maxWidth: 640, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
Paste your chat history
</h2>
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
{projectName} Atlas will extract decisions, ideas, architecture notes, and more.
</p>
</div>
{error && (
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
{error}
</div>
)}
<textarea
value={chatText}
onChange={e => setChatText(e.target.value)}
placeholder={"Paste conversations from ChatGPT, Claude, Gemini, or any AI tool.\n\nCopy the full conversation — Atlas handles the cleanup."}
rows={14}
style={{
width: "100%", padding: "14px 16px", marginBottom: 16,
borderRadius: 10, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.85rem", lineHeight: 1.6,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
outline: "none", resize: "vertical", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
<button
onClick={() => {
if (chatText.trim().length > 20) {
setStage("extracting");
}
}}
disabled={chatText.trim().length < 20}
style={{
width: "100%", padding: "13px",
borderRadius: 8, border: "none",
background: chatText.trim().length > 20 ? "#1a1a1a" : "#e0dcd4",
color: chatText.trim().length > 20 ? "#fff" : "#b5b0a6",
fontSize: "0.9rem", fontWeight: 600,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: chatText.trim().length > 20 ? "pointer" : "not-allowed",
}}
>
Extract insights
</button>
</div>
</div>
);
}
// ── Stage: extracting ─────────────────────────────────────────────────────
if (stage === "extracting") {
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ textAlign: "center" }}>
<div style={{
width: 48, height: 48, borderRadius: "50%",
border: "3px solid #e0dcd4", borderTopColor: "#1a1a1a",
animation: "vibn-chat-spin 0.8s linear infinite",
margin: "0 auto 20px",
}} />
<style>{`@keyframes vibn-chat-spin { to { transform:rotate(360deg); } }`}</style>
<h3 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 6px" }}>
Analysing your chats
</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>
Atlas is extracting decisions, ideas, and insights
</p>
</div>
</div>
);
}
// ── Stage: review ─────────────────────────────────────────────────────────
return (
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ maxWidth: 760, margin: "0 auto" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
What Atlas found
</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>
Review and edit the extracted insights for <strong>{projectName}</strong>. These will seed your PRD or MVP plan.
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, marginBottom: 28 }}>
{/* Left column */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Decisions made"
items={result.decisions}
accent="#1a3a5c"
onChange={items => setResult(r => ({ ...r, decisions: items }))}
/>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Ideas & features"
items={result.ideas}
accent="#2e5a4a"
onChange={items => setResult(r => ({ ...r, ideas: items }))}
/>
</div>
</div>
{/* Right column */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Open questions"
items={result.openQuestions}
accent="#9a7b3a"
onChange={items => setResult(r => ({ ...r, openQuestions: items }))}
/>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Architecture notes"
items={result.architecture}
accent="#4a3728"
onChange={items => setResult(r => ({ ...r, architecture: items }))}
/>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Target users"
items={result.targetUsers}
accent="#4a2a5a"
onChange={items => setResult(r => ({ ...r, targetUsers: items }))}
/>
</div>
</div>
</div>
{/* Decision buttons */}
<div style={{
background: "#1a1a1a", borderRadius: 12, padding: "22px 24px",
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, flexWrap: "wrap",
}}>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 3 }}>Ready to move forward?</div>
<div style={{ fontSize: "0.75rem", color: "#8a8478" }}>Choose how you want to proceed with {projectName}.</div>
</div>
<div style={{ display: "flex", gap: 10 }}>
<button
onClick={handlePRD}
style={{
padding: "11px 22px", borderRadius: 8, border: "none",
background: "#fff", color: "#1a1a1a",
fontSize: "0.85rem", fontWeight: 700, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
>
Generate PRD
</button>
<button
onClick={handleMVP}
style={{
padding: "11px 22px", borderRadius: 8,
border: "1px solid rgba(255,255,255,0.2)", background: "transparent", color: "#fff",
fontSize: "0.85rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
onMouseEnter={e => (e.currentTarget.style.background = "rgba(255,255,255,0.08)")}
onMouseLeave={e => (e.currentTarget.style.background = "transparent")}
>
Plan MVP
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,363 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useParams } from "next/navigation";
interface ArchRow {
category: string;
item: string;
status: "found" | "partial" | "missing";
detail?: string;
}
interface AnalysisResult {
summary: string;
rows: ArchRow[];
suggestedSurfaces: string[];
}
interface CodeImportMainProps {
projectId: string;
projectName: string;
sourceData?: { repoUrl?: string };
analysisResult?: AnalysisResult;
creationStage?: string;
}
type Stage = "input" | "cloning" | "mapping" | "surfaces";
const STATUS_COLORS = {
found: { bg: "#f0fdf4", text: "#15803d", label: "Found" },
partial: { bg: "#fffbeb", text: "#b45309", label: "Partial" },
missing: { bg: "#fff1f2", text: "#be123c", label: "Missing" },
};
const CATEGORY_ORDER = [
"Tech Stack", "Infrastructure", "Database", "API Surface",
"Frontend", "Auth", "Third-party", "Missing / Gaps",
];
const PROGRESS_STEPS = [
{ key: "cloning", label: "Cloning repository" },
{ key: "reading", label: "Reading key files" },
{ key: "analyzing", label: "Mapping architecture" },
{ key: "done", label: "Analysis complete" },
];
export function CodeImportMain({
projectId,
projectName,
sourceData,
analysisResult: initialResult,
creationStage,
}: CodeImportMainProps) {
const router = useRouter();
const params = useParams();
const workspace = params?.workspace as string;
const hasRepo = !!sourceData?.repoUrl;
const getInitialStage = (): Stage => {
if (initialResult) return "mapping";
if (creationStage === "surfaces") return "surfaces";
if (hasRepo) return "cloning";
return "input";
};
const [stage, setStage] = useState<Stage>(getInitialStage);
const [repoUrl, setRepoUrl] = useState(sourceData?.repoUrl ?? "");
const [progressStep, setProgressStep] = useState<string>("cloning");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<AnalysisResult | null>(initialResult ?? null);
const [confirmedSurfaces, setConfirmedSurfaces] = useState<string[]>(
initialResult?.suggestedSurfaces ?? []
);
// Kick off analysis when in cloning stage
useEffect(() => {
if (stage !== "cloning") return;
startAnalysis();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stage]);
// Poll for analysis status when cloning
useEffect(() => {
if (stage !== "cloning") return;
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/projects/${projectId}/analysis-status`);
const data = await res.json();
setProgressStep(data.stage ?? "cloning");
if (data.stage === "done" && data.analysisResult) {
setResult(data.analysisResult);
setConfirmedSurfaces(data.analysisResult.suggestedSurfaces ?? []);
clearInterval(interval);
setStage("mapping");
}
} catch { /* keep polling */ }
}, 2500);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stage]);
const startAnalysis = async () => {
setError(null);
try {
await fetch(`/api/projects/${projectId}/analyze-repo`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repoUrl }),
});
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to start analysis");
setStage("input");
}
};
const handleConfirmSurfaces = async () => {
try {
await fetch(`/api/projects/${projectId}/design-surfaces`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ surfaces: confirmedSurfaces }),
});
router.push(`/${workspace}/project/${projectId}/design`);
} catch { /* navigate anyway */ }
};
const toggleSurface = (s: string) => {
setConfirmedSurfaces(prev =>
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
);
};
// ── Stage: input ──────────────────────────────────────────────────────────
if (stage === "input") {
const isValid = repoUrl.trim().startsWith("http");
return (
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
Import your repository
</h2>
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
{projectName} paste a clone URL to map your existing stack.
</p>
</div>
{error && (
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
{error}
</div>
)}
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
Repository URL (HTTPS)
</label>
<input
type="text"
value={repoUrl}
onChange={e => setRepoUrl(e.target.value)}
placeholder="https://github.com/yourorg/your-repo"
style={{
width: "100%", padding: "12px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
onKeyDown={e => { if (e.key === "Enter" && isValid) setStage("cloning"); }}
autoFocus
/>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.55, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
Atlas will clone and map your stack tech, database, auth, APIs, and what's missing for a complete go-to-market build.
</div>
<button
onClick={() => { if (isValid) setStage("cloning"); }}
disabled={!isValid}
style={{
width: "100%", padding: "13px", borderRadius: 8, border: "none",
background: isValid ? "#1a1a1a" : "#e0dcd4",
color: isValid ? "#fff" : "#b5b0a6",
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: isValid ? "pointer" : "not-allowed",
}}
>
Map this repo
</button>
</div>
</div>
);
}
// ── Stage: cloning ────────────────────────────────────────────────────────
if (stage === "cloning") {
const currentIdx = PROGRESS_STEPS.findIndex(s => s.key === progressStep);
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ textAlign: "center", maxWidth: 400 }}>
<div style={{
width: 52, height: 52, borderRadius: "50%",
border: "3px solid #e0dcd4", borderTopColor: "#1a1a1a",
animation: "vibn-repo-spin 0.85s linear infinite",
margin: "0 auto 24px",
}} />
<style>{`@keyframes vibn-repo-spin { to { transform:rotate(360deg); } }`}</style>
<h3 style={{ fontSize: "1.1rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 8px" }}>
Mapping your codebase
</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 28px" }}>
{repoUrl || sourceData?.repoUrl || "Repository"}
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8, textAlign: "left" }}>
{PROGRESS_STEPS.map((step, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return (
<div key={step.key} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 22, height: 22, borderRadius: "50%", flexShrink: 0,
background: done ? "#1a1a1a" : active ? "#f6f4f0" : "#f6f4f0",
border: active ? "2px solid #1a1a1a" : done ? "none" : "2px solid #e0dcd4",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.6rem", fontWeight: 700, color: done ? "#fff" : "#a09a90",
}}>
{done ? "✓" : active ? <span style={{ width: 8, height: 8, borderRadius: "50%", background: "#1a1a1a", display: "block" }} /> : ""}
</div>
<span style={{ fontSize: "0.8rem", fontWeight: active ? 600 : 400, color: done ? "#6b6560" : active ? "#1a1a1a" : "#b5b0a6" }}>
{step.label}
</span>
</div>
);
})}
</div>
</div>
</div>
);
}
// ── Stage: mapping ────────────────────────────────────────────────────────
if (stage === "mapping" && result) {
const byCategory: Record<string, ArchRow[]> = {};
for (const row of result.rows) {
const cat = row.category || "Other";
if (!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(row);
}
const categories = [
...CATEGORY_ORDER.filter(c => byCategory[c]),
...Object.keys(byCategory).filter(c => !CATEGORY_ORDER.includes(c)),
];
return (
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ maxWidth: 800, margin: "0 auto" }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
Architecture map
</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 4px" }}>
{projectName} {result.summary}
</p>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", overflow: "hidden", marginBottom: 24 }}>
{categories.map((cat, catIdx) => (
<div key={cat}>
{catIdx > 0 && <div style={{ height: 1, background: "#f0ece4" }} />}
<div style={{ padding: "12px 20px", background: "#faf8f5", fontSize: "0.68rem", fontWeight: 700, color: "#6b6560", letterSpacing: "0.06em", textTransform: "uppercase" }}>
{cat}
</div>
{byCategory[cat].map((row, i) => {
const sc = STATUS_COLORS[row.status];
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 20px", borderTop: "1px solid #f6f4f0" }}>
<div style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a", fontWeight: 500 }}>{row.item}</div>
{row.detail && <div style={{ fontSize: "0.75rem", color: "#8a8478", flex: 2 }}>{row.detail}</div>}
<div style={{ padding: "3px 10px", borderRadius: 4, background: sc.bg, color: sc.text, fontSize: "0.68rem", fontWeight: 700, flexShrink: 0 }}>
{sc.label}
</div>
</div>
);
})}
</div>
))}
</div>
<button
onClick={() => setStage("surfaces")}
style={{
width: "100%", padding: "13px", borderRadius: 8, border: "none",
background: "#1a1a1a", color: "#fff",
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
>
Choose what to build next
</button>
</div>
</div>
);
}
// ── Stage: surfaces ───────────────────────────────────────────────────────
const SURFACE_OPTIONS = [
{ id: "marketing", label: "Marketing Site", icon: "◎", desc: "Landing page, pricing, blog" },
{ id: "web-app", label: "Web App", icon: "⬡", desc: "Core SaaS product with auth" },
{ id: "admin", label: "Admin Panel", icon: "◫", desc: "Ops dashboard, content management" },
{ id: "api", label: "API Layer", icon: "⌁", desc: "REST/GraphQL endpoints" },
];
return (
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
What should Atlas build?
</h2>
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
Based on the gap analysis, Atlas suggests the surfaces below. Confirm or adjust.
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 24 }}>
{SURFACE_OPTIONS.map(s => {
const selected = confirmedSurfaces.includes(s.id);
return (
<button
key={s.id}
onClick={() => toggleSurface(s.id)}
style={{
padding: "18px", borderRadius: 10, textAlign: "left",
border: `2px solid ${selected ? "#1a1a1a" : "#e8e4dc"}`,
background: selected ? "#1a1a1a08" : "#fff",
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
transition: "all 0.12s",
}}
onMouseEnter={e => { if (!selected) e.currentTarget.style.borderColor = "#d0ccc4"; }}
onMouseLeave={e => { if (!selected) e.currentTarget.style.borderColor = "#e8e4dc"; }}
>
<div style={{ fontSize: "1.2rem", marginBottom: 8 }}>{s.icon}</div>
<div style={{ fontSize: "0.84rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 3 }}>{s.label}</div>
<div style={{ fontSize: "0.73rem", color: "#8a8478" }}>{s.desc}</div>
</button>
);
})}
</div>
<button
onClick={handleConfirmSurfaces}
disabled={confirmedSurfaces.length === 0}
style={{
width: "100%", padding: "13px", borderRadius: 8, border: "none",
background: confirmedSurfaces.length > 0 ? "#1a1a1a" : "#e0dcd4",
color: confirmedSurfaces.length > 0 ? "#fff" : "#b5b0a6",
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: confirmedSurfaces.length > 0 ? "pointer" : "not-allowed",
}}
>
Go to Design
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,705 @@
"use client";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { AtlasChat } from "@/components/AtlasChat";
import { useRouter, useParams } from "next/navigation";
import Link from "next/link";
import { ArrowUpDown, Filter, LayoutPanelLeft, Search } from "lucide-react";
import { JM, JV } from "@/components/project-creation/modal-theme";
import {
type ChatContextRef,
contextRefKey,
} from "@/lib/chat-context-refs";
const DISCOVERY_PHASES = [
"big_picture",
"users_personas",
"features_scope",
"business_model",
"screens_data",
"risks_questions",
] as const;
const PHASE_DISPLAY: Record<string, string> = {
big_picture: "Big picture",
users_personas: "Users & personas",
features_scope: "Features & scope",
business_model: "Business model",
screens_data: "Screens & data",
risks_questions: "Risks & questions",
};
// Maps discovery phases → the PRD sections they populate
const PRD_SECTIONS: { label: string; phase: string | null }[] = [
{ label: "Executive Summary", phase: "big_picture" },
{ label: "Problem Statement", phase: "big_picture" },
{ label: "Vision & Success Metrics", phase: "big_picture" },
{ label: "Users & Personas", phase: "users_personas" },
{ label: "User Flows", phase: "users_personas" },
{ label: "Feature Requirements", phase: "features_scope" },
{ label: "Screen Specs", phase: "features_scope" },
{ label: "Business Model", phase: "business_model" },
{ label: "Integrations & Dependencies", phase: "screens_data" },
{ label: "Non-Functional Reqs", phase: "features_scope" },
{ label: "Risks & Mitigations", phase: "risks_questions" },
{ label: "Open Questions", phase: "risks_questions" },
];
type SidebarTab = "tasks" | "phases";
type GroupBy = "none" | "phase" | "status";
function sectionDone(
phase: string | null,
savedPhaseIds: Set<string>,
allDone: boolean
): boolean {
return phase === null ? allDone : savedPhaseIds.has(phase);
}
interface FreshIdeaMainProps {
projectId: string;
projectName: string;
}
export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
const router = useRouter();
const params = useParams();
const workspace = params?.workspace as string;
const [savedPhaseIds, setSavedPhaseIds] = useState<Set<string>>(new Set());
const [allDone, setAllDone] = useState(false);
const [prdLoading, setPrdLoading] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [hasPrd, setHasPrd] = useState(false);
const [sidebarTab, setSidebarTab] = useState<SidebarTab>("tasks");
const [sectionSearch, setSectionSearch] = useState("");
const [phaseScope, setPhaseScope] = useState<string>("all");
const [groupBy, setGroupBy] = useState<GroupBy>("none");
const [pendingOnly, setPendingOnly] = useState(false);
const [sortAlpha, setSortAlpha] = useState(false);
const [chatContextRefs, setChatContextRefs] = useState<ChatContextRef[]>([]);
const addSectionToChat = useCallback((label: string, phase: string | null) => {
setChatContextRefs(prev => {
const next: ChatContextRef = { kind: "section", label, phaseId: phase };
const k = contextRefKey(next);
if (prev.some(r => contextRefKey(r) === k)) return prev;
return [...prev, next];
});
}, []);
const addPhaseToChat = useCallback((phaseId: string, label: string) => {
setChatContextRefs(prev => {
const next: ChatContextRef = { kind: "phase", phaseId, label };
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(() => {
// Check if PRD already exists on the project
fetch(`/api/projects/${projectId}`)
.then(r => r.json())
.then(d => { if (d.project?.prd) setHasPrd(true); })
.catch(() => {});
const poll = () => {
fetch(`/api/projects/${projectId}/save-phase`)
.then(r => r.json())
.then(d => {
const ids = new Set<string>((d.phases ?? []).map((p: { phase: string }) => p.phase));
setSavedPhaseIds(ids);
const done = DISCOVERY_PHASES.every(id => ids.has(id));
setAllDone(done);
})
.catch(() => {});
};
poll();
const interval = setInterval(poll, 8_000);
return () => clearInterval(interval);
}, [projectId]);
const handleGeneratePRD = async () => {
if (prdLoading) return;
setPrdLoading(true);
try {
router.push(`/${workspace}/project/${projectId}/tasks`);
} finally {
setPrdLoading(false);
}
};
const handleMVP = () => {
router.push(`/${workspace}/project/${projectId}/mvp-setup/launch`);
};
// PRD exists — show a thin notice bar at the top, then keep the chat fully accessible
const completedSections = PRD_SECTIONS.filter(({ phase }) =>
phase === null ? allDone : savedPhaseIds.has(phase)
).length;
const totalSections = PRD_SECTIONS.length;
const filteredSections = useMemo(() => {
const q = sectionSearch.trim().toLowerCase();
let rows = PRD_SECTIONS.map((s, index) => ({ ...s, index }));
if (q) {
rows = rows.filter(r => r.label.toLowerCase().includes(q));
}
if (phaseScope !== "all") {
rows = rows.filter(r => r.phase === phaseScope);
}
if (pendingOnly) {
rows = rows.filter(r => !sectionDone(r.phase, savedPhaseIds, allDone));
}
if (sortAlpha) {
rows = [...rows].sort((a, b) => a.label.localeCompare(b.label));
} else {
rows = [...rows].sort((a, b) => a.index - b.index);
}
return rows;
}, [sectionSearch, phaseScope, pendingOnly, sortAlpha, savedPhaseIds, allDone]);
const effectiveGroupBy: GroupBy = sidebarTab === "phases" ? "phase" : groupBy;
return (
<div style={{
height: "100%", display: "flex", flexDirection: "row", overflow: "hidden",
fontFamily: JM.fontSans,
}}>
{/* ── Left: Atlas chat (Justine describe column) ── */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
{hasPrd && (
<div style={{
background: JM.primaryGradient,
boxShadow: JM.primaryShadow,
padding: "10px 20px",
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 16, flexShrink: 0,
borderBottom: `1px solid rgba(255,255,255,0.12)`,
}}>
<div style={{ fontSize: 13, color: "rgba(255,255,255,0.92)", fontFamily: JM.fontSans }}>
PRD saved keep refining here or open the full document.
</div>
<Link
href={`/${workspace}/project/${projectId}/tasks`}
style={{
padding: "6px 14px", borderRadius: 8,
background: "#fff", color: JM.ink,
fontSize: 12, fontWeight: 600,
textDecoration: "none", flexShrink: 0,
fontFamily: JM.fontSans,
}}
>
View PRD
</Link>
</div>
)}
{allDone && !dismissed && !hasPrd && (
<div style={{
background: JM.primaryGradient,
boxShadow: JM.primaryShadow,
padding: "14px 20px",
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 16, flexShrink: 0, flexWrap: "wrap",
borderBottom: `1px solid rgba(255,255,255,0.12)`,
}}>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: "#fff", fontFamily: JM.fontDisplay, marginBottom: 2 }}>
Discovery complete what&apos;s next?
</div>
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.75)", fontFamily: JM.fontSans }}>
All 6 phases captured. Generate your PRD or open the MVP plan flow.
</div>
</div>
<div style={{ display: "flex", gap: 8, flexShrink: 0, alignItems: "center" }}>
<button
type="button"
onClick={handleGeneratePRD}
disabled={prdLoading}
style={{
padding: "8px 16px", borderRadius: 8, border: "none",
background: "#fff", color: JM.ink,
fontSize: 13, fontWeight: 700,
fontFamily: JM.fontSans, cursor: "pointer",
}}
>
{prdLoading ? "Navigating…" : "Generate PRD →"}
</button>
<button
type="button"
onClick={handleMVP}
style={{
padding: "8px 16px", borderRadius: 8,
border: "1px solid rgba(255,255,255,0.35)",
background: "transparent", color: "#fff",
fontSize: 13, fontWeight: 600,
fontFamily: JM.fontSans, cursor: "pointer",
}}
>
Plan MVP
</button>
<button
type="button"
onClick={() => setDismissed(true)}
style={{
background: "none", border: "none", cursor: "pointer",
color: "rgba(255,255,255,0.55)", fontSize: 18, padding: "4px 6px",
}}
title="Dismiss"
>
×
</button>
</div>
</div>
)}
<AtlasChat
projectId={projectId}
projectName={projectName}
chatContextRefs={chatContextRefs}
onRemoveChatContextRef={removeChatContextRef}
/>
</div>
{/* ── Right: Teams-style task rail (requirements = PRD sections as tasks) ── */}
<div style={{
width: 348, flexShrink: 0,
background: "#F4F2FA",
borderLeft: `1px solid ${JM.border}`,
display: "flex", flexDirection: "column",
overflow: "hidden",
}}>
{/* Tab bar */}
<div style={{
display: "flex", alignItems: "center",
borderBottom: `1px solid ${JM.border}`,
flexShrink: 0,
padding: "0 8px",
gap: 2,
background: "#FAF8FF",
}}>
<span style={{ display: "flex", padding: "10px 6px", color: JM.muted }} title="Panel">
<LayoutPanelLeft size={16} strokeWidth={1.75} />
</span>
{([
{ id: "tasks" as const, label: "Tasks" },
{ id: "phases" as const, label: "Phases" },
]).map(t => {
const active = sidebarTab === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setSidebarTab(t.id)}
style={{
padding: "10px 12px 8px",
border: "none",
background: "none",
cursor: "pointer",
fontSize: 13,
fontWeight: active ? 600 : 500,
color: active ? JM.ink : JM.muted,
fontFamily: JM.fontSans,
borderBottom: active ? `2px solid ${JM.indigo}` : "2px solid transparent",
marginBottom: -1,
}}
>
{t.label}
</button>
);
})}
</div>
{/* Search + tools */}
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "8px 10px",
borderBottom: `1px solid ${JM.border}`,
background: "#FAF8FF",
}}>
<Search size={15} strokeWidth={1.75} color={JM.muted} style={{ flexShrink: 0 }} />
<input
type="search"
value={sectionSearch}
onChange={e => setSectionSearch(e.target.value)}
placeholder="Search sections…"
aria-label="Search sections"
style={{
flex: 1, minWidth: 0,
border: "none", background: "transparent",
fontSize: 12, fontFamily: JM.fontSans,
color: JM.ink, outline: "none",
}}
/>
<button
type="button"
title={sortAlpha ? "Sort: document order" : "Sort: AZ"}
onClick={() => setSortAlpha(s => !s)}
style={{
border: "none", background: sortAlpha ? JV.violetTint : "transparent",
borderRadius: 6, padding: 6, cursor: "pointer", color: JM.mid,
}}
>
<ArrowUpDown size={15} strokeWidth={1.75} />
</button>
<button
type="button"
title={pendingOnly ? "Show all sections" : "Pending only"}
onClick={() => setPendingOnly(p => !p)}
style={{
border: "none", background: pendingOnly ? JV.violetTint : "transparent",
borderRadius: 6, padding: 6, cursor: "pointer", color: JM.mid,
}}
>
<Filter size={15} strokeWidth={1.75} />
</button>
</div>
{/* Scope + group (Tasks tab only shows group pills; Phases tab locks grouping) */}
<div style={{
padding: "8px 10px 10px",
borderBottom: `1px solid ${JM.border}`,
background: "#F7F5FC",
}}>
<select
value={phaseScope}
onChange={e => setPhaseScope(e.target.value)}
aria-label="Filter by discovery phase"
style={{
width: "100%",
padding: "8px 10px",
borderRadius: 8,
border: `1px solid ${JM.border}`,
background: "#fff",
fontSize: 12,
fontFamily: JM.fontSans,
color: JM.ink,
marginBottom: 8,
cursor: "pointer",
}}
>
<option value="all">All sections</option>
{DISCOVERY_PHASES.map(p => (
<option key={p} value={p}>{PHASE_DISPLAY[p]}</option>
))}
</select>
{sidebarTab === "tasks" && (
<div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
<span style={{ fontSize: 10, fontWeight: 600, color: JM.muted, fontFamily: JM.fontSans }}>
Group by
</span>
{([
{ id: "none" as const, label: "None" },
{ id: "phase" as const, label: "Phase" },
{ id: "status" as const, label: "Status" },
]).map(opt => {
const on = groupBy === opt.id;
return (
<button
key={opt.id}
type="button"
onClick={() => setGroupBy(opt.id)}
style={{
padding: "4px 10px",
borderRadius: 999,
border: `1px solid ${on ? JM.indigo : JM.border}`,
background: on ? JV.violetTint : "#fff",
fontSize: 11,
fontWeight: on ? 600 : 500,
color: on ? JM.indigo : JM.mid,
fontFamily: JM.fontSans,
cursor: "pointer",
}}
>
{opt.label}
</button>
);
})}
</div>
)}
{sidebarTab === "phases" && (
<div style={{ fontSize: 11, color: JM.muted, fontFamily: JM.fontSans }}>
Grouped by discovery phase
</div>
)}
</div>
{/* Progress summary */}
<div style={{
padding: "10px 12px",
borderBottom: `1px solid ${JM.border}`,
background: "#F4F2FA",
flexShrink: 0,
}}>
<div style={{ height: 3, background: "#E0E7FF", borderRadius: 99, overflow: "hidden" }}>
<div style={{
height: "100%", borderRadius: 99,
background: JM.primaryGradient,
width: `${Math.round((completedSections / totalSections) * 100)}%`,
transition: "width 0.4s ease",
}} />
</div>
<div style={{ fontSize: 10, color: JM.muted, marginTop: 6, fontFamily: JM.fontSans }}>
{completedSections} of {totalSections} sections · Requirements task
</div>
<div style={{ fontSize: 10, color: JM.indigo, marginTop: 5, fontFamily: JM.fontSans, opacity: 0.9 }}>
Click a section row or phase header to attach it to your next message.
</div>
</div>
{/* Task list */}
<div style={{ flex: 1, overflowY: "auto", background: "#F4F2FA" }}>
{(() => {
const rows = filteredSections;
if (rows.length === 0) {
return (
<div style={{
padding: "28px 16px",
textAlign: "center",
fontSize: 12,
color: JM.muted,
fontFamily: JM.fontSans,
lineHeight: 1.5,
}}>
No sections match your search or filters.
</div>
);
}
const renderRow = (label: string, phase: string | null, key: string) => {
const isDone = sectionDone(phase, savedPhaseIds, allDone);
const phaseSlug = phase ? phase.replace(/_/g, "-") : "prd";
const phaseLine = phase ? PHASE_DISPLAY[phase] ?? phase : "PRD";
return (
<button
key={key}
type="button"
title="Add this section to chat context for Vibn"
onClick={() => addSectionToChat(label, phase)}
style={{
padding: "10px 12px",
borderBottom: `1px solid rgba(229,231,235,0.85)`,
borderTop: "none",
borderLeft: "none",
borderRight: "none",
display: "flex", gap: 10, alignItems: "flex-start",
background: isDone ? "rgba(237,233,254,0.55)" : "transparent",
width: "100%",
textAlign: "left",
cursor: "pointer",
font: "inherit",
}}
>
<div style={{
width: 22, height: 22, borderRadius: "50%", flexShrink: 0, marginTop: 1,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 700,
background: isDone ? JM.indigo : "#fff",
border: isDone ? "none" : `1.5px solid ${JM.border}`,
color: isDone ? "#fff" : "transparent",
fontFamily: JM.fontSans,
}}>
{isDone ? "✓" : ""}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{
fontSize: 13,
fontWeight: 600,
color: JM.ink,
lineHeight: 1.3,
fontFamily: JM.fontSans,
}}>
{label}
</div>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
marginTop: 4,
}}>
<span style={{
fontSize: 11,
fontWeight: 500,
color: JM.indigo,
fontFamily: JM.fontSans,
}}>
{phaseSlug}
</span>
<span style={{
fontSize: 10,
fontWeight: 600,
color: isDone ? "#059669" : JM.muted,
fontFamily: JM.fontSans,
textTransform: "uppercase",
letterSpacing: "0.04em",
}}>
{isDone ? "Done" : "Pending"}
</span>
</div>
<div style={{
fontSize: 10,
color: JM.muted,
marginTop: 3,
fontFamily: JM.fontSans,
lineHeight: 1.35,
}}>
Discovery · {phaseLine}
{!isDone ? " · complete in chat" : ""}
</div>
</div>
</button>
);
};
if (effectiveGroupBy === "none") {
return rows.map(r => renderRow(r.label, r.phase, `${r.label}-${r.index}`));
}
if (effectiveGroupBy === "phase") {
const byPhase = new Map<string, typeof rows>();
for (const r of rows) {
const pk = r.phase ?? "null";
if (!byPhase.has(pk)) byPhase.set(pk, []);
byPhase.get(pk)!.push(r);
}
const order = [...DISCOVERY_PHASES, "null"];
return order.flatMap(pk => {
const list = byPhase.get(pk);
if (!list?.length) return [];
const header = pk === "null" ? "Final" : PHASE_DISPLAY[pk] ?? pk;
const phaseClickable = pk !== "null";
return [
phaseClickable ? (
<button
key={`h-${pk}`}
type="button"
title={`Add discovery phase "${header}" to chat context`}
onClick={() => addPhaseToChat(pk, header)}
style={{
display: "block",
width: "100%",
padding: "8px 12px 6px",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: JM.muted,
fontFamily: JM.fontSans,
background: "#EDE9FE",
border: "none",
borderBottom: `1px solid ${JM.border}`,
cursor: "pointer",
textAlign: "left",
}}
>
{header}
</button>
) : (
<div
key={`h-${pk}`}
style={{
padding: "8px 12px 4px",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: JM.muted,
fontFamily: JM.fontSans,
background: "#EDE9FE",
borderBottom: `1px solid ${JM.border}`,
}}
>
{header}
</div>
),
...list.map(r => renderRow(r.label, r.phase, `${r.label}-${r.index}`)),
];
});
}
const doneRows = rows.filter(r => sectionDone(r.phase, savedPhaseIds, allDone));
const todoRows = rows.filter(r => !sectionDone(r.phase, savedPhaseIds, allDone));
const statusBlocks: ReactNode[] = [];
if (todoRows.length > 0) {
statusBlocks.push(
<div
key="h-todo"
style={{
padding: "8px 12px 4px",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: JM.muted,
fontFamily: JM.fontSans,
background: "#EDE9FE",
borderBottom: `1px solid ${JM.border}`,
}}
>
To do
</div>
);
todoRows.forEach(r => {
statusBlocks.push(renderRow(r.label, r.phase, `todo-${r.label}-${r.index}`));
});
}
if (doneRows.length > 0) {
statusBlocks.push(
<div
key="h-done"
style={{
padding: "8px 12px 4px",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: JM.muted,
fontFamily: JM.fontSans,
background: "#EDE9FE",
borderBottom: `1px solid ${JM.border}`,
}}
>
Done
</div>
);
doneRows.forEach(r => {
statusBlocks.push(renderRow(r.label, r.phase, `done-${r.label}-${r.index}`));
});
}
return statusBlocks;
})()}
</div>
{allDone && (
<div style={{ padding: "10px 12px", borderTop: `1px solid ${JM.border}`, flexShrink: 0, background: "#FAF8FF" }}>
<Link
href={`/${workspace}/project/${projectId}/tasks`}
style={{
display: "block", textAlign: "center",
padding: "10px 0", borderRadius: 8,
background: JM.primaryGradient,
color: "#fff",
fontSize: 12, fontWeight: 600,
textDecoration: "none",
fontFamily: JM.fontSans,
boxShadow: JM.primaryShadow,
}}
>
Open Tasks
</Link>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,353 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useParams } from "next/navigation";
interface MigrateMainProps {
projectId: string;
projectName: string;
sourceData?: { repoUrl?: string; liveUrl?: string; hosting?: string };
analysisResult?: Record<string, unknown>;
migrationPlan?: string;
creationStage?: string;
}
type Stage = "input" | "auditing" | "review" | "planning" | "plan";
const HOSTING_OPTIONS = [
{ value: "", label: "Select hosting provider" },
{ value: "vercel", label: "Vercel" },
{ value: "aws", label: "AWS" },
{ value: "heroku", label: "Heroku" },
{ value: "digitalocean", label: "DigitalOcean" },
{ value: "gcp", label: "Google Cloud Platform" },
{ value: "azure", label: "Microsoft Azure" },
{ value: "railway", label: "Railway" },
{ value: "render", label: "Render" },
{ value: "netlify", label: "Netlify" },
{ value: "self-hosted", label: "Self-hosted / VPS" },
{ value: "other", label: "Other" },
];
function MarkdownRenderer({ md }: { md: string }) {
const lines = md.split('\n');
return (
<div style={{ fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem", color: "#1a1a1a", lineHeight: 1.7 }}>
{lines.map((line, i) => {
if (line.startsWith('## ')) return <h2 key={i} style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 500, margin: "24px 0 10px", color: "#1a1a1a" }}>{line.slice(3)}</h2>;
if (line.startsWith('### ')) return <h3 key={i} style={{ fontSize: "0.88rem", fontWeight: 700, margin: "18px 0 6px", color: "#1a1a1a" }}>{line.slice(4)}</h3>;
if (line.startsWith('# ')) return <h1 key={i} style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.5rem", fontWeight: 400, margin: "0 0 16px", color: "#1a1a1a" }}>{line.slice(2)}</h1>;
if (line.match(/^- \[ \] /)) return (
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 5 }}>
<input type="checkbox" style={{ marginTop: 3, accentColor: "#1a1a1a" }} />
<span>{line.slice(6)}</span>
</div>
);
if (line.match(/^- \[x\] /i)) return (
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 5 }}>
<input type="checkbox" defaultChecked style={{ marginTop: 3, accentColor: "#1a1a1a" }} />
<span style={{ textDecoration: "line-through", color: "#a09a90" }}>{line.slice(6)}</span>
</div>
);
if (line.startsWith('- ') || line.startsWith('* ')) return <div key={i} style={{ paddingLeft: 16, marginBottom: 4 }}> {line.slice(2)}</div>;
if (line.startsWith('---')) return <hr key={i} style={{ border: "none", borderTop: "1px solid #e8e4dc", margin: "16px 0" }} />;
if (!line.trim()) return <div key={i} style={{ height: "0.6em" }} />;
// Bold inline
const parts = line.split(/(\*\*.*?\*\*)/g).map((seg, j) =>
seg.startsWith("**") && seg.endsWith("**")
? <strong key={j}>{seg.slice(2, -2)}</strong>
: <span key={j}>{seg}</span>
);
return <p key={i} style={{ margin: "0 0 4px" }}>{parts}</p>;
})}
</div>
);
}
export function MigrateMain({
projectId,
projectName,
sourceData,
analysisResult: initialAnalysis,
migrationPlan: initialPlan,
creationStage,
}: MigrateMainProps) {
const router = useRouter();
const params = useParams();
const workspace = params?.workspace as string;
const getInitialStage = (): Stage => {
if (initialPlan) return "plan";
if (creationStage === "planning") return "planning";
if (creationStage === "review" || initialAnalysis) return "review";
if (sourceData?.repoUrl || sourceData?.liveUrl) return "auditing";
return "input";
};
const [stage, setStage] = useState<Stage>(getInitialStage);
const [repoUrl, setRepoUrl] = useState(sourceData?.repoUrl ?? "");
const [liveUrl, setLiveUrl] = useState(sourceData?.liveUrl ?? "");
const [hosting, setHosting] = useState(sourceData?.hosting ?? "");
const [analysisResult, setAnalysisResult] = useState<Record<string, unknown> | null>(initialAnalysis ?? null);
const [migrationPlan, setMigrationPlan] = useState<string>(initialPlan ?? "");
const [progressStep, setProgressStep] = useState<string>("cloning");
const [error, setError] = useState<string | null>(null);
// Poll during audit
useEffect(() => {
if (stage !== "auditing") return;
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/projects/${projectId}/analysis-status`);
const data = await res.json();
setProgressStep(data.stage ?? "cloning");
if (data.stage === "done" && data.analysisResult) {
setAnalysisResult(data.analysisResult);
clearInterval(interval);
setStage("review");
}
} catch { /* keep polling */ }
}, 2500);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stage]);
const startAudit = async () => {
setError(null);
setStage("auditing");
if (repoUrl) {
try {
await fetch(`/api/projects/${projectId}/analyze-repo`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repoUrl, liveUrl, hosting }),
});
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to start audit");
setStage("input");
}
} else {
// No repo — just use live URL fingerprinting via generate-migration-plan directly
setStage("review");
setAnalysisResult({ summary: `Live product at ${liveUrl}`, rows: [], suggestedSurfaces: [] });
}
};
const startPlanning = async () => {
setStage("planning");
setError(null);
try {
const res = await fetch(`/api/projects/${projectId}/generate-migration-plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ analysisResult, sourceData: { repoUrl, liveUrl, hosting } }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Planning failed");
setMigrationPlan(data.migrationPlan);
setStage("plan");
} catch (e) {
setError(e instanceof Error ? e.message : "Planning failed");
setStage("review");
}
};
// ── Stage: input ──────────────────────────────────────────────────────────
if (stage === "input") {
const canProceed = repoUrl.trim().startsWith("http") || liveUrl.trim().startsWith("http");
return (
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
Tell us about your product
</h2>
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
{projectName} Atlas will audit your current setup and build a safe migration plan.
</p>
</div>
{error && (
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
{error}
</div>
)}
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
Repository URL (recommended)
</label>
<input type="text" value={repoUrl} onChange={e => setRepoUrl(e.target.value)}
placeholder="https://github.com/yourorg/your-repo"
style={{ width: "100%", padding: "11px 14px", marginBottom: 16, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.9rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a", outline: "none", boxSizing: "border-box" }}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")} autoFocus
/>
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
Live URL (optional)
</label>
<input type="text" value={liveUrl} onChange={e => setLiveUrl(e.target.value)}
placeholder="https://yourproduct.com"
style={{ width: "100%", padding: "11px 14px", marginBottom: 16, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.9rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a", outline: "none", boxSizing: "border-box" }}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
Current hosting provider
</label>
<select value={hosting} onChange={e => setHosting(e.target.value)}
style={{ width: "100%", padding: "11px 14px", marginBottom: 20, borderRadius: 8, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.88rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: hosting ? "#1a1a1a" : "#a09a90", outline: "none", boxSizing: "border-box", appearance: "none" }}
>
{HOSTING_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.55, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
<strong style={{ color: "#4a2a5a" }}>Non-destructive.</strong> Your existing product stays live throughout. Atlas duplicates, never deletes.
</div>
<button onClick={startAudit} disabled={!canProceed}
style={{ width: "100%", padding: "13px", borderRadius: 8, border: "none", background: canProceed ? "#1a1a1a" : "#e0dcd4", color: canProceed ? "#fff" : "#b5b0a6", fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: canProceed ? "pointer" : "not-allowed" }}
>
Start audit
</button>
</div>
</div>
);
}
// ── Stage: auditing ───────────────────────────────────────────────────────
if (stage === "auditing") {
const steps = [
{ key: "cloning", label: "Cloning repository" },
{ key: "reading", label: "Reading configuration" },
{ key: "analyzing", label: "Auditing infrastructure" },
{ key: "done", label: "Audit complete" },
];
const currentIdx = steps.findIndex(s => s.key === progressStep);
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ textAlign: "center", maxWidth: 400 }}>
<div style={{ width: 52, height: 52, borderRadius: "50%", border: "3px solid #e0dcd4", borderTopColor: "#4a2a5a", animation: "vibn-mig-spin 0.85s linear infinite", margin: "0 auto 24px" }} />
<style>{`@keyframes vibn-mig-spin { to { transform:rotate(360deg); } }`}</style>
<h3 style={{ fontSize: "1.1rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 8px" }}>Auditing your product</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 28px" }}>This is non-destructive your live product is untouched</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8, textAlign: "left" }}>
{steps.map((step, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return (
<div key={step.key} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ width: 22, height: 22, borderRadius: "50%", flexShrink: 0, background: done ? "#4a2a5a" : "#f6f4f0", border: active ? "2px solid #4a2a5a" : done ? "none" : "2px solid #e0dcd4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.6rem", fontWeight: 700, color: done ? "#fff" : "#a09a90" }}>
{done ? "✓" : active ? <span style={{ width: 8, height: 8, borderRadius: "50%", background: "#4a2a5a", display: "block" }} /> : ""}
</div>
<span style={{ fontSize: "0.8rem", fontWeight: active ? 600 : 400, color: done ? "#6b6560" : active ? "#1a1a1a" : "#b5b0a6" }}>{step.label}</span>
</div>
);
})}
</div>
</div>
</div>
);
}
// ── Stage: review ─────────────────────────────────────────────────────────
if (stage === "review") {
const rows = (analysisResult?.rows as Array<{ category: string; item: string; status: string; detail?: string }>) ?? [];
const summary = (analysisResult?.summary as string) ?? '';
return (
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ maxWidth: 760, margin: "0 auto" }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>Audit complete</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>{summary || `${projectName} — review your current infrastructure below.`}</p>
</div>
{rows.length > 0 && (
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", overflow: "hidden", marginBottom: 24 }}>
{rows.map((row, i) => {
const colorMap = { found: { bg: "#f0fdf4", text: "#15803d", label: "Found" }, partial: { bg: "#fffbeb", text: "#b45309", label: "Partial" }, missing: { bg: "#fff1f2", text: "#be123c", label: "Missing" } };
const sc = colorMap[row.status as keyof typeof colorMap] ?? colorMap.found;
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderTop: i > 0 ? "1px solid #f6f4f0" : "none" }}>
<div style={{ fontSize: "0.7rem", color: "#a09a90", width: 110, flexShrink: 0 }}>{row.category}</div>
<div style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a", fontWeight: 500 }}>{row.item}</div>
{row.detail && <div style={{ fontSize: "0.75rem", color: "#8a8478", flex: 2 }}>{row.detail}</div>}
<div style={{ padding: "3px 10px", borderRadius: 4, background: sc.bg, color: sc.text, fontSize: "0.68rem", fontWeight: 700, flexShrink: 0 }}>{sc.label}</div>
</div>
);
})}
</div>
)}
{error && (
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
{error}
</div>
)}
<div style={{ background: "#1a1a1a", borderRadius: 12, padding: "22px 24px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 3 }}>Ready to build the migration plan?</div>
<div style={{ fontSize: "0.75rem", color: "#8a8478" }}>Atlas will generate a phased migration doc with Mirror, Validate, Cutover, and Decommission phases.</div>
</div>
<button onClick={startPlanning}
style={{ padding: "11px 22px", borderRadius: 8, border: "none", background: "#fff", color: "#1a1a1a", fontSize: "0.85rem", fontWeight: 700, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer", flexShrink: 0 }}
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
>
Generate plan
</button>
</div>
</div>
</div>
);
}
// ── Stage: planning ───────────────────────────────────────────────────────
if (stage === "planning") {
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ textAlign: "center" }}>
<div style={{ width: 52, height: 52, borderRadius: "50%", border: "3px solid #e0dcd4", borderTopColor: "#4a2a5a", animation: "vibn-mig-spin 0.85s linear infinite", margin: "0 auto 20px" }} />
<h3 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 6px" }}>Generating migration plan</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>Atlas is designing a safe, phased migration strategy</p>
</div>
</div>
);
}
// ── Stage: plan ───────────────────────────────────────────────────────────
return (
<div style={{ height: "100%", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
{/* Non-destructive banner */}
<div style={{ background: "#4a2a5a12", borderBottom: "1px solid #4a2a5a30", padding: "12px 32px", display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
<span style={{ fontSize: "1rem" }}>🛡</span>
<div>
<span style={{ fontSize: "0.8rem", fontWeight: 700, color: "#4a2a5a" }}>Non-destructive migration </span>
<span style={{ fontSize: "0.8rem", color: "#6b6560" }}>your existing product stays live throughout every phase. Atlas duplicates, never deletes.</span>
</div>
</div>
<div style={{ padding: "32px 40px" }}>
<div style={{ maxWidth: 760, margin: "0 auto" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>Migration Plan</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>{projectName} four phased migration with rollback plan</p>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "28px 32px" }}>
<MarkdownRenderer md={migrationPlan} />
</div>
<div style={{ marginTop: 20, display: "flex", gap: 10 }}>
<button
onClick={() => router.push(`/${workspace}/project/${projectId}/design`)}
style={{ padding: "11px 22px", borderRadius: 8, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.85rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer" }}
>
Go to Design
</button>
<button
onClick={() => window.print()}
style={{ padding: "11px 22px", borderRadius: 8, border: "1px solid #e0dcd4", background: "#fff", color: "#6b6560", fontSize: "0.85rem", fontWeight: 500, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer" }}
>
Print / Export
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
"use client";
import { Suspense, useCallback, useEffect, useState } from "react";
import { JM, JV } from "@/components/project-creation/modal-theme";
import { AtlasChat } from "@/components/AtlasChat";
import {
BuildLivePlanPanel,
addSectionContextRef,
} from "@/components/project-main/BuildLivePlanPanel";
import {
type ChatContextRef,
contextRefKey,
} from "@/lib/chat-context-refs";
export function MvpSetupDescribeView({ projectId, workspace }: { projectId: string; workspace: string }) {
const [chatContextRefs, setChatContextRefs] = useState<ChatContextRef[]>([]);
const [tab, setTab] = useState<"chat" | "plan">("chat");
const [narrow, setNarrow] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 900px)");
const apply = () => setNarrow(mq.matches);
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}, []);
const removeChatContextRef = useCallback((key: string) => {
setChatContextRefs(prev => prev.filter(r => contextRefKey(r) !== key));
}, []);
const addPlanSectionToChat = useCallback((label: string, phaseId: string | null) => {
setChatContextRefs(prev => addSectionContextRef(prev, label, phaseId));
}, []);
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0, background: JV.chatColumnBg }}>
<div
style={{
padding: "18px 28px 14px",
background: "#fff",
borderBottom: `1px solid ${JM.border}`,
flexShrink: 0,
}}
>
<div style={{ fontSize: 17, fontWeight: 700, color: JM.ink, marginBottom: 3, fontFamily: JM.fontDisplay }}>
Describe
</div>
<div style={{ fontSize: 12.5, color: JM.muted }}>
Tell Vibn about your idea your plan fills in on the right as you go.
</div>
</div>
{narrow && (
<div
style={{
display: "flex",
borderBottom: `1px solid ${JM.border}`,
background: "#EEF0FF",
flexShrink: 0,
}}
>
{(["chat", "plan"] as const).map(id => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
style={{
flex: 1,
padding: "11px 8px",
border: "none",
background: "transparent",
fontSize: 13,
fontWeight: tab === id ? 600 : 500,
color: tab === id ? JM.indigo : JM.muted,
borderBottom: tab === id ? `2px solid ${JM.indigo}` : "2px solid transparent",
cursor: "pointer",
fontFamily: JM.fontSans,
}}
>
{id === "chat" ? "Chat" : "Your plan"}
</button>
))}
</div>
)}
<div style={{ flex: 1, display: "flex", minHeight: 0, overflow: "hidden" }}>
<div
style={{
flex: 1,
minWidth: 0,
display: narrow && tab !== "chat" ? "none" : "flex",
flexDirection: "column",
}}
>
<AtlasChat
projectId={projectId}
conversationScope="overview"
contextEmptyLabel="Plan"
emptyStateHint="Answer Vibns questions — each phase you complete updates your plan."
chatContextRefs={chatContextRefs}
onRemoveChatContextRef={removeChatContextRef}
/>
</div>
<div
style={{
width: narrow ? undefined : 308,
flex: narrow && tab === "plan" ? 1 : undefined,
flexShrink: 0,
minWidth: 0,
display: narrow && tab !== "plan" ? "none" : "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<Suspense fallback={<div style={{ flex: 1, background: JV.prdPanelBg }} />}>
<BuildLivePlanPanel
projectId={projectId}
workspace={workspace}
chatContextRefs={chatContextRefs}
onAddSectionRef={addPlanSectionToChat}
compactHeader={narrow}
/>
</Suspense>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,174 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { JM } from "@/components/project-creation/modal-theme";
const BUILD_LEFT_BG = "#faf8f5";
const BUILD_LEFT_BORDER = "#e8e4dc";
export function MvpSetupLayoutClient({
workspace,
projectId,
children,
}: {
workspace: string;
projectId: string;
children: ReactNode;
}) {
const pathname = usePathname() ?? "";
const base = `/${workspace}/project/${projectId}/mvp-setup`;
const steps = [
{ href: `${base}/describe`, label: "Describe", sub: "Your idea", suffix: "/describe" },
{ href: `${base}/architect`, label: "Architect", sub: "Discovery", suffix: "/architect" },
{ href: `${base}/design`, label: "Design", sub: "Look & feel", suffix: "/design" },
{ href: `${base}/website`, label: "Website", sub: "Grow", suffix: "/website" },
{ href: `${base}/launch`, label: "Build MVP", sub: "Review & launch", suffix: "/launch" },
] as const;
return (
<div
style={{
display: "flex",
height: "100%",
overflow: "hidden",
fontFamily: JM.fontSans,
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
}}
>
<div
style={{
width: 200,
flexShrink: 0,
borderRight: `1px solid ${BUILD_LEFT_BORDER}`,
background: "#fff",
display: "flex",
flexDirection: "column",
padding: "18px 12px",
overflow: "hidden",
}}
>
<div style={{ padding: "0 6px", marginBottom: 20 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<div
style={{
width: 26,
height: 26,
background: JM.primaryGradient,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ fontSize: 13, fontWeight: 700, color: "#fff" }}>V</span>
</div>
<span style={{ fontSize: 16, fontWeight: 700, color: JM.ink, letterSpacing: "-0.02em" }}>MVP setup</span>
</div>
<div style={{ fontSize: 11, color: JM.muted, paddingLeft: 34 }}>New product flow</div>
</div>
<div
style={{
fontSize: 9.5,
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: JM.muted,
padding: "0 6px",
marginBottom: 8,
}}
>
Steps
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1, minHeight: 0, overflowY: "auto" }}>
{steps.map(step => {
const active = pathname.includes(`${base}${step.suffix}`);
return (
<Link
key={step.suffix}
href={step.href}
scroll={false}
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "9px 10px",
borderRadius: 8,
textDecoration: "none",
background: active ? "#fafaff" : "transparent",
border: active ? `1px solid rgba(99,102,241,0.2)` : "1px solid transparent",
transition: "background 0.15s",
}}
>
<div
style={{
width: 20,
height: 20,
borderRadius: "50%",
background: active ? JM.primaryGradient : "#e5e7eb",
color: active ? "#fff" : JM.muted,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
fontSize: 9,
fontWeight: 700,
}}
>
{active ? "▲" : "○"}
</div>
<div>
<div style={{ fontSize: 12.5, fontWeight: active ? 600 : 500, color: JM.ink }}>{step.label}</div>
<div style={{ fontSize: 10, color: JM.muted }}>{step.sub}</div>
</div>
</Link>
);
})}
</div>
<div style={{ borderTop: `1px solid ${BUILD_LEFT_BORDER}`, marginTop: 14, paddingTop: 12, flexShrink: 0 }}>
<Link
href={`/${workspace}/projects`}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
background: "#eef2ff",
border: "1px solid #e0e7ff",
borderRadius: 8,
padding: "9px 10px",
fontSize: 12,
fontWeight: 600,
color: JM.indigo,
textDecoration: "none",
}}
>
Save & go to dashboard
</Link>
<Link
href={`/${workspace}/project/${projectId}/build`}
style={{
display: "block",
marginTop: 10,
textAlign: "center",
fontSize: 11,
fontWeight: 600,
color: JM.muted,
textDecoration: "none",
}}
>
Open Build workspace
</Link>
</div>
</div>
<div style={{ flex: 1, minWidth: 0, minHeight: 0, overflow: "hidden", display: "flex", flexDirection: "column" }}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import Link from "next/link";
import { JM } from "@/components/project-creation/modal-theme";
export function MvpSetupStepPlaceholder({
title,
subtitle,
body,
primaryHref,
primaryLabel,
nextHref,
nextLabel,
}: {
title: string;
subtitle: string;
body: string;
primaryHref: string;
primaryLabel: string;
nextHref: string;
nextLabel: string;
}) {
return (
<div
style={{
flex: 1,
overflow: "auto",
padding: "28px 32px",
fontFamily: JM.fontSans,
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
}}
>
<div style={{ maxWidth: 520 }}>
<h1 style={{ fontSize: 22, fontWeight: 700, color: JM.ink, margin: "0 0 8px", fontFamily: JM.fontDisplay }}>
{title}
</h1>
<p style={{ fontSize: 13.5, color: JM.muted, margin: "0 0 24px", lineHeight: 1.55 }}>{subtitle}</p>
<p style={{ fontSize: 14, color: JM.ink, lineHeight: 1.65, margin: "0 0 28px" }}>{body}</p>
<Link
href={primaryHref}
style={{
display: "inline-block",
padding: "12px 22px",
borderRadius: 10,
background: JM.primaryGradient,
color: "#fff",
fontSize: 14,
fontWeight: 600,
textDecoration: "none",
boxShadow: JM.primaryShadow,
marginRight: 12,
marginBottom: 12,
}}
>
{primaryLabel}
</Link>
<Link
href={nextHref}
style={{
display: "inline-block",
padding: "12px 18px",
borderRadius: 10,
border: `1px solid ${JM.border}`,
color: JM.indigo,
fontSize: 14,
fontWeight: 600,
textDecoration: "none",
background: "#fff",
}}
>
{nextLabel}
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,358 @@
"use client";
import { Suspense, useState, useEffect } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
import { JM } from "@/components/project-creation/modal-theme";
export type ProjectInfraRouteBase = "run" | "infrastructure";
export interface ProjectInfraPanelProps {
routeBase: ProjectInfraRouteBase;
/** Uppercase rail heading (e.g. Run vs Infrastructure) */
navGroupLabel: string;
}
// ── 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() {
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,
}}>
<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>
{["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 type="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 type="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 type="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 ───────────────────────────────────────────────────────────────────
function ProjectInfraPanelInner({ routeBase, navGroupLabel }: ProjectInfraPanelProps) {
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}/${routeBase}?tab=${id}`, { scroll: false });
};
return (
<div style={{ display: "flex", height: "100%", fontFamily: JM.fontSans, overflow: "hidden" }}>
<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",
}}>
{navGroupLabel}
</div>
{TABS.map(tab => {
const active = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
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",
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.75rem", opacity: 0.65, width: 16, textAlign: "center" }}>{tab.icon}</span>
{tab.label}
</button>
);
})}
</div>
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
{activeTab === "builds" && <BuildsTab project={project} />}
{activeTab === "databases" && <DatabasesTab />}
{activeTab === "services" && <ServicesTab />}
{activeTab === "environment" && <EnvironmentTab />}
{activeTab === "domains" && <DomainsTab project={project} />}
{activeTab === "logs" && <LogsTab project={project} />}
</div>
</div>
);
}
export function ProjectInfraPanel(props: ProjectInfraPanelProps) {
return (
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: JM.muted, fontFamily: JM.fontSans, fontSize: "0.85rem" }}>Loading</div>}>
<ProjectInfraPanelInner {...props} />
</Suspense>
);
}