diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 7df6c84..b36f486 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -587,6 +587,14 @@ const RekordboxReader: React.FC = () => { borderColor="gray.700" overflowY="auto" bg="gray.900" + sx={{ + scrollbarColor: 'var(--chakra-colors-gray-700) var(--chakra-colors-gray-900)', + scrollbarWidth: 'thin', + '&::-webkit-scrollbar': { width: '8px', backgroundColor: 'var(--chakra-colors-gray-900)' }, + '&::-webkit-scrollbar-thumb': { backgroundColor: 'var(--chakra-colors-gray-700)', borderRadius: '8px' }, + '&::-webkit-scrollbar-thumb:hover': { backgroundColor: 'var(--chakra-colors-gray-600)' }, + '&::-webkit-scrollbar-track': { backgroundColor: 'var(--chakra-colors-gray-900)' } + }} > {playlistManager} @@ -621,7 +629,14 @@ const RekordboxReader: React.FC = () => { _hover={{ color: "blue.300", bg: "whiteAlpha.200" }} /> - + {playlistManager} @@ -636,7 +651,14 @@ const RekordboxReader: React.FC = () => { overflow="hidden" > {/* Song List */} - + { bg="gray.900" minH={0} sx={{ - '&::-webkit-scrollbar': { - width: '8px', - borderRadius: '8px', - backgroundColor: 'gray.900', - }, - '&::-webkit-scrollbar-thumb': { - backgroundColor: 'gray.700', - borderRadius: '8px', - }, + scrollbarColor: 'var(--chakra-colors-gray-700) var(--chakra-colors-gray-900)', + scrollbarWidth: 'thin', + '&::-webkit-scrollbar': { width: '8px', backgroundColor: 'var(--chakra-colors-gray-900)' }, + '&::-webkit-scrollbar-thumb': { backgroundColor: 'var(--chakra-colors-gray-700)', borderRadius: '8px' }, + '&::-webkit-scrollbar-thumb:hover': { backgroundColor: 'var(--chakra-colors-gray-600)' }, + '&::-webkit-scrollbar-track': { backgroundColor: 'var(--chakra-colors-gray-900)' }, }} > diff --git a/packages/frontend/src/components/PaginatedSongList.tsx b/packages/frontend/src/components/PaginatedSongList.tsx index edd6512..0274ff7 100644 --- a/packages/frontend/src/components/PaginatedSongList.tsx +++ b/packages/frontend/src/components/PaginatedSongList.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react'; +import React, { useState, useRef, useEffect, useCallback, useMemo, memo, startTransition } from 'react'; import { Box, Flex, @@ -58,17 +58,29 @@ const SongItem = memo<{ onRowDragOver?: (e: React.DragEvent) => void; onRowDrop?: (e: React.DragEvent) => void; onRowDragStartCapture?: (e: React.DragEvent) => void; -}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, showDropIndicatorTop, onDragStart, onRowDragOver, onRowDrop, onRowDragStartCapture }) => { + index: number; + onCheckboxToggle?: (index: number, checked: boolean, shift: boolean) => void; +}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, showDropIndicatorTop, onDragStart, onRowDragOver, onRowDrop, onRowDragStartCapture, index, onCheckboxToggle }) => { // Memoize the formatted duration to prevent recalculation const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]); + // Local optimistic selection for instant visual feedback + const [localChecked, setLocalChecked] = useState(isSelected); + useEffect(() => { + setLocalChecked(isSelected); + }, [isSelected]); const handleClick = useCallback(() => { onSelect(song); }, [onSelect, song]); const handleCheckboxClick = useCallback((e: React.ChangeEvent) => { e.stopPropagation(); - onToggleSelection(song.id); - }, [onToggleSelection, song.id]); + setLocalChecked(e.target.checked); + if (onCheckboxToggle) { + onCheckboxToggle(index, e.target.checked, (e as any).nativeEvent?.shiftKey === true || (e as any).shiftKey === true); + } else { + onToggleSelection(song.id); + } + }, [onCheckboxToggle, index, onToggleSelection, song.id]); const handlePlayClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); @@ -103,7 +115,7 @@ const SongItem = memo<{ )} {showCheckbox && ( e.stopPropagation()} @@ -176,6 +188,7 @@ export const PaginatedSongList: React.FC = memo(({ const [dragHoverIndex, setDragHoverIndex] = useState(null); const [endDropHover, setEndDropHover] = useState(false); const [isReorderDragging, setIsReorderDragging] = useState(false); + const lastSelectedIndexRef = useRef(null); // Store current values in refs to avoid stale closures const hasMoreRef = useRef(hasMore); @@ -211,31 +224,60 @@ export const PaginatedSongList: React.FC = memo(({ // const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]); const toggleSelection = useCallback((songId: string) => { - setSelectedSongs(prev => { - const newSelection = new Set(prev); - if (newSelection.has(songId)) { - newSelection.delete(songId); - } else { - newSelection.add(songId); - } - return newSelection; + startTransition(() => { + setSelectedSongs(prev => { + const newSelection = new Set(prev); + if (newSelection.has(songId)) { + newSelection.delete(songId); + } else { + newSelection.add(songId); + } + return newSelection; + }); }); }, []); + // Range selection using shift-click between last selected and current + const toggleSelectionRange = useCallback((fromIndex: number, toIndex: number, checked: boolean) => { + if (fromIndex === null || toIndex === null) return; + const [start, end] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex]; + startTransition(() => { + setSelectedSongs(prev => { + const next = new Set(prev); + for (let i = start; i <= end; i++) { + const id = songs[i]?.id; + if (!id) continue; + if (checked) next.add(id); else next.delete(id); + } + return next; + }); + }); + }, [songs]); + + const handleCheckboxToggle = useCallback((index: number, checked: boolean, shift: boolean) => { + const song = songs[index]; + if (!song) return; + if (shift && lastSelectedIndexRef.current !== null) { + toggleSelectionRange(lastSelectedIndexRef.current, index, checked); + } else { + toggleSelection(song.id); + } + lastSelectedIndexRef.current = index; + }, [songs, toggleSelection, toggleSelectionRange]); + const toggleSelectAll = useCallback(() => { - setSelectedSongs(prev => { - const noneSelected = prev.size === 0; - const allSelected = prev.size === songs.length && songs.length > 0; - if (noneSelected) { - // Select all from empty state + startTransition(() => { + setSelectedSongs(prev => { + const noneSelected = prev.size === 0; + const allSelected = prev.size === songs.length && songs.length > 0; + if (noneSelected) { + return new Set(songs.map(s => s.id)); + } + if (allSelected) { + return new Set(); + } return new Set(songs.map(s => s.id)); - } - if (allSelected) { - // Deselect all when everything is selected - return new Set(); - } - // Mixed/some selected: clear first, then select all (single state update reflects final state) - return new Set(songs.map(s => s.id)); + }); }); }, [songs]); @@ -460,6 +502,25 @@ export const PaginatedSongList: React.FC = memo(({ overflowY="auto" mt={2} id="song-list-container" + sx={{ + scrollbarColor: 'var(--chakra-colors-gray-700) var(--chakra-colors-gray-900)', + scrollbarWidth: 'thin', + '&::-webkit-scrollbar': { + width: '8px', + height: '8px', + backgroundColor: 'var(--chakra-colors-gray-900)' + }, + '&::-webkit-scrollbar-thumb': { + backgroundColor: 'var(--chakra-colors-gray-700)', + borderRadius: '8px', + }, + '&::-webkit-scrollbar-thumb:hover': { + backgroundColor: 'var(--chakra-colors-gray-600)' + }, + '&::-webkit-scrollbar-track': { + backgroundColor: 'var(--chakra-colors-gray-900)' + } + }} > @@ -484,6 +545,8 @@ export const PaginatedSongList: React.FC = memo(({ onSelect={handleSongSelect} onToggleSelection={toggleSelection} showCheckbox={selectedSongs.size > 0 || depth === 0} + index={index} + onCheckboxToggle={handleCheckboxToggle} onPlaySong={onPlaySong} showDropIndicatorTop={dragHoverIndex === index} onDragStart={handleDragStart}