Rip out Theia, ship P5.1 attach E2E + Justine UI work-in-progress
Theia rip-out: - Delete app/api/theia-auth/route.ts (Traefik ForwardAuth shim) - Delete app/api/projects/[projectId]/workspace/route.ts and app/api/projects/prewarm/route.ts (Cloud Run Theia provisioning) - Delete lib/cloud-run-workspace.ts and lib/coolify-workspace.ts - Strip provisionTheiaWorkspace + theiaWorkspaceUrl/theiaAppUuid/ theiaError from app/api/projects/create/route.ts response - Remove Theia callbackUrl branch in app/auth/page.tsx - Drop "Open in Theia" button + xterm/Theia PTY copy in build/page.tsx - Drop theiaWorkspaceUrl from deployment/page.tsx Project type - Strip Theia IDE line + theia-code-os from advisor + agent-chat context strings - Scrub Theia mention from lib/auth/workspace-auth.ts comment P5.1 (custom apex domains + DNS): - lib/coolify.ts + lib/opensrs.ts: nameserver normalization, OpenSRS XML auth, Cloud DNS plumbing - scripts/smoke-attach-e2e.ts: full prod GCP + sandbox OpenSRS + prod Coolify smoke covering register/zone/A/NS/PATCH/cleanup In-progress (Justine onboarding/build, MVP setup, agent telemetry): - New (justine)/stories, project (home) layouts, mvp-setup, run, tasks routes + supporting components - Project shell + sidebar + nav refactor for the Stackless palette - Agent session API hardening (sessions, events, stream, approve, retry, stop) + atlas-chat, advisor, design-surfaces refresh - New scripts/sync-db-url-from-coolify.mjs + scripts/prisma-db-push.mjs + docker-compose.local-db.yml for local Prisma workflows - lib/dev-bypass.ts, lib/chat-context-refs.ts, lib/prd-sections.ts - Misc: stories CSS, debug/prisma route, modal-theme, BuildLivePlanPanel Made-with: Cursor
This commit is contained in:
34
app/[workspace]/project/[projectId]/(home)/layout.tsx
Normal file
34
app/[workspace]/project/[projectId]/(home)/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Project home scaffold.
|
||||
*
|
||||
* Mirrors the /[workspace]/projects scaffold: VIBNSidebar on the left,
|
||||
* cream main area on the right. Used only for the project home page
|
||||
* (`/{workspace}/project/{id}`) — sub-routes use the (workspace) group
|
||||
* with the ProjectShell tab nav instead.
|
||||
*/
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Toaster } from "sonner";
|
||||
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
|
||||
import { ProjectAssociationPrompt } from "@/components/project-association-prompt";
|
||||
|
||||
export default function ProjectHomeLayout({ children }: { children: ReactNode }) {
|
||||
const params = useParams();
|
||||
const workspace = params.workspace as string;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
|
||||
<VIBNSidebar workspace={workspace} />
|
||||
<main style={{ flex: 1, overflow: "auto" }}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<ProjectAssociationPrompt workspace={workspace} />
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
696
app/[workspace]/project/[projectId]/(home)/page.tsx
Normal file
696
app/[workspace]/project/[projectId]/(home)/page.tsx
Normal file
@@ -0,0 +1,696 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Project home page.
|
||||
*
|
||||
* Sits between the projects list and the AI interview. Gives users two
|
||||
* simplified entry tiles — Code (their Gitea repo) and Infrastructure
|
||||
* (their Coolify deployment) — plus a quiet "Continue setup" link if
|
||||
* the discovery interview isn't done.
|
||||
*
|
||||
* Styled to match the production "ink & parchment" design:
|
||||
* Newsreader serif headings, Outfit sans body, warm beige borders,
|
||||
* solid black CTAs. No indigo. No gradients.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
|
||||
import {
|
||||
ArrowRight,
|
||||
Code2,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Folder,
|
||||
Loader2,
|
||||
Rocket,
|
||||
} from "lucide-react";
|
||||
|
||||
// ── Design tokens (mirrors the prod ink & parchment palette) ─────────
|
||||
const INK = {
|
||||
fontSerif: '"Newsreader", "Lora", Georgia, serif',
|
||||
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
||||
fontMono: '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
ink: "#1a1a1a",
|
||||
ink2: "#2c2c2a",
|
||||
mid: "#5f5e5a",
|
||||
muted: "#a09a90",
|
||||
stone: "#b5b0a6",
|
||||
border: "#e8e4dc",
|
||||
borderHover: "#d0ccc4",
|
||||
cardBg: "#fff",
|
||||
pageBg: "#f7f4ee",
|
||||
shadow: "0 1px 2px #1a1a1a05",
|
||||
shadowHover: "0 2px 8px #1a1a1a0a",
|
||||
iconWrapBg: "#1a1a1a08",
|
||||
} as const;
|
||||
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
productName?: string;
|
||||
name?: string;
|
||||
productVision?: string;
|
||||
description?: string;
|
||||
giteaRepo?: string;
|
||||
giteaRepoUrl?: string;
|
||||
stage?: "discovery" | "architecture" | "building" | "active";
|
||||
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||
discoveryPhase?: number;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
interface FileTreeItem {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "dir";
|
||||
}
|
||||
|
||||
interface PreviewApp {
|
||||
name: string;
|
||||
url: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function ProjectHomePage() {
|
||||
const params = useParams();
|
||||
const workspace = params.workspace as string;
|
||||
const projectId = params.projectId as string;
|
||||
const { status: authStatus } = useSession();
|
||||
|
||||
const [project, setProject] = useState<ProjectSummary | null>(null);
|
||||
const [projectLoading, setProjectLoading] = useState(true);
|
||||
|
||||
const [files, setFiles] = useState<FileTreeItem[] | null>(null);
|
||||
const [filesLoading, setFilesLoading] = useState(true);
|
||||
|
||||
const [apps, setApps] = useState<PreviewApp[]>([]);
|
||||
const [appsLoading, setAppsLoading] = useState(true);
|
||||
|
||||
const ready = useMemo(
|
||||
() => isClientDevProjectBypass() || authStatus === "authenticated",
|
||||
[authStatus]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) {
|
||||
if (authStatus === "unauthenticated") setProjectLoading(false);
|
||||
return;
|
||||
}
|
||||
fetch(`/api/projects/${projectId}`, { credentials: "include" })
|
||||
.then(r => r.json())
|
||||
.then(d => setProject(d.project ?? null))
|
||||
.catch(() => {})
|
||||
.finally(() => setProjectLoading(false));
|
||||
}, [ready, authStatus, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
fetch(`/api/projects/${projectId}/file?path=`, { credentials: "include" })
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(d => {
|
||||
if (d?.type === "dir" && Array.isArray(d.items)) {
|
||||
setFiles(d.items as FileTreeItem[]);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
})
|
||||
.catch(() => setFiles([]))
|
||||
.finally(() => setFilesLoading(false));
|
||||
}, [ready, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
fetch(`/api/projects/${projectId}/preview-url`, { credentials: "include" })
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(d => setApps(Array.isArray(d?.apps) ? d.apps : []))
|
||||
.catch(() => {})
|
||||
.finally(() => setAppsLoading(false));
|
||||
}, [ready, projectId]);
|
||||
|
||||
const projectName = project?.productName || project?.name || "Untitled project";
|
||||
const projectDesc = project?.productVision || project?.description;
|
||||
const stage = project?.stage ?? "discovery";
|
||||
const interviewIncomplete = stage === "discovery";
|
||||
const liveApp = apps.find(a => a.url) ?? apps[0] ?? null;
|
||||
|
||||
if (projectLoading) {
|
||||
return (
|
||||
<div style={pageWrap}>
|
||||
<div style={centeredFiller}>
|
||||
<Loader2 className="animate-spin" size={22} style={{ color: INK.stone }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div style={pageWrap}>
|
||||
<div style={{ ...centeredFiller, color: INK.muted, fontSize: 14, fontFamily: INK.fontSans }}>
|
||||
Project not found.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={pageWrap}>
|
||||
<div style={pageInner}>
|
||||
{/* ── Hero ─────────────────────────────────────────────── */}
|
||||
<header style={heroStyle}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={eyebrow}>Project</div>
|
||||
<h1 style={heroTitle}>{projectName}</h1>
|
||||
{projectDesc && <p style={heroDesc}>{projectDesc}</p>}
|
||||
</div>
|
||||
<StagePill stage={stage} />
|
||||
</header>
|
||||
|
||||
{/* ── Continue setup link (quiet, only when in discovery) ── */}
|
||||
{interviewIncomplete && (
|
||||
<Link
|
||||
href={`/${workspace}/project/${projectId}/overview`}
|
||||
style={continueRow}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||
<span style={continueDot} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={continueTitle}>Continue setup</div>
|
||||
<div style={continueSub}>
|
||||
Pick up the AI interview where you left off.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={16} style={{ color: INK.ink, flexShrink: 0 }} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* ── Two big tiles ────────────────────────────────────── */}
|
||||
<div style={tileGrid}>
|
||||
<CodeTile
|
||||
workspace={workspace}
|
||||
projectId={projectId}
|
||||
files={files}
|
||||
loading={filesLoading}
|
||||
giteaRepo={project.giteaRepo}
|
||||
/>
|
||||
<InfraTile
|
||||
workspace={workspace}
|
||||
projectId={projectId}
|
||||
app={liveApp}
|
||||
loading={appsLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Tiles
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function CodeTile({
|
||||
workspace,
|
||||
projectId,
|
||||
files,
|
||||
loading,
|
||||
giteaRepo,
|
||||
}: {
|
||||
workspace: string;
|
||||
projectId: string;
|
||||
files: FileTreeItem[] | null;
|
||||
loading: boolean;
|
||||
giteaRepo?: string;
|
||||
}) {
|
||||
const items = files ?? [];
|
||||
const dirCount = items.filter(i => i.type === "dir").length;
|
||||
const fileCount = items.filter(i => i.type === "file").length;
|
||||
const previewItems = items.slice(0, 6);
|
||||
|
||||
return (
|
||||
<Link href={`/${workspace}/project/${projectId}/code`} style={tileLink}>
|
||||
<article
|
||||
style={tileCard}
|
||||
onMouseEnter={hoverEnter}
|
||||
onMouseLeave={hoverLeave}
|
||||
>
|
||||
<header style={tileHeader}>
|
||||
<span style={tileIconWrap}>
|
||||
<Code2 size={16} />
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={tileTitle}>Code</h2>
|
||||
<p style={tileSubtitle}>What the AI is building, file by file.</p>
|
||||
</div>
|
||||
<ArrowRight size={14} style={{ color: INK.muted }} />
|
||||
</header>
|
||||
|
||||
<div style={tileBody}>
|
||||
{loading ? (
|
||||
<TileLoader label="Reading repository…" />
|
||||
) : items.length === 0 ? (
|
||||
<TileEmpty
|
||||
icon={<Folder size={18} />}
|
||||
title="No files yet"
|
||||
subtitle={
|
||||
giteaRepo
|
||||
? "Your repository is empty. The AI will commit the first files when you start building."
|
||||
: "This project doesn't have a repository yet."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={tileMetaRow}>
|
||||
<Metric label="Folders" value={dirCount} />
|
||||
<Metric label="Files" value={fileCount} />
|
||||
</div>
|
||||
<ul style={fileList}>
|
||||
{previewItems.map(item => (
|
||||
<li key={item.path} style={fileRow}>
|
||||
<span style={fileIconWrap}>
|
||||
{item.type === "dir" ? (
|
||||
<Folder size={13} />
|
||||
) : (
|
||||
<FileText size={13} />
|
||||
)}
|
||||
</span>
|
||||
<span style={fileName}>{item.name}</span>
|
||||
<span style={fileType}>
|
||||
{item.type === "dir" ? "folder" : ext(item.name)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{items.length > previewItems.length && (
|
||||
<div style={tileMore}>
|
||||
+{items.length - previewItems.length} more
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function InfraTile({
|
||||
workspace,
|
||||
projectId,
|
||||
app,
|
||||
loading,
|
||||
}: {
|
||||
workspace: string;
|
||||
projectId: string;
|
||||
app: PreviewApp | null;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const status = app?.status?.toLowerCase() ?? "unknown";
|
||||
const isLive = !!app?.url && (status.includes("running") || status.includes("healthy"));
|
||||
const isBuilding = status.includes("queued") || status.includes("in_progress") || status.includes("starting");
|
||||
|
||||
return (
|
||||
<Link href={`/${workspace}/project/${projectId}/infrastructure`} style={tileLink}>
|
||||
<article
|
||||
style={tileCard}
|
||||
onMouseEnter={hoverEnter}
|
||||
onMouseLeave={hoverLeave}
|
||||
>
|
||||
<header style={tileHeader}>
|
||||
<span style={tileIconWrap}>
|
||||
<Rocket size={16} />
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={tileTitle}>Infrastructure</h2>
|
||||
<p style={tileSubtitle}>What's live and how it's running.</p>
|
||||
</div>
|
||||
<ArrowRight size={14} style={{ color: INK.muted }} />
|
||||
</header>
|
||||
|
||||
<div style={tileBody}>
|
||||
{loading ? (
|
||||
<TileLoader label="Checking deployment…" />
|
||||
) : !app ? (
|
||||
<TileEmpty
|
||||
icon={<Rocket size={18} />}
|
||||
title="Nothing is live yet"
|
||||
subtitle="The AI will deploy your project here once the build is ready."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={tileMetaRow}>
|
||||
<StatusBlock
|
||||
color={isLive ? "#2e7d32" : isBuilding ? "#3d5afe" : "#9a7b3a"}
|
||||
label={isLive ? "Live" : isBuilding ? "Building" : statusFriendly(status)}
|
||||
/>
|
||||
<Metric label="App" value={app.name} />
|
||||
</div>
|
||||
{app.url ? (
|
||||
<div style={liveUrlRow}>
|
||||
<span style={liveUrlLabel}>Live URL</span>
|
||||
<span style={liveUrlValue}>{shortUrl(app.url)}</span>
|
||||
<a
|
||||
href={app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={liveUrlOpen}
|
||||
aria-label="Open live site"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div style={liveUrlRow}>
|
||||
<span style={liveUrlLabel}>Status</span>
|
||||
<span style={liveUrlValue}>{statusFriendly(status)}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Small bits
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function StagePill({ stage }: { stage: string }) {
|
||||
const map: Record<string, { label: string; color: string; bg: string }> = {
|
||||
discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a12" },
|
||||
architecture: { label: "Planning", color: "#3d5afe", bg: "#3d5afe10" },
|
||||
building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" },
|
||||
active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
|
||||
};
|
||||
const s = map[stage] ?? map.discovery;
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
padding: "3px 9px", borderRadius: 4,
|
||||
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
|
||||
color: s.color, background: s.bg, fontFamily: INK.fontSans,
|
||||
whiteSpace: "nowrap", flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBlock({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={metricLabel}>Status</span>
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
fontSize: 13, color: INK.ink, fontFamily: INK.fontSans, fontWeight: 500,
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color }} />
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
|
||||
<span style={metricLabel}>{label}</span>
|
||||
<span style={{
|
||||
fontSize: 13, color: INK.ink, fontFamily: INK.fontSans, fontWeight: 500,
|
||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||
}}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TileLoader({ label }: { label: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
gap: 8, padding: "32px 0", color: INK.muted, fontSize: 13,
|
||||
fontFamily: INK.fontSans,
|
||||
}}>
|
||||
<Loader2 className="animate-spin" size={14} /> {label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TileEmpty({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: "28px 8px",
|
||||
textAlign: "center",
|
||||
display: "flex", flexDirection: "column", alignItems: "center", gap: 10,
|
||||
fontFamily: INK.fontSans,
|
||||
}}>
|
||||
<span style={{ ...tileIconWrap, width: 38, height: 38 }}>{icon}</span>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: INK.ink }}>{title}</div>
|
||||
<div style={{ fontSize: 12.5, color: INK.muted, lineHeight: 1.55, maxWidth: 280 }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusFriendly(status: string): string {
|
||||
if (!status || status === "unknown") return "Unknown";
|
||||
return status.replace(/[:_-]+/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function ext(name: string): string {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot > 0 ? name.slice(dot + 1) : "file";
|
||||
}
|
||||
|
||||
function shortUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.host + (u.pathname === "/" ? "" : u.pathname);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function hoverEnter(e: React.MouseEvent<HTMLElement>) {
|
||||
const el = e.currentTarget;
|
||||
el.style.borderColor = INK.borderHover;
|
||||
el.style.boxShadow = INK.shadowHover;
|
||||
}
|
||||
function hoverLeave(e: React.MouseEvent<HTMLElement>) {
|
||||
const el = e.currentTarget;
|
||||
el.style.borderColor = INK.border;
|
||||
el.style.boxShadow = INK.shadow;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Styles
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const pageWrap: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "auto",
|
||||
background: INK.pageBg,
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const pageInner: React.CSSProperties = {
|
||||
maxWidth: 900,
|
||||
margin: "0 auto",
|
||||
padding: "44px 52px 64px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 28,
|
||||
};
|
||||
|
||||
const centeredFiller: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
height: "100%", padding: 64,
|
||||
};
|
||||
|
||||
const heroStyle: React.CSSProperties = {
|
||||
display: "flex", alignItems: "flex-start", justifyContent: "space-between",
|
||||
gap: 24,
|
||||
};
|
||||
|
||||
const eyebrow: React.CSSProperties = {
|
||||
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em",
|
||||
textTransform: "uppercase", color: INK.muted,
|
||||
fontFamily: INK.fontSans, marginBottom: 8,
|
||||
};
|
||||
|
||||
const heroTitle: React.CSSProperties = {
|
||||
fontFamily: INK.fontSerif,
|
||||
fontSize: "1.9rem", fontWeight: 400,
|
||||
color: INK.ink, letterSpacing: "-0.03em",
|
||||
lineHeight: 1.15, margin: 0,
|
||||
};
|
||||
|
||||
const heroDesc: React.CSSProperties = {
|
||||
fontSize: "0.88rem", color: INK.mid, marginTop: 10, maxWidth: 620,
|
||||
lineHeight: 1.6, fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const continueRow: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16,
|
||||
background: INK.cardBg, border: `1px solid ${INK.border}`,
|
||||
borderRadius: 10, padding: "14px 18px",
|
||||
textDecoration: "none", color: "inherit",
|
||||
boxShadow: INK.shadow,
|
||||
fontFamily: INK.fontSans,
|
||||
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||
};
|
||||
|
||||
const continueDot: React.CSSProperties = {
|
||||
width: 7, height: 7, borderRadius: "50%",
|
||||
background: "#d4a04a", flexShrink: 0,
|
||||
};
|
||||
|
||||
const continueTitle: React.CSSProperties = {
|
||||
fontSize: 13, fontWeight: 600, color: INK.ink,
|
||||
};
|
||||
|
||||
const continueSub: React.CSSProperties = {
|
||||
fontSize: 12, color: INK.muted, marginTop: 2,
|
||||
};
|
||||
|
||||
const tileGrid: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
|
||||
gap: 14,
|
||||
};
|
||||
|
||||
const tileLink: React.CSSProperties = {
|
||||
textDecoration: "none", color: "inherit",
|
||||
};
|
||||
|
||||
const tileCard: React.CSSProperties = {
|
||||
background: INK.cardBg,
|
||||
border: `1px solid ${INK.border}`,
|
||||
borderRadius: 10,
|
||||
padding: 22,
|
||||
display: "flex", flexDirection: "column", gap: 18,
|
||||
minHeight: 280,
|
||||
boxShadow: INK.shadow,
|
||||
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const tileHeader: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 12,
|
||||
};
|
||||
|
||||
const tileIconWrap: React.CSSProperties = {
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: INK.iconWrapBg, color: INK.ink,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const tileTitle: React.CSSProperties = {
|
||||
fontFamily: INK.fontSerif,
|
||||
fontSize: "1.05rem", fontWeight: 400,
|
||||
color: INK.ink, letterSpacing: "-0.02em",
|
||||
margin: 0, lineHeight: 1.2,
|
||||
};
|
||||
|
||||
const tileSubtitle: React.CSSProperties = {
|
||||
fontSize: 12, color: INK.muted, marginTop: 3,
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const tileBody: React.CSSProperties = {
|
||||
display: "flex", flexDirection: "column", gap: 14, flex: 1, minHeight: 0,
|
||||
};
|
||||
|
||||
const tileMetaRow: React.CSSProperties = {
|
||||
display: "flex", gap: 28,
|
||||
};
|
||||
|
||||
const metricLabel: React.CSSProperties = {
|
||||
fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.1em",
|
||||
textTransform: "uppercase", color: INK.muted,
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const fileList: React.CSSProperties = {
|
||||
listStyle: "none", padding: 0, margin: 0,
|
||||
display: "flex", flexDirection: "column",
|
||||
border: `1px solid ${INK.border}`, borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
background: "#fdfcfa",
|
||||
};
|
||||
|
||||
const fileRow: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "8px 12px",
|
||||
borderTop: `1px solid ${INK.border}`,
|
||||
fontSize: 12.5, color: INK.ink,
|
||||
};
|
||||
|
||||
const fileIconWrap: React.CSSProperties = {
|
||||
color: INK.stone, display: "flex", alignItems: "center",
|
||||
};
|
||||
|
||||
const fileName: React.CSSProperties = {
|
||||
flex: 1, minWidth: 0, overflow: "hidden",
|
||||
textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||
fontFamily: INK.fontMono, fontSize: 12,
|
||||
};
|
||||
|
||||
const fileType: React.CSSProperties = {
|
||||
fontSize: 10, color: INK.stone, fontWeight: 500,
|
||||
textTransform: "uppercase", letterSpacing: "0.08em",
|
||||
flexShrink: 0, fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const tileMore: React.CSSProperties = {
|
||||
fontSize: 11.5, color: INK.muted, paddingLeft: 4,
|
||||
fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const liveUrlRow: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "10px 12px",
|
||||
background: "#fdfcfa",
|
||||
border: `1px solid ${INK.border}`,
|
||||
borderRadius: 8,
|
||||
};
|
||||
|
||||
const liveUrlLabel: React.CSSProperties = {
|
||||
fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.1em",
|
||||
textTransform: "uppercase", color: INK.muted,
|
||||
flexShrink: 0, fontFamily: INK.fontSans,
|
||||
};
|
||||
|
||||
const liveUrlValue: React.CSSProperties = {
|
||||
flex: 1, minWidth: 0,
|
||||
fontSize: 12, color: INK.ink, fontWeight: 500,
|
||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||
fontFamily: INK.fontMono,
|
||||
};
|
||||
|
||||
const liveUrlOpen: React.CSSProperties = {
|
||||
width: 24, height: 24, borderRadius: 6,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: INK.ink, background: INK.cardBg,
|
||||
border: `1px solid ${INK.border}`, flexShrink: 0,
|
||||
textDecoration: "none",
|
||||
};
|
||||
@@ -3,7 +3,15 @@
|
||||
import { Suspense, useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
|
||||
import Link from "next/link";
|
||||
import { JM } from "@/components/project-creation/modal-theme";
|
||||
import { AtlasChat } from "@/components/AtlasChat";
|
||||
import { PRD_PLAN_SECTIONS } from "@/lib/prd-sections";
|
||||
import {
|
||||
type ChatContextRef,
|
||||
contextRefKey,
|
||||
} from "@/lib/chat-context-refs";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,15 +25,6 @@ interface TreeNode {
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const INFRA_ITEMS = [
|
||||
{ id: "builds", label: "Builds", icon: "⬡" },
|
||||
{ id: "databases", label: "Databases", icon: "◫" },
|
||||
{ id: "services", label: "Services", icon: "◎" },
|
||||
{ id: "environment", label: "Environment", icon: "≡" },
|
||||
{ id: "domains", label: "Domains", icon: "◬" },
|
||||
{ id: "logs", label: "Logs", icon: "≈" },
|
||||
];
|
||||
|
||||
const SURFACE_LABELS: Record<string, string> = {
|
||||
webapp: "Web App", marketing: "Marketing Site", admin: "Admin Panel",
|
||||
};
|
||||
@@ -33,6 +32,74 @@ const SURFACE_ICONS: Record<string, string> = {
|
||||
webapp: "◈", marketing: "◌", admin: "◫",
|
||||
};
|
||||
|
||||
/** Growth page–style left rail: cream panel, icon + label rows */
|
||||
const BUILD_LEFT_BG = "#faf8f5";
|
||||
const BUILD_LEFT_BORDER = "#e8e4dc";
|
||||
|
||||
const BUILD_NAV_GROUP: React.CSSProperties = {
|
||||
fontSize: "0.6rem",
|
||||
fontWeight: 700,
|
||||
color: "#b5b0a6",
|
||||
letterSpacing: "0.09em",
|
||||
textTransform: "uppercase",
|
||||
padding: "14px 12px 6px",
|
||||
fontFamily: JM.fontSans,
|
||||
};
|
||||
|
||||
const BUILD_PRIMARY = [
|
||||
{ id: "chat", label: "Chat", icon: "◆" },
|
||||
{ id: "code", label: "Code", icon: "◇" },
|
||||
{ id: "layouts", label: "Layouts", icon: "◈" },
|
||||
{ id: "tasks", label: "Agent", icon: "◎" },
|
||||
{ id: "preview", label: "Preview", icon: "▢" },
|
||||
] as const;
|
||||
|
||||
function BuildGrowthNavRow({
|
||||
icon,
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
background: active ? "#f0ece4" : "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "6px 12px",
|
||||
borderRadius: 5,
|
||||
fontSize: "0.78rem",
|
||||
fontWeight: active ? 600 : 440,
|
||||
color: active ? "#1a1a1a" : "#5a5550",
|
||||
fontFamily: JM.fontSans,
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0";
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
if (!active) (e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center", flexShrink: 0 }}>
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Language / syntax helpers ─────────────────────────────────────────────────
|
||||
|
||||
function langFromName(name: string): string {
|
||||
@@ -103,28 +170,11 @@ function TreeRow({ node, depth, selectedPath, onSelect, onToggle }: {
|
||||
// ── Left nav shared styles ────────────────────────────────────────────────────
|
||||
|
||||
const NAV_GROUP_LABEL: React.CSSProperties = {
|
||||
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
|
||||
fontSize: "0.6rem", fontWeight: 700, color: JM.muted,
|
||||
letterSpacing: "0.09em", textTransform: "uppercase",
|
||||
padding: "12px 12px 5px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
padding: "12px 12px 5px", fontFamily: JM.fontSans,
|
||||
};
|
||||
|
||||
function NavItem({ label, active, onClick, indent = false }: { label: string; active: boolean; onClick: () => void; indent?: boolean }) {
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
display: "flex", alignItems: "center", gap: 7, width: "100%", textAlign: "left",
|
||||
background: active ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
|
||||
padding: `5px 12px 5px ${indent ? 22 : 12}px`, borderRadius: 5,
|
||||
fontSize: "0.78rem", fontWeight: active ? 600 : 440,
|
||||
color: active ? "#1a1a1a" : "#5a5550", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Placeholder panel ─────────────────────────────────────────────────────────
|
||||
|
||||
function Placeholder({ icon, title, desc }: { icon: string; title: string; desc: string }) {
|
||||
@@ -140,30 +190,6 @@ function Placeholder({ icon, title, desc }: { icon: string; title: string; desc:
|
||||
);
|
||||
}
|
||||
|
||||
// ── Infra content ─────────────────────────────────────────────────────────────
|
||||
|
||||
function InfraContent({ tab, projectId, workspace }: { tab: string; projectId: string; workspace: string }) {
|
||||
const base = `/${workspace}/project/${projectId}/infrastructure`;
|
||||
const descriptions: Record<string, { icon: string; title: string; desc: string }> = {
|
||||
databases: { icon: "◫", title: "Databases", desc: "PostgreSQL, Redis, and other databases — provisioned and managed with connection strings auto-injected." },
|
||||
services: { icon: "◎", title: "Services", desc: "Background workers, queues, email delivery, file storage, and third-party integrations." },
|
||||
environment: { icon: "≡", title: "Environment", desc: "Environment variables and secrets, encrypted at rest and auto-injected into your containers." },
|
||||
domains: { icon: "◬", title: "Domains", desc: "Custom domains and SSL certificates for all your deployed services." },
|
||||
logs: { icon: "≈", title: "Logs", desc: "Runtime logs, request traces, and error reports streaming from deployed services." },
|
||||
builds: { icon: "⬡", title: "Builds", desc: "Deployment history, build logs, and rollback controls for all your apps." },
|
||||
};
|
||||
const d = descriptions[tab];
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ padding: "20px 28px 0", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div style={{ fontSize: "0.68rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>{tab}</div>
|
||||
<Link href={`${base}?tab=${tab}`} style={{ fontSize: "0.72rem", color: "#a09a90", textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Open full view →</Link>
|
||||
</div>
|
||||
{d && <Placeholder icon={d.icon} title={d.title} desc={d.desc} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Layouts content ───────────────────────────────────────────────────────────
|
||||
|
||||
function LayoutsContent({ surfaces, projectId, workspace, activeSurfaceId, onSelectSurface }: {
|
||||
@@ -715,13 +741,6 @@ function AgentMode({ projectId, appName, appPath }: { projectId: string; appName
|
||||
>
|
||||
Approve & commit
|
||||
</button>
|
||||
<a
|
||||
href="https://theia.vibnai.com"
|
||||
target="_blank" rel="noreferrer"
|
||||
style={{ padding: "7px 16px", background: "#f0ece4", color: "#1a1a1a", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", textDecoration: "none", display: "inline-flex", alignItems: "center" }}
|
||||
>
|
||||
Open in Theia →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -926,7 +945,7 @@ function TerminalPanel({ appName }: { appName: string }) {
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 10, padding: "16px 24px", textAlign: "center" }}>
|
||||
<div style={{ fontSize: "0.78rem", color: "#6b6560", lineHeight: 1.6, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", maxWidth: 340 }}>
|
||||
{appName
|
||||
? `Live shell into the ${appName} container via xterm.js + Theia PTY — coming in Phase 4.`
|
||||
? `Live shell into the ${appName} container — coming in Phase 4.`
|
||||
: "Select an app from the left, then open a live shell into its container."}
|
||||
</div>
|
||||
</div>
|
||||
@@ -956,7 +975,8 @@ function FileTree({ projectId, rootPath, selectedPath, onSelectFile }: {
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootPath || status !== "authenticated") return;
|
||||
if (!rootPath) return;
|
||||
if (!isClientDevProjectBypass() && status !== "authenticated") return;
|
||||
setTree([]); setTreeLoading(true);
|
||||
fetchDir(rootPath).then(nodes => { setTree(nodes); setTreeLoading(false); }).catch(() => setTreeLoading(false));
|
||||
}, [rootPath, status, fetchDir]);
|
||||
@@ -1046,21 +1066,6 @@ interface PreviewApp { name: string; url: string | null; status: string; }
|
||||
|
||||
// ── PRD Content ───────────────────────────────────────────────────────────────
|
||||
|
||||
const PRD_SECTIONS = [
|
||||
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
|
||||
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
|
||||
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
|
||||
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
|
||||
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
|
||||
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
|
||||
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
|
||||
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
|
||||
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
|
||||
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: null },
|
||||
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
|
||||
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
|
||||
];
|
||||
|
||||
interface SavedPhase { phase: string; title: string; summary: string; data: Record<string, unknown>; saved_at: string; }
|
||||
|
||||
function PrdContent({ projectId }: { projectId: string }) {
|
||||
@@ -1086,7 +1091,7 @@ function PrdContent({ projectId }: { projectId: string }) {
|
||||
|
||||
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
|
||||
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
||||
const sections = PRD_SECTIONS.map(s => ({
|
||||
const sections = PRD_PLAN_SECTIONS.map(s => ({
|
||||
...s,
|
||||
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
|
||||
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
|
||||
@@ -1272,10 +1277,11 @@ function BuildHubInner() {
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
|
||||
const section = searchParams.get("section") ?? "code";
|
||||
const section = searchParams.get("section") ?? "chat";
|
||||
const tasksSubTabRaw = searchParams.get("tab") ?? "tasks";
|
||||
const tasksSubTab = tasksSubTabRaw === "prd" ? "requirements" : tasksSubTabRaw;
|
||||
const activeApp = searchParams.get("app") ?? "";
|
||||
const activeRoot = searchParams.get("root") ?? "";
|
||||
const activeInfra = searchParams.get("tab") ?? "builds";
|
||||
const activeSurfaceParam = searchParams.get("surface") ?? "";
|
||||
|
||||
const [apps, setApps] = useState<AppEntry[]>([]);
|
||||
@@ -1292,6 +1298,32 @@ function BuildHubInner() {
|
||||
const [fileLoading, setFileLoading] = useState(false);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
|
||||
const [chatContextRefs, setChatContextRefs] = useState<ChatContextRef[]>([]);
|
||||
|
||||
const addAppToChat = useCallback((app: AppEntry) => {
|
||||
setChatContextRefs(prev => {
|
||||
const next: ChatContextRef = { kind: "app", label: app.name, path: app.path };
|
||||
const k = contextRefKey(next);
|
||||
if (prev.some(r => contextRefKey(r) === k)) return prev;
|
||||
return [...prev, next];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeChatContextRef = useCallback((key: string) => {
|
||||
setChatContextRefs(prev => prev.filter(r => contextRefKey(r) !== key));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("section") !== "infrastructure") return;
|
||||
const t = searchParams.get("tab") ?? "builds";
|
||||
router.replace(`/${workspace}/project/${projectId}/run?tab=${encodeURIComponent(t)}`, { scroll: false });
|
||||
}, [searchParams, workspace, projectId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("section") !== "mvp") return;
|
||||
router.replace(`/${workspace}/project/${projectId}/mvp-setup/launch`, { scroll: false });
|
||||
}, [searchParams, workspace, projectId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}/preview-url`)
|
||||
.then(r => r.json())
|
||||
@@ -1336,82 +1368,114 @@ function BuildHubInner() {
|
||||
router.push(`/${workspace}/project/${projectId}/build?${sp.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const workspaceAppActive = (app: AppEntry) => {
|
||||
if (section === "chat") {
|
||||
return chatContextRefs.some(r => r.kind === "app" && r.path === app.path);
|
||||
}
|
||||
if (section === "code" || (section === "tasks" && tasksSubTab !== "requirements")) {
|
||||
return activeApp === app.name;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const onWorkspaceApp = (app: AppEntry) => {
|
||||
if (section === "chat") addAppToChat(app);
|
||||
else if (section === "code") navigate({ section: "code", app: app.name, root: app.path });
|
||||
else if (section === "tasks" && tasksSubTab !== "requirements") {
|
||||
navigate({ section: "tasks", tab: "tasks", app: app.name, root: app.path });
|
||||
}
|
||||
else navigate({ section: "code", app: app.name, root: app.path });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
|
||||
<div style={{ display: "flex", height: "100%", fontFamily: JM.fontSans, overflow: "hidden", background: JM.inputBg }}>
|
||||
{/* Growth-style left rail */}
|
||||
<div style={{
|
||||
width: 200,
|
||||
flexShrink: 0,
|
||||
borderRight: `1px solid ${BUILD_LEFT_BORDER}`,
|
||||
background: BUILD_LEFT_BG,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div style={BUILD_NAV_GROUP}>Build</div>
|
||||
{BUILD_PRIMARY.map(p => (
|
||||
<BuildGrowthNavRow
|
||||
key={p.id}
|
||||
icon={p.icon}
|
||||
label={p.label}
|
||||
active={section === p.id}
|
||||
onClick={() => {
|
||||
if (p.id === "tasks") navigate({ section: "tasks", tab: "tasks" });
|
||||
else navigate({ section: p.id });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* ── Build content ── */}
|
||||
<div style={{ flex: 1, display: "flex", overflow: "hidden", minWidth: 0 }}>
|
||||
|
||||
{/* Inner nav — contextual items driven by top-bar tool icon */}
|
||||
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
|
||||
{/* Code: app list + file tree */}
|
||||
{section === "code" && (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={NAV_GROUP_LABEL}>Apps</div>
|
||||
{apps.length > 0 ? apps.map(app => (
|
||||
<NavItem key={app.name} label={app.name} indent
|
||||
active={activeApp === app.name}
|
||||
onClick={() => navigate({ section: "code", app: app.name, root: app.path })}
|
||||
/>
|
||||
)) : (
|
||||
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>No apps yet</div>
|
||||
)}
|
||||
</div>
|
||||
{activeApp && activeRoot && (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", borderTop: "1px solid #e8e4dc", marginTop: 6 }}>
|
||||
<div style={{ padding: "7px 12px 4px", flexShrink: 0, display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ fontSize: "0.57rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.1em", textTransform: "uppercase", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Files</span>
|
||||
<span style={{ fontSize: "0.62rem", color: "#a09a90", fontFamily: "IBM Plex Mono, monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{activeApp}</span>
|
||||
</div>
|
||||
<FileTree projectId={projectId} rootPath={activeRoot} selectedPath={selectedFilePath} onSelectFile={handleSelectFile} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
borderTop: `1px solid ${BUILD_LEFT_BORDER}`,
|
||||
marginTop: 6,
|
||||
paddingTop: 4,
|
||||
}}>
|
||||
{section === "chat" && (
|
||||
<div style={{ padding: "8px 12px 12px", fontSize: 11, color: JM.mid, lineHeight: 1.45, fontFamily: JM.fontSans }}>
|
||||
Attach monorepo apps from <strong style={{ fontWeight: 600 }}>Workspace</strong> below. Live preview stays on the right when deployed.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "code" && activeApp && activeRoot && (
|
||||
<div style={{ display: "flex", flexDirection: "column", paddingBottom: 10, borderBottom: `1px solid ${BUILD_LEFT_BORDER}`, marginBottom: 8 }}>
|
||||
<div style={NAV_GROUP_LABEL}>Files</div>
|
||||
<div style={{
|
||||
padding: "2px 12px 6px",
|
||||
fontSize: "0.62rem",
|
||||
color: JM.mid,
|
||||
fontFamily: "IBM Plex Mono, monospace",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{activeApp}
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, maxHeight: 280, overflow: "auto" }}>
|
||||
<FileTree projectId={projectId} rootPath={activeRoot} selectedPath={selectedFilePath} onSelectFile={handleSelectFile} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Layouts: surface list */}
|
||||
{section === "layouts" && (
|
||||
<div style={{ overflow: "auto", flex: 1 }}>
|
||||
<div style={{ paddingBottom: 8 }}>
|
||||
<div style={NAV_GROUP_LABEL}>Surfaces</div>
|
||||
{surfaces.length > 0 ? surfaces.map(s => (
|
||||
<NavItem key={s.id} label={SURFACE_LABELS[s.id] ?? s.id} indent
|
||||
<BuildGrowthNavRow
|
||||
key={s.id}
|
||||
icon={SURFACE_ICONS[s.id] ?? "◈"}
|
||||
label={SURFACE_LABELS[s.id] ?? s.id}
|
||||
active={activeSurfaceId === s.id}
|
||||
onClick={() => { setActiveSurfaceId(s.id); navigate({ section: "layouts", surface: s.id }); }}
|
||||
/>
|
||||
)) : (
|
||||
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Not configured</div>
|
||||
<div style={{ padding: "8px 12px", fontSize: "0.74rem", color: JM.muted, fontFamily: JM.fontSans }}>Not configured</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Infrastructure: item list */}
|
||||
{section === "infrastructure" && (
|
||||
<div style={{ overflow: "auto", flex: 1 }}>
|
||||
<div style={NAV_GROUP_LABEL}>Infrastructure</div>
|
||||
{INFRA_ITEMS.map(item => (
|
||||
<NavItem key={item.id} label={item.label} indent
|
||||
active={activeInfra === item.id}
|
||||
onClick={() => navigate({ section: "infrastructure", tab: item.id })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tasks: sub-nav + app list */}
|
||||
{section === "tasks" && (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
{/* Tasks | PRD sub-nav */}
|
||||
<div style={{ flexShrink: 0, padding: "8px 10px", borderBottom: "1px solid #e8e4dc", display: "flex", gap: 4 }}>
|
||||
{[{ id: "tasks", label: "Tasks" }, { id: "prd", label: "PRD" }].map(item => {
|
||||
const isActive = (searchParams.get("tab") ?? "tasks") === item.id;
|
||||
<div style={{ paddingBottom: 8 }}>
|
||||
<div style={{ padding: "8px 10px", borderBottom: `1px solid ${BUILD_LEFT_BORDER}`, display: "flex", gap: 4, marginBottom: 8 }}>
|
||||
{[{ id: "tasks", label: "Runs" }, { id: "requirements", label: "Requirements" }].map(item => {
|
||||
const isActive = tasksSubTab === item.id;
|
||||
return (
|
||||
<button key={item.id} onClick={() => navigate({ section: "tasks", tab: item.id })} style={{
|
||||
<button key={item.id} type="button" onClick={() => navigate({ section: "tasks", tab: item.id })} style={{
|
||||
flex: 1, padding: "5px 0", border: "none", borderRadius: 6, cursor: "pointer",
|
||||
fontSize: "0.72rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
background: isActive ? "#1a1a1a" : "transparent",
|
||||
color: isActive ? "#fff" : "#a09a90",
|
||||
fontSize: "0.72rem", fontWeight: 600, fontFamily: JM.fontSans,
|
||||
background: isActive ? JM.primaryGradient : "transparent",
|
||||
color: isActive ? "#fff" : JM.mid,
|
||||
boxShadow: isActive ? JM.primaryShadow : "none",
|
||||
transition: "all 0.12s",
|
||||
}}>
|
||||
{item.label}
|
||||
@@ -1419,40 +1483,106 @@ function BuildHubInner() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* App list (only in tasks tab) */}
|
||||
{(searchParams.get("tab") ?? "tasks") !== "prd" && (
|
||||
<>
|
||||
<div style={NAV_GROUP_LABEL}>Apps</div>
|
||||
{apps.length > 0 ? apps.map(app => (
|
||||
<NavItem key={app.name} label={app.name} indent
|
||||
active={activeApp === app.name}
|
||||
onClick={() => navigate({ section: "tasks", tab: "tasks", app: app.name, root: app.path })}
|
||||
/>
|
||||
)) : (
|
||||
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>No apps yet</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview: deployed apps list */}
|
||||
{section === "preview" && (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<div style={NAV_GROUP_LABEL}>Apps</div>
|
||||
<div style={{ paddingBottom: 8 }}>
|
||||
<div style={NAV_GROUP_LABEL}>Deployed</div>
|
||||
{previewApps.length > 0 ? previewApps.map(app => (
|
||||
<NavItem key={app.name} label={app.name} indent
|
||||
<BuildGrowthNavRow
|
||||
key={app.name}
|
||||
icon="▢"
|
||||
label={app.name}
|
||||
active={activePreviewApp?.name === app.name}
|
||||
onClick={() => setActivePreviewApp(app)}
|
||||
/>
|
||||
)) : (
|
||||
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>No deployments yet</div>
|
||||
<div style={{ padding: "8px 12px", fontSize: "0.74rem", color: JM.muted, fontFamily: JM.fontSans }}>No deployments yet</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content panel */}
|
||||
<div style={{ flexShrink: 0, borderTop: `1px solid ${BUILD_LEFT_BORDER}`, paddingBottom: 10, background: BUILD_LEFT_BG }}>
|
||||
{apps.length > 0 && (
|
||||
<>
|
||||
<div style={BUILD_NAV_GROUP}>Workspace</div>
|
||||
{apps.map(app => (
|
||||
<BuildGrowthNavRow
|
||||
key={app.name}
|
||||
icon="◈"
|
||||
label={app.name}
|
||||
active={workspaceAppActive(app)}
|
||||
onClick={() => onWorkspaceApp(app)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{section === "chat" && previewApps.length > 0 && (
|
||||
<>
|
||||
<div style={BUILD_NAV_GROUP}>Live</div>
|
||||
{previewApps.map(app => (
|
||||
<BuildGrowthNavRow
|
||||
key={`live-${app.name}`}
|
||||
icon="▢"
|
||||
label={app.name}
|
||||
active={activePreviewApp?.name === app.name}
|
||||
onClick={() => setActivePreviewApp(app)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href={`/${workspace}/project/${projectId}/tasks`}
|
||||
style={{
|
||||
display: "block",
|
||||
margin: "10px 12px 0",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: JM.indigo,
|
||||
textDecoration: "none",
|
||||
fontFamily: JM.fontSans,
|
||||
}}
|
||||
>
|
||||
Open Tasks →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div style={{ flex: 1, display: "flex", overflow: "hidden", minWidth: 0 }}>
|
||||
{section === "chat" && (
|
||||
<div style={{ flex: 1, display: "flex", minWidth: 0, overflow: "hidden" }}>
|
||||
<div style={{ flex: 1, minWidth: 0, minHeight: 0 }}>
|
||||
<AtlasChat
|
||||
projectId={projectId}
|
||||
conversationScope="build"
|
||||
contextEmptyLabel="Workspace"
|
||||
emptyStateHint="Plan and implement in your monorepo. Attach apps from the left, preview on the right when deployed."
|
||||
chatContextRefs={chatContextRefs}
|
||||
onRemoveChatContextRef={removeChatContextRef}
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
width: 400,
|
||||
flexShrink: 0,
|
||||
minWidth: 280,
|
||||
borderLeft: `1px solid ${JM.border}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
background: JM.inputBg,
|
||||
}}>
|
||||
<PreviewContent
|
||||
projectId={projectId}
|
||||
apps={previewApps}
|
||||
activePreviewApp={activePreviewApp}
|
||||
onSelectApp={setActivePreviewApp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{section === "code" && (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<FileViewer
|
||||
@@ -1468,13 +1598,10 @@ function BuildHubInner() {
|
||||
{section === "layouts" && (
|
||||
<LayoutsContent surfaces={surfaces} projectId={projectId} workspace={workspace} activeSurfaceId={activeSurfaceId} onSelectSurface={id => { setActiveSurfaceId(id); navigate({ section: "layouts", surface: id }); }} />
|
||||
)}
|
||||
{section === "infrastructure" && (
|
||||
<InfraContent tab={activeInfra} projectId={projectId} workspace={workspace} />
|
||||
)}
|
||||
{section === "tasks" && (searchParams.get("tab") ?? "tasks") !== "prd" && (
|
||||
{section === "tasks" && tasksSubTab !== "requirements" && (
|
||||
<AgentMode projectId={projectId} appName={activeApp} appPath={activeRoot} />
|
||||
)}
|
||||
{section === "tasks" && searchParams.get("tab") === "prd" && (
|
||||
{section === "tasks" && tasksSubTab === "requirements" && (
|
||||
<PrdContent projectId={projectId} />
|
||||
)}
|
||||
{section === "preview" && (
|
||||
@@ -1492,7 +1619,7 @@ function BuildHubInner() {
|
||||
|
||||
export default function BuildPage() {
|
||||
return (
|
||||
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: JM.muted, fontFamily: JM.fontSans, fontSize: "0.85rem" }}>Loading…</div>}>
|
||||
<BuildHubInner />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,6 @@ interface Project {
|
||||
status?: string;
|
||||
giteaRepoUrl?: string;
|
||||
giteaRepo?: string;
|
||||
theiaWorkspaceUrl?: string;
|
||||
coolifyDeployUrl?: string;
|
||||
customDomain?: string;
|
||||
prd?: string;
|
||||
@@ -70,7 +69,7 @@ export default function DeploymentPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasDeploy = Boolean(project?.coolifyDeployUrl || project?.theiaWorkspaceUrl);
|
||||
const hasDeploy = Boolean(project?.coolifyDeployUrl);
|
||||
const hasRepo = Boolean(project?.giteaRepoUrl);
|
||||
const hasPRD = Boolean(project?.prd);
|
||||
|
||||
|
||||
@@ -1,353 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect } from "react";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface InfraApp {
|
||||
name: string;
|
||||
domain?: string | null;
|
||||
coolifyServiceUuid?: string | null;
|
||||
}
|
||||
|
||||
interface ProjectData {
|
||||
giteaRepo?: string;
|
||||
giteaRepoUrl?: string;
|
||||
apps?: InfraApp[];
|
||||
}
|
||||
|
||||
// ── Tab definitions ───────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = [
|
||||
{ id: "builds", label: "Builds", icon: "⬡" },
|
||||
{ id: "databases", label: "Databases", icon: "◫" },
|
||||
{ id: "services", label: "Services", icon: "◎" },
|
||||
{ id: "environment", label: "Environment", icon: "≡" },
|
||||
{ id: "domains", label: "Domains", icon: "◬" },
|
||||
{ id: "logs", label: "Logs", icon: "≈" },
|
||||
] as const;
|
||||
|
||||
type TabId = typeof TABS[number]["id"];
|
||||
|
||||
// ── Shared empty state ────────────────────────────────────────────────────────
|
||||
|
||||
function ComingSoonPanel({ icon, title, description }: { icon: string; title: string; description: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
padding: 60, textAlign: "center", gap: 16,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: 14, background: "#f0ece4",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "1.5rem", color: "#b5b0a6",
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>{title}</div>
|
||||
<div style={{ fontSize: "0.82rem", color: "#a09a90", maxWidth: 340, lineHeight: 1.6 }}>{description}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: 8, padding: "8px 18px",
|
||||
background: "#1a1a1a", color: "#fff",
|
||||
borderRadius: 7, fontSize: "0.78rem", fontWeight: 500,
|
||||
opacity: 0.4, cursor: "default",
|
||||
}}>
|
||||
Coming soon
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Builds tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function BuildsTab({ project }: { project: ProjectData | null }) {
|
||||
const apps = project?.apps ?? [];
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="⬡"
|
||||
title="No deployments yet"
|
||||
description="Once your apps are deployed via Coolify, build history and deployment logs will appear here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Deployed Apps
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{apps.map(app => (
|
||||
<div key={app.name} style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span style={{ fontSize: "0.85rem", color: "#a09a90" }}>⬡</span>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{app.name}</div>
|
||||
{app.domain && (
|
||||
<div style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 2 }}>{app.domain}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
|
||||
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>Running</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Databases tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function DatabasesTab() {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="◫"
|
||||
title="Databases"
|
||||
description="Provision and manage PostgreSQL, Redis, and other databases for your project. Connection strings and credentials will be auto-injected into your environment."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Services tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ServicesTab() {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="◎"
|
||||
title="Services"
|
||||
description="Background workers, email delivery, queues, file storage, and third-party integrations will be configured and monitored here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Environment tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function EnvironmentTab({ project }: { project: ProjectData | null }) {
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Environment Variables & Secrets
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
overflow: "hidden", marginBottom: 20,
|
||||
}}>
|
||||
{/* Header row */}
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "1fr 1fr auto",
|
||||
padding: "10px 18px", background: "#faf8f5",
|
||||
borderBottom: "1px solid #e8e4dc",
|
||||
fontSize: "0.68rem", fontWeight: 700, color: "#a09a90",
|
||||
letterSpacing: "0.06em", textTransform: "uppercase",
|
||||
}}>
|
||||
<span>Key</span><span>Value</span><span />
|
||||
</div>
|
||||
{/* Placeholder rows */}
|
||||
{["DATABASE_URL", "NEXTAUTH_SECRET", "GITEA_API_TOKEN"].map(k => (
|
||||
<div key={k} style={{
|
||||
display: "grid", gridTemplateColumns: "1fr 1fr auto",
|
||||
padding: "11px 18px", borderBottom: "1px solid #f0ece4",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#1a1a1a" }}>{k}</span>
|
||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#b5b0a6", letterSpacing: 2 }}>••••••••</span>
|
||||
<button style={{ background: "none", border: "none", cursor: "pointer", color: "#a09a90", fontSize: "0.72rem", padding: "2px 6px" }}>Edit</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ padding: "11px 18px", borderTop: "1px solid #f0ece4" }}>
|
||||
<button style={{
|
||||
background: "none", border: "1px dashed #d4cfc8", borderRadius: 6,
|
||||
padding: "6px 14px", fontSize: "0.75rem", color: "#a09a90",
|
||||
cursor: "pointer", width: "100%",
|
||||
}}>
|
||||
+ Add variable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "#b5b0a6", lineHeight: 1.6 }}>
|
||||
Variables are encrypted at rest and auto-injected into deployed containers. Secrets are never exposed in logs.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Domains tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function DomainsTab({ project }: { project: ProjectData | null }) {
|
||||
const apps = (project?.apps ?? []).filter(a => a.domain);
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Domains & SSL
|
||||
</div>
|
||||
{apps.length > 0 ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 20 }}>
|
||||
{apps.map(app => (
|
||||
<div key={app.name} style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.8rem", color: "#1a1a1a", fontWeight: 500 }}>
|
||||
{app.domain}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.7rem", color: "#a09a90", marginTop: 3 }}>{app.name}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
|
||||
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>SSL active</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: "#fff", border: "1px dashed #d4cfc8", borderRadius: 10,
|
||||
padding: "32px 24px", textAlign: "center", marginBottom: 20,
|
||||
}}>
|
||||
<div style={{ fontSize: "0.82rem", color: "#a09a90" }}>No custom domains configured</div>
|
||||
<div style={{ fontSize: "0.73rem", color: "#b5b0a6", marginTop: 6 }}>Deploy an app first, then point a domain here.</div>
|
||||
</div>
|
||||
)}
|
||||
<button style={{
|
||||
background: "#1a1a1a", color: "#fff", border: "none",
|
||||
borderRadius: 8, padding: "9px 20px",
|
||||
fontSize: "0.78rem", fontWeight: 500, cursor: "pointer",
|
||||
opacity: 0.5,
|
||||
}}>
|
||||
+ Add domain
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Logs tab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function LogsTab({ project }: { project: ProjectData | null }) {
|
||||
const apps = project?.apps ?? [];
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="≈"
|
||||
title="No logs yet"
|
||||
description="Runtime logs, request traces, and error reports from your deployed services will stream here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 900 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Runtime Logs
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#1e1e1e", borderRadius: 10, padding: "20px 24px",
|
||||
fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#d4d4d4",
|
||||
lineHeight: 1.6, minHeight: 200,
|
||||
}}>
|
||||
<div style={{ color: "#6a9955" }}>{"# Logs will stream here once connected to Coolify"}</div>
|
||||
<div style={{ color: "#569cd6", marginTop: 8 }}>{"→ Select a service to tail its log output"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inner page ────────────────────────────────────────────────────────────────
|
||||
|
||||
function InfrastructurePageInner() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
|
||||
const activeTab = (searchParams.get("tab") ?? "builds") as TabId;
|
||||
const [project, setProject] = useState<ProjectData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}/apps`)
|
||||
.then(r => r.json())
|
||||
.then(d => setProject({ apps: d.apps ?? [], giteaRepo: d.giteaRepo, giteaRepoUrl: d.giteaRepoUrl }))
|
||||
.catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
const setTab = (id: TabId) => {
|
||||
router.push(`/${workspace}/project/${projectId}/infrastructure?tab=${id}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
|
||||
|
||||
{/* ── Left sub-nav ── */}
|
||||
<div style={{
|
||||
width: 190, flexShrink: 0,
|
||||
borderRight: "1px solid #e8e4dc",
|
||||
background: "#faf8f5",
|
||||
display: "flex", flexDirection: "column",
|
||||
padding: "16px 8px",
|
||||
gap: 2,
|
||||
overflow: "auto",
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
|
||||
letterSpacing: "0.1em", textTransform: "uppercase",
|
||||
padding: "0 8px 10px",
|
||||
}}>
|
||||
Infrastructure
|
||||
</div>
|
||||
{TABS.map(tab => {
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setTab(tab.id)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 9,
|
||||
padding: "7px 10px", borderRadius: 6,
|
||||
background: active ? "#f0ece4" : "transparent",
|
||||
border: "none", cursor: "pointer", width: "100%", textAlign: "left",
|
||||
color: active ? "#1a1a1a" : "#6b6560",
|
||||
fontSize: "0.8rem", fontWeight: active ? 600 : 450,
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
<span style={{ fontSize: "0.75rem", opacity: 0.65, width: 16, textAlign: "center" }}>{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||
{activeTab === "builds" && <BuildsTab project={project} />}
|
||||
{activeTab === "databases" && <DatabasesTab />}
|
||||
{activeTab === "services" && <ServicesTab />}
|
||||
{activeTab === "environment" && <EnvironmentTab project={project} />}
|
||||
{activeTab === "domains" && <DomainsTab project={project} />}
|
||||
{activeTab === "logs" && <LogsTab project={project} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Export ────────────────────────────────────────────────────────────────────
|
||||
import { ProjectInfraPanel } from "@/components/project-main/ProjectInfraPanel";
|
||||
|
||||
export default function InfrastructurePage() {
|
||||
return (
|
||||
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||
<InfrastructurePageInner />
|
||||
</Suspense>
|
||||
<ProjectInfraPanel routeBase="infrastructure" navGroupLabel="Infrastructure" />
|
||||
);
|
||||
}
|
||||
|
||||
81
app/[workspace]/project/[projectId]/(workspace)/layout.tsx
Normal file
81
app/[workspace]/project/[projectId]/(workspace)/layout.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||
import { ProjectShell } from "@/components/layout/project-shell";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
|
||||
const plusJakarta = Plus_Jakarta_Sans({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-justine-jakarta",
|
||||
});
|
||||
|
||||
interface ProjectData {
|
||||
name: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
progress?: number;
|
||||
discoveryPhase?: number;
|
||||
capturedData?: Record<string, string>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
featureCount?: number;
|
||||
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||
}
|
||||
|
||||
async function getProjectData(projectId: string): Promise<ProjectData> {
|
||||
try {
|
||||
const rows = await query<{ data: any; created_at?: string; updated_at?: string }>(
|
||||
`SELECT data, created_at, updated_at FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
[projectId]
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
const { data, created_at, updated_at } = rows[0];
|
||||
return {
|
||||
name: data?.productName || data?.name || "Project",
|
||||
description: data?.productVision || data?.description,
|
||||
status: data?.status,
|
||||
progress: data?.progress ?? 0,
|
||||
discoveryPhase: data?.discoveryPhase ?? 0,
|
||||
capturedData: data?.capturedData ?? {},
|
||||
createdAt: created_at,
|
||||
updatedAt: updated_at,
|
||||
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
|
||||
creationMode: data?.creationMode ?? "fresh",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching project:", error);
|
||||
}
|
||||
return { name: "Project" };
|
||||
}
|
||||
|
||||
export default async function ProjectLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = await params;
|
||||
const project = await getProjectData(projectId);
|
||||
|
||||
return (
|
||||
<div className={plusJakarta.variable} style={{ height: "100%", minHeight: "100dvh" }}>
|
||||
<ProjectShell
|
||||
workspace={workspace}
|
||||
projectId={projectId}
|
||||
projectName={project.name}
|
||||
projectDescription={project.description}
|
||||
projectStatus={project.status}
|
||||
projectProgress={project.progress}
|
||||
discoveryPhase={project.discoveryPhase}
|
||||
capturedData={project.capturedData}
|
||||
createdAt={project.createdAt}
|
||||
updatedAt={project.updatedAt}
|
||||
featureCount={project.featureCount}
|
||||
creationMode={project.creationMode}
|
||||
>
|
||||
{children}
|
||||
</ProjectShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder";
|
||||
|
||||
export default function MvpSetupArchitectPage() {
|
||||
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
|
||||
const base = `/${workspace}/project/${projectId}`;
|
||||
return (
|
||||
<MvpSetupStepPlaceholder
|
||||
title="Architect"
|
||||
subtitle="Lock in discovery — stack choices, surfaces, and what we’re shipping."
|
||||
body="Use Task to run discovery phases and save answers. When you’re ready, continue to Design."
|
||||
primaryHref={`${base}/tasks`}
|
||||
primaryLabel="Open Task"
|
||||
nextHref={`${base}/mvp-setup/design`}
|
||||
nextLabel="Continue to Design"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { MvpSetupDescribeView } from "@/components/project-main/MvpSetupDescribeView";
|
||||
|
||||
export default function MvpSetupDescribePage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
return <MvpSetupDescribeView projectId={projectId} workspace={workspace} />;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder";
|
||||
|
||||
export default function MvpSetupDesignPage() {
|
||||
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
|
||||
const base = `/${workspace}/project/${projectId}`;
|
||||
return (
|
||||
<MvpSetupStepPlaceholder
|
||||
title="Design"
|
||||
subtitle="Pick feel, color, and layout — we’ll apply it across your product surfaces."
|
||||
body="The full design studio lives on the Design tab. When it looks right, move on to how you’ll grow."
|
||||
primaryHref={`${base}/design`}
|
||||
primaryLabel="Open Design"
|
||||
nextHref={`${base}/mvp-setup/website`}
|
||||
nextLabel="Continue to Website"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { MvpSetupLayoutClient } from "@/components/project-main/MvpSetupLayoutClient";
|
||||
|
||||
export default async function MvpSetupWizardLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = await params;
|
||||
return (
|
||||
<MvpSetupLayoutClient workspace={workspace} projectId={projectId}>
|
||||
{children}
|
||||
</MvpSetupLayoutClient>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder";
|
||||
|
||||
export default function MvpSetupWebsitePage() {
|
||||
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
|
||||
const base = `/${workspace}/project/${projectId}`;
|
||||
return (
|
||||
<MvpSetupStepPlaceholder
|
||||
title="Website"
|
||||
subtitle="Voice, topics, and marketing style — what people see before they sign up."
|
||||
body="Tune growth messaging on the Grow tab. Then review everything and kick off your MVP build."
|
||||
primaryHref={`${base}/growth`}
|
||||
primaryLabel="Open Grow"
|
||||
nextHref={`${base}/mvp-setup/launch`}
|
||||
nextLabel="Review & launch"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { BuildMvpJustineV2 } from "@/components/project-main/BuildMvpJustineV2";
|
||||
import { JM } from "@/components/project-creation/modal-theme";
|
||||
|
||||
interface SurfaceEntry {
|
||||
id: string;
|
||||
lockedTheme?: string;
|
||||
}
|
||||
|
||||
export default function MvpSetupLaunchPage() {
|
||||
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
|
||||
const router = useRouter();
|
||||
const [productName, setProductName] = useState("Your product");
|
||||
const [giteaRepo, setGiteaRepo] = useState<string | undefined>();
|
||||
const [surfaces, setSurfaces] = useState<SurfaceEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const p = d.project;
|
||||
if (p) {
|
||||
setProductName(p.productName || p.name || "Your product");
|
||||
setGiteaRepo(p.giteaRepo);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch(`/api/projects/${projectId}/design-surfaces`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const ids: string[] = d.surfaces ?? [];
|
||||
const themes: Record<string, string> = d.surfaceThemes ?? {};
|
||||
setSurfaces(ids.map(id => ({ id, lockedTheme: themes[id] })));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
const webappSurface = surfaces.find(s => s.id === "webapp");
|
||||
const marketingSurface = surfaces.find(s => s.id === "marketing");
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", overflow: "hidden", background: JM.inputBg }}>
|
||||
<BuildMvpJustineV2
|
||||
workspace={workspace}
|
||||
projectId={projectId}
|
||||
projectName={productName}
|
||||
giteaRepo={giteaRepo}
|
||||
accentLabel={webappSurface?.lockedTheme}
|
||||
websiteStyle={marketingSurface?.lockedTheme}
|
||||
onSwitchToPreview={() => {
|
||||
router.push(`/${workspace}/project/${projectId}/build?section=preview`, { scroll: false });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Root: no sidebar — launch step uses full Justine chrome; wizard steps use (wizard)/layout. */
|
||||
export default function MvpSetupRootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div style={{ height: "100%", overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function MvpSetupIndexPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = await params;
|
||||
redirect(`/${workspace}/project/${projectId}/mvp-setup/describe`);
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { JM } from "@/components/project-creation/modal-theme";
|
||||
import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain";
|
||||
import { ChatImportMain } from "@/components/project-main/ChatImportMain";
|
||||
import { CodeImportMain } from "@/components/project-main/CodeImportMain";
|
||||
@@ -35,10 +37,12 @@ export default function ProjectOverviewPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (authStatus !== "authenticated") {
|
||||
const bypass = isClientDevProjectBypass();
|
||||
if (!bypass && authStatus !== "authenticated") {
|
||||
if (authStatus === "unauthenticated") setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!bypass && authStatus === "loading") return;
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then(r => r.json())
|
||||
.then(d => setProject(d.project))
|
||||
@@ -48,15 +52,23 @@ export default function ProjectOverviewPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
height: "100%", fontFamily: JM.fontSans,
|
||||
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
|
||||
}}>
|
||||
<Loader2 style={{ width: 24, height: 24, color: JM.indigo }} className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90", fontSize: "0.88rem" }}>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
height: "100%", fontFamily: JM.fontSans, color: JM.muted, fontSize: 14,
|
||||
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
|
||||
}}>
|
||||
Project not found.
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,459 +1,11 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
// Maps each PRD section to the discovery phase that populates it
|
||||
const PRD_SECTIONS = [
|
||||
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
|
||||
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
|
||||
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
|
||||
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
|
||||
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
|
||||
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
|
||||
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
|
||||
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
|
||||
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
|
||||
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" },
|
||||
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
|
||||
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
|
||||
];
|
||||
|
||||
interface SavedPhase {
|
||||
phase: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
data: Record<string, unknown>;
|
||||
saved_at: string;
|
||||
}
|
||||
|
||||
function formatValue(v: unknown): string {
|
||||
if (v === null || v === undefined) return "—";
|
||||
if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", ");
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function PhaseDataCard({ phase }: { phase: SavedPhase }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== "");
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 10, background: "#f6f4f0", borderRadius: 8,
|
||||
border: "1px solid #e8e4dc", overflow: "hidden",
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
style={{
|
||||
width: "100%", textAlign: "left", padding: "10px 14px",
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
|
||||
{phase.summary}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.7rem", color: "#a09a90", marginLeft: 8, flexShrink: 0 }}>
|
||||
{expanded ? "▲" : "▼"}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && entries.length > 0 && (
|
||||
<div style={{ padding: "4px 14px 14px", borderTop: "1px solid #e8e4dc" }}>
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} style={{ marginTop: 10 }}>
|
||||
<div style={{
|
||||
fontSize: "0.6rem", color: "#b5b0a6", textTransform: "uppercase",
|
||||
letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2,
|
||||
}}>
|
||||
{k.replace(/_/g, " ")}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.78rem", color: "#2a2824", lineHeight: 1.5 }}>
|
||||
{formatValue(v)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ArchApp { name: string; type: string; description: string; tech?: string[]; screens?: string[] }
|
||||
interface ArchInfra { name: string; reason: string }
|
||||
interface ArchPackage { name: string; description: string }
|
||||
interface ArchIntegration { name: string; required?: boolean; notes?: string }
|
||||
interface Architecture {
|
||||
productName?: string;
|
||||
productType?: string;
|
||||
summary?: string;
|
||||
apps?: ArchApp[];
|
||||
packages?: ArchPackage[];
|
||||
infrastructure?: ArchInfra[];
|
||||
integrations?: ArchIntegration[];
|
||||
designSurfaces?: string[];
|
||||
riskNotes?: string[];
|
||||
}
|
||||
|
||||
function ArchitectureView({ arch }: { arch: Architecture }) {
|
||||
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 10 }}>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
const Card = ({ children }: { children: React.ReactNode }) => (
|
||||
<div style={{ background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9, padding: "14px 16px", marginBottom: 8 }}>{children}</div>
|
||||
);
|
||||
const Tag = ({ label }: { label: string }) => (
|
||||
<span style={{ background: "#f0ece4", borderRadius: 4, padding: "2px 7px", fontSize: "0.68rem", color: "#6b6560", fontFamily: "IBM Plex Mono, monospace", marginRight: 4, display: "inline-block", marginBottom: 3 }}>{label}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
{arch.summary && (
|
||||
<div style={{ background: "#1a1a1a", borderRadius: 10, padding: "18px 22px", marginBottom: 24, color: "#e8e4dc", fontSize: "0.88rem", lineHeight: 1.7 }}>
|
||||
{arch.summary}
|
||||
</div>
|
||||
)}
|
||||
{(arch.apps ?? []).length > 0 && (
|
||||
<Section title="Applications">
|
||||
{arch.apps!.map(a => (
|
||||
<Card key={a.name}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>{a.name}</span>
|
||||
<span style={{ fontSize: "0.72rem", color: "#9a9490" }}>{a.type}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.55, marginBottom: a.tech?.length ? 8 : 0 }}>{a.description}</div>
|
||||
{a.tech?.map(t => <Tag key={t} label={t} />)}
|
||||
{a.screens && a.screens.length > 0 && (
|
||||
<div style={{ marginTop: 6, fontSize: "0.72rem", color: "#a09a90" }}>Screens: {a.screens.join(", ")}</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.packages ?? []).length > 0 && (
|
||||
<Section title="Shared Packages">
|
||||
{arch.packages!.map(p => (
|
||||
<Card key={p.name}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
|
||||
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>{p.name}</span>
|
||||
<span style={{ fontSize: "0.78rem", color: "#4a4640" }}>{p.description}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.infrastructure ?? []).length > 0 && (
|
||||
<Section title="Infrastructure">
|
||||
{arch.infrastructure!.map(i => (
|
||||
<Card key={i.name}>
|
||||
<div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 3 }}>{i.name}</div>
|
||||
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>{i.reason}</div>
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.integrations ?? []).length > 0 && (
|
||||
<Section title="Integrations">
|
||||
{arch.integrations!.map(i => (
|
||||
<Card key={i.name}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: i.notes ? 4 : 0 }}>
|
||||
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a" }}>{i.name}</span>
|
||||
{i.required && <span style={{ fontSize: "0.62rem", background: "#fef3c7", color: "#92400e", padding: "1px 6px", borderRadius: 4 }}>required</span>}
|
||||
</div>
|
||||
{i.notes && <div style={{ fontSize: "0.78rem", color: "#4a4640" }}>{i.notes}</div>}
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.riskNotes ?? []).length > 0 && (
|
||||
<Section title="Architectural Risks">
|
||||
{arch.riskNotes!.map((r, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start", marginBottom: 8 }}>
|
||||
<span style={{ fontSize: "0.72rem", color: "#d97706", marginTop: 2, flexShrink: 0 }}>⚠</span>
|
||||
<span style={{ fontSize: "0.82rem", color: "#4a4640", lineHeight: 1.5 }}>{r}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PRDPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
const [prd, setPrd] = useState<string | null>(null);
|
||||
const [architecture, setArchitecture] = useState<Architecture | null>(null);
|
||||
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<"prd" | "architecture">("prd");
|
||||
const [archGenerating, setArchGenerating] = useState(false);
|
||||
const [archError, setArchError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})),
|
||||
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
|
||||
]).then(([projectData, phaseData]) => {
|
||||
setPrd(projectData?.project?.prd ?? null);
|
||||
setArchitecture(projectData?.project?.architecture ?? null);
|
||||
setSavedPhases(phaseData?.phases ?? []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleGenerateArchitecture = async () => {
|
||||
setArchGenerating(true);
|
||||
setArchError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Generation failed");
|
||||
setArchitecture(data.architecture);
|
||||
setActiveTab("architecture");
|
||||
} catch (e) {
|
||||
setArchError(e instanceof Error ? e.message : "Something went wrong");
|
||||
} finally {
|
||||
setArchGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
|
||||
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
||||
|
||||
const sections = PRD_SECTIONS.map(s => ({
|
||||
...s,
|
||||
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
|
||||
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
|
||||
}));
|
||||
|
||||
const doneCount = sections.filter(s => s.isDone).length;
|
||||
const totalPct = Math.round((doneCount / sections.length) * 100);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: "prd" as const, label: "PRD", available: true },
|
||||
{ id: "architecture" as const, label: "Architecture", available: !!architecture },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
|
||||
{/* Tab bar — only when at least one doc exists */}
|
||||
{(prd || architecture) && (
|
||||
<div style={{ display: "flex", gap: 4, marginBottom: 24 }}>
|
||||
{tabs.map(t => {
|
||||
const isActive = activeTab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => t.available && setActiveTab(t.id)}
|
||||
disabled={!t.available}
|
||||
style={{
|
||||
padding: "6px 14px", borderRadius: 8, border: "none", cursor: t.available ? "pointer" : "default",
|
||||
background: isActive ? "#1a1a1a" : "transparent",
|
||||
color: isActive ? "#fff" : t.available ? "#6b6560" : "#c5c0b8",
|
||||
fontSize: "0.8rem", fontWeight: isActive ? 600 : 400,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
transition: "all 0.1s",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
{!t.available && <span style={{ marginLeft: 5, fontSize: "0.65rem", opacity: 0.6 }}>—</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next step banner — PRD done but no architecture yet */}
|
||||
{prd && !architecture && activeTab === "prd" && (
|
||||
<div style={{
|
||||
marginBottom: 24, padding: "18px 22px",
|
||||
background: "#1a1a1a", borderRadius: 10,
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
gap: 16, flexWrap: "wrap",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 4 }}>
|
||||
Next: Generate technical architecture
|
||||
</div>
|
||||
<div style={{ fontSize: "0.76rem", color: "#a09a90", lineHeight: 1.5 }}>
|
||||
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds.
|
||||
</div>
|
||||
{archError && (
|
||||
<div style={{ fontSize: "0.74rem", color: "#f87171", marginTop: 6 }}>⚠ {archError}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerateArchitecture}
|
||||
disabled={archGenerating}
|
||||
style={{
|
||||
padding: "10px 20px", borderRadius: 8, border: "none",
|
||||
background: archGenerating ? "#4a4640" : "#fff",
|
||||
color: archGenerating ? "#a09a90" : "#1a1a1a",
|
||||
fontSize: "0.82rem", fontWeight: 700,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
cursor: archGenerating ? "default" : "pointer",
|
||||
flexShrink: 0, display: "flex", alignItems: "center", gap: 8,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
>
|
||||
{archGenerating && (
|
||||
<span style={{
|
||||
width: 12, height: 12, borderRadius: "50%",
|
||||
border: "2px solid #60606040", borderTopColor: "#a09a90",
|
||||
animation: "spin 0.7s linear infinite", display: "inline-block",
|
||||
}} />
|
||||
)}
|
||||
{archGenerating ? "Analysing PRD…" : "Generate architecture →"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Architecture tab */}
|
||||
{activeTab === "architecture" && architecture && (
|
||||
<ArchitectureView arch={architecture} />
|
||||
)}
|
||||
|
||||
{/* PRD tab — finalized */}
|
||||
{activeTab === "prd" && prd && (
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
|
||||
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
||||
Product Requirements
|
||||
</h3>
|
||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
|
||||
PRD complete
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
|
||||
padding: "28px 32px", lineHeight: 1.8,
|
||||
fontSize: "0.88rem", color: "#2a2824",
|
||||
whiteSpace: "pre-wrap", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
}}>
|
||||
{prd}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PRD tab — section progress (no finalized PRD yet) */}
|
||||
{activeTab === "prd" && !prd && (
|
||||
/* ── Section progress view ── */
|
||||
<div style={{ maxWidth: 680 }}>
|
||||
{/* Progress bar */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 16,
|
||||
padding: "16px 20px", background: "#fff",
|
||||
border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: "IBM Plex Mono, monospace",
|
||||
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
|
||||
}}>
|
||||
{totalPct}%
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
|
||||
<div style={{
|
||||
height: "100%", borderRadius: 2,
|
||||
width: `${totalPct}%`, background: "#1a1a1a",
|
||||
transition: "width 0.6s ease",
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
|
||||
{doneCount}/{sections.length} sections
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
{sections.map((s, i) => (
|
||||
<div
|
||||
key={s.id}
|
||||
style={{
|
||||
padding: "14px 18px", marginBottom: 6,
|
||||
background: "#fff", borderRadius: 10,
|
||||
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
|
||||
animationDelay: `${i * 0.04}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* Status icon */}
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
|
||||
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.65rem", fontWeight: 700,
|
||||
color: s.isDone ? "#2e7d32" : "#c5c0b8",
|
||||
}}>
|
||||
{s.isDone ? "✓" : "○"}
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
flex: 1, fontSize: "0.84rem",
|
||||
color: s.isDone ? "#1a1a1a" : "#a09a90",
|
||||
fontWeight: s.isDone ? 500 : 400,
|
||||
}}>
|
||||
{s.label}
|
||||
</span>
|
||||
|
||||
{s.isDone && s.savedPhase && (
|
||||
<span style={{
|
||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||
color: "#2e7d32", background: "#2e7d3210",
|
||||
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
|
||||
}}>
|
||||
saved
|
||||
</span>
|
||||
)}
|
||||
{!s.isDone && !s.phaseId && (
|
||||
<span style={{
|
||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||
color: "#b5b0a6", padding: "2px 7px",
|
||||
}}>
|
||||
generated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable phase data */}
|
||||
{s.isDone && s.savedPhase && (
|
||||
<PhaseDataCard phase={s.savedPhase} />
|
||||
)}
|
||||
|
||||
{/* Pending hint */}
|
||||
{!s.isDone && (
|
||||
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
|
||||
{s.phaseId
|
||||
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
|
||||
: "Will be generated when PRD is finalized"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{doneCount === 0 && (
|
||||
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
|
||||
Continue chatting with Vibn — saved phases will appear here automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
/** Legacy URL — project work now lives under Tasks (PRD is the first task). */
|
||||
export default async function PrdRedirectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = await params;
|
||||
redirect(`/${workspace}/project/${projectId}/tasks`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProjectInfraPanel } from "@/components/project-main/ProjectInfraPanel";
|
||||
|
||||
export default function RunPage() {
|
||||
return <ProjectInfraPanel routeBase="run" navGroupLabel="Run" />;
|
||||
}
|
||||
507
app/[workspace]/project/[projectId]/(workspace)/tasks/page.tsx
Normal file
507
app/[workspace]/project/[projectId]/(workspace)/tasks/page.tsx
Normal file
@@ -0,0 +1,507 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
// Maps each PRD section to the discovery phase that populates it
|
||||
const PRD_SECTIONS = [
|
||||
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
|
||||
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
|
||||
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
|
||||
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
|
||||
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
|
||||
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
|
||||
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
|
||||
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
|
||||
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
|
||||
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" },
|
||||
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
|
||||
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
|
||||
];
|
||||
|
||||
interface SavedPhase {
|
||||
phase: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
data: Record<string, unknown>;
|
||||
saved_at: string;
|
||||
}
|
||||
|
||||
function formatValue(v: unknown): string {
|
||||
if (v === null || v === undefined) return "—";
|
||||
if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", ");
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function PhaseDataCard({ phase }: { phase: SavedPhase }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== "");
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 10, background: "#f6f4f0", borderRadius: 8,
|
||||
border: "1px solid #e8e4dc", overflow: "hidden",
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
style={{
|
||||
width: "100%", textAlign: "left", padding: "10px 14px",
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
|
||||
{phase.summary}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.7rem", color: "#a09a90", marginLeft: 8, flexShrink: 0 }}>
|
||||
{expanded ? "▲" : "▼"}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && entries.length > 0 && (
|
||||
<div style={{ padding: "4px 14px 14px", borderTop: "1px solid #e8e4dc" }}>
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} style={{ marginTop: 10 }}>
|
||||
<div style={{
|
||||
fontSize: "0.6rem", color: "#b5b0a6", textTransform: "uppercase",
|
||||
letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2,
|
||||
}}>
|
||||
{k.replace(/_/g, " ")}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.78rem", color: "#2a2824", lineHeight: 1.5 }}>
|
||||
{formatValue(v)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ArchApp { name: string; type: string; description: string; tech?: string[]; screens?: string[] }
|
||||
interface ArchInfra { name: string; reason: string }
|
||||
interface ArchPackage { name: string; description: string }
|
||||
interface ArchIntegration { name: string; required?: boolean; notes?: string }
|
||||
interface Architecture {
|
||||
productName?: string;
|
||||
productType?: string;
|
||||
summary?: string;
|
||||
apps?: ArchApp[];
|
||||
packages?: ArchPackage[];
|
||||
infrastructure?: ArchInfra[];
|
||||
integrations?: ArchIntegration[];
|
||||
designSurfaces?: string[];
|
||||
riskNotes?: string[];
|
||||
}
|
||||
|
||||
function ArchitectureView({ arch }: { arch: Architecture }) {
|
||||
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 10 }}>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
const Card = ({ children }: { children: React.ReactNode }) => (
|
||||
<div style={{ background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9, padding: "14px 16px", marginBottom: 8 }}>{children}</div>
|
||||
);
|
||||
const Tag = ({ label }: { label: string }) => (
|
||||
<span style={{ background: "#f0ece4", borderRadius: 4, padding: "2px 7px", fontSize: "0.68rem", color: "#6b6560", fontFamily: "IBM Plex Mono, monospace", marginRight: 4, display: "inline-block", marginBottom: 3 }}>{label}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
{arch.summary && (
|
||||
<div style={{ background: "#1a1a1a", borderRadius: 10, padding: "18px 22px", marginBottom: 24, color: "#e8e4dc", fontSize: "0.88rem", lineHeight: 1.7 }}>
|
||||
{arch.summary}
|
||||
</div>
|
||||
)}
|
||||
{(arch.apps ?? []).length > 0 && (
|
||||
<Section title="Applications">
|
||||
{arch.apps!.map(a => (
|
||||
<Card key={a.name}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>{a.name}</span>
|
||||
<span style={{ fontSize: "0.72rem", color: "#9a9490" }}>{a.type}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.55, marginBottom: a.tech?.length ? 8 : 0 }}>{a.description}</div>
|
||||
{a.tech?.map(t => <Tag key={t} label={t} />)}
|
||||
{a.screens && a.screens.length > 0 && (
|
||||
<div style={{ marginTop: 6, fontSize: "0.72rem", color: "#a09a90" }}>Screens: {a.screens.join(", ")}</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.packages ?? []).length > 0 && (
|
||||
<Section title="Shared Packages">
|
||||
{arch.packages!.map(p => (
|
||||
<Card key={p.name}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
|
||||
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>{p.name}</span>
|
||||
<span style={{ fontSize: "0.78rem", color: "#4a4640" }}>{p.description}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.infrastructure ?? []).length > 0 && (
|
||||
<Section title="Infrastructure">
|
||||
{arch.infrastructure!.map(i => (
|
||||
<Card key={i.name}>
|
||||
<div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 3 }}>{i.name}</div>
|
||||
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>{i.reason}</div>
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.integrations ?? []).length > 0 && (
|
||||
<Section title="Integrations">
|
||||
{arch.integrations!.map(i => (
|
||||
<Card key={i.name}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: i.notes ? 4 : 0 }}>
|
||||
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a" }}>{i.name}</span>
|
||||
{i.required && <span style={{ fontSize: "0.62rem", background: "#fef3c7", color: "#92400e", padding: "1px 6px", borderRadius: 4 }}>required</span>}
|
||||
</div>
|
||||
{i.notes && <div style={{ fontSize: "0.78rem", color: "#4a4640" }}>{i.notes}</div>}
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.riskNotes ?? []).length > 0 && (
|
||||
<Section title="Architectural Risks">
|
||||
{arch.riskNotes!.map((r, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start", marginBottom: 8 }}>
|
||||
<span style={{ fontSize: "0.72rem", color: "#d97706", marginTop: 2, flexShrink: 0 }}>⚠</span>
|
||||
<span style={{ fontSize: "0.82rem", color: "#4a4640", lineHeight: 1.5 }}>{r}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TasksPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
const [prd, setPrd] = useState<string | null>(null);
|
||||
const [architecture, setArchitecture] = useState<Architecture | null>(null);
|
||||
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<"prd" | "architecture">("prd");
|
||||
const [archGenerating, setArchGenerating] = useState(false);
|
||||
const [archError, setArchError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})),
|
||||
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
|
||||
]).then(([projectData, phaseData]) => {
|
||||
setPrd(projectData?.project?.prd ?? null);
|
||||
setArchitecture(projectData?.project?.architecture ?? null);
|
||||
setSavedPhases(phaseData?.phases ?? []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
const handleGenerateArchitecture = async () => {
|
||||
setArchGenerating(true);
|
||||
setArchError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Generation failed");
|
||||
setArchitecture(data.architecture);
|
||||
setActiveTab("architecture");
|
||||
} catch (e) {
|
||||
setArchError(e instanceof Error ? e.message : "Something went wrong");
|
||||
} finally {
|
||||
setArchGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
|
||||
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
||||
|
||||
const sections = PRD_SECTIONS.map(s => ({
|
||||
...s,
|
||||
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
|
||||
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
|
||||
}));
|
||||
|
||||
const doneCount = sections.filter(s => s.isDone).length;
|
||||
const totalPct = Math.round((doneCount / sections.length) * 100);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
|
||||
Loading tasks…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const reqStatus = prd
|
||||
? "Complete"
|
||||
: doneCount > 0
|
||||
? `In progress · ${doneCount}/${sections.length} sections`
|
||||
: "Not started";
|
||||
const archStatus = architecture
|
||||
? "Complete"
|
||||
: prd
|
||||
? "Ready to generate"
|
||||
: "Blocked — finish requirements first";
|
||||
|
||||
const taskCardBase: CSSProperties = {
|
||||
flex: "1 1 240px",
|
||||
maxWidth: 320,
|
||||
textAlign: "left" as const,
|
||||
padding: "14px 16px",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
transition: "border-color 0.12s, box-shadow 0.12s",
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
|
||||
<header style={{ marginBottom: 24, maxWidth: 720 }}>
|
||||
<h1 style={{
|
||||
fontFamily: "var(--font-lora), ui-serif, serif",
|
||||
fontSize: "1.35rem",
|
||||
fontWeight: 500,
|
||||
color: "#1a1a1a",
|
||||
margin: "0 0 8px",
|
||||
}}>
|
||||
Tasks
|
||||
</h1>
|
||||
<p style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.55, margin: 0 }}>
|
||||
Work is tracked as tasks—similar in spirit to agent task boards like{" "}
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui" target="_blank" rel="noopener noreferrer" style={{ color: "#4a4640" }}>
|
||||
Claude Agent Teams UI
|
||||
</a>
|
||||
. Your <strong>product requirements (PRD)</strong> is the first task; technical architecture is the next once requirements are captured.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Task selector — PRD is a task; architecture is a follow-on task */}
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 28, flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("prd")}
|
||||
style={{
|
||||
...taskCardBase,
|
||||
border: activeTab === "prd" ? "2px solid #1a1a1a" : "1px solid #e8e4dc",
|
||||
background: activeTab === "prd" ? "#faf8f5" : "#fff",
|
||||
boxShadow: activeTab === "prd" ? "0 2px 8px #1a1a1a0a" : "0 1px 2px #1a1a1a05",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>
|
||||
Product requirements
|
||||
</div>
|
||||
<div style={{ fontSize: "0.72rem", color: "#888780", lineHeight: 1.4 }}>
|
||||
PRD · {reqStatus}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => architecture && setActiveTab("architecture")}
|
||||
disabled={!architecture}
|
||||
style={{
|
||||
...taskCardBase,
|
||||
border: activeTab === "architecture" ? "2px solid #1a1a1a" : "1px solid #e8e4dc",
|
||||
background: activeTab === "architecture" ? "#faf8f5" : "#fff",
|
||||
boxShadow: activeTab === "architecture" ? "0 2px 8px #1a1a1a0a" : "0 1px 2px #1a1a1a05",
|
||||
opacity: architecture ? 1 : 0.72,
|
||||
cursor: architecture ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>
|
||||
Technical architecture
|
||||
</div>
|
||||
<div style={{ fontSize: "0.72rem", color: "#888780", lineHeight: 1.4 }}>
|
||||
{archStatus}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Next step banner — PRD done but no architecture yet */}
|
||||
{prd && !architecture && activeTab === "prd" && (
|
||||
<div style={{
|
||||
marginBottom: 24, padding: "18px 22px",
|
||||
background: "#1a1a1a", borderRadius: 10,
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
gap: 16, flexWrap: "wrap",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 4 }}>
|
||||
Next: Generate technical architecture
|
||||
</div>
|
||||
<div style={{ fontSize: "0.76rem", color: "#a09a90", lineHeight: 1.5 }}>
|
||||
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds.
|
||||
</div>
|
||||
{archError && (
|
||||
<div style={{ fontSize: "0.74rem", color: "#f87171", marginTop: 6 }}>⚠ {archError}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerateArchitecture}
|
||||
disabled={archGenerating}
|
||||
style={{
|
||||
padding: "10px 20px", borderRadius: 8, border: "none",
|
||||
background: archGenerating ? "#4a4640" : "#fff",
|
||||
color: archGenerating ? "#a09a90" : "#1a1a1a",
|
||||
fontSize: "0.82rem", fontWeight: 700,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
cursor: archGenerating ? "default" : "pointer",
|
||||
flexShrink: 0, display: "flex", alignItems: "center", gap: 8,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
>
|
||||
{archGenerating && (
|
||||
<span style={{
|
||||
width: 12, height: 12, borderRadius: "50%",
|
||||
border: "2px solid #60606040", borderTopColor: "#a09a90",
|
||||
animation: "spin 0.7s linear infinite", display: "inline-block",
|
||||
}} />
|
||||
)}
|
||||
{archGenerating ? "Analysing PRD…" : "Generate architecture →"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Architecture tab */}
|
||||
{activeTab === "architecture" && architecture && (
|
||||
<ArchitectureView arch={architecture} />
|
||||
)}
|
||||
|
||||
{/* PRD tab — finalized */}
|
||||
{activeTab === "prd" && prd && (
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
|
||||
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
||||
Product Requirements
|
||||
</h3>
|
||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
|
||||
PRD complete
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
|
||||
padding: "28px 32px", lineHeight: 1.8,
|
||||
fontSize: "0.88rem", color: "#2a2824",
|
||||
whiteSpace: "pre-wrap", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
}}>
|
||||
{prd}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PRD tab — section progress (no finalized PRD yet) */}
|
||||
{activeTab === "prd" && !prd && (
|
||||
/* ── Section progress view ── */
|
||||
<div style={{ maxWidth: 680 }}>
|
||||
{/* Progress bar */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 16,
|
||||
padding: "16px 20px", background: "#fff",
|
||||
border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: "IBM Plex Mono, monospace",
|
||||
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
|
||||
}}>
|
||||
{totalPct}%
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
|
||||
<div style={{
|
||||
height: "100%", borderRadius: 2,
|
||||
width: `${totalPct}%`, background: "#1a1a1a",
|
||||
transition: "width 0.6s ease",
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
|
||||
{doneCount}/{sections.length} sections
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
{sections.map((s, i) => (
|
||||
<div
|
||||
key={s.id}
|
||||
style={{
|
||||
padding: "14px 18px", marginBottom: 6,
|
||||
background: "#fff", borderRadius: 10,
|
||||
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
|
||||
animationDelay: `${i * 0.04}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* Status icon */}
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
|
||||
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.65rem", fontWeight: 700,
|
||||
color: s.isDone ? "#2e7d32" : "#c5c0b8",
|
||||
}}>
|
||||
{s.isDone ? "✓" : "○"}
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
flex: 1, fontSize: "0.84rem",
|
||||
color: s.isDone ? "#1a1a1a" : "#a09a90",
|
||||
fontWeight: s.isDone ? 500 : 400,
|
||||
}}>
|
||||
{s.label}
|
||||
</span>
|
||||
|
||||
{s.isDone && s.savedPhase && (
|
||||
<span style={{
|
||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||
color: "#2e7d32", background: "#2e7d3210",
|
||||
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
|
||||
}}>
|
||||
saved
|
||||
</span>
|
||||
)}
|
||||
{!s.isDone && !s.phaseId && (
|
||||
<span style={{
|
||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||
color: "#b5b0a6", padding: "2px 7px",
|
||||
}}>
|
||||
generated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable phase data */}
|
||||
{s.isDone && s.savedPhase && (
|
||||
<PhaseDataCard phase={s.savedPhase} />
|
||||
)}
|
||||
|
||||
{/* Pending hint */}
|
||||
{!s.isDone && (
|
||||
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
|
||||
{s.phaseId
|
||||
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
|
||||
: "Will be generated when PRD is finalized"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{doneCount === 0 && (
|
||||
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
|
||||
Continue chatting with Vibn — saved phases will appear here automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +1,12 @@
|
||||
import { ProjectShell } from "@/components/layout/project-shell";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
/**
|
||||
* Passthrough layout for the project route.
|
||||
*
|
||||
* Two sibling route groups provide their own scaffolds:
|
||||
* - (home)/ — VIBNSidebar scaffold for the project home page.
|
||||
* - (workspace)/ — ProjectShell (top tab nav) for overview/build/run/etc.
|
||||
*/
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface ProjectData {
|
||||
name: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
progress?: number;
|
||||
discoveryPhase?: number;
|
||||
capturedData?: Record<string, string>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
featureCount?: number;
|
||||
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||
}
|
||||
|
||||
async function getProjectData(projectId: string): Promise<ProjectData> {
|
||||
try {
|
||||
const rows = await query<{ data: any; created_at?: string; updated_at?: string }>(
|
||||
`SELECT data, created_at, updated_at FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
[projectId]
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
const { data, created_at, updated_at } = rows[0];
|
||||
return {
|
||||
name: data?.productName || data?.name || "Project",
|
||||
description: data?.productVision || data?.description,
|
||||
status: data?.status,
|
||||
progress: data?.progress ?? 0,
|
||||
discoveryPhase: data?.discoveryPhase ?? 0,
|
||||
capturedData: data?.capturedData ?? {},
|
||||
createdAt: created_at,
|
||||
updatedAt: updated_at,
|
||||
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
|
||||
creationMode: data?.creationMode ?? "fresh",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching project:", error);
|
||||
}
|
||||
return { name: "Project" };
|
||||
}
|
||||
|
||||
export default async function ProjectLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ workspace: string; projectId: string }>;
|
||||
}) {
|
||||
const { workspace, projectId } = await params;
|
||||
const project = await getProjectData(projectId);
|
||||
|
||||
return (
|
||||
<ProjectShell
|
||||
workspace={workspace}
|
||||
projectId={projectId}
|
||||
projectName={project.name}
|
||||
projectDescription={project.description}
|
||||
projectStatus={project.status}
|
||||
projectProgress={project.progress}
|
||||
discoveryPhase={project.discoveryPhase}
|
||||
capturedData={project.capturedData}
|
||||
createdAt={project.createdAt}
|
||||
updatedAt={project.updatedAt}
|
||||
featureCount={project.featureCount}
|
||||
creationMode={project.creationMode}
|
||||
>
|
||||
{children}
|
||||
</ProjectShell>
|
||||
);
|
||||
export default function ProjectRootLayout({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function ProjectsPage() {
|
||||
style={{ position: "relative", animationDelay: `${i * 0.05}s` }}
|
||||
>
|
||||
<Link
|
||||
href={`/${workspace}/project/${p.id}/overview`}
|
||||
href={`/${workspace}/project/${p.id}`}
|
||||
style={{
|
||||
width: "100%", display: "flex", alignItems: "center",
|
||||
padding: "18px 22px", borderRadius: 10,
|
||||
|
||||
Reference in New Issue
Block a user