Files
vibn-frontend/components/layout/vibn-sidebar.tsx
2026-03-02 17:16:56 -08:00

293 lines
11 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 Project {
id: string;
productName: string;
status?: string;
}
interface VIBNSidebarProps {
workspace: string;
}
function StatusDot({ status }: { status?: string }) {
const color =
status === "live" ? "#2e7d32"
: status === "building" ? "#3d5afe"
: "#d4a04a";
const anim = status === "building" ? "vibn-breathe 2.5s ease infinite" : "none";
return (
<span style={{
width: 7, height: 7, borderRadius: "50%",
background: color, display: "inline-block",
flexShrink: 0, animation: anim,
}} />
);
}
const COLLAPSED_KEY = "vibn_sidebar_collapsed";
const COLLAPSED_W = 56;
const EXPANDED_W = 220;
export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
const pathname = usePathname();
const { data: session } = useSession();
const [projects, setProjects] = useState<Project[]>([]);
const [collapsed, setCollapsed] = useState(false);
const [mounted, setMounted] = useState(false);
// Restore collapse state from localStorage
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;
});
};
useEffect(() => {
fetch("/api/projects")
.then((r) => r.json())
.then((d) => setProjects(d.projects ?? []))
.catch(() => {});
}, []);
const activeProjectId = pathname?.match(/\/project\/([^/]+)/)?.[1] ?? null;
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;
// Don't animate on initial mount (avoid flash)
const transition = mounted ? "width 0.2s cubic-bezier(0.4,0,0.2,1)" : "none";
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 row */}
{collapsed ? (
/* Collapsed: logo centered, toggle below it */
<div style={{ flexShrink: 0 }}>
<div style={{ display: "flex", justifyContent: "center", padding: "16px 0 8px" }}>
<Link href={`/${workspace}/projects`} title="VIBN" style={{ textDecoration: "none" }}>
<div style={{ width: 28, height: 28, 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: 28, height: 22, borderRadius: 5,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.82rem", fontWeight: 700, transition: "background 0.12s, color 0.12s",
}}
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); (e.currentTarget.style.color = "#1a1a1a"); }}
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); (e.currentTarget.style.color = "#6b6560"); }}
>
</button>
</div>
</div>
) : (
/* Expanded: logo + name on left, toggle on right */
<div style={{ padding: "16px 12px 16px 18px", 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: 28, height: 28, 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.95rem", 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: 26, height: 24, borderRadius: 5,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.82rem", fontWeight: 700, flexShrink: 0,
transition: "background 0.12s, color 0.12s",
}}
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); (e.currentTarget.style.color = "#1a1a1a"); }}
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); (e.currentTarget.style.color = "#6b6560"); }}
>
</button>
</div>
)}
{/* Top nav */}
<div style={{ padding: collapsed ? "4px 8px" : "4px 10px", flexShrink: 0 }}>
{topNavItems.map((n) => {
const isActive = n.id === "projects" ? isProjects
: n.id === "activity" ? isActivity
: n.id === "settings" ? isSettings
: false;
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: 9,
padding: collapsed ? "9px 0" : "8px 10px",
borderRadius: 6,
background: isActive ? "#f6f4f0" : "transparent",
color: isActive ? "#1a1a1a" : "#6b6560",
fontSize: "0.82rem",
fontWeight: isActive ? 600 : 500,
transition: "all 0.12s",
textDecoration: "none",
}}
>
<span style={{
fontSize: collapsed ? "1rem" : "0.8rem",
opacity: collapsed ? (isActive ? 0.9 : 0.45) : 0.45,
width: collapsed ? "auto" : 18,
textAlign: "center",
transition: "font-size 0.15s",
}}>
{n.icon}
</span>
{!collapsed && n.label}
</Link>
);
})}
</div>
<div style={{ height: 1, background: "#eae6de", margin: "10px 18px", flexShrink: 0 }} />
{/* Projects list */}
<div style={{ padding: collapsed ? "2px 8px" : "2px 10px", flex: 1, overflow: "auto" }}>
{!collapsed && (
<div style={{
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
letterSpacing: "0.1em", textTransform: "uppercase",
padding: "6px 10px 8px",
}}>
Projects
</div>
)}
{projects.map((p) => {
const isActive = activeProjectId === p.id;
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.82rem",
fontWeight: isActive ? 600 : 450,
transition: "background 0.12s",
textDecoration: "none",
overflow: "hidden",
}}
>
<StatusDot status={p.status} />
{!collapsed && (
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{p.productName}
</span>
)}
</Link>
);
})}
</div>
{/* User footer */}
<div style={{
padding: collapsed ? "12px 0" : "14px 18px",
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: 28, height: 28, borderRadius: "50%",
background: "#f0ece4", display: "flex", alignItems: "center",
justifyContent: "center", fontSize: "0.72rem", fontWeight: 600,
color: "#8a8478", flexShrink: 0, cursor: "default",
}}
>
{userInitial}
</div>
{!collapsed && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: "0.78rem", 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>
);
}