diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index dd4a9a14..d244d2af 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -5,13 +5,13 @@ import { useOfflineStore } from '../store/offlineStore'; import { useOfflineJobStore } from '../store/offlineJobStore'; import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore'; import { useAuthStore } from '../store/authStore'; -import { useSidebarStore } from '../store/sidebarStore'; +import { useSidebarStore, type SidebarItemConfig } from '../store/sidebarStore'; import { NavLink } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Settings, PanelLeftClose, PanelLeft, AudioLines, HardDriveDownload, HardDriveUpload, - ChevronDown, Check, Music2, X, ChevronRight, PlayCircle, + ChevronDown, Check, Music2, X, ChevronRight, PlayCircle, Trash2, } from 'lucide-react'; import PsysonicLogo from './PsysonicLogo'; import PSmallLogo from './PSmallLogo'; @@ -20,6 +20,23 @@ import { getPlaylists } from '../api/subsonic'; import { usePlaylistStore } from '../store/playlistStore'; import { ALL_NAV_ITEMS } from '../config/navItems'; 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({ @@ -49,6 +66,7 @@ export default function Sidebar({ const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const sidebarItems = useSidebarStore(s => s.items); + const setSidebarItems = useSidebarStore(s => s.setItems); const randomNavMode = useAuthStore(s => s.randomNavMode); const [libraryDropdownOpen, setLibraryDropdownOpen] = 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; 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(null); + const navDropTargetRef = useRef(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>(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('.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 el = libraryTriggerRef.current; if (!el) return; @@ -122,22 +373,13 @@ export default function Sidebar({ fetchPlaylists(); }, [playlistsExpanded, isLoggedIn, fetchPlaylists]); - // Resolve ordered, visible items per section from store config - const visibleLibrary = sidebarItems - .filter(cfg => { - 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]); - + useEffect(() => () => { + longPressTimersRef.current.forEach(t => window.clearTimeout(t)); + longPressTimersRef.current.clear(); + }, []); return ( + <> + {navDndTrashHint != null && + createPortal( +
+ +
, + document.body, + )} + ); } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 1b50f124..383c42b7 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -35,6 +35,7 @@ import { useArtistLayoutStore, type ArtistSectionId, type ArtistSectionConfig } import { useHomeStore, HomeSectionId } from '../store/homeStore'; import { useDragDrop, useDragSource } from '../contexts/DragDropContext'; import { ALL_NAV_ITEMS } from '../config/navItems'; +import { applySidebarDropReorder } from '../utils/sidebarNavReorder'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; import { ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser, @@ -4420,29 +4421,13 @@ function SidebarCustomizer() { const fromSection = parsed.section as 'library' | 'system'; const target = dropTargetRef.current; dropTargetRef.current = null; setDropTarget(null); - if (!target || target.section !== fromSection) return; - const sectionItems = fromSection === 'library' ? [...libraryItems] : [...systemItems]; - const insertBefore = target.before ? target.idx : target.idx + 1; - 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); + const next = applySidebarDropReorder(itemsRef.current, fromSection, fromIdx, target, randomNavMode); + if (next) setItems(next); }; el.addEventListener('psy-drop', onPsyDrop); return () => el.removeEventListener('psy-drop', onPsyDrop); - }, [libraryItems, systemItems, setItems]); + }, [libraryItems, systemItems, setItems, randomNavMode]); const handleMouseMove = (e: React.MouseEvent) => { if (!isPsyDragging || !containerRef.current) return; diff --git a/src/styles/layout.css b/src/styles/layout.css index ad2b637d..1b55396a 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -435,6 +435,53 @@ body.is-overlay-scrollbar-thumb-drag .resizer { 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 { display: flex; align-items: center; diff --git a/src/utils/sidebarNavReorder.ts b/src/utils/sidebarNavReorder.ts new file mode 100644 index 00000000..eabb7856 --- /dev/null +++ b/src/utils/sidebarNavReorder.ts @@ -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; +}