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:
@@ -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,68 +62,109 @@ 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");
|
||||||
|
|
||||||
const topNavItems = [
|
const topNavItems = [
|
||||||
{ id: "projects", label: "Projects", icon: "⌗", href: `/${workspace}/projects` },
|
{ id: "projects", label: "Projects", icon: "⌗", href: `/${workspace}/projects` },
|
||||||
{ id: "activity", label: "Activity", icon: "↗", href: `/${workspace}/activity` },
|
{ id: "activity", label: "Activity", icon: "↗", href: `/${workspace}/activity` },
|
||||||
{ id: "settings", label: "Settings", icon: "⚙", href: `/${workspace}/settings` },
|
{ id: "settings", label: "Settings", icon: "⚙", href: `/${workspace}/settings` },
|
||||||
];
|
];
|
||||||
|
|
||||||
const userInitial = session?.user?.name?.[0]?.toUpperCase()
|
const userInitial = session?.user?.name?.[0]?.toUpperCase()
|
||||||
?? 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",
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
fontFamily: "Outfit, sans-serif",
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
transition,
|
||||||
|
position: "relative",
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* Logo row */}
|
||||||
|
<div style={{
|
||||||
|
padding: collapsed ? "20px 0" : "22px 18px 18px",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
alignItems: "center",
|
||||||
fontFamily: "Outfit, sans-serif",
|
justifyContent: collapsed ? "center" : "space-between",
|
||||||
|
gap: 9,
|
||||||
|
transition: "padding 0.2s",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}>
|
||||||
>
|
<Link
|
||||||
{/* Logo */}
|
href={`/${workspace}/projects`}
|
||||||
<Link
|
style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none" }}
|
||||||
href={`/${workspace}/projects`}
|
title="VIBN"
|
||||||
style={{
|
|
||||||
padding: "22px 18px 18px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 9,
|
|
||||||
textDecoration: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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 style={{ width: 28, height: 28, borderRadius: 7, overflow: "hidden", flexShrink: 0 }}>
|
||||||
</div>
|
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||||
<span
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.95rem", fontWeight: 600, color: "#1a1a1a",
|
||||||
|
letterSpacing: "-0.03em", fontFamily: "Newsreader, serif",
|
||||||
|
whiteSpace: "nowrap", overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
vibn
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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={{
|
style={{
|
||||||
fontSize: "0.95rem", fontWeight: 600, color: "#1a1a1a",
|
background: "none", border: "none", cursor: "pointer",
|
||||||
letterSpacing: "-0.03em", fontFamily: "Newsreader, serif",
|
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")}
|
||||||
>
|
>
|
||||||
vibn
|
››
|
||||||
</span>
|
</button>
|
||||||
</Link>
|
)}
|
||||||
|
|
||||||
{/* 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,49 +244,57 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StatusDot status={p.status} />
|
<StatusDot status={p.status} />
|
||||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
{!collapsed && (
|
||||||
{p.productName}
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
</span>
|
{p.productName}
|
||||||
|
</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>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
{!collapsed && (
|
||||||
<div style={{ fontSize: "0.78rem", fontWeight: 500, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "Account"}
|
<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>
|
||||||
<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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user