feat: Add searchable playlist selection modal

- Create PlaylistSelectionModal component with search functionality
- Replace long dropdown menu with 'Add to Playlist...' button
- Add search bar to easily find playlists by name
- Show playlist track counts in the modal
- Add success toast notifications when songs are added
- Support nested playlists (folders) in search results
- Improve UX for users with many playlists

The actions dropdown is now much cleaner and finding playlists is easier
with the searchable modal interface.
This commit is contained in:
Geert Rademakes 2025-08-06 15:25:36 +02:00
parent a28fe003a0
commit a3d1b4d211
2 changed files with 204 additions and 40 deletions

View File

@ -1,27 +1,30 @@
import React, { useState, useCallback, useMemo, useRef, useEffect, memo } from 'react';
import { useDebounce } from '../hooks/useDebounce';
import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react';
import {
Box,
Flex,
Text,
Input,
InputGroup,
InputLeftElement,
Checkbox,
Button,
IconButton,
HStack,
Menu,
MenuButton,
MenuList,
MenuItem,
MenuDivider,
IconButton,
Checkbox,
Tooltip,
Spinner,
useDisclosure,
Input,
InputGroup,
InputLeftElement,
} from '@chakra-ui/react';
import { Search2Icon, ChevronDownIcon } from '@chakra-ui/icons';
import { FiPlay } from 'react-icons/fi';
import type { Song, PlaylistNode } from '../types/interfaces';
import { formatDuration, formatTotalDuration } from '../utils/formatters';
import { FiPlay } from 'react-icons/fi';
import { useDebounce } from '../hooks/useDebounce';
import { PlaylistSelectionModal } from './PlaylistSelectionModal';
interface PaginatedSongListProps {
songs: Song[];
@ -150,6 +153,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
}) => {
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
const { isOpen: isPlaylistModalOpen, onOpen: onPlaylistModalOpen, onClose: onPlaylistModalClose } = useDisclosure();
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -386,41 +390,27 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
</HStack>
{selectedSongs.size > 0 && (
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
<HStack spacing={2}>
<Button
size="sm"
colorScheme="blue"
onClick={onPlaylistModalOpen}
>
Actions
</MenuButton>
<MenuList>
{allPlaylists.map((playlist) => (
<MenuItem
key={playlist.id}
onClick={() => {
handleBulkAddToPlaylist(playlist.name);
}}
>
Add to {playlist.name}
</MenuItem>
))}
Add to Playlist...
</Button>
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
<>
<MenuDivider />
<MenuItem
color="red.300"
<Button
size="sm"
variant="outline"
colorScheme="red"
onClick={() => {
handleBulkRemoveFromPlaylist();
}}
>
Remove from {currentPlaylist}
</MenuItem>
</>
</Button>
)}
</MenuList>
</Menu>
</HStack>
)}
</Flex>
</Box>
@ -477,6 +467,15 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
)}
</Flex>
</Box>
{/* Playlist Selection Modal */}
<PlaylistSelectionModal
isOpen={isPlaylistModalOpen}
onClose={onPlaylistModalClose}
playlists={playlists}
onPlaylistSelect={handleBulkAddToPlaylist}
selectedSongCount={selectedSongs.size}
/>
</Flex>
);
});

View File

@ -0,0 +1,165 @@
import React, { useState, useMemo } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
Input,
InputGroup,
InputLeftElement,
VStack,
HStack,
Text,
Icon,
useToast,
Box,
Divider,
} from '@chakra-ui/react';
import { SearchIcon, FolderIcon } from '@chakra-ui/icons';
import { FiFolder, FiMusic } from 'react-icons/fi';
import type { PlaylistNode } from '../types/interfaces';
interface PlaylistSelectionModalProps {
isOpen: boolean;
onClose: () => void;
playlists: PlaylistNode[];
onPlaylistSelect: (playlistName: string) => void;
selectedSongCount: number;
}
export const PlaylistSelectionModal: React.FC<PlaylistSelectionModalProps> = ({
isOpen,
onClose,
playlists,
onPlaylistSelect,
selectedSongCount,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const toast = useToast();
// Flatten all playlists (including nested ones) for search
const allPlaylists = useMemo(() => {
const flattenPlaylists = (nodes: PlaylistNode[]): PlaylistNode[] => {
const result: PlaylistNode[] = [];
for (const node of nodes) {
if (node.type === 'playlist') {
result.push(node);
} else if (node.type === 'folder' && node.children) {
result.push(...flattenPlaylists(node.children));
}
}
return result;
};
return flattenPlaylists(playlists);
}, [playlists]);
// Filter playlists based on search query
const filteredPlaylists = useMemo(() => {
if (!searchQuery.trim()) return allPlaylists;
const query = searchQuery.toLowerCase();
return allPlaylists.filter(playlist =>
playlist.name.toLowerCase().includes(query)
);
}, [allPlaylists, searchQuery]);
const handlePlaylistSelect = (playlistName: string) => {
onPlaylistSelect(playlistName);
onClose();
setSearchQuery('');
toast({
title: 'Songs Added',
description: `${selectedSongCount} song${selectedSongCount !== 1 ? 's' : ''} added to "${playlistName}"`,
status: 'success',
duration: 3000,
isClosable: true,
});
};
const handleClose = () => {
onClose();
setSearchQuery('');
};
return (
<Modal isOpen={isOpen} onClose={handleClose} size="md">
<ModalOverlay />
<ModalContent bg="gray.800" borderColor="gray.700" borderWidth="1px">
<ModalHeader color="white">
Add to Playlist
<Text fontSize="sm" color="gray.400" fontWeight="normal" mt={1}>
Select a playlist to add {selectedSongCount} song{selectedSongCount !== 1 ? 's' : ''}
</Text>
</ModalHeader>
<ModalBody>
<VStack spacing={4} align="stretch">
{/* Search Input */}
<InputGroup>
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.400" />
</InputLeftElement>
<Input
placeholder="Search playlists..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
bg="gray.700"
borderColor="gray.600"
color="white"
_placeholder={{ color: 'gray.400' }}
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
_hover={{ borderColor: 'gray.500' }}
/>
</InputGroup>
{/* Playlist List */}
<Box maxH="300px" overflowY="auto">
{filteredPlaylists.length === 0 ? (
<Text color="gray.400" textAlign="center" py={4}>
{searchQuery ? 'No playlists found' : 'No playlists available'}
</Text>
) : (
<VStack spacing={1} align="stretch">
{filteredPlaylists.map((playlist) => (
<Box
key={playlist.id}
p={3}
borderRadius="md"
bg="gray.700"
cursor="pointer"
_hover={{ bg: 'gray.600' }}
onClick={() => handlePlaylistSelect(playlist.name)}
transition="background-color 0.2s"
>
<HStack spacing={3}>
<Icon as={FiMusic} color="blue.400" />
<VStack align="start" spacing={0} flex={1}>
<Text fontWeight="medium" color="white">
{playlist.name}
</Text>
<Text fontSize="sm" color="gray.400">
{playlist.tracks?.length || 0} tracks
</Text>
</VStack>
</HStack>
</Box>
))}
</VStack>
)}
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={handleClose} color="gray.400">
Cancel
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};