"use client"; import { useState, useEffect, useRef, useCallback, type CSSProperties } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/shared/utils/cn"; import { getActiveSidebarHref } from "@/shared/utils/sidebarRouteMatch"; import { filterSidebarSectionsByQuery } from "@/shared/utils/sidebarSearch"; import { expandActiveSection, hydrateExpandedSections, toggleExpandedSection, } from "@/shared/utils/sidebarExpansionState"; import { APP_CONFIG } from "@/shared/constants/appConfig"; import OmniRouteLogo from "./OmniRouteLogo"; import Button from "./Button"; import Input from "./Input"; import { ConfirmModal } from "./Modal"; import CloudSyncStatus from "./CloudSyncStatus"; import { useTranslations } from "next-intl"; import { HIDDEN_SIDEBAR_GROUP_LABELS_SETTING_KEY, normalizeHiddenSidebarGroupLabels, } from "@/shared/constants/sidebarGroupVisibility"; import { HIDDEN_SIDEBAR_ITEMS_SETTING_KEY, SIDEBAR_SETTINGS_UPDATED_EVENT, SIDEBAR_SECTION_ORDER_KEY, SIDEBAR_ITEM_ORDER_KEY, SIDEBAR_SECTIONS, normalizeHiddenSidebarItems, applySectionOrder, applyItemOrder, getSidebarIconAccent, type SidebarSectionId, type SidebarItemDefinition, type SidebarItemGroup, type SidebarItemOrder, } from "@/shared/constants/sidebarVisibility"; const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1"; const DEFAULT_EXPANDED: SidebarSectionId = "omni-proxy"; const EXPANDED_SECTIONS_KEY = "sidebar-expanded-sections"; const PINNED_SECTIONS_KEY = "sidebar-pinned-sections"; type SidebarGlyphStyle = CSSProperties & { "--sidebar-icon-accent": string; color: string; }; type SidebarProps = { onClose?: () => void; collapsed?: boolean; onToggleCollapse?: () => void; isMacElectron?: boolean; }; type HoveredItem = { id: string; label: string; x: number; y: number } | null; function loadFromStorage(key: string, fallback: T): T { try { const stored = localStorage.getItem(key); if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed)) return parsed as T; } } catch {} return fallback; } function saveToStorage(key: string, value: unknown) { try { localStorage.setItem(key, JSON.stringify(value)); } catch {} } export default function Sidebar({ onClose, collapsed = false, onToggleCollapse, isMacElectron = false, }: SidebarProps) { const getIconStyle = (itemId: string): SidebarGlyphStyle => { const accent = getSidebarIconAccent(itemId); return { "--sidebar-icon-accent": accent, color: accent, }; }; const pathname = usePathname(); const t = useTranslations("sidebar"); const tc = useTranslations("common"); const sidebarRef = useRef(null); const [showShutdownModal, setShowShutdownModal] = useState(false); const [showRestartModal, setShowRestartModal] = useState(false); const [isShuttingDown, setIsShuttingDown] = useState(false); const [isRestarting, setIsRestarting] = useState(false); const [isDisconnected, setIsDisconnected] = useState(false); const [showDebug, setShowDebug] = useState(false); const [hiddenSidebarItems, setHiddenSidebarItems] = useState([]); const [hiddenSidebarGroupLabels, setHiddenSidebarGroupLabels] = useState([]); const [sidebarSectionOrder, setSidebarSectionOrder] = useState([]); const [sidebarItemOrder, setSidebarItemOrder] = useState({}); const [customAppName, setCustomAppName] = useState(null); const [customLogo, setCustomLogo] = useState(null); const [expandedSections, setExpandedSections] = useState>( new Set([DEFAULT_EXPANDED]) ); const [pinnedSections, setPinnedSections] = useState>(new Set()); const [sidebarExpansionLoaded, setSidebarExpansionLoaded] = useState(false); const skipInitialActiveExpansion = useRef(false); const [hoveredItem, setHoveredItem] = useState(null); const [searchQuery, setSearchQuery] = useState(""); // Load persisted state on mount. A stored [] intentionally means "all sections collapsed". useEffect(() => { const storedExpanded = loadFromStorage(EXPANDED_SECTIONS_KEY, [ DEFAULT_EXPANDED, ]); const pinnedRaw = (() => { try { return localStorage.getItem(PINNED_SECTIONS_KEY); } catch { return null; } })(); const storedPinned: SidebarSectionId[] = pinnedRaw !== null ? (JSON.parse(pinnedRaw) as SidebarSectionId[]) : (SIDEBAR_SECTIONS.filter((s) => s.defaultPinned).map((s) => s.id) as SidebarSectionId[]); const initialPinned = new Set(storedPinned); const initialExpanded = hydrateExpandedSections(storedExpanded, initialPinned); skipInitialActiveExpansion.current = storedExpanded.length === 0; setExpandedSections(initialExpanded); setPinnedSections(initialPinned); setSidebarExpansionLoaded(true); }, []); useEffect(() => { const applySettings = (data) => { setShowDebug(data?.debugMode === true); setHiddenSidebarItems(normalizeHiddenSidebarItems(data?.[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY])); setHiddenSidebarGroupLabels( normalizeHiddenSidebarGroupLabels(data?.[HIDDEN_SIDEBAR_GROUP_LABELS_SETTING_KEY]) ); setCustomAppName(data?.instanceName || null); setCustomLogo(data?.customLogoBase64 || data?.customLogoUrl || null); }; fetch("/api/settings") .then((res) => res.json()) .then((data) => { applySettings(data); if (Array.isArray(data?.[SIDEBAR_SECTION_ORDER_KEY])) { setSidebarSectionOrder(data[SIDEBAR_SECTION_ORDER_KEY] as SidebarSectionId[]); } if (data?.[SIDEBAR_ITEM_ORDER_KEY] && typeof data[SIDEBAR_ITEM_ORDER_KEY] === "object") { setSidebarItemOrder(data[SIDEBAR_ITEM_ORDER_KEY] as SidebarItemOrder); } }) .catch(() => {}); const handleSettingsUpdated = (event: Event) => { const detail = (event as CustomEvent>).detail || {}; if ("debugMode" in detail) setShowDebug(detail.debugMode === true); if (HIDDEN_SIDEBAR_ITEMS_SETTING_KEY in detail) { setHiddenSidebarItems( normalizeHiddenSidebarItems(detail[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]) ); } if (HIDDEN_SIDEBAR_GROUP_LABELS_SETTING_KEY in detail) { setHiddenSidebarGroupLabels( normalizeHiddenSidebarGroupLabels(detail[HIDDEN_SIDEBAR_GROUP_LABELS_SETTING_KEY]) ); } if (SIDEBAR_SECTION_ORDER_KEY in detail && Array.isArray(detail[SIDEBAR_SECTION_ORDER_KEY])) { setSidebarSectionOrder(detail[SIDEBAR_SECTION_ORDER_KEY] as SidebarSectionId[]); } if ( SIDEBAR_ITEM_ORDER_KEY in detail && detail[SIDEBAR_ITEM_ORDER_KEY] && typeof detail[SIDEBAR_ITEM_ORDER_KEY] === "object" ) { setSidebarItemOrder(detail[SIDEBAR_ITEM_ORDER_KEY] as SidebarItemOrder); } if ("instanceName" in detail) setCustomAppName((detail.instanceName as string) || null); if ("customLogoBase64" in detail) { setCustomLogo((detail.customLogoBase64 as string) || null); } else if ("customLogoUrl" in detail) { setCustomLogo((detail.customLogoUrl as string) || null); } }; window.addEventListener(SIDEBAR_SETTINGS_UPDATED_EVENT, handleSettingsUpdated as EventListener); return () => window.removeEventListener( SIDEBAR_SETTINGS_UPDATED_EVENT, handleSettingsUpdated as EventListener ); }, []); const getSidebarLabel = (key: string, fallback: string) => typeof t.has === "function" && t.has(key) ? t(key) : fallback; const resolveItem = (item: SidebarItemDefinition, hidden: Set) => { if (hidden.has(item.id)) return null; const subtitle = item.subtitleKey ? getSidebarLabel(item.subtitleKey, item.subtitleFallback ?? "") : item.subtitleFallback; return { ...item, label: getSidebarLabel(item.i18nKey, item.labelFallback ?? item.id), subtitle: subtitle || undefined, }; }; const hiddenSidebarSet = new Set(hiddenSidebarItems); const hiddenSidebarGroupLabelsSet = new Set(hiddenSidebarGroupLabels); const orderedSections = applySectionOrder( SIDEBAR_SECTIONS.filter((section) => section.visibility !== "debug" || showDebug), sidebarSectionOrder ); const visibleSections = orderedSections .map((section) => { const orderedChildren = applyItemOrder( section.children, sidebarItemOrder[section.id as SidebarSectionId] ?? [] ); const children = orderedChildren .map((child) => { if ("type" in child && child.type === "group") { const items = child.items .map((item) => resolveItem(item, hiddenSidebarSet)) .filter(Boolean) as (SidebarItemDefinition & { label: string })[]; if (items.length === 0) return null; // Smart-grouping: single visible item → inline flat (no group header) if (items.length === 1) return items[0]; return { ...child, title: getSidebarLabel(child.titleKey, child.titleFallback), separatorHidden: hiddenSidebarGroupLabelsSet.has(child.id), items, } as SidebarItemGroup & { title: string; separatorHidden: boolean; items: (SidebarItemDefinition & { label: string })[]; }; } return resolveItem(child as SidebarItemDefinition, hiddenSidebarSet); }) .filter(Boolean); return { ...section, title: getSidebarLabel(section.titleKey, section.titleFallback), children, }; }) .filter((section) => { const allItems = section.children.flatMap((child: any) => child.type === "group" ? child.items : [child] ); return allItems.length > 0; }); const allVisibleItems = visibleSections.flatMap((section) => section.children.flatMap((child: any) => (child.type === "group" ? child.items : [child])) ); const activeHref = getActiveSidebarHref(pathname, allVisibleItems); const isSearching = searchQuery.trim().length > 0; const displaySections = isSearching ? filterSidebarSectionsByQuery(visibleSections, searchQuery) : visibleSections; // Keep the active page visible while preserving accordion semantics for unpinned sections. useEffect(() => { if (collapsed || !sidebarExpansionLoaded) return; if (skipInitialActiveExpansion.current) { skipInitialActiveExpansion.current = false; return; } for (const section of visibleSections) { const sectionItems = section.children.flatMap((child: any) => child.type === "group" ? child.items : [child] ); if (sectionItems.some((item: any) => !item.external && item.href === activeHref)) { setExpandedSections((prev) => { const next = expandActiveSection(pinnedSections, section.id as SidebarSectionId); if ([...next].every((id) => prev.has(id)) && next.size === prev.size) return prev; saveToStorage(EXPANDED_SECTIONS_KEY, [...next]); return next; }); break; } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeHref, collapsed, pinnedSections, sidebarExpansionLoaded]); // Accordion toggle: opening a section closes all non-pinned sections const toggleSection = useCallback( (sectionId: SidebarSectionId) => { setExpandedSections((prev) => { const next = toggleExpandedSection(prev, pinnedSections, sectionId); saveToStorage(EXPANDED_SECTIONS_KEY, [...next]); return next; }); }, [pinnedSections] ); const togglePin = useCallback((sectionId: SidebarSectionId) => { setPinnedSections((prev) => { const next = new Set(prev); if (next.has(sectionId)) { next.delete(sectionId); } else { next.add(sectionId); // Ensure the section is expanded when pinned setExpandedSections((prevExp) => { if (prevExp.has(sectionId)) return prevExp; const nextExp = new Set(prevExp); nextExp.add(sectionId); saveToStorage(EXPANDED_SECTIONS_KEY, [...nextExp]); return nextExp; }); } saveToStorage(PINNED_SECTIONS_KEY, [...next]); return next; }); }, []); const handleShutdown = async () => { setIsShuttingDown(true); try { await fetch("/api/shutdown", { method: "POST" }); } catch (e) { // Expected to fail as server shuts down } setIsShuttingDown(false); setShowShutdownModal(false); setIsDisconnected(true); }; const handleRestart = async () => { setIsRestarting(true); try { await fetch("/api/restart", { method: "POST" }); } catch (e) { // Expected to fail as server restarts } setIsRestarting(false); setShowRestartModal(false); setIsDisconnected(true); setTimeout(() => globalThis.location.reload(), 3000); }; const handleMouseEnter = useCallback( (e: React.MouseEvent, id: string, label: string) => { if (!collapsed) return; const rect = e.currentTarget.getBoundingClientRect(); const sidebarRect = sidebarRef.current?.getBoundingClientRect(); setHoveredItem({ id, label, x: (sidebarRect?.right ?? 64) + 8, y: rect.top + rect.height / 2, }); }, [collapsed] ); const handleMouseLeave = useCallback(() => setHoveredItem(null), []); const renderNavLink = (item) => { const active = !item.external && activeHref === item.href; const className = cn( "flex items-center gap-3 rounded-lg transition-all group", collapsed ? "justify-center px-2 py-2.5" : "px-3 py-1.5", active ? "bg-primary/10 text-primary" : "text-text-muted hover:bg-surface/50 hover:text-text-main" ); const iconClassName = cn( "material-symbols-outlined text-[18px] shrink-0", active ? "fill-1" : "group-hover:text-primary transition-colors" ); const content = ( <> {item.icon} {!collapsed && (
{item.label} {item.subtitle && ( {item.subtitle} )}
)} ); const sharedProps = { onMouseEnter: (e: React.MouseEvent) => handleMouseEnter(e, item.id, item.label), onMouseLeave: handleMouseLeave, }; if (item.external) { return ( {content} ); } return ( {content} ); }; return ( <> {/* Styled tooltip for collapsed (mini) sidebar */} {collapsed && hoveredItem && (
{hoveredItem.label}
)} setShowShutdownModal(false)} onConfirm={handleShutdown} title={t("shutdown")} message={t("shutdownConfirm")} confirmText={t("shutdown")} cancelText={tc("cancel")} variant="danger" loading={isShuttingDown} /> setShowRestartModal(false)} onConfirm={handleRestart} title={t("restart")} message={t("restartConfirm")} confirmText={t("restart")} cancelText={tc("cancel")} variant="warning" loading={isRestarting} /> {isDisconnected && (
power_off

{t("serverDisconnected")}

{t("serverDisconnectedMsg")}

)} ); }