From fcdb58e0d80a168af85dea64606c7a4211bf6ec1 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:42:35 +0200 Subject: [PATCH] fix(sidebar): reorder sidebar nav items by id to stop drag drift (#1206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- CHANGELOG.md | 6 ++ src/components/Sidebar.tsx | 8 -- src/components/settings/SidebarCustomizer.tsx | 41 ++++---- src/components/sidebar/SidebarNavBody.tsx | 30 +++--- src/hooks/useSidebarNavDnd.ts | 56 +++++------ .../sidebarNavReorder.test.ts | 98 ++++++++++++++++++- .../componentHelpers/sidebarNavReorder.ts | 60 +++++++----- 7 files changed, 196 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca54770f..214dda22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -384,6 +384,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Pressing Play, Shuffle or Add to queue on a playlist no longer reloads the whole page with a spinner — it just starts playback. Editing the playlist (adding or removing songs) still refreshes the list as before. +### Sidebar items jumped back when reordered + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1206](https://github.com/Psychotoxical/psysonic/pull/1206)**, reported by [@tummydummy](https://github.com/tummydummy) + +* In Settings → Personalization → Sidebar, dragging an item to a new position could snap it back or land it one place off, depending on which items were hidden. Reordering now tracks each item directly, so it stays exactly where you release it — both in the customizer and when long-pressing items in the sidebar itself. + ## [1.48.1] - 2026-06-15 ## Fixed diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 8dfa22bc..ed210dd4 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -147,11 +147,6 @@ export default function Sidebar({ // 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 randomNavModeRef = useRef(randomNavMode); - // 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 - randomNavModeRef.current = randomNavMode; - const { navDnd, navDndTrashHint, @@ -161,7 +156,6 @@ export default function Sidebar({ } = useSidebarNavDnd({ isCollapsed, sidebarItemsRef, - randomNavModeRef, setSidebarItems, }); const newReleasesUnreadCount = useSidebarNewReleasesUnread({ @@ -257,9 +251,7 @@ export default function Sidebar({ musicFolders={musicFolders} pickLibrary={pickLibrary} visibleLibraryConfigs={visibleLibraryConfigs} - libraryItemsForReorder={libraryItemsForReorder} visibleSystemConfigs={visibleSystemConfigs} - systemItemsForReorder={systemItemsForReorder} playlistsExpanded={playlistsExpanded} setPlaylistsExpanded={setPlaylistsExpanded} playlists={playlists} diff --git a/src/components/settings/SidebarCustomizer.tsx b/src/components/settings/SidebarCustomizer.tsx index 6f520a1c..ab0f0194 100644 --- a/src/components/settings/SidebarCustomizer.tsx +++ b/src/components/settings/SidebarCustomizer.tsx @@ -6,16 +6,16 @@ import { useAuthStore } from '../../store/authStore'; import { useSidebarStore, SidebarItemConfig, CONSERVED_SIDEBAR_NAV_IDS } from '../../store/sidebarStore'; import { useLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable'; import { ALL_NAV_ITEMS } from '../../config/navItems'; -import { applySidebarDropReorder } from '../../utils/componentHelpers/sidebarNavReorder'; +import { applySidebarReorderById } from '../../utils/componentHelpers/sidebarNavReorder'; import { SettingsGroup } from './SettingsGroup'; import { SettingsToggle } from './SettingsToggle'; -type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null; +type DropTarget = { id: string; before: boolean; section: 'library' | 'system' } | null; -function SidebarGripHandle({ idx, section, label }: { idx: number; section: 'library' | 'system'; label: string }) { +function SidebarGripHandle({ id, section, label }: { id: string; section: 'library' | 'system'; label: string }) { const { t } = useTranslation(); const { onMouseDown } = useDragSource(() => ({ - data: JSON.stringify({ type: 'sidebar_reorder', index: idx, section }), + data: JSON.stringify({ type: 'sidebar_reorder', id, section }), label, })); return ( @@ -72,47 +72,48 @@ export function SidebarCustomizer() { const onPsyDrop = (e: Event) => { const detail = (e as CustomEvent).detail; if (!detail?.data) return; - let parsed: { type?: string; index?: number; section?: string }; + let parsed: { type?: string; id?: string; section?: string }; try { parsed = JSON.parse(detail.data); } catch { return; } - if (parsed.type !== 'sidebar_reorder' || parsed.index == null || !parsed.section) return; + if (parsed.type !== 'sidebar_reorder' || !parsed.id || !parsed.section) return; - const fromIdx = parsed.index; + const draggedId = parsed.id; const fromSection = parsed.section as 'library' | 'system'; const target = dropTargetRef.current; dropTargetRef.current = null; setDropTarget(null); - const next = applySidebarDropReorder(itemsRef.current, fromSection, fromIdx, target, randomNavMode); + const next = applySidebarReorderById(itemsRef.current, fromSection, draggedId, target); if (next) setItems(next); }; el.addEventListener('psy-drop', onPsyDrop); return () => el.removeEventListener('psy-drop', onPsyDrop); - }, [libraryItems, systemItems, setItems, randomNavMode]); + }, [setItems]); const handleMouseMove = (e: React.MouseEvent) => { if (!isPsyDragging || !containerRef.current) return; - const rows = containerRef.current.querySelectorAll('[data-sidebar-idx]'); + const rows = containerRef.current.querySelectorAll('[data-sidebar-id]'); let target: DropTarget = null; for (const row of rows) { const rect = row.getBoundingClientRect(); - const idx = Number(row.dataset.sidebarIdx); + const id = row.dataset.sidebarId; const section = row.dataset.sidebarSection as 'library' | 'system'; - if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true, section }; break; } - target = { idx, before: false, section }; + if (!id) continue; + if (e.clientY < rect.top + rect.height / 2) { target = { id, before: true, section }; break; } + target = { id, before: false, section }; } dropTargetRef.current = target; setDropTarget(target); }; - const renderRow = (cfg: SidebarItemConfig, localIdx: number, section: 'library' | 'system') => { + const renderRow = (cfg: SidebarItemConfig, section: 'library' | 'system') => { const meta = ALL_NAV_ITEMS[cfg.id]; if (!meta) return null; const Icon = meta.icon; - const isBefore = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && dropTarget.before; - const isAfter = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && !dropTarget.before; + const isBefore = isPsyDragging && dropTarget?.section === section && dropTarget.id === cfg.id && dropTarget.before; + const isAfter = isPsyDragging && dropTarget?.section === section && dropTarget.id === cfg.id && !dropTarget.before; return (
- + {t(meta.labelKey)}