668 lines
21 KiB
TypeScript
668 lines
21 KiB
TypeScript
"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>
|
||
);
|
||
}
|