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:
parent
a28fe003a0
commit
a3d1b4d211
@ -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>
|
||||
))}
|
||||
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
color="red.300"
|
||||
onClick={() => {
|
||||
handleBulkRemoveFromPlaylist();
|
||||
}}
|
||||
>
|
||||
Remove from {currentPlaylist}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
Add to Playlist...
|
||||
</Button>
|
||||
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
onClick={() => {
|
||||
handleBulkRemoveFromPlaylist();
|
||||
}}
|
||||
>
|
||||
Remove from {currentPlaylist}
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
});
|
||||
165
packages/frontend/src/components/PlaylistSelectionModal.tsx
Normal file
165
packages/frontend/src/components/PlaylistSelectionModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user