mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(sidebar): long-press drag to reorder and hide nav items (#269)
Hold for 1s then drag to reorder configurable library and system links; drop outside the sidebar to hide items (same visibility as Settings). Show a trash hint near the cursor when outside removal applies. Extract shared reorder helpers into src/utils/sidebarNavReorder.ts and reuse them from the Settings sidebar customizer.
This commit is contained in:
+376
-37
@@ -5,13 +5,13 @@ import { useOfflineStore } from '../store/offlineStore';
|
|||||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useSidebarStore } from '../store/sidebarStore';
|
import { useSidebarStore, type SidebarItemConfig } from '../store/sidebarStore';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
PanelLeftClose, PanelLeft, AudioLines, HardDriveDownload, HardDriveUpload,
|
PanelLeftClose, PanelLeft, AudioLines, HardDriveDownload, HardDriveUpload,
|
||||||
ChevronDown, Check, Music2, X, ChevronRight, PlayCircle,
|
ChevronDown, Check, Music2, X, ChevronRight, PlayCircle, Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import PsysonicLogo from './PsysonicLogo';
|
import PsysonicLogo from './PsysonicLogo';
|
||||||
import PSmallLogo from './PSmallLogo';
|
import PSmallLogo from './PSmallLogo';
|
||||||
@@ -20,6 +20,23 @@ import { getPlaylists } from '../api/subsonic';
|
|||||||
import { usePlaylistStore } from '../store/playlistStore';
|
import { usePlaylistStore } from '../store/playlistStore';
|
||||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||||
import OverlayScrollArea from './OverlayScrollArea';
|
import OverlayScrollArea from './OverlayScrollArea';
|
||||||
|
import {
|
||||||
|
applySidebarDropReorder,
|
||||||
|
getLibraryItemsForReorder,
|
||||||
|
getSystemItemsForReorder,
|
||||||
|
isSidebarNavItemUserHideable,
|
||||||
|
type SidebarNavDropTarget,
|
||||||
|
} from '../utils/sidebarNavReorder';
|
||||||
|
|
||||||
|
const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
|
||||||
|
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
|
||||||
|
|
||||||
|
function isPointerOutsideAsideSidebar(clientX: number, clientY: number): boolean {
|
||||||
|
const aside = document.querySelector('aside.sidebar');
|
||||||
|
if (!aside) return false;
|
||||||
|
const r = aside.getBoundingClientRect();
|
||||||
|
return clientX < r.left || clientX > r.right || clientY < r.top || clientY > r.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function Sidebar({
|
export default function Sidebar({
|
||||||
@@ -49,6 +66,7 @@ export default function Sidebar({
|
|||||||
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
|
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
|
||||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||||
const sidebarItems = useSidebarStore(s => s.items);
|
const sidebarItems = useSidebarStore(s => s.items);
|
||||||
|
const setSidebarItems = useSidebarStore(s => s.setItems);
|
||||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||||
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
|
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
|
||||||
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
|
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
|
||||||
@@ -68,6 +86,239 @@ export default function Sidebar({
|
|||||||
filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null;
|
filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null;
|
||||||
const libraryTriggerPlain = filterId === 'all';
|
const libraryTriggerPlain = filterId === 'all';
|
||||||
|
|
||||||
|
const libraryItemsForReorder = useMemo(
|
||||||
|
() => getLibraryItemsForReorder(sidebarItems, randomNavMode),
|
||||||
|
[sidebarItems, randomNavMode],
|
||||||
|
);
|
||||||
|
const systemItemsForReorder = useMemo(
|
||||||
|
() => getSystemItemsForReorder(sidebarItems),
|
||||||
|
[sidebarItems],
|
||||||
|
);
|
||||||
|
const visibleLibraryConfigs = useMemo(
|
||||||
|
() => libraryItemsForReorder.filter(c => c.visible),
|
||||||
|
[libraryItemsForReorder],
|
||||||
|
);
|
||||||
|
const visibleSystemConfigs = useMemo(
|
||||||
|
() => systemItemsForReorder.filter(c => c.visible),
|
||||||
|
[systemItemsForReorder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [navDnd, setNavDnd] = useState<{
|
||||||
|
section: 'library' | 'system';
|
||||||
|
fromIdx: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [navDropTarget, setNavDropTarget] = useState<SidebarNavDropTarget | null>(null);
|
||||||
|
const navDropTargetRef = useRef<SidebarNavDropTarget | null>(null);
|
||||||
|
navDropTargetRef.current = navDropTarget;
|
||||||
|
const sidebarItemsRef = useRef(sidebarItems);
|
||||||
|
sidebarItemsRef.current = sidebarItems;
|
||||||
|
const randomNavModeRef = useRef(randomNavMode);
|
||||||
|
randomNavModeRef.current = randomNavMode;
|
||||||
|
/** DOM timers are numeric; avoid NodeJS `Timeout` typing from `setTimeout`. */
|
||||||
|
const longPressTimersRef = useRef<Map<number, number>>(new Map());
|
||||||
|
const suppressNavClickRef = useRef(false);
|
||||||
|
const lastPointerDuringNavDndRef = useRef({ x: 0, y: 0 });
|
||||||
|
const [navDndTrashHint, setNavDndTrashHint] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!navDnd) return;
|
||||||
|
|
||||||
|
const updateDropFromPoint = (clientX: number, clientY: number) => {
|
||||||
|
if (isPointerOutsideAsideSidebar(clientX, clientY)) {
|
||||||
|
navDropTargetRef.current = null;
|
||||||
|
setNavDropTarget(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = document.querySelectorAll<HTMLElement>('.sidebar [data-sidebar-nav-dnd-row]');
|
||||||
|
let target: SidebarNavDropTarget | null = null;
|
||||||
|
for (const row of rows) {
|
||||||
|
const section = row.dataset.sidebarSection as 'library' | 'system' | undefined;
|
||||||
|
if (section !== navDnd.section) continue;
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const idx = Number(row.dataset.sidebarIdx);
|
||||||
|
if (Number.isNaN(idx)) continue;
|
||||||
|
if (clientY < rect.top + rect.height / 2) {
|
||||||
|
target = { idx, before: true, section };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
target = { idx, before: false, section };
|
||||||
|
}
|
||||||
|
navDropTargetRef.current = target;
|
||||||
|
setNavDropTarget(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDrag = (apply: boolean) => {
|
||||||
|
window.removeEventListener('pointermove', onMove, { capture: true });
|
||||||
|
window.removeEventListener('pointerup', onUp, true);
|
||||||
|
window.removeEventListener('pointercancel', onUp, true);
|
||||||
|
window.removeEventListener('keydown', onKey, true);
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
setNavDndTrashHint(null);
|
||||||
|
|
||||||
|
const currentDnd = navDnd;
|
||||||
|
const drop = navDropTargetRef.current;
|
||||||
|
setNavDnd(null);
|
||||||
|
setNavDropTarget(null);
|
||||||
|
navDropTargetRef.current = null;
|
||||||
|
|
||||||
|
if (!apply || !currentDnd) return;
|
||||||
|
|
||||||
|
const { x, y } = lastPointerDuringNavDndRef.current;
|
||||||
|
if (isPointerOutsideAsideSidebar(x, y)) {
|
||||||
|
const sectionItems =
|
||||||
|
currentDnd.section === 'library'
|
||||||
|
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
|
||||||
|
: getSystemItemsForReorder(sidebarItemsRef.current);
|
||||||
|
const id = sectionItems[currentDnd.fromIdx]?.id;
|
||||||
|
if (id && isSidebarNavItemUserHideable(id)) {
|
||||||
|
const nextItems: SidebarItemConfig[] = sidebarItemsRef.current.map(i =>
|
||||||
|
i.id === id ? { ...i, visible: false } : i,
|
||||||
|
);
|
||||||
|
setSidebarItems(nextItems);
|
||||||
|
suppressNavClickRef.current = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = applySidebarDropReorder(
|
||||||
|
sidebarItemsRef.current,
|
||||||
|
currentDnd.section,
|
||||||
|
currentDnd.fromIdx,
|
||||||
|
drop,
|
||||||
|
randomNavModeRef.current,
|
||||||
|
);
|
||||||
|
if (next) {
|
||||||
|
setSidebarItems(next);
|
||||||
|
suppressNavClickRef.current = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
|
||||||
|
|
||||||
|
const outside = isPointerOutsideAsideSidebar(e.clientX, e.clientY);
|
||||||
|
const sectionItems =
|
||||||
|
navDnd.section === 'library'
|
||||||
|
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
|
||||||
|
: getSystemItemsForReorder(sidebarItemsRef.current);
|
||||||
|
const draggedId = sectionItems[navDnd.fromIdx]?.id;
|
||||||
|
const canTrash = Boolean(draggedId && isSidebarNavItemUserHideable(draggedId));
|
||||||
|
if (outside && canTrash) {
|
||||||
|
setNavDndTrashHint({ x: e.clientX, y: e.clientY });
|
||||||
|
} else {
|
||||||
|
setNavDndTrashHint(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDropFromPoint(e.clientX, e.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = (e: PointerEvent) => {
|
||||||
|
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
|
||||||
|
endDrag(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
endDrag(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
window.addEventListener('pointermove', onMove, { capture: true, passive: false });
|
||||||
|
window.addEventListener('pointerup', onUp, true);
|
||||||
|
window.addEventListener('pointercancel', onUp, true);
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', onMove, { capture: true });
|
||||||
|
window.removeEventListener('pointerup', onUp, true);
|
||||||
|
window.removeEventListener('pointercancel', onUp, true);
|
||||||
|
window.removeEventListener('keydown', onKey, true);
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
setNavDndTrashHint(null);
|
||||||
|
};
|
||||||
|
}, [navDnd, setSidebarItems]);
|
||||||
|
|
||||||
|
const handleNavRowPointerDown = useCallback(
|
||||||
|
(e: React.PointerEvent, section: 'library' | 'system', sectionIdx: number) => {
|
||||||
|
if (isCollapsed || navDnd) return;
|
||||||
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||||
|
|
||||||
|
const pid = e.pointerId;
|
||||||
|
const sx = e.clientX;
|
||||||
|
const sy = e.clientY;
|
||||||
|
|
||||||
|
let cleaned = false;
|
||||||
|
const cleanupEarly = () => {
|
||||||
|
if (cleaned) return;
|
||||||
|
cleaned = true;
|
||||||
|
document.removeEventListener('pointermove', onEarlyMove);
|
||||||
|
document.removeEventListener('pointerup', onEarlyUp, true);
|
||||||
|
document.removeEventListener('pointercancel', onEarlyUp, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEarlyMove = (ev: PointerEvent) => {
|
||||||
|
if (ev.pointerId !== pid) return;
|
||||||
|
if (Math.hypot(ev.clientX - sx, ev.clientY - sy) > SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX) {
|
||||||
|
const t = longPressTimersRef.current.get(pid);
|
||||||
|
if (t != null) window.clearTimeout(t);
|
||||||
|
longPressTimersRef.current.delete(pid);
|
||||||
|
cleanupEarly();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEarlyUp = (ev: PointerEvent) => {
|
||||||
|
if (ev.pointerId !== pid) return;
|
||||||
|
const t = longPressTimersRef.current.get(pid);
|
||||||
|
if (t != null) window.clearTimeout(t);
|
||||||
|
longPressTimersRef.current.delete(pid);
|
||||||
|
cleanupEarly();
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
longPressTimersRef.current.delete(pid);
|
||||||
|
cleanupEarly();
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
lastPointerDuringNavDndRef.current = { x: sx, y: sy };
|
||||||
|
setNavDnd({ section, fromIdx: sectionIdx });
|
||||||
|
navDropTargetRef.current = { idx: sectionIdx, before: true, section };
|
||||||
|
setNavDropTarget({ idx: sectionIdx, before: true, section });
|
||||||
|
try {
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(pid);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, SIDEBAR_NAV_LONG_PRESS_MS) as unknown as number;
|
||||||
|
|
||||||
|
longPressTimersRef.current.set(pid, timer);
|
||||||
|
document.addEventListener('pointermove', onEarlyMove);
|
||||||
|
document.addEventListener('pointerup', onEarlyUp, true);
|
||||||
|
document.addEventListener('pointercancel', onEarlyUp, true);
|
||||||
|
},
|
||||||
|
[isCollapsed, navDnd],
|
||||||
|
);
|
||||||
|
|
||||||
|
const navDndRowClass = useCallback(
|
||||||
|
(section: 'library' | 'system', sectionIdx: number) => {
|
||||||
|
const dragging = navDnd?.section === section && navDnd.fromIdx === sectionIdx;
|
||||||
|
let drop = '';
|
||||||
|
if (
|
||||||
|
navDnd &&
|
||||||
|
navDropTarget?.section === section &&
|
||||||
|
navDropTarget.idx === sectionIdx &&
|
||||||
|
!(navDnd.section === section && navDnd.fromIdx === sectionIdx)
|
||||||
|
) {
|
||||||
|
drop = navDropTarget.before
|
||||||
|
? 'sidebar-nav-dnd-row--drop-before'
|
||||||
|
: 'sidebar-nav-dnd-row--drop-after';
|
||||||
|
}
|
||||||
|
return `sidebar-nav-dnd-row${dragging ? ' sidebar-nav-dnd-row--dragging' : ''}${drop ? ` ${drop}` : ''}`.trim();
|
||||||
|
},
|
||||||
|
[navDnd, navDropTarget],
|
||||||
|
);
|
||||||
|
|
||||||
const updateDropdownPosition = useCallback(() => {
|
const updateDropdownPosition = useCallback(() => {
|
||||||
const el = libraryTriggerRef.current;
|
const el = libraryTriggerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -122,22 +373,13 @@ export default function Sidebar({
|
|||||||
fetchPlaylists();
|
fetchPlaylists();
|
||||||
}, [playlistsExpanded, isLoggedIn, fetchPlaylists]);
|
}, [playlistsExpanded, isLoggedIn, fetchPlaylists]);
|
||||||
|
|
||||||
// Resolve ordered, visible items per section from store config
|
useEffect(() => () => {
|
||||||
const visibleLibrary = sidebarItems
|
longPressTimersRef.current.forEach(t => window.clearTimeout(t));
|
||||||
.filter(cfg => {
|
longPressTimersRef.current.clear();
|
||||||
if (cfg == null || !cfg.visible || ALL_NAV_ITEMS[cfg.id]?.section !== 'library') return false;
|
}, []);
|
||||||
// Hide mode-inactive mix entries so the active mode controls what's shown
|
|
||||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
|
||||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
|
||||||
const visibleSystem = sidebarItems
|
|
||||||
.filter(cfg => cfg != null && cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system')
|
|
||||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
|
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
|
||||||
<div className="sidebar-brand">
|
<div className="sidebar-brand">
|
||||||
{isCollapsed
|
{isCollapsed
|
||||||
@@ -155,7 +397,17 @@ export default function Sidebar({
|
|||||||
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
|
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<nav className="sidebar-nav" aria-label="Main navigation">
|
<nav
|
||||||
|
className="sidebar-nav"
|
||||||
|
aria-label="Main navigation"
|
||||||
|
onClickCapture={e => {
|
||||||
|
if (suppressNavClickRef.current) {
|
||||||
|
suppressNavClickRef.current = false;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<OverlayScrollArea
|
<OverlayScrollArea
|
||||||
className="sidebar-nav-scroll"
|
className="sidebar-nav-scroll"
|
||||||
viewportClassName="sidebar-nav-viewport"
|
viewportClassName="sidebar-nav-viewport"
|
||||||
@@ -248,10 +500,29 @@ export default function Sidebar({
|
|||||||
) : (
|
) : (
|
||||||
<span className="nav-section-label">{t('sidebar.library')}</span>
|
<span className="nav-section-label">{t('sidebar.library')}</span>
|
||||||
))}
|
))}
|
||||||
{visibleLibrary.map(item => (
|
{visibleLibraryConfigs.map(cfg => {
|
||||||
item.to === '/playlists' ? (
|
const item = ALL_NAV_ITEMS[cfg.id];
|
||||||
// Playlists item with expand button
|
if (!item) return null;
|
||||||
<div key={item.to} className="sidebar-playlists-wrapper">
|
const sectionIdx = libraryItemsForReorder.findIndex(x => x.id === cfg.id);
|
||||||
|
const dndRow = !isCollapsed && sectionIdx >= 0;
|
||||||
|
const rowClass = dndRow ? navDndRowClass('library', sectionIdx) : undefined;
|
||||||
|
const dndProps = dndRow
|
||||||
|
? {
|
||||||
|
'data-sidebar-nav-dnd-row': '',
|
||||||
|
'data-sidebar-section': 'library' as const,
|
||||||
|
'data-sidebar-idx': String(sectionIdx),
|
||||||
|
onPointerDown: (e: React.PointerEvent) =>
|
||||||
|
handleNavRowPointerDown(e, 'library', sectionIdx),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return item.to === '/playlists' ? (
|
||||||
|
<div
|
||||||
|
key={item.to}
|
||||||
|
className={`sidebar-playlists-wrapper${rowClass ? ` ${rowClass}` : ''}`}
|
||||||
|
{...dndProps}
|
||||||
|
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
|
||||||
|
>
|
||||||
<div className="sidebar-playlists-header-row">
|
<div className="sidebar-playlists-header-row">
|
||||||
<NavLink
|
<NavLink
|
||||||
to={item.to}
|
to={item.to}
|
||||||
@@ -299,7 +570,7 @@ export default function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : isCollapsed ? (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
@@ -311,8 +582,26 @@ export default function Sidebar({
|
|||||||
<item.icon size={isCollapsed ? 22 : 18} />
|
<item.icon size={isCollapsed ? 22 : 18} />
|
||||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)
|
) : (
|
||||||
))}
|
<div
|
||||||
|
key={item.to}
|
||||||
|
className={rowClass}
|
||||||
|
{...dndProps}
|
||||||
|
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
<NavLink
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||||
|
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||||
|
data-tooltip-pos="bottom"
|
||||||
|
>
|
||||||
|
<item.icon size={isCollapsed ? 22 : 18} />
|
||||||
|
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Spacer: everything from here onward sticks to the bottom of the sidebar. */}
|
{/* Spacer: everything from here onward sticks to the bottom of the sidebar. */}
|
||||||
<div className="sidebar-bottom-spacer" />
|
<div className="sidebar-bottom-spacer" />
|
||||||
@@ -346,19 +635,53 @@ export default function Sidebar({
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
{visibleSystemConfigs.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
|
||||||
{visibleSystem.map(item => (
|
{visibleSystemConfigs.map(cfg => {
|
||||||
<NavLink
|
const item = ALL_NAV_ITEMS[cfg.id];
|
||||||
key={item.to}
|
if (!item) return null;
|
||||||
to={item.to}
|
const sectionIdx = systemItemsForReorder.findIndex(x => x.id === cfg.id);
|
||||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
const dndRow = !isCollapsed && sectionIdx >= 0;
|
||||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
const rowClass = dndRow ? navDndRowClass('system', sectionIdx) : undefined;
|
||||||
data-tooltip-pos="bottom"
|
const dndProps = dndRow
|
||||||
>
|
? {
|
||||||
<item.icon size={isCollapsed ? 22 : 18} />
|
'data-sidebar-nav-dnd-row': '',
|
||||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
'data-sidebar-section': 'system' as const,
|
||||||
</NavLink>
|
'data-sidebar-idx': String(sectionIdx),
|
||||||
))}
|
onPointerDown: (e: React.PointerEvent) =>
|
||||||
|
handleNavRowPointerDown(e, 'system', sectionIdx),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return isCollapsed ? (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||||
|
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||||
|
data-tooltip-pos="bottom"
|
||||||
|
>
|
||||||
|
<item.icon size={isCollapsed ? 22 : 18} />
|
||||||
|
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||||
|
</NavLink>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={item.to}
|
||||||
|
className={rowClass}
|
||||||
|
{...dndProps}
|
||||||
|
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
<NavLink
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||||
|
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||||
|
data-tooltip-pos="bottom"
|
||||||
|
>
|
||||||
|
<item.icon size={isCollapsed ? 22 : 18} />
|
||||||
|
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/settings"
|
to="/settings"
|
||||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||||
@@ -406,5 +729,21 @@ export default function Sidebar({
|
|||||||
</OverlayScrollArea>
|
</OverlayScrollArea>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
{navDndTrashHint != null &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className="sidebar-nav-dnd-trash-hint"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: navDndTrashHint.x + 14,
|
||||||
|
top: navDndTrashHint.y + 14,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Trash2 size={22} strokeWidth={2.25} />
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-19
@@ -35,6 +35,7 @@ import { useArtistLayoutStore, type ArtistSectionId, type ArtistSectionConfig }
|
|||||||
import { useHomeStore, HomeSectionId } from '../store/homeStore';
|
import { useHomeStore, HomeSectionId } from '../store/homeStore';
|
||||||
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
||||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||||
|
import { applySidebarDropReorder } from '../utils/sidebarNavReorder';
|
||||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||||
import {
|
import {
|
||||||
ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser,
|
ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser,
|
||||||
@@ -4420,29 +4421,13 @@ function SidebarCustomizer() {
|
|||||||
const fromSection = parsed.section as 'library' | 'system';
|
const fromSection = parsed.section as 'library' | 'system';
|
||||||
const target = dropTargetRef.current;
|
const target = dropTargetRef.current;
|
||||||
dropTargetRef.current = null; setDropTarget(null);
|
dropTargetRef.current = null; setDropTarget(null);
|
||||||
if (!target || target.section !== fromSection) return;
|
|
||||||
|
|
||||||
const sectionItems = fromSection === 'library' ? [...libraryItems] : [...systemItems];
|
const next = applySidebarDropReorder(itemsRef.current, fromSection, fromIdx, target, randomNavMode);
|
||||||
const insertBefore = target.before ? target.idx : target.idx + 1;
|
if (next) setItems(next);
|
||||||
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
|
|
||||||
|
|
||||||
const [moved] = sectionItems.splice(fromIdx, 1);
|
|
||||||
sectionItems.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
|
||||||
|
|
||||||
// Merge reordered section back into flat items array.
|
|
||||||
// Only update positions of the *visible* items (same filter as libraryItems/systemItems)
|
|
||||||
// so hidden entries like randomMix/randomAlbums are never overwritten with undefined.
|
|
||||||
const all = [...itemsRef.current];
|
|
||||||
const visibleIds = new Set(sectionItems.map(c => c.id));
|
|
||||||
const positions = all.map((cfg, i) => ({ cfg, i }))
|
|
||||||
.filter(({ cfg }) => visibleIds.has(cfg.id))
|
|
||||||
.map(({ i }) => i);
|
|
||||||
positions.forEach((pos, i) => { all[pos] = sectionItems[i]; });
|
|
||||||
setItems(all);
|
|
||||||
};
|
};
|
||||||
el.addEventListener('psy-drop', onPsyDrop);
|
el.addEventListener('psy-drop', onPsyDrop);
|
||||||
return () => el.removeEventListener('psy-drop', onPsyDrop);
|
return () => el.removeEventListener('psy-drop', onPsyDrop);
|
||||||
}, [libraryItems, systemItems, setItems]);
|
}, [libraryItems, systemItems, setItems, randomNavMode]);
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent) => {
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
if (!isPsyDragging || !containerRef.current) return;
|
if (!isPsyDragging || !containerRef.current) return;
|
||||||
|
|||||||
@@ -435,6 +435,53 @@ body.is-overlay-scrollbar-thumb-drag .resizer {
|
|||||||
padding: var(--space-4) var(--space-3) var(--space-2);
|
padding: var(--space-4) var(--space-3) var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Long-press reorder targets (library / system blocks in Sidebar) */
|
||||||
|
.sidebar-nav-dnd-row {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-dnd-row--dragging {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-dnd-row--drop-before::before,
|
||||||
|
.sidebar-nav-dnd-row--drop-after::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
right: 6px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 1px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-dnd-row--drop-before::before {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-dnd-row--drop-after::after {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag-out-of-sidebar: hide item — trash follows pointer */
|
||||||
|
.sidebar-nav-dnd-trash-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100002;
|
||||||
|
color: var(--danger, #e05555);
|
||||||
|
background: color-mix(in srgb, var(--danger, #e05555) 18%, var(--bg-card, #1e1e2e));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--danger, #e05555) 45%, transparent);
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||||
|
import type { SidebarItemConfig } from '../store/sidebarStore';
|
||||||
|
|
||||||
|
export type SidebarNavSection = 'library' | 'system';
|
||||||
|
|
||||||
|
export type SidebarNavDropTarget = {
|
||||||
|
idx: number;
|
||||||
|
before: boolean;
|
||||||
|
section: SidebarNavSection;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getLibraryItemsForReorder(
|
||||||
|
items: SidebarItemConfig[],
|
||||||
|
randomNavMode: 'hub' | 'separate',
|
||||||
|
): SidebarItemConfig[] {
|
||||||
|
return items.filter(cfg => {
|
||||||
|
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
|
||||||
|
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||||
|
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSystemItemsForReorder(items: SidebarItemConfig[]): SidebarItemConfig[] {
|
||||||
|
return items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same entries as in Settings toggles — safe to hide via drag-out. */
|
||||||
|
export function isSidebarNavItemUserHideable(id: string): boolean {
|
||||||
|
return Boolean(ALL_NAV_ITEMS[id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorders one sidebar section (library or system) like the Settings customizer.
|
||||||
|
* Returns a new `items` array, or null if nothing changes.
|
||||||
|
*/
|
||||||
|
export function applySidebarDropReorder(
|
||||||
|
allItems: SidebarItemConfig[],
|
||||||
|
section: SidebarNavSection,
|
||||||
|
fromIdx: number,
|
||||||
|
target: SidebarNavDropTarget | null,
|
||||||
|
randomNavMode: 'hub' | 'separate',
|
||||||
|
): SidebarItemConfig[] | null {
|
||||||
|
if (!target || target.section !== section) return null;
|
||||||
|
|
||||||
|
const sectionItems =
|
||||||
|
section === 'library'
|
||||||
|
? [...getLibraryItemsForReorder(allItems, randomNavMode)]
|
||||||
|
: [...getSystemItemsForReorder(allItems)];
|
||||||
|
|
||||||
|
const insertBefore = target.before ? target.idx : target.idx + 1;
|
||||||
|
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return null;
|
||||||
|
|
||||||
|
const [moved] = sectionItems.splice(fromIdx, 1);
|
||||||
|
sectionItems.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
||||||
|
|
||||||
|
const visibleIds = new Set(sectionItems.map(c => c.id));
|
||||||
|
const next = [...allItems];
|
||||||
|
const positions = next
|
||||||
|
.map((cfg, i) => ({ cfg, i }))
|
||||||
|
.filter(({ cfg }) => visibleIds.has(cfg.id))
|
||||||
|
.map(({ i }) => i);
|
||||||
|
positions.forEach((pos, i) => {
|
||||||
|
next[pos] = sectionItems[i];
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user