import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { BarChart3, ChevronLeft, ChevronRight, Home, Info, ListVideo, LogOut, Settings, Shield, Tv, } from "lucide-react"; import type { Me } from "../lib/api"; import type { Page } from "../lib/urlState"; import AvatarImg from "./Avatar"; // Primary app navigation: a collapsible left rail with icon+label entries (Design C). The // modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes // a thin icon-only rail. Account actions sit at the bottom in a popover. export default function NavSidebar({ me, page, setPage, onOpenAbout, }: { me: Me; page: Page; setPage: (p: Page) => void; onOpenAbout: () => void; }) { const { t } = useTranslation(); const [collapsed, setCollapsed] = useState( () => localStorage.getItem("siftlode.navCollapsed") === "1" ); const [acctOpen, setAcctOpen] = useState(false); const acctBtnRef = useRef(null); const acctPanelRef = useRef(null); // Popover position (fixed, viewport coords). It's portaled to so it escapes the // nav's backdrop-filter, which would otherwise trap fixed positioning + stacking and let // clicks fall through to the controls behind it. const [acctPos, setAcctPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 }); function toggleAccount() { if (!acctOpen) { const r = acctBtnRef.current?.getBoundingClientRect(); if (r) setAcctPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom }); } setAcctOpen((o) => !o); } // Dismiss on outside click / Escape via document listeners — NOT a full-screen backdrop // div, which (sitting between the popover and the page) would break the popover's // backdrop-filter and make it look solid. useEffect(() => { if (!acctOpen) return; function onDoc(e: MouseEvent) { const t = e.target as Node; if (acctPanelRef.current?.contains(t) || acctBtnRef.current?.contains(t)) return; setAcctOpen(false); } function onKey(e: KeyboardEvent) { if (e.key === "Escape") setAcctOpen(false); } document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); }; }, [acctOpen]); function toggleCollapsed() { setCollapsed((c) => { const next = !c; localStorage.setItem("siftlode.navCollapsed", next ? "1" : "0"); return next; }); } async function logout() { await fetch("/auth/logout", { method: "POST", credentials: "include" }); location.reload(); } const items: { page: Page; icon: typeof Home; label: string }[] = [ { page: "feed", icon: Home, label: t("header.account.feed") }, { page: "channels", icon: Tv, label: t("header.account.channels") }, { page: "playlists", icon: ListVideo, label: t("header.account.playlists") }, ]; if (me.role === "admin") items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") }); const rowBase = "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; const name = me.display_name ?? me.email.split("@")[0]; return ( ); }