426 lines
13 KiB
TypeScript
426 lines
13 KiB
TypeScript
import React, { useState, useCallback, useMemo, useRef, useEffect, memo } from 'react';
|
|
import { useDebounce } from '../hooks/useDebounce';
|
|
import {
|
|
Box,
|
|
Flex,
|
|
Text,
|
|
Input,
|
|
InputGroup,
|
|
InputLeftElement,
|
|
Checkbox,
|
|
Button,
|
|
HStack,
|
|
Menu,
|
|
MenuButton,
|
|
MenuList,
|
|
MenuItem,
|
|
MenuDivider,
|
|
IconButton,
|
|
Spinner,
|
|
|
|
} from '@chakra-ui/react';
|
|
import { Search2Icon, ChevronDownIcon } from '@chakra-ui/icons';
|
|
import type { Song, PlaylistNode } from '../types/interfaces';
|
|
import { formatDuration, formatTotalDuration } from '../utils/formatters';
|
|
|
|
interface PaginatedSongListProps {
|
|
songs: Song[];
|
|
onAddToPlaylist: (songIds: string[], playlistName: string) => void;
|
|
onRemoveFromPlaylist?: (songIds: string[]) => void;
|
|
playlists: PlaylistNode[];
|
|
onSongSelect: (song: Song) => void;
|
|
selectedSongId: string | null;
|
|
currentPlaylist: string | null;
|
|
loading: boolean;
|
|
hasMore: boolean;
|
|
totalSongs: number;
|
|
onLoadMore: () => void;
|
|
onSearch: (query: string) => void;
|
|
searchQuery: string;
|
|
depth?: number;
|
|
}
|
|
|
|
// Memoized song item component to prevent unnecessary re-renders
|
|
const SongItem = memo<{
|
|
song: Song;
|
|
isSelected: boolean;
|
|
isHighlighted: boolean;
|
|
onSelect: (song: Song) => void;
|
|
onToggleSelection: (songId: string) => void;
|
|
showCheckbox: boolean;
|
|
}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox }) => {
|
|
const handleClick = useCallback(() => {
|
|
onSelect(song);
|
|
}, [onSelect, song]);
|
|
|
|
const handleCheckboxClick = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
e.stopPropagation();
|
|
onToggleSelection(song.id);
|
|
}, [onToggleSelection, song.id]);
|
|
|
|
return (
|
|
<Flex
|
|
key={song.id}
|
|
p={3}
|
|
borderBottom="1px"
|
|
borderColor="gray.700"
|
|
cursor="pointer"
|
|
bg={isHighlighted ? "blue.900" : "transparent"}
|
|
_hover={{ bg: isHighlighted ? "blue.800" : "gray.700" }}
|
|
onClick={handleClick}
|
|
transition="background-color 0.2s"
|
|
>
|
|
{showCheckbox && (
|
|
<Checkbox
|
|
isChecked={isSelected}
|
|
onChange={handleCheckboxClick}
|
|
mr={3}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
)}
|
|
<Box flex={1} minW={0}>
|
|
<Text fontSize="sm" fontWeight="medium" color="white" noOfLines={1}>
|
|
{song.title}
|
|
</Text>
|
|
<Text fontSize="xs" color="gray.400" noOfLines={1}>
|
|
{song.artist}
|
|
</Text>
|
|
</Box>
|
|
<Box textAlign="right" ml={2}>
|
|
<Text fontSize="xs" color="gray.500">
|
|
{formatDuration(song.totalTime || '')}
|
|
</Text>
|
|
<Text fontSize="xs" color="gray.600">
|
|
{song.averageBpm} BPM
|
|
</Text>
|
|
</Box>
|
|
</Flex>
|
|
);
|
|
});
|
|
|
|
SongItem.displayName = 'SongItem';
|
|
|
|
export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|
songs,
|
|
onAddToPlaylist,
|
|
onRemoveFromPlaylist,
|
|
playlists,
|
|
onSongSelect,
|
|
selectedSongId,
|
|
currentPlaylist,
|
|
loading,
|
|
hasMore,
|
|
totalSongs,
|
|
onLoadMore,
|
|
onSearch,
|
|
searchQuery,
|
|
depth = 0
|
|
}) => {
|
|
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
|
|
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
const loadingRef = useRef<HTMLDivElement>(null);
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
const isTriggeringRef = useRef(false);
|
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Store current values in refs to avoid stale closures
|
|
const hasMoreRef = useRef(hasMore);
|
|
const loadingRef_state = useRef(loading);
|
|
const onLoadMoreRef = useRef(onLoadMore);
|
|
|
|
// Update refs when props change
|
|
useEffect(() => {
|
|
hasMoreRef.current = hasMore;
|
|
loadingRef_state.current = loading;
|
|
onLoadMoreRef.current = onLoadMore;
|
|
}, [hasMore, loading, onLoadMore]);
|
|
|
|
// Debounce search to prevent excessive API calls
|
|
const debouncedSearchQuery = useDebounce(localSearchQuery, 300);
|
|
|
|
// Memoized helper function to get all playlists (excluding folders) from the playlist tree
|
|
const getAllPlaylists = useCallback((nodes: PlaylistNode[]): PlaylistNode[] => {
|
|
let result: PlaylistNode[] = [];
|
|
for (const node of nodes) {
|
|
if (node.type === 'playlist') {
|
|
result.push(node);
|
|
} else if (node.type === 'folder' && node.children) {
|
|
result = result.concat(getAllPlaylists(node.children));
|
|
}
|
|
}
|
|
return result;
|
|
}, []);
|
|
|
|
// Memoized flattened list of all playlists
|
|
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;
|
|
});
|
|
}, []);
|
|
|
|
const toggleSelectAll = useCallback(() => {
|
|
setSelectedSongs(prev =>
|
|
prev.size === songs.length ? new Set() : new Set(songs.map(s => s.id))
|
|
);
|
|
}, [songs]);
|
|
|
|
const handleBulkAddToPlaylist = useCallback((playlistName: string) => {
|
|
if (selectedSongs.size > 0) {
|
|
onAddToPlaylist(Array.from(selectedSongs), playlistName);
|
|
setSelectedSongs(new Set()); // Clear selection after action
|
|
}
|
|
}, [selectedSongs, onAddToPlaylist]);
|
|
|
|
const handleBulkRemoveFromPlaylist = useCallback(() => {
|
|
if (selectedSongs.size > 0 && onRemoveFromPlaylist) {
|
|
onRemoveFromPlaylist(Array.from(selectedSongs));
|
|
setSelectedSongs(new Set()); // Clear selection after action
|
|
}
|
|
}, [selectedSongs, onRemoveFromPlaylist]);
|
|
|
|
// Memoized song selection handler
|
|
const handleSongSelect = useCallback((song: Song) => {
|
|
onSongSelect(song);
|
|
}, [onSongSelect]);
|
|
|
|
// Memoized search handler with debouncing
|
|
const handleSearch = useCallback((query: string) => {
|
|
setLocalSearchQuery(query);
|
|
onSearch(query);
|
|
}, [onSearch]);
|
|
|
|
// Memoized song items to prevent unnecessary re-renders
|
|
const songItems = useMemo(() => {
|
|
return songs.map(song => (
|
|
<SongItem
|
|
key={song.id}
|
|
song={song}
|
|
isSelected={selectedSongs.has(song.id)}
|
|
isHighlighted={selectedSongId === song.id}
|
|
onSelect={handleSongSelect}
|
|
onToggleSelection={toggleSelection}
|
|
showCheckbox={selectedSongs.size > 0 || depth === 0}
|
|
/>
|
|
));
|
|
}, [songs, selectedSongs, selectedSongId, handleSongSelect, toggleSelection, depth]);
|
|
|
|
// Memoized total duration calculation
|
|
const totalDuration = useMemo(() => {
|
|
const totalSeconds = songs.reduce((total, song) => {
|
|
if (!song.totalTime) return total;
|
|
const seconds = Math.floor(Number(song.totalTime) / (song.totalTime.length > 4 ? 1000 : 1));
|
|
return total + seconds;
|
|
}, 0);
|
|
return formatTotalDuration(totalSeconds);
|
|
}, [songs]);
|
|
|
|
// Memoized playlist options for bulk actions
|
|
const playlistOptions = useMemo(() => {
|
|
return allPlaylists.map(playlist => (
|
|
<MenuItem key={playlist.id} onClick={() => handleBulkAddToPlaylist(playlist.name)}>
|
|
{playlist.name}
|
|
</MenuItem>
|
|
));
|
|
}, [allPlaylists, handleBulkAddToPlaylist]);
|
|
|
|
// Handle debounced search
|
|
useEffect(() => {
|
|
if (debouncedSearchQuery !== searchQuery) {
|
|
onSearch(debouncedSearchQuery);
|
|
}
|
|
}, [debouncedSearchQuery, searchQuery, onSearch]);
|
|
|
|
// Intersection Observer for infinite scroll
|
|
useEffect(() => {
|
|
if (loadingRef.current) {
|
|
observerRef.current = new IntersectionObserver(
|
|
(entries) => {
|
|
// Use current values from refs to avoid stale closure
|
|
if (entries[0].isIntersecting && hasMoreRef.current && !loadingRef_state.current && !isTriggeringRef.current) {
|
|
isTriggeringRef.current = true;
|
|
onLoadMoreRef.current();
|
|
// Reset the flag after a short delay to prevent multiple triggers
|
|
timeoutRef.current = setTimeout(() => {
|
|
isTriggeringRef.current = false;
|
|
}, 1000);
|
|
}
|
|
},
|
|
{
|
|
threshold: 0.1,
|
|
rootMargin: '100px' // Start loading when 100px away from the bottom
|
|
}
|
|
);
|
|
|
|
observerRef.current.observe(loadingRef.current);
|
|
}
|
|
|
|
return () => {
|
|
if (observerRef.current) {
|
|
observerRef.current.disconnect();
|
|
}
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
};
|
|
}, []); // Remove dependencies to prevent recreation
|
|
|
|
return (
|
|
<Flex direction="column" height="100%">
|
|
{/* Sticky Header */}
|
|
<Box
|
|
position="sticky"
|
|
top={0}
|
|
bg="gray.900"
|
|
zIndex={1}
|
|
pb={4}
|
|
>
|
|
{/* Search Bar */}
|
|
<InputGroup mb={4}>
|
|
<InputLeftElement pointerEvents="none">
|
|
<Search2Icon color="gray.300" />
|
|
</InputLeftElement>
|
|
<Input
|
|
placeholder="Search songs by title, artist, album, or genre..."
|
|
value={localSearchQuery}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLocalSearchQuery(e.target.value)}
|
|
bg="gray.800"
|
|
borderColor="gray.600"
|
|
_hover={{ borderColor: "gray.500" }}
|
|
_focus={{ borderColor: "blue.300", boxShadow: "0 0 0 1px var(--chakra-colors-blue-300)" }}
|
|
/>
|
|
</InputGroup>
|
|
|
|
{/* Bulk Actions Toolbar */}
|
|
<Flex justify="space-between" align="center" p={2} bg="gray.800" borderRadius="md">
|
|
<HStack spacing={4}>
|
|
<Checkbox
|
|
isChecked={selectedSongs.size === songs.length && songs.length > 0}
|
|
isIndeterminate={selectedSongs.size > 0 && selectedSongs.size < songs.length}
|
|
onChange={toggleSelectAll}
|
|
colorScheme="blue"
|
|
sx={{
|
|
'& > span:first-of-type': {
|
|
opacity: 1,
|
|
border: '2px solid',
|
|
borderColor: 'gray.500'
|
|
}
|
|
}}
|
|
>
|
|
{selectedSongs.size === 0
|
|
? "Select All"
|
|
: `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`}
|
|
</Checkbox>
|
|
<Text color="gray.400" fontSize="sm">
|
|
{songs.length} of {totalSongs} songs • {totalDuration}
|
|
{hasMore && songs.length > 0 && (
|
|
<Text as="span" color="blue.400" ml={2}>
|
|
• Scroll for more
|
|
</Text>
|
|
)}
|
|
</Text>
|
|
</HStack>
|
|
|
|
{selectedSongs.size > 0 && (
|
|
<Menu>
|
|
<MenuButton
|
|
as={Button}
|
|
rightIcon={<ChevronDownIcon />}
|
|
size="sm"
|
|
colorScheme="blue"
|
|
>
|
|
Actions
|
|
</MenuButton>
|
|
<MenuList>
|
|
{allPlaylists.map((playlist) => (
|
|
<MenuItem
|
|
key={playlist.id}
|
|
onClick={() => {
|
|
handleBulkAddToPlaylist(playlist.name);
|
|
}}
|
|
>
|
|
Add to {playlist.name}
|
|
</MenuItem>
|
|
))}
|
|
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
|
|
<>
|
|
<MenuDivider />
|
|
<MenuItem
|
|
color="red.300"
|
|
onClick={() => {
|
|
handleBulkRemoveFromPlaylist();
|
|
}}
|
|
>
|
|
Remove from {currentPlaylist}
|
|
</MenuItem>
|
|
</>
|
|
)}
|
|
</MenuList>
|
|
</Menu>
|
|
)}
|
|
</Flex>
|
|
</Box>
|
|
|
|
{/* Scrollable Song List */}
|
|
<Box
|
|
ref={scrollContainerRef}
|
|
flex={1}
|
|
overflowY="auto"
|
|
mt={2}
|
|
id="song-list-container"
|
|
>
|
|
<Flex direction="column" gap={2}>
|
|
{songItems}
|
|
|
|
{/* Loading indicator for infinite scroll */}
|
|
{loading && (
|
|
<Flex justify="center" align="center" p={6} key="loading-spinner" gap={3}>
|
|
<Spinner size="md" color="blue.400" />
|
|
<Text color="gray.400" fontSize="sm">
|
|
Loading more songs...
|
|
</Text>
|
|
</Flex>
|
|
)}
|
|
|
|
{/* Intersection observer target */}
|
|
<div ref={loadingRef} style={{ height: '20px' }} key="intersection-target" />
|
|
|
|
{/* Loading more indicator (subtle) */}
|
|
{!loading && hasMore && songs.length > 0 && (
|
|
<Flex justify="center" p={3} key="loading-more-indicator">
|
|
<Text color="gray.600" fontSize="xs">
|
|
Scroll for more songs
|
|
</Text>
|
|
</Flex>
|
|
)}
|
|
|
|
{/* End of results message */}
|
|
{!hasMore && songs.length > 0 && (
|
|
<Flex justify="center" p={4} key="end-message">
|
|
<Text color="gray.500" fontSize="sm">
|
|
No more songs to load
|
|
</Text>
|
|
</Flex>
|
|
)}
|
|
|
|
{/* No results message */}
|
|
{!loading && songs.length === 0 && (
|
|
<Flex justify="center" p={8} key="no-results">
|
|
<Text color="gray.500">
|
|
{searchQuery ? 'No songs found matching your search' : 'No songs available'}
|
|
</Text>
|
|
</Flex>
|
|
)}
|
|
</Flex>
|
|
</Box>
|
|
</Flex>
|
|
);
|
|
});
|