mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
@@ -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 effect’s 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 effect’s 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 effect’s 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 }} />
|
||||
) : (
|
||||
|
||||
+8
-2
@@ -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 (
|
||||
@@ -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 effect’s 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' }}>
|
||||
|
||||
@@ -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 effect’s 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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user