Files
vibn-frontend/components/layout/vibn-sidebar.tsx
Mark Henderson bb021be088 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
2026-03-06 13:26:08 -08:00

459 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { signOut, useSession } from "next-auth/react";
interface VIBNSidebarProps {
workspace: string;
}
interface ProjectData {
id: string;
productName?: string;
name?: string;
status?: string;
giteaRepo?: string;
giteaRepoUrl?: string;
surfaces?: string[];
surfaceThemes?: Record<string, string>;
apps?: Array<{ name: string; path: string; coolifyServiceUuid?: string | null; domain?: string | null }>;
}
interface AppEntry {
name: string;
path: string;
}
// ── Section helpers ─────────────────────────────────────────────────────────
function SectionHeading({ label, collapsed }: { label: string; collapsed: boolean }) {
if (collapsed) return null;
return (
<div style={{
fontSize: "0.58rem", fontWeight: 700, color: "#b5b0a6",
letterSpacing: "0.1em", textTransform: "uppercase",
padding: "14px 12px 5px",
}}>
{label}
</div>
);
}
function SectionRow({
icon, label, href, dim, collapsed,
}: {
icon: string;
label: string;
href?: string;
dim?: boolean;
collapsed: boolean;
}) {
const style: React.CSSProperties = {
display: "flex", alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 8, padding: collapsed ? "7px 0" : "5px 12px",
borderRadius: 5, textDecoration: "none",
color: dim ? "#c5c0b8" : "#4a4640",
fontSize: "0.78rem", fontWeight: 450,
transition: "background 0.1s",
width: "100%", boxSizing: "border-box" as const,
};
const inner = (
<>
<span style={{ fontSize: "0.72rem", opacity: 0.6, flexShrink: 0, width: collapsed ? "auto" : 14, textAlign: "center" }}>
{icon}
</span>
{!collapsed && (
<span style={{
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
fontStyle: dim ? "italic" : "normal",
}}>
{label}
</span>
)}
</>
);
if (href) {
return (
<Link href={href} title={collapsed ? label : undefined} style={style}
onMouseEnter={e => { if (!dim) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{inner}
</Link>
);
}
return (
<div title={collapsed ? label : undefined} style={{ ...style, cursor: "default" }}>
{inner}
</div>
);
}
function SectionDivider() {
return <div style={{ height: 1, background: "#eae6de", margin: "8px 12px" }} />;
}
// ── Surface label map ────────────────────────────────────────────────────────
const SURFACE_LABELS: Record<string, string> = {
"marketing": "Marketing site",
"web-app": "Web app",
"admin": "Admin panel",
"api": "API layer",
};
const SURFACE_ICONS: Record<string, string> = {
"marketing": "◎",
"web-app": "⬡",
"admin": "◫",
"api": "⌁",
};
// ── Main sidebar ─────────────────────────────────────────────────────────────
const COLLAPSED_KEY = "vibn_sidebar_collapsed";
const COLLAPSED_W = 52;
const EXPANDED_W = 216;
export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
const pathname = usePathname();
const { data: session } = useSession();
const [collapsed, setCollapsed] = useState(false);
const [mounted, setMounted] = useState(false);
// Project-specific data
const [project, setProject] = useState<ProjectData | null>(null);
const [apps, setApps] = useState<AppEntry[]>([]);
// Global projects list (used when NOT inside a project)
const [projects, setProjects] = useState<Array<{ id: string; productName: string; status?: string }>>([]);
const activeProjectId = pathname?.match(/\/project\/([^/]+)/)?.[1] ?? null;
const activeTab = pathname?.match(/\/project\/[^/]+\/([^/]+)/)?.[1] ?? null;
// Restore collapse state
useEffect(() => {
const stored = localStorage.getItem(COLLAPSED_KEY);
if (stored === "1") setCollapsed(true);
setMounted(true);
}, []);
const toggle = () => {
setCollapsed(prev => {
localStorage.setItem(COLLAPSED_KEY, prev ? "0" : "1");
return !prev;
});
};
// Fetch global projects list (for non-project pages)
useEffect(() => {
if (activeProjectId) return;
fetch("/api/projects")
.then(r => r.json())
.then(d => setProjects(d.projects ?? []))
.catch(() => {});
}, [activeProjectId]);
// Fetch project-specific data when inside a project
useEffect(() => {
if (!activeProjectId) { setProject(null); setApps([]); return; }
fetch(`/api/projects/${activeProjectId}`)
.then(r => r.json())
.then(d => setProject(d.project ?? null))
.catch(() => {});
fetch(`/api/projects/${activeProjectId}/apps`)
.then(r => r.json())
.then(d => setApps(d.apps ?? []))
.catch(() => {});
}, [activeProjectId]);
const isProjects = !activeProjectId && (pathname?.includes("/projects") || pathname?.includes("/project"));
const isActivity = !activeProjectId && pathname?.includes("/activity");
const isSettings = !activeProjectId && pathname?.includes("/settings");
const topNavItems = [
{ id: "projects", label: "Projects", icon: "⌗", href: `/${workspace}/projects` },
{ id: "activity", label: "Activity", icon: "↗", href: `/${workspace}/activity` },
{ id: "settings", label: "Settings", icon: "⚙", href: `/${workspace}/settings` },
];
const userInitial = session?.user?.name?.[0]?.toUpperCase()
?? session?.user?.email?.[0]?.toUpperCase()
?? "?";
const w = collapsed ? COLLAPSED_W : EXPANDED_W;
const transition = mounted ? "width 0.2s cubic-bezier(0.4,0,0.2,1)" : "none";
const base = `/${workspace}/project/${activeProjectId}`;
// Surfaces locked in on design page
const surfaces = project?.surfaces ?? [];
// Coolify/monorepo apps
const infraApps = project?.apps ?? [];
return (
<nav style={{
width: w, height: "100vh",
background: "#fff", borderRight: "1px solid #e8e4dc",
display: "flex", flexDirection: "column",
fontFamily: "Outfit, sans-serif",
flexShrink: 0, overflow: "hidden",
transition, position: "relative",
}}>
{/* ── Logo + toggle ── */}
{collapsed ? (
<div style={{ flexShrink: 0 }}>
<div style={{ display: "flex", justifyContent: "center", padding: "14px 0 6px" }}>
<Link href={`/${workspace}/projects`} title="VIBN" style={{ textDecoration: "none" }}>
<div style={{ width: 26, height: 26, borderRadius: 7, overflow: "hidden" }}>
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</div>
</Link>
</div>
<div style={{ display: "flex", justifyContent: "center", paddingBottom: 8 }}>
<button onClick={toggle} title="Expand sidebar" style={{
background: "#f0ece4", border: "none", cursor: "pointer",
color: "#6b6560", width: 26, height: 20, borderRadius: 5,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.8rem", fontWeight: 700,
}}
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); }}
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); }}
></button>
</div>
</div>
) : (
<div style={{ padding: "14px 10px 14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 9, flexShrink: 0 }}>
<Link href={`/${workspace}/projects`} style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none", minWidth: 0 }}>
<div style={{ width: 26, height: 26, borderRadius: 7, overflow: "hidden", flexShrink: 0 }}>
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</div>
<span style={{ fontSize: "0.92rem", fontWeight: 600, color: "#1a1a1a", letterSpacing: "-0.03em", fontFamily: "Newsreader, serif", whiteSpace: "nowrap" }}>
vibn
</span>
</Link>
<button onClick={toggle} title="Collapse sidebar" style={{
background: "#f0ece4", border: "none", cursor: "pointer",
color: "#6b6560", width: 24, height: 22, borderRadius: 5,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.8rem", fontWeight: 700, flexShrink: 0,
}}
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); }}
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); }}
></button>
</div>
)}
{/* ── Top nav ── */}
<div style={{ padding: collapsed ? "2px 6px" : "2px 8px", flexShrink: 0 }}>
{topNavItems.map(n => {
const isActive = n.id === "projects" ? isProjects
: n.id === "activity" ? isActivity
: isSettings;
return (
<Link key={n.id} href={n.href} title={collapsed ? n.label : undefined} style={{
width: "100%", display: "flex", alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 8, padding: collapsed ? "8px 0" : "7px 10px",
borderRadius: 6,
background: isActive ? "#f6f4f0" : "transparent",
color: isActive ? "#1a1a1a" : "#6b6560",
fontSize: "0.8rem", fontWeight: isActive ? 600 : 500,
transition: "background 0.12s", textDecoration: "none",
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<span style={{ fontSize: collapsed ? "0.95rem" : "0.78rem", opacity: collapsed ? (isActive ? 0.9 : 0.45) : 0.45, width: collapsed ? "auto" : 16, textAlign: "center" }}>
{n.icon}
</span>
{!collapsed && n.label}
</Link>
);
})}
</div>
<div style={{ height: 1, background: "#eae6de", margin: "8px 14px", flexShrink: 0 }} />
{/* ── Lower section ── */}
<div style={{ flex: 1, overflow: "auto", paddingBottom: 8 }}>
{activeProjectId && project ? (
/* ── PROJECT VIEW: 7 product layer sections ── */
<>
{/* Project name */}
{!collapsed && (
<div style={{ padding: "4px 12px 10px" }}>
<div style={{ fontSize: "0.82rem", fontWeight: 700, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{project.productName || project.name || "Project"}
</div>
<div style={{ fontSize: "0.68rem", color: "#a09a90", marginTop: 1 }}>
{project.status === "live" ? "● Live"
: project.status === "building" ? "● Building"
: "● Defining"}
</div>
</div>
)}
{/* ── Apps ── */}
<SectionHeading label="Apps" collapsed={collapsed} />
{apps.length > 0 ? (
apps.map(app => (
<SectionRow
key={app.name}
icon="▢"
label={app.name}
href={project.giteaRepoUrl ? `${project.giteaRepoUrl}/src/branch/main/${app.path}` : undefined}
collapsed={collapsed}
/>
))
) : (
<SectionRow icon="▢" label="No apps yet" dim collapsed={collapsed} />
)}
<SectionDivider />
{/* ── Layouts ── */}
<SectionHeading label="Layouts" collapsed={collapsed} />
{surfaces.length > 0 ? (
surfaces.map(s => (
<SectionRow
key={s}
icon={SURFACE_ICONS[s] ?? "◌"}
label={SURFACE_LABELS[s] ?? s}
href={`${base}/design`}
collapsed={collapsed}
/>
))
) : (
<SectionRow icon="◌" label="Not configured" dim collapsed={collapsed} />
)}
<SectionDivider />
{/* ── Infrastructure ── */}
<SectionHeading label="Infrastructure" collapsed={collapsed} />
{infraApps.length > 0 ? (
infraApps.map(app => (
<SectionRow
key={app.name}
icon="◈"
label={app.domain ?? app.name}
href={app.domain ? `https://${app.domain}` : undefined}
collapsed={collapsed}
/>
))
) : project?.giteaRepo ? (
<SectionRow icon="◈" label="Gitea connected" href={project.giteaRepoUrl} collapsed={collapsed} />
) : (
<SectionRow icon="◈" label="Not deployed" dim collapsed={collapsed} />
)}
<SectionDivider />
{/* ── Growth ── */}
<SectionHeading label="Growth" collapsed={collapsed} />
<SectionRow icon="↗" label="Not set up" dim href={`${base}/grow`} collapsed={collapsed} />
<SectionDivider />
{/* ── Monetize ── */}
<SectionHeading label="Monetize" collapsed={collapsed} />
<SectionRow icon="◉" label="Not set up" dim collapsed={collapsed} />
<SectionDivider />
{/* ── Support ── */}
<SectionHeading label="Support" collapsed={collapsed} />
<SectionRow icon="?" label="Not set up" dim collapsed={collapsed} />
<SectionDivider />
{/* ── Analytics ── */}
<SectionHeading label="Analytics" collapsed={collapsed} />
<SectionRow icon="∿" label="Not set up" dim href={`${base}/insights`} collapsed={collapsed} />
</>
) : (
/* ── GLOBAL VIEW: projects list ── */
<div style={{ padding: collapsed ? "2px 6px" : "2px 8px" }}>
{!collapsed && (
<div style={{ fontSize: "0.58rem", fontWeight: 600, color: "#a09a90", letterSpacing: "0.1em", textTransform: "uppercase", padding: "6px 10px 8px" }}>
Projects
</div>
)}
{projects.map(p => {
const isActive = activeProjectId === p.id;
const color = p.status === "live" ? "#2e7d32" : p.status === "building" ? "#3d5afe" : "#d4a04a";
return (
<Link key={p.id} href={`/${workspace}/project/${p.id}/overview`}
title={collapsed ? p.productName : undefined}
style={{
width: "100%", display: "flex", alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 9, padding: collapsed ? "9px 0" : "7px 10px",
borderRadius: 6,
background: isActive ? "#f6f4f0" : "transparent",
color: "#1a1a1a", fontSize: "0.8rem",
fontWeight: isActive ? 600 : 450,
transition: "background 0.12s", textDecoration: "none", overflow: "hidden",
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color, display: "inline-block", flexShrink: 0 }} />
{!collapsed && (
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{p.productName}
</span>
)}
</Link>
);
})}
</div>
)}
</div>
{/* ── User footer ── */}
<div style={{
padding: collapsed ? "10px 0" : "12px 14px",
borderTop: "1px solid #eae6de",
display: "flex", alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 9, flexShrink: 0,
}}>
<div title={collapsed ? (session?.user?.name ?? session?.user?.email ?? "Account") : undefined}
style={{
width: 26, height: 26, borderRadius: "50%",
background: "#f0ece4", display: "flex", alignItems: "center",
justifyContent: "center", fontSize: "0.7rem", fontWeight: 600,
color: "#8a8478", flexShrink: 0, cursor: "default",
}}>
{userInitial}
</div>
{!collapsed && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "0.76rem", fontWeight: 500, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "Account"}
</div>
<button onClick={() => signOut({ callbackUrl: "/auth" })} style={{
background: "none", border: "none", padding: 0,
fontSize: "0.62rem", color: "#a09a90", cursor: "pointer",
fontFamily: "Outfit, sans-serif",
}}>
Sign out
</button>
</div>
)}
</div>
</nav>
);
}