import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { usePlayerStore } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; import { useOfflineJobStore } from '../store/offlineJobStore'; import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore'; import { useAuthStore } from '../store/authStore'; import { useSidebarStore } from '../store/sidebarStore'; import { NavLink } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Settings, PanelLeftClose, PanelLeft, AudioLines, HardDriveDownload, HardDriveUpload, ChevronDown, Check, Music2, X, ChevronRight, PlayCircle, } from 'lucide-react'; import PsysonicLogo from './PsysonicLogo'; import PSmallLogo from './PSmallLogo'; import WhatsNewBanner from './WhatsNewBanner'; import { getPlaylists } from '../api/subsonic'; import { usePlaylistStore } from '../store/playlistStore'; import { ALL_NAV_ITEMS } from '../config/navItems'; import OverlayScrollArea from './OverlayScrollArea'; export default function Sidebar({ isCollapsed = false, toggleCollapse, }: { isCollapsed?: boolean; toggleCollapse?: () => void; }) { const { t } = useTranslation(); const isPlaying = usePlayerStore(s => s.isPlaying); const currentTrack = usePlayerStore(s => s.currentTrack); const offlineJobs = useOfflineJobStore(s => s.jobs); const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads); const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading'); const syncJobStatus = useDeviceSyncJobStore(s => s.status); const syncJobDone = useDeviceSyncJobStore(s => s.done); const syncJobSkip = useDeviceSyncJobStore(s => s.skipped); const syncJobFail = useDeviceSyncJobStore(s => s.failed); const syncJobTotal = useDeviceSyncJobStore(s => s.total); const isSyncing = syncJobStatus === 'running'; const offlineAlbums = useOfflineStore(s => s.albums); const serverId = useAuthStore(s => s.activeServerId ?? ''); const isLoggedIn = useAuthStore(s => s.isLoggedIn); const musicFolders = useAuthStore(s => s.musicFolders); const musicLibraryFilterByServer = useAuthStore(s => s.musicLibraryFilterByServer); const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const sidebarItems = useSidebarStore(s => s.items); const randomNavMode = useAuthStore(s => s.randomNavMode); const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false); const [playlistsExpanded, setPlaylistsExpanded] = useState(false); const playlistsRaw = usePlaylistStore(s => s.playlists); const playlistsLoading = usePlaylistStore(s => s.playlistsLoading); const fetchPlaylists = usePlaylistStore(s => s.fetchPlaylists); // Sort playlists alphabetically by name const playlists = useMemo(() => { return [...playlistsRaw].sort((a, b) => a.name.localeCompare(b.name)); }, [playlistsRaw]); const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 }); const libraryTriggerRef = useRef(null); const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1; const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all'; const selectedFolderName = filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null; const libraryTriggerPlain = filterId === 'all'; const updateDropdownPosition = useCallback(() => { const el = libraryTriggerRef.current; if (!el) return; const r = el.getBoundingClientRect(); setDropdownRect({ top: r.bottom + 4, left: r.left, width: r.width, }); }, []); useLayoutEffect(() => { if (!libraryDropdownOpen) return; updateDropdownPosition(); const onWin = () => updateDropdownPosition(); window.addEventListener('resize', onWin); window.addEventListener('scroll', onWin, true); return () => { window.removeEventListener('resize', onWin); window.removeEventListener('scroll', onWin, true); }; }, [libraryDropdownOpen, updateDropdownPosition]); useEffect(() => { if (!libraryDropdownOpen) return; const onDown = (e: MouseEvent) => { const t = e.target as Node; if (libraryTriggerRef.current?.contains(t)) return; const panel = document.querySelector('.nav-library-dropdown-panel'); if (panel?.contains(t)) return; setLibraryDropdownOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setLibraryDropdownOpen(false); }; document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; }, [libraryDropdownOpen]); const pickLibrary = (id: 'all' | string) => { setMusicLibraryFilter(id); setLibraryDropdownOpen(false); }; // Fetch playlists when expanded useEffect(() => { if (!playlistsExpanded || !isLoggedIn) return; fetchPlaylists(); }, [playlistsExpanded, isLoggedIn, fetchPlaylists]); // Resolve ordered, visible items per section from store config const visibleLibrary = sidebarItems .filter(cfg => { if (cfg == null || !cfg.visible || ALL_NAV_ITEMS[cfg.id]?.section !== 'library') return false; // Hide mode-inactive mix entries so the active mode controls what's shown if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false; if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false; return true; }) .map(cfg => ALL_NAV_ITEMS[cfg.id]); const visibleSystem = sidebarItems .filter(cfg => cfg != null && cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system') .map(cfg => ALL_NAV_ITEMS[cfg.id]); return ( ); }