import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { Activity, BarChart3, Bell, ChevronLeft, ChevronRight, Home, Info, ListVideo, LogOut, MessageSquare, Settings, Shield, SlidersHorizontal, Users, Tv, UserPlus, } from "lucide-react"; import { api, type Me } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { getUnreadCount, subscribe } from "../lib/notifications"; import { LS } from "../lib/storage"; import type { Page } from "../lib/urlState"; import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; import LanguageSwitcher from "./LanguageSwitcher"; // 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, onChangeLanguage, language, }: { me: Me; page: Page; setPage: (p: Page) => void; onOpenAbout: () => void; onChangeLanguage: (code: LangCode) => void; language: LangCode; }) { const { t } = useTranslation(); const [collapsed, setCollapsed] = useState( () => localStorage.getItem(LS.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(LS.navCollapsed, next ? "1" : "0"); return next; }); } async function logout() { await fetch("/auth/logout", { method: "POST", credentials: "include" }); location.reload(); } // Accounts that have signed in on this browser (loaded only while the popover is open). const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts, enabled: acctOpen, }); const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active); async function switchTo(id: number) { try { await api.switchAccount(id); // Drop the previous account's in-module sub-view/overlay before the reload (which would // otherwise preserve history.state across the swap). Otherwise the new account lands on a // stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity // (often the new account's own id → an empty self-thread). Start at the module root instead. const st = { ...(window.history.state || {}) }; delete st._sub; delete st._ov; window.history.replaceState(st, ""); location.reload(); // cleanest way to reload all per-user state for the new account } catch { /* a revoked/removed account — leave the popover open */ } } // Durable, server-backed unread count for the inbox badge. Polled live (pauses when the // tab is hidden); coexists with the client-side transient bell at the bottom of the rail. const unreadQuery = useLiveQuery( ["notif-unread"], api.notificationUnreadCount, { intervalMs: 30000 } ); // One indicator for both layers: durable server notifications + the client-side transient // events (the former separate bell is now folded into the inbox page). const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); const unread = (unreadQuery.data?.count ?? 0) + clientUnread; // Direct messages are their own module with their own unread badge (demo can't message). const msgUnreadQuery = useLiveQuery( ["message-unread"], api.messageUnreadCount, { intervalMs: 30000, enabled: !me.is_demo } ); const msgUnread = msgUnreadQuery.data?.count ?? 0; type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number }; // User-facing content modules vs. admin/system modules, separated by a divider in the rail. const userItems: NavItem[] = [ { 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") }, { page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread }, // Direct messaging — its own module; hidden for the shared demo account. ...(me.is_demo ? [] : [{ page: "messages" as Page, icon: MessageSquare, label: t("messages.navLabel"), badge: msgUnread }]), // Per-user sync status + your own API usage (admins get an extra system-wide tab inside). { page: "stats", icon: BarChart3, label: t("header.account.stats") }, ]; const systemItems: NavItem[] = me.role === "admin" ? [ { page: "scheduler", icon: Activity, label: t("header.account.scheduler") }, { page: "config", icon: SlidersHorizontal, label: t("header.account.config") }, { page: "users", icon: Users, label: t("header.account.users") }, ] : []; 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]; const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => { const active = page === p; // On the active row the background is the accent colour, so a same-accent badge would be // red-on-red. Invert it there (light pill, accent-coloured number) for clean contrast. const badgeColor = active ? "bg-accent-fg text-accent" : "bg-accent text-accent-fg"; const show = !!badge && badge > 0; return ( ); }; return ( ); }