refactor(settings): shared id-based reorder for the customizer panels (#1207)

Extract the drag-to-reorder boilerplate the sidebar, artist-layout, lyrics,
queue-toolbar and servers customizers each hand-rolled into a single
`useListReorderDnd` hook plus an id-based `applyListReorderById`, and migrate
all five onto it. Reorders resolve by stable item id, never positional index,
so a render filter can never share an index space with the reorder and desync
it (the class behind #1164). No user-facing change.

- New: useListReorderDnd, applyListReorderById (+ tests), ReorderGripHandle.
- applySidebarReorderById now builds on the shared core; section/conserved
  guards stay sidebar-specific.
- Queue/mini-queue/playlist reorder stay backend-index based (out of scope).
This commit is contained in:
Psychotoxical
2026-06-28 16:17:31 +02:00
committed by GitHub
parent fcdb58e0d8
commit 2b80a87bfc
10 changed files with 300 additions and 406 deletions
@@ -1,8 +1,9 @@
import React, { useEffect, useRef, useState } from 'react';
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical } from 'lucide-react';
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
import { useArtistLayoutStore, type ArtistSectionConfig, type ArtistSectionId } from '../../store/artistLayoutStore';
import { useListReorderDnd } from '../../hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '../../utils/componentHelpers/listReorder';
import { ReorderGripHandle } from './ReorderGripHandle';
const ARTIST_SECTION_LABEL_KEYS: Record<ArtistSectionId, string> = {
bio: 'settings.artistLayoutBio',
@@ -12,111 +13,45 @@ const ARTIST_SECTION_LABEL_KEYS: Record<ArtistSectionId, string> = {
featured: 'settings.artistLayoutFeatured',
};
type ArtistDropTarget = { idx: number; before: boolean } | null;
function ArtistSectionGripHandle({ idx, label }: { idx: number; label: string }) {
const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({
data: JSON.stringify({ type: 'artist_section_reorder', index: idx }),
label,
}));
return (
<span
className="sidebar-customizer-grip"
data-tooltip={t('settings.sidebarDrag')}
data-tooltip-pos="right"
onMouseDown={onMouseDown}
>
<GripVertical size={16} />
</span>
);
}
const REORDER_TYPE = 'artist_section_reorder';
export function ArtistLayoutCustomizer() {
const { t } = useTranslation();
const sections = useArtistLayoutStore(s => s.sections);
const setSections = useArtistLayoutStore(s => s.setSections);
const toggleSection = useArtistLayoutStore(s => s.toggleSection);
const { isDragging: isPsyDragging } = useDragDrop();
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
const [dropTarget, setDropTarget] = useState<ArtistDropTarget>(null);
const dropTargetRef = useRef<ArtistDropTarget>(null);
const sectionsRef = useRef(sections);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// React Compiler refs rule: ref kept in sync with the latest value for use in handlers; not render data.
// eslint-disable-next-line react-hooks/refs
sectionsRef.current = sections;
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
}, [isPsyDragging]);
const apply = useCallback((draggedId: string, target: ListReorderDropTarget) => {
const next = applyListReorderById(sectionsRef.current, draggedId, target);
if (next) setSections(next);
}, [setSections]);
useEffect(() => {
if (!containerEl) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: { type?: string; index?: number };
try { parsed = JSON.parse(detail.data as string); } catch { return; }
if (parsed.type !== 'artist_section_reorder' || parsed.index == null) return;
const fromIdx = parsed.index;
const target = dropTargetRef.current;
dropTargetRef.current = null; setDropTarget(null);
if (!target) return;
const insertBefore = target.before ? target.idx : target.idx + 1;
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
const next = [...sectionsRef.current];
const [moved] = next.splice(fromIdx, 1);
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
setSections(next);
};
containerEl.addEventListener('psy-drop', onPsyDrop);
return () => containerEl.removeEventListener('psy-drop', onPsyDrop);
}, [containerEl, setSections]);
const handleMouseMove = (e: React.MouseEvent) => {
if (!isPsyDragging || !containerEl) return;
const rows = containerEl.querySelectorAll<HTMLElement>('[data-artist-idx]');
let target: ArtistDropTarget = null;
for (const row of rows) {
const rect = row.getBoundingClientRect();
const idx = Number(row.dataset.artistIdx);
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
target = { idx, before: false };
}
dropTargetRef.current = target;
setDropTarget(target);
};
const { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({ type: REORDER_TYPE, apply });
return (
<>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
{t('settings.artistLayoutDesc')}
</p>
<div
style={{ padding: '4px 0' }}
ref={setContainerEl}
onMouseMove={handleMouseMove}
>
{sections.map((section: ArtistSectionConfig, i) => {
<div style={{ padding: '4px 0' }} ref={setContainer} onMouseMove={onMouseMove}>
{sections.map((section: ArtistSectionConfig) => {
const label = t(ARTIST_SECTION_LABEL_KEYS[section.id]);
const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before;
const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before;
const edge = isDragging ? dropEdge(section.id) : null;
return (
<div
key={section.id}
data-artist-idx={i}
data-reorder-id={section.id}
className="sidebar-customizer-row"
style={{
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
borderTop: edge === 'before' ? '2px solid var(--accent)' : undefined,
borderBottom: edge === 'after' ? '2px solid var(--accent)' : undefined,
}}
>
<ArtistSectionGripHandle idx={i} label={label} />
<ReorderGripHandle id={section.id} type={REORDER_TYPE} label={label} />
<span style={{ flex: 1, fontSize: 14, opacity: section.visible ? 1 : 0.45 }}>{label}</span>
<label className="toggle-switch" aria-label={label}>
<input type="checkbox" checked={section.visible} onChange={() => toggleSection(section.id)} />
@@ -1,10 +1,11 @@
import React, { useEffect, useRef, useState } from 'react';
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
import { useAuthStore } from '../../store/authStore';
import type { LyricsSourceId } from '../../store/authStoreTypes';
import { useListReorderDnd } from '../../hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '../../utils/componentHelpers/listReorder';
import { ReorderGripHandle } from './ReorderGripHandle';
import { SettingsToggle } from './SettingsToggle';
const LYRICS_SOURCE_LABEL_KEYS: Record<LyricsSourceId, string> = {
@@ -13,25 +14,7 @@ const LYRICS_SOURCE_LABEL_KEYS: Record<LyricsSourceId, string> = {
netease: 'settings.lyricsSourceNetease',
};
type LyricsDropTarget = { idx: number; before: boolean } | null;
function LyricsSourceGripHandle({ idx, label }: { idx: number; label: string }) {
const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({
data: JSON.stringify({ type: 'lyrics_source_reorder', index: idx }),
label,
}));
return (
<span
className="sidebar-customizer-grip"
data-tooltip={t('settings.sidebarDrag')}
data-tooltip-pos="right"
onMouseDown={onMouseDown}
>
<GripVertical size={16} />
</span>
);
}
const REORDER_TYPE = 'lyrics_source_reorder';
export function LyricsSourcesCustomizer() {
const { t } = useTranslation();
@@ -41,62 +24,17 @@ export function LyricsSourcesCustomizer() {
const setYouLyPlusEnabled = useAuthStore(s => s.setYouLyPlusEnabled);
const lyricsStaticOnly = useAuthStore(s => s.lyricsStaticOnly);
const setLyricsStaticOnly = useAuthStore(s => s.setLyricsStaticOnly);
const { isDragging: isPsyDragging } = useDragDrop();
// useState (not useRef) so the listener-effect re-binds if the container
// element is ever remounted.
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
const [dropTarget, setDropTarget] = useState<LyricsDropTarget>(null);
const dropTargetRef = useRef<LyricsDropTarget>(null);
const sourcesRef = useRef(lyricsSources);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// React Compiler refs rule: ref kept in sync with the latest value for use in handlers; not render data.
// eslint-disable-next-line react-hooks/refs
sourcesRef.current = lyricsSources;
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
}, [isPsyDragging]);
const apply = useCallback((draggedId: string, target: ListReorderDropTarget) => {
const next = applyListReorderById(sourcesRef.current, draggedId, target);
if (next) setLyricsSources(next);
}, [setLyricsSources]);
useEffect(() => {
if (!containerEl) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: { type?: string; index?: number };
try { parsed = JSON.parse(detail.data as string); } catch { return; }
if (parsed.type !== 'lyrics_source_reorder' || parsed.index == null) return;
const fromIdx = parsed.index;
const target = dropTargetRef.current;
dropTargetRef.current = null; setDropTarget(null);
if (!target) return;
const insertBefore = target.before ? target.idx : target.idx + 1;
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
const next = [...sourcesRef.current];
const [moved] = next.splice(fromIdx, 1);
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
setLyricsSources(next);
};
containerEl.addEventListener('psy-drop', onPsyDrop);
return () => containerEl.removeEventListener('psy-drop', onPsyDrop);
}, [containerEl, setLyricsSources]);
const handleMouseMove = (e: React.MouseEvent) => {
if (!isPsyDragging || !containerEl) return;
const rows = containerEl.querySelectorAll<HTMLElement>('[data-lyrics-idx]');
let target: LyricsDropTarget = null;
for (const row of rows) {
const rect = row.getBoundingClientRect();
const idx = Number(row.dataset.lyricsIdx);
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
target = { idx, before: false };
}
dropTargetRef.current = target;
setDropTarget(target);
};
const { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({ type: REORDER_TYPE, apply });
const toggleSource = (id: LyricsSourceId) => {
setLyricsSources(sourcesRef.current.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s));
@@ -123,26 +61,21 @@ export function LyricsSourcesCustomizer() {
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 0.4rem' }}>
{youLyPlusEnabled ? t('settings.lyricsSourcesFallbackHint') : t('settings.lyricsSourcesPrimaryHint')}
</div>
<div
style={{ padding: '4px 0', marginBottom: '0.75rem' }}
ref={setContainerEl}
onMouseMove={handleMouseMove}
>
{lyricsSources.map((src, i) => {
<div style={{ padding: '4px 0', marginBottom: '0.75rem' }} ref={setContainer} onMouseMove={onMouseMove}>
{lyricsSources.map((src) => {
const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]);
const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before;
const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before;
const edge = isDragging ? dropEdge(src.id) : null;
return (
<div
key={src.id}
data-lyrics-idx={i}
data-reorder-id={src.id}
className="sidebar-customizer-row"
style={{
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
borderTop: edge === 'before' ? '2px solid var(--accent)' : undefined,
borderBottom: edge === 'after' ? '2px solid var(--accent)' : undefined,
}}
>
<LyricsSourceGripHandle idx={i} label={label} />
<ReorderGripHandle id={src.id} type={REORDER_TYPE} label={label} />
<span style={{ flex: 1, fontSize: 14, opacity: src.enabled ? 1 : 0.45 }}>{label}</span>
<label className="toggle-switch" aria-label={label}>
<input type="checkbox" checked={src.enabled} onChange={() => toggleSource(src.id)} />
@@ -1,10 +1,10 @@
import React, { useEffect, useRef, useState } from 'react';
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Blend, GripVertical, Infinity as InfinityIcon, ListMusic, MoveRight, Share2, Shuffle, Trash2, Waves } from 'lucide-react';
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
import { Blend, Infinity as InfinityIcon, ListMusic, MoveRight, Share2, Shuffle, Trash2, Waves } from 'lucide-react';
import { useQueueToolbarStore, QueueToolbarButtonId } from '../../store/queueToolbarStore';
type QueueToolbarDropTarget = { idx: number; before: boolean } | null;
import { useListReorderDnd } from '../../hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '../../utils/componentHelpers/listReorder';
import { ReorderGripHandle } from './ReorderGripHandle';
const QUEUE_TOOLBAR_BUTTON_ICONS: Record<QueueToolbarButtonId, typeof Shuffle | null> = {
shuffle: Shuffle,
@@ -30,101 +30,40 @@ const QUEUE_TOOLBAR_LABEL_KEYS: Record<QueueToolbarButtonId, string> = {
infinite: 'queue.infiniteQueue',
};
function QueueToolbarGripHandle({ idx, label }: { idx: number; label: string }) {
const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({
data: JSON.stringify({ type: 'queue_toolbar_reorder', index: idx }),
label,
}));
return (
<span
className="sidebar-customizer-grip"
data-tooltip={t('settings.sidebarDrag')}
data-tooltip-pos="right"
onMouseDown={onMouseDown}
>
<GripVertical size={16} />
</span>
);
}
const REORDER_TYPE = 'queue_toolbar_reorder';
export function QueueToolbarCustomizer() {
const { t } = useTranslation();
const { buttons, setButtons, toggleButton } = useQueueToolbarStore();
const { isDragging: isPsyDragging } = useDragDrop();
const containerRef = useRef<HTMLDivElement>(null);
const [dropTarget, setDropTarget] = useState<QueueToolbarDropTarget>(null);
const dropTargetRef = useRef<QueueToolbarDropTarget>(null);
const buttonsRef = useRef(buttons);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// React Compiler refs rule: ref kept in sync with the latest value for use in handlers; not render data.
// eslint-disable-next-line react-hooks/refs
buttonsRef.current = buttons;
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
}, [isPsyDragging]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: { type?: string; index?: number };
try { parsed = JSON.parse(detail.data as string); } catch { return; }
if (parsed.type !== 'queue_toolbar_reorder' || parsed.index == null) return;
const fromIdx = parsed.index;
const target = dropTargetRef.current;
dropTargetRef.current = null; setDropTarget(null);
if (!target) return;
const insertBefore = target.before ? target.idx : target.idx + 1;
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
const next = [...buttonsRef.current];
const [moved] = next.splice(fromIdx, 1);
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
setButtons(next);
};
el.addEventListener('psy-drop', onPsyDrop);
return () => el.removeEventListener('psy-drop', onPsyDrop);
const apply = useCallback((draggedId: string, target: ListReorderDropTarget) => {
const next = applyListReorderById(buttonsRef.current, draggedId, target);
if (next) setButtons(next);
}, [setButtons]);
const handleMouseMove = (e: React.MouseEvent) => {
if (!isPsyDragging || !containerRef.current) return;
const rows = containerRef.current.querySelectorAll<HTMLElement>('[data-queue-toolbar-idx]');
let target: QueueToolbarDropTarget = null;
for (const row of rows) {
const rect = row.getBoundingClientRect();
const idx = Number(row.dataset.queueToolbarIdx);
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
target = { idx, before: false };
}
dropTargetRef.current = target;
setDropTarget(target);
};
const { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({ type: REORDER_TYPE, apply });
return (
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ padding: '4px 0' }}>
{buttons.map((btn, idx) => {
<div ref={setContainer} onMouseMove={onMouseMove} style={{ padding: '4px 0' }}>
{buttons.map((btn) => {
const Icon = QUEUE_TOOLBAR_BUTTON_ICONS[btn.id];
const label = t(QUEUE_TOOLBAR_LABEL_KEYS[btn.id]);
const isBefore = isPsyDragging && dropTarget?.idx === idx && dropTarget.before;
const isAfter = isPsyDragging && dropTarget?.idx === idx && !dropTarget.before;
const edge = isDragging ? dropEdge(btn.id) : null;
return (
<div
key={btn.id}
data-queue-toolbar-idx={idx}
data-reorder-id={btn.id}
className="sidebar-customizer-row"
style={{
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
borderTop: edge === 'before' ? '2px solid var(--accent)' : undefined,
borderBottom: edge === 'after' ? '2px solid var(--accent)' : undefined,
}}
>
<QueueToolbarGripHandle idx={idx} label={label} />
<ReorderGripHandle id={btn.id} type={REORDER_TYPE} label={label} />
{Icon ? (
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
) : (
@@ -2,10 +2,16 @@ import { useTranslation } from 'react-i18next';
import { GripVertical } from 'lucide-react';
import { useDragSource } from '../../contexts/DragDropContext';
export function ServerGripHandle({ idx, label }: { idx: number; label: string }) {
/**
* Drag handle shared by the reorder customizers. Emits an id-based payload
* (`{ type, id, section? }`) consumed by `useListReorderDnd`.
*/
export function ReorderGripHandle({
id, type, section, label,
}: { id: string; type: string; section?: string; label: string }) {
const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({
data: JSON.stringify({ type: 'server_reorder', index: idx }),
data: JSON.stringify(section ? { type, id, section } : { type, id }),
label,
}));
return (
+20 -65
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { open as openUrl } from '@tauri-apps/plugin-shell';
@@ -17,7 +17,6 @@ import {
clearServerHttpContext,
syncServerHttpContextForProfile,
} from '../../utils/server/syncServerHttpContext';
import { useDragDrop } from '../../contexts/DragDropContext';
import { type ServerMagicPayload } from '../../utils/server/serverMagicString';
import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '../../utils/server/serverEndpoint';
import {
@@ -38,7 +37,9 @@ import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey';
import { switchActiveServer } from '../../utils/server/switchActiveServer';
import { AddServerForm } from './AddServerForm';
import { ServerCapabilityHeaderBadge } from './ServerCapabilityHeaderBadge';
import { ServerGripHandle } from './ServerGripHandle';
import { useListReorderDnd } from '../../hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '../../utils/componentHelpers/listReorder';
import { ReorderGripHandle } from './ReorderGripHandle';
import { tooltipAttrs } from '../tooltipAttrs';
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
@@ -61,8 +62,6 @@ function showLegacyAudiomuseToggleRow(
return isNavidromeAudiomuseSoftwareEligible(identity) && instantMixProbe !== 'empty';
}
type ServerDropTarget = { idx: number; before: boolean } | null;
export function ServersTab({
initialInvite,
}: {
@@ -71,16 +70,12 @@ export function ServersTab({
const { t } = useTranslation();
const navigate = useNavigate();
const auth = useAuthStore();
const psyDragState = useDragDrop();
const librarySync = useLibraryIndexSync();
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
const [showAddForm, setShowAddForm] = useState<boolean>(initialInvite != null);
const [editingServerId, setEditingServerId] = useState<string | null>(null);
const [pastedServerInvite, setPastedServerInvite] = useState<ServerMagicPayload | null>(initialInvite);
const [serverContainerEl, setServerContainerEl] = useState<HTMLDivElement | null>(null);
const [serverDropTarget, setServerDropTarget] = useState<ServerDropTarget>(null);
const serverDropTargetRef = useRef<ServerDropTarget>(null);
const serversRef = useRef(auth.servers);
// 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
@@ -103,56 +98,15 @@ export function ServersTab({
}
}, [initialInvite]);
// Clear drop target when drag ends
useEffect(() => {
if (!psyDragState.isDragging) {
serverDropTargetRef.current = null;
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setServerDropTarget(null);
}
}, [psyDragState.isDragging]);
const applyServerReorder = useCallback((draggedId: string, target: ListReorderDropTarget) => {
const next = applyListReorderById(serversRef.current, draggedId, target);
if (next) auth.setServers(next);
}, [auth]);
// psy-drop listener for server reorder
useEffect(() => {
if (!serverContainerEl) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: { type?: string; index?: number };
try { parsed = JSON.parse(detail.data as string); } catch { return; }
if (parsed.type !== 'server_reorder' || parsed.index == null) return;
const fromIdx = parsed.index;
const target = serverDropTargetRef.current;
serverDropTargetRef.current = null; setServerDropTarget(null);
if (!target) return;
const insertBefore = target.before ? target.idx : target.idx + 1;
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
const next = [...serversRef.current];
const [moved] = next.splice(fromIdx, 1);
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
auth.setServers(next);
};
serverContainerEl.addEventListener('psy-drop', onPsyDrop);
return () => serverContainerEl.removeEventListener('psy-drop', onPsyDrop);
}, [serverContainerEl, auth]);
const handleServerDragMove = (e: React.MouseEvent) => {
if (!psyDragState.isDragging || !serverContainerEl) return;
const rows = serverContainerEl.querySelectorAll<HTMLElement>('[data-server-idx]');
let target: ServerDropTarget = null;
for (const row of rows) {
const rect = row.getBoundingClientRect();
const idx = Number(row.dataset.serverIdx);
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
target = { idx, before: false };
}
serverDropTargetRef.current = target;
setServerDropTarget(target);
};
const { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({
type: 'server_reorder',
apply: applyServerReorder,
});
const testConnection = async (server: ServerProfile) => {
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
@@ -403,11 +357,11 @@ export function ServersTab({
</div>
) : (
<div
ref={setServerContainerEl}
onMouseMove={handleServerDragMove}
ref={setContainer}
onMouseMove={onMouseMove}
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
>
{auth.servers.map((srv, srvIdx) => {
{auth.servers.map((srv) => {
if (editingServerId === srv.id) {
return (
<AddServerForm
@@ -424,8 +378,9 @@ export function ServersTab({
}
const isActive = srv.id === auth.activeServerId;
const status = connStatus[srv.id];
const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before;
const isAfter = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && !serverDropTarget.before;
const dropEdgeKind = isDragging ? dropEdge(srv.id) : null;
const isBefore = dropEdgeKind === 'before';
const isAfter = dropEdgeKind === 'after';
const serverSoftware = formatServerSoftware(auth.subsonicServerIdentityByServer[srv.id]);
const serverIdentity = auth.subsonicServerIdentityByServer[srv.id];
const resolvedAudiomuse = resolveFeatureForServer(srv.id, FEATURE_AUDIOMUSE_SIMILAR_TRACKS);
@@ -439,7 +394,7 @@ export function ServersTab({
return (
<div
key={srv.id}
data-server-idx={srvIdx}
data-reorder-id={srv.id}
className="settings-card"
style={{
border: isActive ? '1px solid var(--accent)' : undefined,
@@ -457,7 +412,7 @@ export function ServersTab({
}}
>
<div style={{ display: 'flex', alignItems: 'stretch', gap: '0.75rem' }}>
<ServerGripHandle idx={srvIdx} label={serverListDisplayLabel(srv, auth.servers)} />
<ReorderGripHandle id={srv.id} type="server_reorder" label={serverListDisplayLabel(srv, auth.servers)} />
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px', flexWrap: 'wrap' }}>
+19 -76
View File
@@ -1,44 +1,23 @@
import React, { useEffect, useRef, useState } from 'react';
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical } from 'lucide-react';
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
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 { applySidebarReorderById } from '../../utils/componentHelpers/sidebarNavReorder';
import { useListReorderDnd } from '../../hooks/useListReorderDnd';
import type { ListReorderDropTarget } from '../../utils/componentHelpers/listReorder';
import { ReorderGripHandle } from './ReorderGripHandle';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
type DropTarget = { id: string; before: boolean; section: 'library' | 'system' } | null;
function SidebarGripHandle({ id, section, label }: { id: string; section: 'library' | 'system'; label: string }) {
const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({
data: JSON.stringify({ type: 'sidebar_reorder', id, section }),
label,
}));
return (
<span
className="sidebar-customizer-grip"
data-tooltip={t('settings.sidebarDrag')}
data-tooltip-pos="right"
onMouseDown={onMouseDown}
>
<GripVertical size={16} />
</span>
);
}
const REORDER_TYPE = 'sidebar_reorder';
export function SidebarCustomizer() {
const { t } = useTranslation();
const { items, setItems, toggleItem } = useSidebarStore();
const { isDragging: isPsyDragging } = useDragDrop();
const containerRef = useRef<HTMLDivElement>(null);
const [dropTarget, setDropTarget] = useState<DropTarget>(null);
const dropTargetRef = useRef<DropTarget>(null);
const itemsRef = useRef(items);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// React Compiler refs rule: ref kept in sync with the latest value for use in handlers; not render data.
// eslint-disable-next-line react-hooks/refs
itemsRef.current = items;
const randomNavMode = useAuthStore(s => s.randomNavMode);
@@ -60,68 +39,32 @@ export function SidebarCustomizer() {
});
const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
}, [isPsyDragging]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: { type?: string; id?: string; section?: string };
try { parsed = JSON.parse(detail.data); } catch { return; }
if (parsed.type !== 'sidebar_reorder' || !parsed.id || !parsed.section) return;
const draggedId = parsed.id;
const fromSection = parsed.section as 'library' | 'system';
const target = dropTargetRef.current;
dropTargetRef.current = null; setDropTarget(null);
const next = applySidebarReorderById(itemsRef.current, fromSection, draggedId, target);
if (next) setItems(next);
};
el.addEventListener('psy-drop', onPsyDrop);
return () => el.removeEventListener('psy-drop', onPsyDrop);
const apply = useCallback((draggedId: string, target: ListReorderDropTarget) => {
const section = ALL_NAV_ITEMS[draggedId]?.section;
if (section !== 'library' && section !== 'system') return;
const next = applySidebarReorderById(itemsRef.current, section, draggedId, target);
if (next) setItems(next);
}, [setItems]);
const handleMouseMove = (e: React.MouseEvent) => {
if (!isPsyDragging || !containerRef.current) return;
const rows = containerRef.current.querySelectorAll<HTMLElement>('[data-sidebar-id]');
let target: DropTarget = null;
for (const row of rows) {
const rect = row.getBoundingClientRect();
const id = row.dataset.sidebarId;
const section = row.dataset.sidebarSection as 'library' | 'system';
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 { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({ type: REORDER_TYPE, apply });
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.id === cfg.id && dropTarget.before;
const isAfter = isPsyDragging && dropTarget?.section === section && dropTarget.id === cfg.id && !dropTarget.before;
const edge = isDragging ? dropEdge(cfg.id) : null;
return (
<div
key={cfg.id}
data-sidebar-id={cfg.id}
data-sidebar-section={section}
data-reorder-id={cfg.id}
data-reorder-section={section}
className="sidebar-customizer-row"
style={{
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
borderTop: edge === 'before' ? '2px solid var(--accent)' : undefined,
borderBottom: edge === 'after' ? '2px solid var(--accent)' : undefined,
}}
>
<SidebarGripHandle id={cfg.id} section={section} label={t(meta.labelKey)} />
<ReorderGripHandle id={cfg.id} type={REORDER_TYPE} section={section} label={t(meta.labelKey)} />
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 14 }}>{t(meta.labelKey)}</span>
<label className="toggle-switch" aria-label={t(meta.labelKey)}>
@@ -157,7 +100,7 @@ export function SidebarCustomizer() {
</SettingsGroup>
<SettingsGroup>
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div ref={setContainer} onMouseMove={onMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{/* Library block */}
<div style={{ padding: '4px 0' }}>
<div className="sidebar-customizer-block-label">{t('sidebar.library')}</div>
+91
View File
@@ -0,0 +1,91 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDragDrop } from '../contexts/DragDropContext';
import type { ListReorderDropTarget } from '../utils/componentHelpers/listReorder';
interface Options {
/** Payload discriminator the drag source emits, e.g. `'lyrics_source_reorder'`. */
type: string;
/**
* Apply the move. Receives the dragged row id and the resolved drop target.
* Consumers own the actual list mutation (store-specific). Memoise this
* (`useCallback`) so the drop listener does not re-bind every render.
*/
apply: (draggedId: string, target: ListReorderDropTarget) => void;
}
interface Result {
isDragging: boolean;
/** Attach to the rows' container: `ref={setContainer}`. */
setContainer: (el: HTMLElement | null) => void;
/** Attach to the rows' container: `onMouseMove={onMouseMove}`. */
onMouseMove: (e: React.MouseEvent) => void;
/** Which edge (if any) of row `id` should show the drop indicator. */
dropEdge: (id: string) => 'before' | 'after' | null;
}
/**
* Drag-to-reorder wiring shared by the customizer panels. Tracks the hovered
* drop target, listens for the `psy-drop` event, and resolves the target by
* **stable id** (read from `data-reorder-id`, with optional
* `data-reorder-section`). The actual reorder is delegated to `apply`, keeping
* this hook list-agnostic. Pair with `ReorderGripHandle` on each row and
* `applyListReorderById` (or a section-aware variant) inside `apply`.
*/
export function useListReorderDnd({ type, apply }: Options): Result {
const { isDragging } = useDragDrop();
const [container, setContainer] = useState<HTMLElement | null>(null);
const [dropTarget, setDropTarget] = useState<ListReorderDropTarget | null>(null);
const dropTargetRef = useRef<ListReorderDropTarget | null>(null);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers; not render data.
// eslint-disable-next-line react-hooks/refs
dropTargetRef.current = dropTarget;
// Clear the drop indicator as soon as any drag ends.
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with the drag input.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!isDragging) { dropTargetRef.current = null; setDropTarget(null); }
}, [isDragging]);
useEffect(() => {
if (!container) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: { type?: string; id?: string; section?: string };
try { parsed = JSON.parse(detail.data as string); } catch { return; }
if (parsed.type !== type || !parsed.id) return;
const target = dropTargetRef.current;
dropTargetRef.current = null; setDropTarget(null);
if (!target) return;
apply(parsed.id, target);
};
container.addEventListener('psy-drop', onPsyDrop);
return () => container.removeEventListener('psy-drop', onPsyDrop);
}, [container, type, apply]);
const onMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging || !container) return;
const rows = container.querySelectorAll<HTMLElement>('[data-reorder-id]');
let target: ListReorderDropTarget | null = null;
for (const row of rows) {
const id = row.dataset.reorderId;
if (!id) continue;
const section = row.dataset.reorderSection;
const rect = row.getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
target = section ? { id, before, section } : { id, before };
if (before) break;
}
dropTargetRef.current = target;
setDropTarget(target);
}, [isDragging, container]);
const dropEdge = useCallback((id: string): 'before' | 'after' | null => {
if (!isDragging || dropTarget?.id !== id) return null;
return dropTarget.before ? 'before' : 'after';
}, [isDragging, dropTarget]);
return { isDragging, setContainer, onMouseMove, dropEdge };
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { applyListReorderById } from './listReorder';
type Item = { id: string; visible?: boolean };
const list = (...ids: string[]): Item[] => ids.map(id => ({ id }));
const ids = (items: Item[] | null): string[] | null => items && items.map(i => i.id);
describe('applyListReorderById', () => {
const base = list('a', 'b', 'c', 'd', 'e');
it('moves an item to before the target', () => {
expect(ids(applyListReorderById(base, 'd', { id: 'b', before: true })))
.toEqual(['a', 'd', 'b', 'c', 'e']);
});
it('moves an item to after the target', () => {
expect(ids(applyListReorderById(base, 'a', { id: 'c', before: false })))
.toEqual(['b', 'c', 'a', 'd', 'e']);
});
it('moves an item upward', () => {
expect(ids(applyListReorderById(base, 'e', { id: 'a', before: true })))
.toEqual(['e', 'a', 'b', 'c', 'd']);
});
it('keeps unrelated items (incl. hidden ones) in place', () => {
// 'x' stands in for a hidden/gated row that is never an anchor.
const withHidden = list('a', 'x', 'b', 'c');
expect(ids(applyListReorderById(withHidden, 'c', { id: 'a', before: false })))
.toEqual(['a', 'c', 'x', 'b']);
});
it('returns null when dropping onto itself', () => {
expect(applyListReorderById(base, 'b', { id: 'b', before: true })).toBeNull();
});
it('returns null on a no-op edge (already adjacent)', () => {
// 'a' before 'b' — a is already right before b → no change.
expect(applyListReorderById(base, 'a', { id: 'b', before: true })).toBeNull();
// 'b' after 'a' — same position → no change.
expect(applyListReorderById(base, 'b', { id: 'a', before: false })).toBeNull();
});
it('returns null for an unknown dragged id (defensive guard)', () => {
expect(applyListReorderById(base, 'nope', { id: 'b', before: true })).toBeNull();
});
it('returns null for an unknown target id (defensive guard)', () => {
expect(applyListReorderById(base, 'a', { id: 'nope', before: true })).toBeNull();
});
it('does not mutate the input array', () => {
const snapshot = ids(base);
applyListReorderById(base, 'a', { id: 'e', before: false });
expect(ids(base)).toEqual(snapshot);
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* Shared, id-based list reordering for the drag-to-reorder customizers
* (sidebar, artist layout, lyrics sources, queue toolbar, servers).
*
* Reorders are resolved from stable item ids, never positional indices, so the
* filter that decides which rows are *shown* can never share an index space
* with the reorder and desync it (the #1164 class of bug). See
* `useListReorderDnd` for the DnD wiring and `sidebarNavReorder` for the
* section-aware sidebar variant that builds on this.
*/
export type ListReorderDropTarget = {
/** Stable id of the row the cursor is over — never a positional index. */
id: string;
/** Insert above (`true`) or below (`false`) the target row. */
before: boolean;
/** Optional section discriminator for multi-section lists (e.g. sidebar). */
section?: string;
};
/**
* Moves `draggedId` next to `target.id` within `items`, identifying both rows
* by id. Returns a new array, or `null` when nothing should change — unknown
* id, dropping onto self, or a no-op edge.
*/
export function applyListReorderById<T extends { id: string }>(
items: T[],
draggedId: string,
target: ListReorderDropTarget,
): T[] | null {
if (draggedId === target.id) return null;
const fromIdx = items.findIndex(i => i.id === draggedId);
if (fromIdx < 0) return null;
const next = [...items];
const [moved] = next.splice(fromIdx, 1);
const anchor = next.findIndex(i => i.id === target.id);
if (anchor < 0) return null;
next.splice(target.before ? anchor : anchor + 1, 0, moved);
// No-op if the resulting order is identical (e.g. dropped on its own edge).
if (next.every((c, i) => c.id === items[i].id)) return null;
return next;
}
@@ -1,5 +1,6 @@
import { ALL_NAV_ITEMS } from '../../config/navItems';
import { CONSERVED_SIDEBAR_NAV_IDS, type SidebarItemConfig } from '../../store/sidebarStore';
import { applyListReorderById, type ListReorderDropTarget } from './listReorder';
export type SidebarNavSection = 'library' | 'system';
@@ -82,26 +83,14 @@ export function applySidebarReorderById(
allItems: SidebarItemConfig[],
section: SidebarNavSection,
draggedId: string,
target: SidebarNavDropTarget | null,
target: ListReorderDropTarget | null,
): SidebarItemConfig[] | null {
if (!target || target.section !== section) return null;
const targetId = target.id;
if (draggedId === targetId) return null;
// Guard: both ids must be real, non-conserved items that belong to `section`.
if (!itemBelongsToSection(draggedId, section)) return null;
if (!itemBelongsToSection(targetId, section)) return null;
if (!itemBelongsToSection(target.id, section)) return null;
const fromIdx = allItems.findIndex(c => c.id === draggedId);
if (fromIdx < 0) return null;
const next = [...allItems];
const [moved] = next.splice(fromIdx, 1);
const anchor = next.findIndex(c => c.id === targetId);
if (anchor < 0) return null;
next.splice(target.before ? anchor : anchor + 1, 0, moved);
// No-op if the resulting order is identical (e.g. dropped on its own edge).
if (next.every((c, i) => c.id === allItems[i].id)) return null;
return next;
// The move itself is the shared id-based reorder over the canonical array.
return applyListReorderById(allItems, draggedId, target);
}