mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
27a59f6b23
Move smart playlist creation and management into Playlists, including Navidrome-only gating, smart-name/icon presentation, and smarter refresh handling while server-side smart rules are being applied.
771 lines
30 KiB
TypeScript
771 lines
30 KiB
TypeScript
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, type SidebarItemConfig } 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, Sparkles, Trash2,
|
|
} 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';
|
|
import {
|
|
applySidebarDropReorder,
|
|
getLibraryItemsForReorder,
|
|
getSystemItemsForReorder,
|
|
isSidebarNavItemUserHideable,
|
|
type SidebarNavDropTarget,
|
|
} from '../utils/sidebarNavReorder';
|
|
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
|
|
|
const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
|
|
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
|
|
const SMART_PREFIX = 'psy-smart-';
|
|
|
|
function isSmartPlaylistName(name: string): boolean {
|
|
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
|
|
}
|
|
|
|
function displayPlaylistName(name: string): string {
|
|
const n = name ?? '';
|
|
if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
|
|
return n;
|
|
}
|
|
|
|
function isPointerOutsideAsideSidebar(clientX: number, clientY: number): boolean {
|
|
const aside = document.querySelector('aside.sidebar');
|
|
if (!aside) return false;
|
|
const r = aside.getBoundingClientRect();
|
|
return clientX < r.left || clientX > r.right || clientY < r.top || clientY > r.bottom;
|
|
}
|
|
|
|
|
|
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 setSidebarItems = useSidebarStore(s => s.setItems);
|
|
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
|
const luckyMixBase = useLuckyMixAvailable();
|
|
// Sidebar surfaces Lucky Mix as its own entry only in "separate" nav mode —
|
|
// in hub mode it lives inside the Build-a-Mix landing page instead.
|
|
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
|
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<HTMLButtonElement>(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 libraryItemsForReorder = useMemo(
|
|
() => getLibraryItemsForReorder(sidebarItems, randomNavMode),
|
|
[sidebarItems, randomNavMode],
|
|
);
|
|
const systemItemsForReorder = useMemo(
|
|
() => getSystemItemsForReorder(sidebarItems),
|
|
[sidebarItems],
|
|
);
|
|
const visibleLibraryConfigs = useMemo(
|
|
() =>
|
|
libraryItemsForReorder.filter(c => {
|
|
if (!c.visible) return false;
|
|
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
|
|
return true;
|
|
}),
|
|
[libraryItemsForReorder, luckyMixAvailable],
|
|
);
|
|
const visibleSystemConfigs = useMemo(
|
|
() => systemItemsForReorder.filter(c => c.visible),
|
|
[systemItemsForReorder],
|
|
);
|
|
|
|
const [navDnd, setNavDnd] = useState<{
|
|
section: 'library' | 'system';
|
|
fromIdx: number;
|
|
} | null>(null);
|
|
const [navDropTarget, setNavDropTarget] = useState<SidebarNavDropTarget | null>(null);
|
|
const navDropTargetRef = useRef<SidebarNavDropTarget | null>(null);
|
|
navDropTargetRef.current = navDropTarget;
|
|
const sidebarItemsRef = useRef(sidebarItems);
|
|
sidebarItemsRef.current = sidebarItems;
|
|
const randomNavModeRef = useRef(randomNavMode);
|
|
randomNavModeRef.current = randomNavMode;
|
|
/** DOM timers are numeric; avoid NodeJS `Timeout` typing from `setTimeout`. */
|
|
const longPressTimersRef = useRef<Map<number, number>>(new Map());
|
|
const suppressNavClickRef = useRef(false);
|
|
const lastPointerDuringNavDndRef = useRef({ x: 0, y: 0 });
|
|
const [navDndTrashHint, setNavDndTrashHint] = useState<{ x: number; y: number } | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!navDnd) return;
|
|
|
|
const updateDropFromPoint = (clientX: number, clientY: number) => {
|
|
if (isPointerOutsideAsideSidebar(clientX, clientY)) {
|
|
navDropTargetRef.current = null;
|
|
setNavDropTarget(null);
|
|
return;
|
|
}
|
|
const rows = document.querySelectorAll<HTMLElement>('.sidebar [data-sidebar-nav-dnd-row]');
|
|
let target: SidebarNavDropTarget | null = null;
|
|
for (const row of rows) {
|
|
const section = row.dataset.sidebarSection as 'library' | 'system' | undefined;
|
|
if (section !== navDnd.section) continue;
|
|
const rect = row.getBoundingClientRect();
|
|
const idx = Number(row.dataset.sidebarIdx);
|
|
if (Number.isNaN(idx)) continue;
|
|
if (clientY < rect.top + rect.height / 2) {
|
|
target = { idx, before: true, section };
|
|
break;
|
|
}
|
|
target = { idx, before: false, section };
|
|
}
|
|
navDropTargetRef.current = target;
|
|
setNavDropTarget(target);
|
|
};
|
|
|
|
const endDrag = (apply: boolean) => {
|
|
window.removeEventListener('pointermove', onMove, { capture: true });
|
|
window.removeEventListener('pointerup', onUp, true);
|
|
window.removeEventListener('pointercancel', onUp, true);
|
|
window.removeEventListener('keydown', onKey, true);
|
|
document.body.style.userSelect = '';
|
|
setNavDndTrashHint(null);
|
|
|
|
const currentDnd = navDnd;
|
|
const drop = navDropTargetRef.current;
|
|
setNavDnd(null);
|
|
setNavDropTarget(null);
|
|
navDropTargetRef.current = null;
|
|
|
|
if (!apply || !currentDnd) return;
|
|
|
|
const { x, y } = lastPointerDuringNavDndRef.current;
|
|
if (isPointerOutsideAsideSidebar(x, y)) {
|
|
const sectionItems =
|
|
currentDnd.section === 'library'
|
|
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
|
|
: getSystemItemsForReorder(sidebarItemsRef.current);
|
|
const id = sectionItems[currentDnd.fromIdx]?.id;
|
|
if (id && isSidebarNavItemUserHideable(id)) {
|
|
const nextItems: SidebarItemConfig[] = sidebarItemsRef.current.map(i =>
|
|
i.id === id ? { ...i, visible: false } : i,
|
|
);
|
|
setSidebarItems(nextItems);
|
|
suppressNavClickRef.current = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const next = applySidebarDropReorder(
|
|
sidebarItemsRef.current,
|
|
currentDnd.section,
|
|
currentDnd.fromIdx,
|
|
drop,
|
|
randomNavModeRef.current,
|
|
);
|
|
if (next) {
|
|
setSidebarItems(next);
|
|
suppressNavClickRef.current = true;
|
|
}
|
|
};
|
|
|
|
const onMove = (e: PointerEvent) => {
|
|
e.preventDefault();
|
|
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
|
|
|
|
const outside = isPointerOutsideAsideSidebar(e.clientX, e.clientY);
|
|
const sectionItems =
|
|
navDnd.section === 'library'
|
|
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
|
|
: getSystemItemsForReorder(sidebarItemsRef.current);
|
|
const draggedId = sectionItems[navDnd.fromIdx]?.id;
|
|
const canTrash = Boolean(draggedId && isSidebarNavItemUserHideable(draggedId));
|
|
if (outside && canTrash) {
|
|
setNavDndTrashHint({ x: e.clientX, y: e.clientY });
|
|
} else {
|
|
setNavDndTrashHint(null);
|
|
}
|
|
|
|
updateDropFromPoint(e.clientX, e.clientY);
|
|
};
|
|
|
|
const onUp = (e: PointerEvent) => {
|
|
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
|
|
endDrag(true);
|
|
};
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
endDrag(false);
|
|
}
|
|
};
|
|
|
|
document.body.style.userSelect = 'none';
|
|
window.addEventListener('pointermove', onMove, { capture: true, passive: false });
|
|
window.addEventListener('pointerup', onUp, true);
|
|
window.addEventListener('pointercancel', onUp, true);
|
|
window.addEventListener('keydown', onKey, true);
|
|
|
|
return () => {
|
|
window.removeEventListener('pointermove', onMove, { capture: true });
|
|
window.removeEventListener('pointerup', onUp, true);
|
|
window.removeEventListener('pointercancel', onUp, true);
|
|
window.removeEventListener('keydown', onKey, true);
|
|
document.body.style.userSelect = '';
|
|
setNavDndTrashHint(null);
|
|
};
|
|
}, [navDnd, setSidebarItems]);
|
|
|
|
const handleNavRowPointerDown = useCallback(
|
|
(e: React.PointerEvent, section: 'library' | 'system', sectionIdx: number) => {
|
|
if (isCollapsed || navDnd) return;
|
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
|
|
const pid = e.pointerId;
|
|
const sx = e.clientX;
|
|
const sy = e.clientY;
|
|
|
|
let cleaned = false;
|
|
const cleanupEarly = () => {
|
|
if (cleaned) return;
|
|
cleaned = true;
|
|
document.removeEventListener('pointermove', onEarlyMove);
|
|
document.removeEventListener('pointerup', onEarlyUp, true);
|
|
document.removeEventListener('pointercancel', onEarlyUp, true);
|
|
};
|
|
|
|
const onEarlyMove = (ev: PointerEvent) => {
|
|
if (ev.pointerId !== pid) return;
|
|
if (Math.hypot(ev.clientX - sx, ev.clientY - sy) > SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX) {
|
|
const t = longPressTimersRef.current.get(pid);
|
|
if (t != null) window.clearTimeout(t);
|
|
longPressTimersRef.current.delete(pid);
|
|
cleanupEarly();
|
|
}
|
|
};
|
|
|
|
const onEarlyUp = (ev: PointerEvent) => {
|
|
if (ev.pointerId !== pid) return;
|
|
const t = longPressTimersRef.current.get(pid);
|
|
if (t != null) window.clearTimeout(t);
|
|
longPressTimersRef.current.delete(pid);
|
|
cleanupEarly();
|
|
};
|
|
|
|
const timer = window.setTimeout(() => {
|
|
longPressTimersRef.current.delete(pid);
|
|
cleanupEarly();
|
|
window.getSelection()?.removeAllRanges();
|
|
lastPointerDuringNavDndRef.current = { x: sx, y: sy };
|
|
setNavDnd({ section, fromIdx: sectionIdx });
|
|
navDropTargetRef.current = { idx: sectionIdx, before: true, section };
|
|
setNavDropTarget({ idx: sectionIdx, before: true, section });
|
|
try {
|
|
(e.currentTarget as HTMLElement).setPointerCapture(pid);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, SIDEBAR_NAV_LONG_PRESS_MS) as unknown as number;
|
|
|
|
longPressTimersRef.current.set(pid, timer);
|
|
document.addEventListener('pointermove', onEarlyMove);
|
|
document.addEventListener('pointerup', onEarlyUp, true);
|
|
document.addEventListener('pointercancel', onEarlyUp, true);
|
|
},
|
|
[isCollapsed, navDnd],
|
|
);
|
|
|
|
const navDndRowClass = useCallback(
|
|
(section: 'library' | 'system', sectionIdx: number) => {
|
|
const dragging = navDnd?.section === section && navDnd.fromIdx === sectionIdx;
|
|
let drop = '';
|
|
if (
|
|
navDnd &&
|
|
navDropTarget?.section === section &&
|
|
navDropTarget.idx === sectionIdx &&
|
|
!(navDnd.section === section && navDnd.fromIdx === sectionIdx)
|
|
) {
|
|
drop = navDropTarget.before
|
|
? 'sidebar-nav-dnd-row--drop-before'
|
|
: 'sidebar-nav-dnd-row--drop-after';
|
|
}
|
|
return `sidebar-nav-dnd-row${dragging ? ' sidebar-nav-dnd-row--dragging' : ''}${drop ? ` ${drop}` : ''}`.trim();
|
|
},
|
|
[navDnd, navDropTarget],
|
|
);
|
|
|
|
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]);
|
|
|
|
useEffect(() => () => {
|
|
longPressTimersRef.current.forEach(t => window.clearTimeout(t));
|
|
longPressTimersRef.current.clear();
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
|
|
<div className="sidebar-brand">
|
|
{isCollapsed
|
|
? <PSmallLogo style={{ height: '32px', width: 'auto' }} />
|
|
: <PsysonicLogo style={{ height: '28px', width: 'auto' }} />
|
|
}
|
|
</div>
|
|
|
|
<button
|
|
className="collapse-btn"
|
|
onClick={toggleCollapse}
|
|
data-tooltip={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
|
|
data-tooltip-pos="right"
|
|
>
|
|
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
|
|
</button>
|
|
|
|
<nav
|
|
className="sidebar-nav"
|
|
aria-label="Main navigation"
|
|
onClickCapture={e => {
|
|
if (suppressNavClickRef.current) {
|
|
suppressNavClickRef.current = false;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}}
|
|
>
|
|
<OverlayScrollArea
|
|
className="sidebar-nav-scroll"
|
|
viewportClassName="sidebar-nav-viewport"
|
|
railInset="panel"
|
|
measureDeps={[
|
|
isCollapsed,
|
|
playlistsExpanded,
|
|
playlists.length,
|
|
isLoggedIn,
|
|
randomNavMode,
|
|
filterId,
|
|
hasOfflineContent,
|
|
activeJobs.length,
|
|
isSyncing,
|
|
syncJobTotal,
|
|
sidebarItems.length,
|
|
]}
|
|
>
|
|
{!isCollapsed && (showLibraryPicker ? (
|
|
<>
|
|
<button
|
|
ref={libraryTriggerRef}
|
|
type="button"
|
|
className={`nav-library-scope-trigger ${libraryTriggerPlain ? 'nav-library-scope-trigger--plain' : ''} ${libraryDropdownOpen ? 'nav-library-scope-trigger--open' : ''}`}
|
|
onClick={() => {
|
|
setLibraryDropdownOpen(o => !o);
|
|
}}
|
|
aria-label={t('sidebar.libraryScope')}
|
|
aria-expanded={libraryDropdownOpen}
|
|
aria-haspopup="listbox"
|
|
data-tooltip={libraryDropdownOpen ? undefined : t('sidebar.libraryScope')}
|
|
data-tooltip-pos="bottom"
|
|
>
|
|
{!libraryTriggerPlain ? (
|
|
<Music2 size={16} className="nav-library-scope-icon" strokeWidth={2} aria-hidden />
|
|
) : null}
|
|
<div className="nav-library-scope-text">
|
|
<span className="nav-library-scope-title">{t('sidebar.library')}</span>
|
|
{selectedFolderName ? (
|
|
<span className="nav-library-scope-subtitle" data-tooltip={selectedFolderName} data-tooltip-pos="right">
|
|
{selectedFolderName}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<ChevronDown size={16} strokeWidth={2.25} className="nav-library-scope-chevron" aria-hidden />
|
|
</button>
|
|
{libraryDropdownOpen &&
|
|
createPortal(
|
|
<div
|
|
className={`nav-library-dropdown-panel${musicFolders.length > 10 ? ' nav-library-dropdown-panel--many-libraries' : ''}`}
|
|
role="listbox"
|
|
aria-label={t('sidebar.libraryScope')}
|
|
style={{
|
|
position: 'fixed',
|
|
top: dropdownRect.top,
|
|
left: dropdownRect.left,
|
|
width: dropdownRect.width,
|
|
minWidth: dropdownRect.width,
|
|
maxWidth: dropdownRect.width,
|
|
boxSizing: 'border-box',
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={filterId === 'all'}
|
|
className={`nav-library-dropdown-item ${filterId === 'all' ? 'nav-library-dropdown-item--selected' : ''}`}
|
|
onClick={() => pickLibrary('all')}
|
|
>
|
|
<span className="nav-library-dropdown-item-label">{t('sidebar.allLibraries')}</span>
|
|
{filterId === 'all' ? <Check size={16} className="nav-library-dropdown-check" strokeWidth={2.5} /> : <span className="nav-library-dropdown-check-spacer" />}
|
|
</button>
|
|
{musicFolders.map(f => (
|
|
<button
|
|
key={f.id}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={filterId === f.id}
|
|
className={`nav-library-dropdown-item ${filterId === f.id ? 'nav-library-dropdown-item--selected' : ''}`}
|
|
onClick={() => pickLibrary(f.id)}
|
|
>
|
|
<span className="nav-library-dropdown-item-label">{f.name}</span>
|
|
{filterId === f.id ? <Check size={16} className="nav-library-dropdown-check" strokeWidth={2.5} /> : <span className="nav-library-dropdown-check-spacer" />}
|
|
</button>
|
|
))}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</>
|
|
) : (
|
|
<span className="nav-section-label">{t('sidebar.library')}</span>
|
|
))}
|
|
{visibleLibraryConfigs.map(cfg => {
|
|
const item = ALL_NAV_ITEMS[cfg.id];
|
|
if (!item) return null;
|
|
const sectionIdx = libraryItemsForReorder.findIndex(x => x.id === cfg.id);
|
|
const dndRow = !isCollapsed && sectionIdx >= 0;
|
|
const rowClass = dndRow ? navDndRowClass('library', sectionIdx) : undefined;
|
|
const dndProps = dndRow
|
|
? {
|
|
'data-sidebar-nav-dnd-row': '',
|
|
'data-sidebar-section': 'library' as const,
|
|
'data-sidebar-idx': String(sectionIdx),
|
|
onPointerDown: (e: React.PointerEvent) =>
|
|
handleNavRowPointerDown(e, 'library', sectionIdx),
|
|
}
|
|
: {};
|
|
|
|
return item.to === '/playlists' ? (
|
|
<div
|
|
key={item.to}
|
|
className={`sidebar-playlists-wrapper${rowClass ? ` ${rowClass}` : ''}`}
|
|
{...dndProps}
|
|
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
|
|
>
|
|
<div className="sidebar-playlists-header-row">
|
|
<NavLink
|
|
to={item.to}
|
|
className={({ isActive }) => `nav-link sidebar-playlists-main-link ${isActive ? 'active' : ''}`}
|
|
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
|
data-tooltip-pos="bottom"
|
|
>
|
|
<item.icon size={isCollapsed ? 22 : 18} />
|
|
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
|
</NavLink>
|
|
{!isCollapsed && (
|
|
<button
|
|
className={`sidebar-playlists-toggle ${playlistsExpanded ? 'expanded' : ''}`}
|
|
onClick={() => setPlaylistsExpanded(!playlistsExpanded)}
|
|
aria-expanded={playlistsExpanded}
|
|
aria-label={playlistsExpanded ? t('sidebar.collapsePlaylists') : t('sidebar.expandPlaylists')}
|
|
data-tooltip={playlistsExpanded ? t('sidebar.collapsePlaylists') : t('sidebar.expandPlaylists')}
|
|
>
|
|
<ChevronRight size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{!isCollapsed && playlistsExpanded && (
|
|
<div className="sidebar-playlists-list">
|
|
{playlistsLoading ? (
|
|
<div className="sidebar-playlists-loading">
|
|
<div className="spinner" style={{ width: 14, height: 14 }} />
|
|
</div>
|
|
) : playlists.length === 0 ? (
|
|
<div className="sidebar-playlists-empty">{t('playlists.empty')}</div>
|
|
) : (
|
|
playlists.map((pl: { id: string; name: string }) => (
|
|
<NavLink
|
|
key={pl.id}
|
|
to={`/playlists/${pl.id}`}
|
|
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
|
|
data-tooltip={isCollapsed ? displayPlaylistName(pl.name) : undefined}
|
|
data-tooltip-pos="bottom"
|
|
>
|
|
{isSmartPlaylistName(pl.name) ? <Sparkles size={12} /> : <PlayCircle size={12} />}
|
|
<span>{displayPlaylistName(pl.name)}</span>
|
|
</NavLink>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : isCollapsed ? (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
end={item.to === '/'}
|
|
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
|
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
|
data-tooltip-pos="bottom"
|
|
>
|
|
<item.icon size={isCollapsed ? 22 : 18} />
|
|
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
|
</NavLink>
|
|
) : (
|
|
<div
|
|
key={item.to}
|
|
className={rowClass}
|
|
{...dndProps}
|
|
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
|
|
>
|
|
<NavLink
|
|
to={item.to}
|
|
end={item.to === '/'}
|
|
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
|
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
|
data-tooltip-pos="bottom"
|
|
>
|
|
<item.icon size={isCollapsed ? 22 : 18} />
|
|
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
|
</NavLink>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Spacer: everything from here onward sticks to the bottom of the sidebar. */}
|
|
<div className="sidebar-bottom-spacer" />
|
|
|
|
{/* What's New banner — only visible while the current release hasn't been seen. */}
|
|
<WhatsNewBanner collapsed={isCollapsed} />
|
|
|
|
{/* Now Playing — fixed, always visible */}
|
|
<NavLink
|
|
to="/now-playing"
|
|
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
|
|
data-tooltip={isCollapsed ? t('sidebar.nowPlaying') : undefined}
|
|
data-tooltip-pos="bottom"
|
|
>
|
|
<span className="nav-np-icon-wrap">
|
|
<AudioLines size={isCollapsed ? 22 : 18} />
|
|
{isPlaying && currentTrack && <span className="nav-np-dot" />}
|
|
</span>
|
|
{!isCollapsed && <span>{t('sidebar.nowPlaying')}</span>}
|
|
</NavLink>
|
|
|
|
{hasOfflineContent && (
|
|
<NavLink
|
|
to="/offline"
|
|
className={({ isActive }) => `nav-link nav-link-offline ${isActive ? 'active' : ''}`}
|
|
data-tooltip={isCollapsed ? t('sidebar.offlineLibrary') : undefined}
|
|
data-tooltip-pos="bottom"
|
|
>
|
|
<HardDriveDownload size={isCollapsed ? 22 : 18} />
|
|
{!isCollapsed && <span>{t('sidebar.offlineLibrary')}</span>}
|
|
</NavLink>
|
|
)}
|
|
|
|
{visibleSystemConfigs.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
|
{visibleSystemConfigs.map(cfg => {
|
|
const item = ALL_NAV_ITEMS[cfg.id];
|
|
if (!item) return null;
|
|
const sectionIdx = systemItemsForReorder.findIndex(x => x.id === cfg.id);
|
|
const dndRow = !isCollapsed && sectionIdx >= 0;
|
|
const rowClass = dndRow ? navDndRowClass('system', sectionIdx) : undefined;
|
|
const dndProps = dndRow
|
|
? {
|
|
'data-sidebar-nav-dnd-row': '',
|
|
'data-sidebar-section': 'system' as const,
|
|
'data-sidebar-idx': String(sectionIdx),
|
|
onPointerDown: (e: React.PointerEvent) =>
|
|
handleNavRowPointerDown(e, 'system', sectionIdx),
|
|
}
|
|
: {};
|
|
|
|
return isCollapsed ? (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
|
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
|
data-tooltip-pos="bottom"
|
|
>
|
|
<item.icon size={isCollapsed ? 22 : 18} />
|
|
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
|
</NavLink>
|
|
) : (
|
|
<div
|
|
key={item.to}
|
|
className={rowClass}
|
|
{...dndProps}
|
|
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
|
|
>
|
|
<NavLink
|
|
to={item.to}
|
|
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
|
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
|
data-tooltip-pos="bottom"
|
|
>
|
|
<item.icon size={isCollapsed ? 22 : 18} />
|
|
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
|
</NavLink>
|
|
</div>
|
|
);
|
|
})}
|
|
<NavLink
|
|
to="/settings"
|
|
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
|
data-tooltip={isCollapsed ? t('sidebar.settings') : undefined}
|
|
data-tooltip-pos="bottom"
|
|
>
|
|
<Settings size={isCollapsed ? 22 : 18} />
|
|
{!isCollapsed && <span>{t('sidebar.settings')}</span>}
|
|
</NavLink>
|
|
|
|
{activeJobs.length > 0 && (
|
|
<div
|
|
className={`sidebar-offline-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
|
|
data-tooltip={isCollapsed ? t('sidebar.downloadingTracks', { n: activeJobs.length }) : undefined}
|
|
data-tooltip-pos="right"
|
|
>
|
|
<HardDriveDownload size={isCollapsed ? 18 : 14} className="spin-slow" />
|
|
{!isCollapsed && (
|
|
<span>{t('sidebar.downloadingTracks', { n: activeJobs.length })}</span>
|
|
)}
|
|
<button
|
|
className="sidebar-offline-cancel"
|
|
onClick={cancelAllDownloads}
|
|
data-tooltip={t('sidebar.cancelDownload')}
|
|
data-tooltip-pos="right"
|
|
aria-label={t('sidebar.cancelDownload')}
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{isSyncing && (
|
|
<div
|
|
className={`sidebar-offline-queue sidebar-sync-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
|
|
data-tooltip={isCollapsed ? t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal }) : undefined}
|
|
data-tooltip-pos="right"
|
|
>
|
|
<HardDriveUpload size={isCollapsed ? 18 : 14} className="spin-slow" />
|
|
{!isCollapsed && (
|
|
<span>{t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal })}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</OverlayScrollArea>
|
|
</nav>
|
|
</aside>
|
|
{navDndTrashHint != null &&
|
|
createPortal(
|
|
<div
|
|
className="sidebar-nav-dnd-trash-hint"
|
|
style={{
|
|
position: 'fixed',
|
|
left: navDndTrashHint.x + 14,
|
|
top: navDndTrashHint.y + 14,
|
|
}}
|
|
aria-hidden
|
|
>
|
|
<Trash2 size={22} strokeWidth={2.25} />
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
);
|
|
}
|