Files
vibn-frontend/components/layout/project-shell.tsx
Mark Henderson 7f452c0420 Add Launch, Grow, Insights tabs; rename Deploy → Launch
- Rename Deploy tab label to Launch in ProjectShell
- Add Grow and Insights placeholder pages with Stackless styling

Made-with: Cursor
2026-03-02 16:39:13 -08:00

297 lines
10 KiB
TypeScript

"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ReactNode } from "react";
import { VIBNSidebar } from "./vibn-sidebar";
import { Toaster } from "sonner";
interface ProjectShellProps {
children: ReactNode;
workspace: string;
projectId: string;
projectName: string;
projectDescription?: string;
projectStatus?: string;
projectProgress?: number;
discoveryPhase?: number;
capturedData?: Record<string, string>;
createdAt?: string;
updatedAt?: string;
featureCount?: number;
}
const 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" },
];
const DISCOVERY_PHASES = [
"Big Picture",
"Users & Personas",
"Features",
"Business Model",
"Screens",
"Risks",
];
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,
discoveryPhase = 0,
capturedData = {},
createdAt,
updatedAt,
featureCount = 0,
}: ProjectShellProps) {
const pathname = usePathname();
const activeTab = TABS.find((t) => pathname?.includes(`/${t.path}`))?.id ?? "overview";
const progress = projectProgress ?? 0;
const capturedEntries = Object.entries(capturedData);
return (
<>
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
{/* Left sidebar */}
<VIBNSidebar workspace={workspace} />
{/* Main column */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{/* Project header */}
<div 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 */}
<div style={{
padding: "0 32px",
borderBottom: "1px solid #e8e4dc",
display: "flex",
background: "#fff",
flexShrink: 0,
}}>
{TABS.map((t) => (
<Link
key={t.id}
href={`/${workspace}/project/${projectId}/${t.path}`}
style={{
padding: "12px 18px",
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",
fontFamily: "Outfit, sans-serif",
textDecoration: "none",
display: "block",
whiteSpace: "nowrap",
}}
>
{t.label}
</Link>
))}
</div>
{/* Page content */}
<div style={{ flex: 1, overflow: "auto" }}>
{children}
</div>
</div>
{/* Right panel */}
<div style={{
width: 230,
borderLeft: "1px solid #e8e4dc",
background: "#fff",
padding: "22px 18px",
overflow: "auto",
flexShrink: 0,
fontFamily: "Outfit, sans-serif",
}}>
{/* Discovery phases */}
<SectionLabel>Discovery</SectionLabel>
{DISCOVERY_PHASES.map((phase, i) => {
const isDone = i < discoveryPhase;
const isActive = i === discoveryPhase;
return (
<div
key={i}
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}
</span>
</div>
);
})}
<div style={{ height: 1, background: "#f0ece4", margin: "16px 0" }} />
{/* Captured data */}
<SectionLabel>Captured</SectionLabel>
{capturedEntries.length > 0 ? (
capturedEntries.map(([k, v], i) => (
<div key={i} style={{ marginBottom: 14 }}>
<div style={{
fontSize: "0.62rem", color: "#b5b0a6",
textTransform: "uppercase", letterSpacing: "0.05em",
marginBottom: 3, fontWeight: 600,
}}>
{k}
</div>
<div style={{ fontSize: "0.8rem", color: "#4a4640", lineHeight: 1.45 }}>
{v}
</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 */}
<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" />
</>
);
}