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, Clapperboard, Download, Home, Info, ListVideo, LogOut, MessageSquare, Settings, Shield, SlidersHorizontal, Users, Tv, UserPlus, } from "lucide-react"; import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { getUnreadCount, subscribe } from "../lib/notifications"; import type { Page } from "../lib/urlState"; import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; import LanguageSwitcher from "./LanguageSwitcher"; import SyncStatus from "./SyncStatus"; // 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, collapsed, onToggleCollapse, onGoToFullHistory, }: { me: Me; page: Page; setPage: (p: Page) => void; onOpenAbout: () => void; onChangeLanguage: (code: LangCode) => void; language: LangCode; // Collapse state is owned by App (persisted to the user's preferences); the rail just reflects // it and asks App to flip it. collapsed: boolean; onToggleCollapse: () => void; onGoToFullHistory: () => void; }) { const { t } = useTranslation(); 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]); async function logout() { // Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then // drops this tab's override and reloads onto whatever account remains the default. try { await api.logout(); } catch { /* clear locally and reload regardless */ } clearActiveAccount(); 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); function switchTo(id: number) { // Per-tab switch: point THIS tab at the account and reload; other tabs keep their own // identity. No server round-trip — req() sends the account header (validated against the // browser wallet server-side), so the cookie's default account is left untouched. setActiveAccount(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 } // 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; // Download center badge = items still working (queued/running/paused). Reuses the same // lightweight per-user index the feed cards poll, so no extra request shape. const dlIndexQuery = useLiveQuery(["download-index"], api.downloadIndex, { intervalMs: 30000, enabled: !me.is_demo, }); const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length; 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") }, // Optional Plex module — browse/play your Plex library. Shown only when the admin enabled it. ...(me.plex_enabled ? [{ page: "plex" as Page, icon: Clapperboard, label: t("plex.navLabel") }] : []), { 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 }]), // Download center — YouTube → server → your device. Hidden for the shared demo account // (spends server disk + needs a real identity). ...(me.is_demo ? [] : [{ page: "downloads" as Page, icon: Download, label: t("downloads.navLabel"), badge: dlActive }]), // 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]; // Role shown next to the name (expanded) / as an avatar dot (collapsed). Demo isn't a real // role (it's a flag on a `user` row), so surface it first. const roleKey: "admin" | "user" | "demo" = me.is_demo ? "demo" : me.role === "admin" ? "admin" : "user"; const roleChipCls = roleKey === "admin" ? "bg-accent/15 text-accent" : roleKey === "demo" ? "bg-amber-500/20 text-amber-500" : "bg-muted/15 text-muted"; const roleDotCls = roleKey === "admin" ? "bg-accent" : roleKey === "demo" ? "bg-amber-500" : "bg-muted"; 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 ( ); }