refactor: rework project page layout - sidebar as product OS, full-width content

- VIBNSidebar: when inside a project, lower section now shows 7 product
  layer sections (Apps, Layouts, Infrastructure, Growth, Monetize, Support,
  Analytics) instead of the projects list. Sections self-fetch data from
  /api/projects/[id] and /api/projects/[id]/apps. On non-project pages,
  reverts to the projects list as before.
- ProjectShell: removed the project header strip (name/status/progress bar)
  and the persistent 230px right panel entirely. Tab bar now sits at the
  top of the content area with no header above it. Content is full-width.
  Each page manages its own internal layout.

Made-with: Cursor
This commit is contained in:
2026-03-06 13:26:08 -08:00
parent ab100f2e76
commit bb021be088
2 changed files with 365 additions and 445 deletions

View File

@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ReactNode, useEffect, useState } from "react";
import { ReactNode } from "react";
import { VIBNSidebar } from "./vibn-sidebar";
import { Toaster } from "sonner";
@@ -23,14 +23,14 @@ interface ProjectShellProps {
}
const ALL_TABS = [
{ id: "overview", label: "Atlas", path: "overview" },
{ id: "prd", label: "PRD", path: "prd" },
{ id: "design", label: "Design", path: "design" },
{ id: "build", label: "Build", path: "build" },
{ id: "deployment", label: "Launch", path: "deployment" },
{ id: "grow", label: "Grow", path: "grow" },
{ id: "insights", label: "Insights", path: "insights" },
{ id: "settings", label: "Settings", path: "settings" },
{ id: "overview", label: "Atlas", path: "overview" },
{ id: "prd", label: "PRD", path: "prd" },
{ id: "design", label: "Design", path: "design" },
{ id: "build", label: "Build", path: "build" },
{ id: "deployment", label: "Launch", path: "deployment" },
{ id: "grow", label: "Grow", path: "grow" },
{ id: "insights", label: "Insights", path: "insights" },
{ id: "settings", label: "Settings", path: "settings" },
];
function getTabsForMode(
@@ -38,10 +38,8 @@ function getTabsForMode(
) {
switch (mode) {
case "code-import":
// Hide PRD — this project already has code; goal is go-to-market surfaces
return ALL_TABS.filter(t => t.id !== "prd");
case "migration":
// Hide PRD, rename overview, hide Grow and Insights (less relevant)
return ALL_TABS
.filter(t => !["prd", "grow", "insights"].includes(t.id))
.map(t => t.id === "overview" ? { ...t, label: "Migration Plan" } : t);
@@ -50,323 +48,79 @@ function getTabsForMode(
}
}
const DISCOVERY_PHASES = [
{ id: "big_picture", label: "Big Picture" },
{ id: "users_personas", label: "Users & Personas" },
{ id: "features_scope", label: "Features" },
{ id: "business_model", label: "Business Model" },
{ id: "screens_data", label: "Screens" },
{ id: "risks_questions", label: "Risks" },
];
interface SavedPhase {
phase: string;
title: string;
summary: string;
data: Record<string, unknown>;
saved_at: string;
}
function timeAgo(dateStr?: string): string {
if (!dateStr) return "—";
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "—";
const diff = (Date.now() - date.getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
const days = Math.floor(diff / 86400);
if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function SectionLabel({ children }: { children: ReactNode }) {
return (
<div style={{
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
}}>
{children}
</div>
);
}
function StatusTag({ status }: { status?: string }) {
const label = status === "live" ? "Live" : status === "building" ? "Building" : "Defining";
const color = status === "live" ? "#2e7d32" : status === "building" ? "#3d5afe" : "#9a7b3a";
const bg = status === "live" ? "#2e7d3210" : status === "building" ? "#3d5afe10" : "#d4a04a12";
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
padding: "3px 9px", borderRadius: 4,
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
color, background: bg, fontFamily: "Outfit, sans-serif",
}}>
{label}
</span>
);
}
export function ProjectShell({
children,
workspace,
projectId,
projectName,
projectDescription,
projectStatus,
projectProgress,
createdAt,
updatedAt,
featureCount = 0,
creationMode,
}: ProjectShellProps) {
const pathname = usePathname();
const TABS = getTabsForMode(creationMode);
const activeTab = TABS.find((t) => pathname?.includes(`/${t.path}`))?.id ?? "overview";
const progress = projectProgress ?? 0;
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
useEffect(() => {
fetch(`/api/projects/${projectId}/save-phase`)
.then(r => r.json())
.then(d => setSavedPhases(d.phases ?? []))
.catch(() => {});
// Refresh every 10s while the user is chatting with Atlas
const interval = setInterval(() => {
fetch(`/api/projects/${projectId}/save-phase`)
.then(r => r.json())
.then(d => setSavedPhases(d.phases ?? []))
.catch(() => {});
}, 10_000);
return () => clearInterval(interval);
}, [projectId]);
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
const firstUnsavedIdx = DISCOVERY_PHASES.findIndex(p => !savedPhaseIds.has(p.id));
const activeTab = TABS.find(t => pathname?.includes(`/${t.path}`))?.id ?? "overview";
return (
<>
<style>{`
@media (max-width: 768px) {
.vibn-left-sidebar { display: none !important; }
.vibn-right-panel { display: none !important; }
.vibn-tab-bar { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.vibn-tab-bar a { padding: 10px 14px !important; font-size: 0.75rem !important; }
.vibn-project-header { padding: 12px 16px !important; }
.vibn-page-content { padding-bottom: env(safe-area-inset-bottom); }
.vibn-left-sidebar { display: none !important; }
.vibn-tab-bar { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.vibn-tab-bar a { padding: 10px 14px !important; font-size: 0.75rem !important; }
.vibn-page-content { padding-bottom: env(safe-area-inset-bottom); }
}
@media (max-width: 480px) {
.vibn-tab-bar a { padding: 10px 10px !important; }
.vibn-tab-bar a { padding: 10px 10px !important; }
}
`}</style>
<div style={{ display: "flex", height: "100dvh", background: "#f6f4f0", overflow: "hidden" }}>
{/* Left sidebar */}
<div className="vibn-left-sidebar" style={{ display: "flex" }}>
<VIBNSidebar workspace={workspace} />
</div>
{/* Main column */}
{/* Main column — full width */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{/* Project header */}
<div className="vibn-project-header" style={{
padding: "18px 32px",
borderBottom: "1px solid #e8e4dc",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
background: "#fff",
flexShrink: 0,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<div style={{
width: 34, height: 34, borderRadius: 9,
background: "#1a1a1a12",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<span style={{ fontFamily: "Newsreader, serif", fontSize: "1rem", fontWeight: 500, color: "#1a1a1a" }}>
{projectName[0]?.toUpperCase() ?? "P"}
</span>
</div>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<h2 style={{
fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a",
letterSpacing: "-0.02em", fontFamily: "Outfit, sans-serif", margin: 0,
}}>
{projectName}
</h2>
<StatusTag status={projectStatus} />
</div>
{projectDescription && (
<p style={{
fontSize: "0.75rem", color: "#a09a90", marginTop: 1,
fontFamily: "Outfit, sans-serif",
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
maxWidth: 400,
}}>
{projectDescription}
</p>
)}
</div>
</div>
<div style={{
fontFamily: "IBM Plex Mono, monospace",
fontSize: "0.78rem", fontWeight: 500,
color: "#1a1a1a", background: "#f6f4f0",
padding: "6px 12px", borderRadius: 6,
}}>
{progress}%
</div>
</div>
{/* Tab bar */}
{/* Tab bar — sits at the top, no header above it */}
<div className="vibn-tab-bar" style={{
padding: "0 32px",
padding: "0 24px",
borderBottom: "1px solid #e8e4dc",
display: "flex",
background: "#fff",
flexShrink: 0,
}}>
{TABS.map((t) => (
{TABS.map(t => (
<Link
key={t.id}
href={`/${workspace}/project/${projectId}/${t.path}`}
style={{
padding: "12px 18px",
padding: "13px 16px",
fontSize: "0.8rem",
fontWeight: 500,
color: activeTab === t.id ? "#1a1a1a" : "#a09a90",
borderBottom: activeTab === t.id ? "2px solid #1a1a1a" : "2px solid transparent",
transition: "all 0.12s",
transition: "color 0.12s",
fontFamily: "Outfit, sans-serif",
textDecoration: "none",
display: "block",
whiteSpace: "nowrap",
}}
onMouseEnter={e => { if (activeTab !== t.id) (e.currentTarget as HTMLElement).style.color = "#6b6560"; }}
onMouseLeave={e => { if (activeTab !== t.id) (e.currentTarget as HTMLElement).style.color = "#a09a90"; }}
>
{t.label}
</Link>
))}
</div>
{/* Page content */}
{/* Page content — full width, each page manages its own layout */}
<div className="vibn-page-content" style={{ flex: 1, overflow: "auto" }}>
{children}
</div>
</div>
{/* Right panel — hidden on design tab (design page has its own right panel) */}
<div className="vibn-right-panel" style={{
width: activeTab === "design" ? 0 : 230,
borderLeft: activeTab === "design" ? "none" : "1px solid #e8e4dc",
background: "#fff",
padding: activeTab === "design" ? 0 : "22px 18px",
overflow: "auto",
flexShrink: 0,
fontFamily: "Outfit, sans-serif",
display: activeTab === "design" ? "none" : undefined,
}}>
{/* Right panel content — varies by creation mode */}
{(creationMode === "code-import" || creationMode === "migration") ? (
<>
<SectionLabel>
{creationMode === "migration" ? "Migration" : "Import"}
</SectionLabel>
<div style={{ fontSize: "0.78rem", color: "#a09a90", lineHeight: 1.5, marginBottom: 16 }}>
{creationMode === "migration"
? "Atlas will audit your existing product and generate a safe, phased migration plan."
: "Atlas will clone your repository and map the architecture, then suggest surfaces to build."}
</div>
</>
) : (
<>
{/* Discovery phases */}
<SectionLabel>Discovery</SectionLabel>
{DISCOVERY_PHASES.map((phase, i) => {
const isDone = savedPhaseIds.has(phase.id);
const isActive = !isDone && i === firstUnsavedIdx;
return (
<div
key={phase.id}
style={{
display: "flex", alignItems: "center", gap: 10,
padding: "9px 0",
borderBottom: i < DISCOVERY_PHASES.length - 1 ? "1px solid #f0ece4" : "none",
}}
>
<div style={{
width: 20, height: 20, borderRadius: 5, flexShrink: 0,
background: isDone ? "#2e7d3210" : isActive ? "#d4a04a12" : "#f6f4f0",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.58rem", fontWeight: 700,
color: isDone ? "#2e7d32" : isActive ? "#9a7b3a" : "#c5c0b8",
}}>
{isDone ? "✓" : isActive ? "→" : i + 1}
</div>
<span style={{
fontSize: "0.78rem",
fontWeight: isActive ? 600 : 400,
color: isDone ? "#6b6560" : isActive ? "#1a1a1a" : "#b5b0a6",
}}>
{phase.label}
</span>
</div>
);
})}
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
{/* Captured data — summaries from saved phases */}
<SectionLabel>Captured</SectionLabel>
{savedPhases.length > 0 ? (
savedPhases.map((p) => (
<div key={p.phase} style={{ marginBottom: 14 }}>
<div style={{
fontSize: "0.62rem", color: "#2e7d32",
textTransform: "uppercase", letterSpacing: "0.05em",
marginBottom: 3, fontWeight: 600, display: "flex", alignItems: "center", gap: 4,
}}>
<span></span><span>{p.title}</span>
</div>
<div style={{ fontSize: "0.75rem", color: "#4a4640", lineHeight: 1.45 }}>
{p.summary}
</div>
</div>
))
) : (
<p style={{ fontSize: "0.78rem", color: "#c5c0b8", lineHeight: 1.5, margin: 0 }}>
Atlas will capture key details here as you chat.
</p>
)}
</>
)}
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
{/* Project info — always shown */}
<SectionLabel>Project Info</SectionLabel>
{[
{ k: "Created", v: timeAgo(createdAt) },
{ k: "Last active", v: timeAgo(updatedAt) },
{ k: "Features", v: featureCount > 0 ? `${featureCount} defined` : "None yet" },
].map((item, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<div style={{
fontSize: "0.62rem", color: "#b5b0a6",
textTransform: "uppercase", letterSpacing: "0.05em",
marginBottom: 3, fontWeight: 600,
}}>
{item.k}
</div>
<div style={{ fontSize: "0.8rem", color: "#4a4640" }}>{item.v}</div>
</div>
))}
</div>
</div>
<Toaster position="top-center" />
</>
);