Files
vibn-agent-runner/vibn-frontend/components/layout/vibn-sidebar.tsx

668 lines
21 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 TabItem {
id: string;
label: string;
path: string;
}
interface VIBNSidebarProps {
workspace: string;
tabs?: TabItem[];
activeTab?: string;
}
interface ProjectData {
id: string;
productName?: string;
name?: string;
status?: string;
}
// ── Main sidebar ─────────────────────────────────────────────────────────────
const COLLAPSED_KEY = "vibn_sidebar_collapsed";
const COLLAPSED_W = 52;
const EXPANDED_W = 216;
export function VIBNSidebar({ workspace, tabs, activeTab }: 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);
// 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;
// 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);
return;
}
fetch(`/api/projects/${activeProjectId}`)
.then((r) => r.json())
.then((d) => setProject(d.project ?? null))
.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}`;
return (
<nav
style={{
width: w,
height: "100vh",
background: "#fff",
borderRight: "1px solid #e8e4dc",
display: "flex",
flexDirection: "column",
fontFamily: "var(--font-inter), ui-sans-serif, 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: "var(--font-lora), ui-serif, 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: name + status + section tabs ── */
<>
{!collapsed && (
<>
<div style={{ padding: "6px 12px 8px" }}>
<div
style={{
fontSize: "0.82rem",
fontWeight: 700,
color: "#1a1a1a",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{project.productName || project.name || "Project"}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
marginTop: 3,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
flexShrink: 0,
display: "inline-block",
background:
project.status === "live"
? "#2e7d32"
: project.status === "building"
? "#3d5afe"
: "#d4a04a",
}}
/>
<span style={{ fontSize: "0.68rem", color: "#8a8478" }}>
{project.status === "live"
? "Live"
: project.status === "building"
? "Building"
: "Defining"}
</span>
</div>
</div>
{tabs && tabs.length > 0 && (
<div style={{ padding: "2px 8px" }}>
{tabs.map((t) => {
const isActive = activeTab === t.id;
return (
<Link
key={t.id}
href={`/${workspace}/project/${activeProjectId}/${t.path}`}
style={{
width: "100%",
display: "flex",
alignItems: "center",
padding: "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";
}}
>
{t.label}
</Link>
);
})}
</div>
)}
</>
)}
{collapsed && (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
paddingTop: 8,
gap: 6,
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
display: "inline-block",
background:
project.status === "live"
? "#2e7d32"
: project.status === "building"
? "#3d5afe"
: "#d4a04a",
}}
title={project.productName || project.name}
/>
{tabs &&
tabs.map((t) => {
const isActive = activeTab === t.id;
return (
<Link
key={t.id}
href={`/${workspace}/project/${activeProjectId}/${t.path}`}
title={t.label}
style={{
width: 28,
height: 28,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: isActive ? "#f6f4f0" : "transparent",
color: isActive ? "#1a1a1a" : "#a09a90",
fontSize: "0.6rem",
fontWeight: 700,
textDecoration: "none",
textTransform: "uppercase",
letterSpacing: "0.02em",
transition: "background 0.12s",
}}
onMouseEnter={(e) => {
if (!isActive)
(e.currentTarget as HTMLElement).style.background =
"#f6f4f0";
}}
onMouseLeave={(e) => {
if (!isActive)
(e.currentTarget as HTMLElement).style.background =
"transparent";
}}
>
{t.label.slice(0, 2)}
</Link>
);
})}
</div>
)}
</>
) : (
/* ── 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}`}
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: "/signin" })}
style={{
background: "none",
border: "none",
padding: 0,
fontSize: "0.62rem",
color: "#a09a90",
cursor: "pointer",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}
>
Sign out
</button>
</div>
)}
</div>
</nav>
);
}