- Add project description line to project header (from productVision) - Sidebar: add Activity nav item (Projects / Activity / Settings) - New Activity page: timeline feed with type filters (Atlas/Builds/Deploys/You) - New Activity layout using VIBNSidebar - Rewrite Deploy tab: Project URLs, Custom Domain, Env Vars, Deploy History — fully Stackless style, real data from project API, no more MOCK_PROJECT - Rewrite Project Settings tab: remove all Firebase refs (db, auth, Firestore) — General (name/description), Repo link, Collaborators, Export JSON/PDF, — Danger Zone with double-confirm delete — uses /api/projects/[id] PATCH for saves Made-with: Cursor
295 lines
9.9 KiB
TypeScript
295 lines
9.9 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: "Deploy", path: "deployment" },
|
|
{ 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" />
|
|
</>
|
|
);
|
|
}
|