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