Add collapsible sidebar with icon-only skinny mode

- Toggle with ‹‹/›› button; state persists in localStorage
- Collapsed (56px): icons only, nav labels as native title tooltips,
  project list shows status dots only, user avatar centered
- Smooth 200ms cubic-bezier width transition; no flash on initial load
- Expanded (220px): unchanged visual layout

Made-with: Cursor
This commit is contained in:
2026-03-02 16:57:39 -08:00
parent 0146ae7df6
commit d3a5655948

View File

@@ -22,20 +22,38 @@ function StatusDot({ status }: { status?: string }) {
: "#d4a04a"; : "#d4a04a";
const anim = status === "building" ? "vibn-breathe 2.5s ease infinite" : "none"; const anim = status === "building" ? "vibn-breathe 2.5s ease infinite" : "none";
return ( return (
<span <span style={{
style={{
width: 7, height: 7, borderRadius: "50%", width: 7, height: 7, borderRadius: "50%",
background: color, display: "inline-block", background: color, display: "inline-block",
flexShrink: 0, animation: anim, flexShrink: 0, animation: anim,
}} }} />
/>
); );
} }
const COLLAPSED_KEY = "vibn_sidebar_collapsed";
const COLLAPSED_W = 56;
const EXPANDED_W = 220;
export function VIBNSidebar({ workspace }: VIBNSidebarProps) { export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const { data: session } = useSession(); const { data: session } = useSession();
const [projects, setProjects] = useState<Project[]>([]); 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(() => { useEffect(() => {
fetch("/api/projects") fetch("/api/projects")
@@ -44,10 +62,7 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
.catch(() => {}); .catch(() => {});
}, []); }, []);
// Derive active project from URL
const activeProjectId = pathname?.match(/\/project\/([^/]+)/)?.[1] ?? null; const activeProjectId = pathname?.match(/\/project\/([^/]+)/)?.[1] ?? null;
// Derive active top-level section
const isProjects = !activeProjectId && (pathname?.includes("/projects") || pathname?.includes("/project")); const isProjects = !activeProjectId && (pathname?.includes("/projects") || pathname?.includes("/project"));
const isActivity = !activeProjectId && pathname?.includes("/activity"); const isActivity = !activeProjectId && pathname?.includes("/activity");
const isSettings = !activeProjectId && pathname?.includes("/settings"); const isSettings = !activeProjectId && pathname?.includes("/settings");
@@ -62,10 +77,14 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
?? session?.user?.email?.[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 ( return (
<nav <nav style={{
style={{ width: w,
width: 220,
height: "100vh", height: "100vh",
background: "#fff", background: "#fff",
borderRight: "1px solid #e8e4dc", borderRight: "1px solid #e8e4dc",
@@ -73,39 +92,79 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
flexDirection: "column", flexDirection: "column",
fontFamily: "Outfit, sans-serif", fontFamily: "Outfit, sans-serif",
flexShrink: 0, flexShrink: 0,
}} overflow: "hidden",
> transition,
{/* Logo */} position: "relative",
<Link }}>
href={`/${workspace}/projects`}
style={{ {/* Logo row */}
padding: "22px 18px 18px", <div style={{
padding: collapsed ? "20px 0" : "22px 18px 18px",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: collapsed ? "center" : "space-between",
gap: 9, gap: 9,
textDecoration: "none", transition: "padding 0.2s",
}}
>
<div
style={{
width: 28, height: 28, borderRadius: 7, overflow: "hidden",
flexShrink: 0, flexShrink: 0,
}} }}>
<Link
href={`/${workspace}/projects`}
style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none" }}
title="VIBN"
> >
<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" }} /> <img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</div> </div>
<span {!collapsed && (
style={{ <span style={{
fontSize: "0.95rem", fontWeight: 600, color: "#1a1a1a", fontSize: "0.95rem", fontWeight: 600, color: "#1a1a1a",
letterSpacing: "-0.03em", fontFamily: "Newsreader, serif", letterSpacing: "-0.03em", fontFamily: "Newsreader, serif",
}} whiteSpace: "nowrap", overflow: "hidden",
> }}>
vibn vibn
</span> </span>
)}
</Link> </Link>
{/* Collapse toggle — only visible in expanded mode, or as a tiny button in collapsed */}
{!collapsed && (
<button
onClick={toggle}
title="Collapse sidebar"
style={{
background: "none", border: "none", cursor: "pointer",
color: "#c5c0b8", padding: "4px 6px", borderRadius: 5,
fontSize: "0.75rem", lineHeight: 1, flexShrink: 0,
transition: "color 0.12s",
}}
onMouseEnter={e => (e.currentTarget.style.color = "#6b6560")}
onMouseLeave={e => (e.currentTarget.style.color = "#c5c0b8")}
>
</button>
)}
</div>
{/* Expand button shown in collapsed mode */}
{collapsed && (
<button
onClick={toggle}
title="Expand sidebar"
style={{
background: "none", border: "none", cursor: "pointer",
color: "#c5c0b8", fontSize: "0.7rem", lineHeight: 1,
padding: "4px 0 8px", width: "100%", textAlign: "center",
transition: "color 0.12s", flexShrink: 0,
}}
onMouseEnter={e => (e.currentTarget.style.color = "#6b6560")}
onMouseLeave={e => (e.currentTarget.style.color = "#c5c0b8")}
>
</button>
)}
{/* Top nav */} {/* Top nav */}
<div style={{ padding: "4px 10px" }}> <div style={{ padding: collapsed ? "4px 8px" : "4px 10px", flexShrink: 0 }}>
{topNavItems.map((n) => { {topNavItems.map((n) => {
const isActive = n.id === "projects" ? isProjects const isActive = n.id === "projects" ? isProjects
: n.id === "activity" ? isActivity : n.id === "activity" ? isActivity
@@ -115,12 +174,14 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
<Link <Link
key={n.id} key={n.id}
href={n.href} href={n.href}
title={collapsed ? n.label : undefined}
style={{ style={{
width: "100%", width: "100%",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 9, gap: 9,
padding: "8px 10px", padding: collapsed ? "9px 0" : "8px 10px",
borderRadius: 6, borderRadius: 6,
background: isActive ? "#f6f4f0" : "transparent", background: isActive ? "#f6f4f0" : "transparent",
color: isActive ? "#1a1a1a" : "#6b6560", color: isActive ? "#1a1a1a" : "#6b6560",
@@ -130,40 +191,48 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
textDecoration: "none", textDecoration: "none",
}} }}
> >
<span style={{ fontSize: "0.8rem", opacity: 0.45, width: 18, textAlign: "center" }}> <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} {n.icon}
</span> </span>
{n.label} {!collapsed && n.label}
</Link> </Link>
); );
})} })}
</div> </div>
<div style={{ height: 1, background: "#eae6de", margin: "10px 18px" }} /> <div style={{ height: 1, background: "#eae6de", margin: "10px 18px", flexShrink: 0 }} />
{/* Projects list */} {/* Projects list */}
<div style={{ padding: "2px 10px", flex: 1, overflow: "auto" }}> <div style={{ padding: collapsed ? "2px 8px" : "2px 10px", flex: 1, overflow: "auto" }}>
<div {!collapsed && (
style={{ <div style={{
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90", fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
letterSpacing: "0.1em", textTransform: "uppercase", letterSpacing: "0.1em", textTransform: "uppercase",
padding: "6px 10px 8px", padding: "6px 10px 8px",
}} }}>
>
Projects Projects
</div> </div>
)}
{projects.map((p) => { {projects.map((p) => {
const isActive = activeProjectId === p.id; const isActive = activeProjectId === p.id;
return ( return (
<Link <Link
key={p.id} key={p.id}
href={`/${workspace}/project/${p.id}/overview`} href={`/${workspace}/project/${p.id}/overview`}
title={collapsed ? p.productName : undefined}
style={{ style={{
width: "100%", width: "100%",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 9, gap: 9,
padding: "7px 10px", padding: collapsed ? "9px 0" : "7px 10px",
borderRadius: 6, borderRadius: 6,
background: isActive ? "#f6f4f0" : "transparent", background: isActive ? "#f6f4f0" : "transparent",
color: "#1a1a1a", color: "#1a1a1a",
@@ -175,36 +244,43 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
}} }}
> >
<StatusDot status={p.status} /> <StatusDot status={p.status} />
{!collapsed && (
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{p.productName} {p.productName}
</span> </span>
)}
</Link> </Link>
); );
})} })}
</div> </div>
{/* User footer */} {/* User footer */}
<div <div style={{
style={{ padding: collapsed ? "12px 0" : "14px 18px",
padding: "14px 18px",
borderTop: "1px solid #eae6de", borderTop: "1px solid #eae6de",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 9, gap: 9,
}} flexShrink: 0,
> }}>
<div <div
title={collapsed ? (session?.user?.name ?? session?.user?.email ?? "Account") : undefined}
style={{ style={{
width: 28, height: 28, borderRadius: "50%", width: 28, height: 28, borderRadius: "50%",
background: "#f0ece4", display: "flex", alignItems: "center", background: "#f0ece4", display: "flex", alignItems: "center",
justifyContent: "center", fontSize: "0.72rem", fontWeight: 600, justifyContent: "center", fontSize: "0.72rem", fontWeight: 600,
color: "#8a8478", flexShrink: 0, color: "#8a8478", flexShrink: 0, cursor: "default",
}} }}
> >
{userInitial} {userInitial}
</div> </div>
{!collapsed && (
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "0.78rem", fontWeight: 500, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <div style={{
fontSize: "0.78rem", fontWeight: 500, color: "#1a1a1a",
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}>
{session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "Account"} {session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "Account"}
</div> </div>
<button <button
@@ -218,6 +294,7 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
Sign out Sign out
</button> </button>
</div> </div>
)}
</div> </div>
</nav> </nav>
); );