Files
Psychotoxical-psysonic/src/components/Sidebar.tsx
T
Psychotoxical fcdb58e0d8 fix(sidebar): reorder sidebar nav items by id to stop drag drift (#1206)
* fix(sidebar): reorder nav items by id instead of positional index

The Settings sidebar customizer rendered library rows with one filter and
reordered them with another, so a hidden/gated item (luckyMix when AudioMuse
is unavailable) made the rendered list shorter than the reorder list and drag
indices landed one slot off — rows jumped back on drop. Replace the index-based
reorder with an id-based primitive shared by the customizer and the in-sidebar
long-press DnD, so a render filter can no longer desync the move. Unknown,
cross-section, conserved, or no-op drops are rejected.

* docs(changelog): note sidebar reorder fix (#1206)
2026-06-28 15:42:35 +02:00

309 lines
12 KiB
TypeScript

import { useState, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { clearOfflinePinTasks } from '../utils/offline/offlinePinQueue';
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
import { useAuthStore } from '../store/authStore';
import { useSidebarStore } from '../store/sidebarStore';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PanelLeft, PanelLeftClose, Trash2 } from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
import { usePlaylistStore } from '../store/playlistStore';
import OverlayScrollArea from './OverlayScrollArea';
import {
getLibraryItemsForReorder,
getSystemItemsForReorder,
} from '../utils/componentHelpers/sidebarNavReorder';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnread';
import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd';
import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown';
import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible';
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
import SidebarNavBody from './sidebar/SidebarNavBody';
export default function Sidebar({
isCollapsed = false,
toggleCollapse,
}: {
isCollapsed?: boolean;
toggleCollapse?: () => void;
}) {
const { t } = useTranslation();
const location = useLocation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const offlineJobs = useOfflineJobStore(s => s.jobs);
const pinQueue = useOfflineJobStore(s => s.pinQueue);
const cancelAllDownloadsStore = useOfflineJobStore(s => s.cancelAllDownloads);
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
const activePin = pinQueue.find(p => p.status === 'downloading')
?? pinQueue.find(p => p.status === 'queued');
const queuedPinCount = pinQueue.filter(p => p.status === 'queued').length;
const cancelAllDownloads = () => {
clearOfflinePinTasks();
cancelAllDownloadsStore();
};
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 offlineCtx = useOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
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 hotCacheEnabled = useAuthStore(s => s.hotCacheEnabled);
const setHotCacheEnabled = useAuthStore(s => s.setHotCacheEnabled);
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
const loggingMode = useAuthStore(s => s.loggingMode);
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
const hasOfflineContent = offlineCtx.capabilities.manualPins;
const isServerOffline = offlineCtx.active;
const sidebarItems = useSidebarStore(s => s.items);
const setSidebarItems = useSidebarStore(s => s.setItems);
const randomNavMode = useAuthStore(s => s.randomNavMode);
const nowPlayingAtTop = useAuthStore(s => s.nowPlayingAtTop);
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, dropdownRect, libraryTriggerRef } =
useSidebarLibraryDropdown();
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 [sidebarViewportEl, setSidebarViewportEl] = useState<HTMLDivElement | null>(null);
const isSidebarScrolling = useSidebarScrollVisible(sidebarViewportEl);
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1 && !isServerOffline;
const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all';
const selectedFolderName =
filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null;
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;
if (isServerOffline && !isOfflineSidebarNavAllowed(
c.id,
offlineNav.favoritesOfflineBrowse,
offlineNav.localLibraryBrowse,
offlineNav.playerStatsBrowse,
offlineNav.playlistsOfflineBrowse,
)) {
return false;
}
return true;
}),
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, offlineNav],
);
const visibleSystemConfigs = useMemo(
() => systemItemsForReorder.filter(c => {
if (!c.visible) return false;
if (isServerOffline && !isOfflineSidebarNavAllowed(
c.id,
offlineNav.favoritesOfflineBrowse,
offlineNav.localLibraryBrowse,
offlineNav.playerStatsBrowse,
offlineNav.playlistsOfflineBrowse,
)) {
return false;
}
return true;
}),
[systemItemsForReorder, isServerOffline, offlineNav],
);
const sidebarItemsRef = useRef(sidebarItems);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
sidebarItemsRef.current = sidebarItems;
const {
navDnd,
navDndTrashHint,
suppressNavClickRef,
handleNavRowPointerDown,
navDndRowClass,
} = useSidebarNavDnd({
isCollapsed,
sidebarItemsRef,
setSidebarItems,
});
const newReleasesUnreadCount = useSidebarNewReleasesUnread({
serverId,
filterId,
isLoggedIn,
pathname: location.pathname,
});
const { perfProbeOpen, setPerfProbeOpen } = useSidebarPerfProbe();
const perfFlags = usePerfProbeFlags();
const pickLibrary = (id: 'all' | string) => {
if (isServerOffline) return;
setMusicLibraryFilter(id);
setLibraryDropdownOpen(false);
};
useEffect(() => {
if (isServerOffline) setLibraryDropdownOpen(false);
}, [isServerOffline, setLibraryDropdownOpen]);
// Fetch playlists when expanded
useEffect(() => {
if (!playlistsExpanded || !isLoggedIn) return;
fetchPlaylists();
}, [playlistsExpanded, isLoggedIn, fetchPlaylists]);
return (
<>
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-brand" aria-hidden>
{isCollapsed
? <PSmallLogo style={{ height: '32px', width: 'auto' }} />
: <PsysonicLogo style={{ height: '28px', width: 'auto' }} />
}
</div>
<button
className="collapse-btn"
onClick={toggleCollapse}
style={{
opacity: isSidebarScrolling ? 0 : 1,
pointerEvents: isSidebarScrolling ? 'none' : 'auto',
}}
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"
viewportRef={setSidebarViewportEl}
railInset="panel"
measureDeps={[
isCollapsed,
playlistsExpanded,
playlists.length,
isLoggedIn,
randomNavMode,
filterId,
hasOfflineContent,
activeJobs.length,
isSyncing,
syncJobTotal,
sidebarItems.length,
]}
>
<SidebarNavBody
isCollapsed={isCollapsed}
showLibraryPicker={showLibraryPicker}
filterId={filterId}
selectedFolderName={selectedFolderName}
libraryDropdownOpen={libraryDropdownOpen}
setLibraryDropdownOpen={setLibraryDropdownOpen}
dropdownRect={dropdownRect}
libraryTriggerRef={libraryTriggerRef}
musicFolders={musicFolders}
pickLibrary={pickLibrary}
visibleLibraryConfigs={visibleLibraryConfigs}
visibleSystemConfigs={visibleSystemConfigs}
playlistsExpanded={playlistsExpanded}
setPlaylistsExpanded={setPlaylistsExpanded}
playlists={playlists}
playlistsLoading={playlistsLoading}
newReleasesUnreadCount={newReleasesUnreadCount}
navDnd={navDnd}
navDndRowClass={navDndRowClass}
handleNavRowPointerDown={handleNavRowPointerDown}
isPlaying={isPlaying}
hasNowPlayingTrack={!!currentTrack}
nowPlayingAtTop={nowPlayingAtTop}
hasOfflineContent={hasOfflineContent}
activeJobsCount={activeJobs.length}
activePinName={activePin?.albumName ?? null}
queuedPinCount={queuedPinCount}
cancelAllDownloads={cancelAllDownloads}
isSyncing={isSyncing}
syncJobDone={syncJobDone}
syncJobSkip={syncJobSkip}
syncJobFail={syncJobFail}
syncJobTotal={syncJobTotal}
/>
</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,
)}
<SidebarPerfProbeModal
open={perfProbeOpen}
onClose={() => setPerfProbeOpen(false)}
perfFlags={perfFlags}
hotCacheEnabled={hotCacheEnabled}
setHotCacheEnabled={setHotCacheEnabled}
normalizationEngine={normalizationEngine}
setNormalizationEngine={setNormalizationEngine}
loggingMode={loggingMode}
setLoggingMode={setLoggingMode}
/>
</>
);
}