285 lines
9.3 KiB
TypeScript
285 lines
9.3 KiB
TypeScript
import React, { ChangeEvent } from 'react';
|
|
import {
|
|
Box,
|
|
Flex,
|
|
Text,
|
|
Button,
|
|
IconButton,
|
|
HStack,
|
|
Menu,
|
|
MenuButton,
|
|
MenuList,
|
|
MenuItem,
|
|
MenuDivider,
|
|
Checkbox
|
|
} from '@chakra-ui/react';
|
|
import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input";
|
|
import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons";
|
|
import type { Song, PlaylistNode } from "../types/interfaces";
|
|
import { useState, useCallback, useMemo } from "react";
|
|
import type { MouseEvent } from 'react';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { formatDuration, formatTotalDuration } from '../utils/formatters';
|
|
|
|
interface SongListProps {
|
|
songs: Song[];
|
|
onAddToPlaylist: (songIds: string[], playlistName: string) => void;
|
|
onRemoveFromPlaylist?: (songIds: string[]) => void;
|
|
playlists: PlaylistNode[];
|
|
onSongSelect: (song: Song) => void;
|
|
selectedSongId: string | null;
|
|
currentPlaylist: string | null;
|
|
depth?: number;
|
|
}
|
|
|
|
export const SongList: React.FC<SongListProps> = ({
|
|
songs,
|
|
onAddToPlaylist,
|
|
onRemoveFromPlaylist,
|
|
playlists,
|
|
onSongSelect,
|
|
selectedSongId,
|
|
currentPlaylist,
|
|
depth = 0
|
|
}) => {
|
|
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
// 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;
|
|
}, []);
|
|
|
|
// Get flattened list of all playlists
|
|
const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]);
|
|
|
|
const filteredSongs = useMemo(() => {
|
|
if (!searchQuery) return songs;
|
|
const query = searchQuery.toLowerCase();
|
|
return songs.filter(
|
|
song =>
|
|
song.title.toLowerCase().includes(query) ||
|
|
song.artist.toLowerCase().includes(query)
|
|
);
|
|
}, [songs, searchQuery]);
|
|
|
|
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 = (playlistName: string) => {
|
|
if (selectedSongs.size > 0) {
|
|
onAddToPlaylist(Array.from(selectedSongs), playlistName);
|
|
setSelectedSongs(new Set()); // Clear selection after action
|
|
}
|
|
};
|
|
|
|
// Calculate total duration
|
|
const totalDuration = useMemo(() => {
|
|
return filteredSongs.reduce((total, song) => {
|
|
if (!song.totalTime) return total;
|
|
// Convert to seconds, handling milliseconds if present
|
|
const seconds = Math.floor(Number(song.totalTime) / (song.totalTime.length > 4 ? 1000 : 1));
|
|
return total + seconds;
|
|
}, 0);
|
|
}, [filteredSongs]);
|
|
|
|
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 or artist..."
|
|
value={searchQuery}
|
|
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchQuery(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 === filteredSongs.length}
|
|
isIndeterminate={selectedSongs.size > 0 && selectedSongs.size < filteredSongs.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">
|
|
{filteredSongs.length} song{filteredSongs.length === 1 ? '' : 's'} • {formatTotalDuration(totalDuration)}
|
|
</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={() => {
|
|
onRemoveFromPlaylist(Array.from(selectedSongs));
|
|
setSelectedSongs(new Set());
|
|
}}
|
|
>
|
|
Remove from {currentPlaylist}
|
|
</MenuItem>
|
|
</>
|
|
)}
|
|
</MenuList>
|
|
</Menu>
|
|
)}
|
|
</Flex>
|
|
</Box>
|
|
|
|
{/* Scrollable Song List */}
|
|
<Box flex={1} overflowY="auto" mt={2}>
|
|
<Flex direction="column" gap={2}>
|
|
{filteredSongs.map((song) => (
|
|
<Flex
|
|
key={song.id}
|
|
alignItems="center"
|
|
p={2}
|
|
pl={depth > 0 ? 4 + (depth * 4) : 2}
|
|
borderBottom="1px"
|
|
borderColor="gray.700"
|
|
bg={selectedSongId === song.id ? "gray.700" : "transparent"}
|
|
_hover={{ bg: "gray.800", cursor: "pointer" }}
|
|
onClick={() => onSongSelect(song)}
|
|
>
|
|
<Checkbox
|
|
isChecked={selectedSongs.has(song.id)}
|
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
e.stopPropagation();
|
|
toggleSelection(song.id);
|
|
}}
|
|
mr={4}
|
|
onClick={(e) => e.stopPropagation()}
|
|
size={depth > 0 ? "sm" : "md"}
|
|
/>
|
|
<Box flex="1">
|
|
<Text
|
|
fontWeight="bold"
|
|
color={selectedSongId === song.id ? "white" : "gray.100"}
|
|
fontSize={depth > 0 ? "sm" : "md"}
|
|
>
|
|
{song.title}
|
|
</Text>
|
|
<Text
|
|
fontSize={depth > 0 ? "xs" : "sm"}
|
|
color={selectedSongId === song.id ? "gray.300" : "gray.500"}
|
|
>
|
|
{song.artist} • {formatDuration(song.totalTime)}
|
|
</Text>
|
|
</Box>
|
|
<Menu>
|
|
<MenuButton
|
|
as={IconButton}
|
|
aria-label="Options"
|
|
icon={<ChevronDownIcon />}
|
|
variant="ghost"
|
|
onClick={(e) => e.stopPropagation()}
|
|
size={depth > 0 ? "xs" : "sm"}
|
|
ml="auto"
|
|
/>
|
|
<MenuList>
|
|
{allPlaylists.map((playlist) => (
|
|
<MenuItem
|
|
key={playlist.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onAddToPlaylist([song.id], playlist.name);
|
|
}}
|
|
>
|
|
Add to {playlist.name}
|
|
</MenuItem>
|
|
))}
|
|
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
|
|
<>
|
|
<MenuDivider />
|
|
<MenuItem
|
|
color="red.300"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRemoveFromPlaylist([song.id]);
|
|
}}
|
|
>
|
|
Remove from {currentPlaylist}
|
|
</MenuItem>
|
|
</>
|
|
)}
|
|
</MenuList>
|
|
</Menu>
|
|
</Flex>
|
|
))}
|
|
</Flex>
|
|
</Box>
|
|
</Flex>
|
|
);
|
|
};
|