2025-04-25 10:29:24 +02:00

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>
);
};