feat: UX improvements for frontend

- Clear selected song when changing playlists (already implemented)
- Replace 'Add to playlist...' and 'Remove from playlist' buttons with 'Actions' dropdown in PaginatedSongList
- Add key (tonality) next to duration in song list display format (e.g., 1:16 - A8)
- Update song matching page title to include storage provider (e.g., 'Sync and Matching (WEBDAV)')
- Remove storage provider text from sync buttons for cleaner interface
- Fix bulk selection state clearing when switching playlists to prevent stale selections
This commit is contained in:
Geert Rademakes 2025-09-19 10:19:40 +02:00
parent e58d42bea2
commit aa04849442
3 changed files with 53 additions and 27 deletions

View File

@ -12,8 +12,13 @@ import {
Input, Input,
InputGroup, InputGroup,
InputLeftElement, InputLeftElement,
Menu,
MenuButton,
MenuList,
MenuItem,
MenuDivider,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { Search2Icon } from '@chakra-ui/icons'; import { Search2Icon, ChevronDownIcon } from '@chakra-ui/icons';
import { FiPlay } from 'react-icons/fi'; import { FiPlay } from 'react-icons/fi';
import type { Song, PlaylistNode } from '../types/interfaces'; import type { Song, PlaylistNode } from '../types/interfaces';
import { api } from '../services/api'; import { api } from '../services/api';
@ -131,7 +136,7 @@ const SongItem = memo<{
</Box> </Box>
<Box textAlign="right" ml={2}> <Box textAlign="right" ml={2}>
<Text fontSize="xs" color="gray.500"> <Text fontSize="xs" color="gray.500">
{formattedDuration} {formattedDuration}{song.tonality ? ` - ${song.tonality}` : ''}
</Text> </Text>
<Text fontSize="xs" color="gray.600"> <Text fontSize="xs" color="gray.600">
{song.averageBpm} BPM {song.averageBpm} BPM
@ -203,6 +208,13 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
onLoadMoreRef.current = onLoadMore; onLoadMoreRef.current = onLoadMore;
}, [hasMore, loading, onLoadMore]); }, [hasMore, loading, onLoadMore]);
// Clear selection when switching playlists
useEffect(() => {
if (isSwitchingPlaylist) {
setSelectedSongs(new Set());
}
}, [isSwitchingPlaylist]);
// Debounce search to prevent excessive API calls // Debounce search to prevent excessive API calls
const debouncedSearchQuery = useDebounce(localSearchQuery, 300); const debouncedSearchQuery = useDebounce(localSearchQuery, 300);
@ -522,27 +534,32 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
</HStack> </HStack>
{selectedSongs.size > 0 && ( {selectedSongs.size > 0 && (
<HStack spacing={2}> <Menu>
<Button <MenuButton
as={Button}
size="sm" size="sm"
colorScheme="blue" colorScheme="blue"
onClick={onPlaylistModalOpen} rightIcon={<ChevronDownIcon />}
> >
Actions
</MenuButton>
<MenuList>
<MenuItem onClick={onPlaylistModalOpen}>
Add to Playlist... Add to Playlist...
</Button> </MenuItem>
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && ( {currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
<Button <>
size="sm" <MenuDivider />
variant="outline" <MenuItem
colorScheme="red" color="red.300"
onClick={() => { onClick={handleBulkRemoveFromPlaylist}
handleBulkRemoveFromPlaylist();
}}
> >
Remove from {currentPlaylist} Remove from {currentPlaylist}
</Button> </MenuItem>
</>
)} )}
</HStack> </MenuList>
</Menu>
)} )}
</Flex> </Flex>
</Box> </Box>

View File

@ -19,7 +19,7 @@ import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input";
import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons"; import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons";
import { FiPlay, FiMusic } from 'react-icons/fi'; import { FiPlay, FiMusic } from 'react-icons/fi';
import type { Song, PlaylistNode } from "../types/interfaces"; import type { Song, PlaylistNode } from "../types/interfaces";
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo, useEffect } from "react";
import { formatDuration, formatTotalDuration } from '../utils/formatters'; import { formatDuration, formatTotalDuration } from '../utils/formatters';
@ -33,6 +33,7 @@ interface SongListProps {
currentPlaylist: string | null; currentPlaylist: string | null;
depth?: number; depth?: number;
onPlaySong?: (song: Song) => void; onPlaySong?: (song: Song) => void;
isSwitchingPlaylist?: boolean;
} }
export const SongList: React.FC<SongListProps> = ({ export const SongList: React.FC<SongListProps> = ({
@ -44,11 +45,19 @@ export const SongList: React.FC<SongListProps> = ({
selectedSongId, selectedSongId,
currentPlaylist, currentPlaylist,
depth = 0, depth = 0,
onPlaySong onPlaySong,
isSwitchingPlaylist = false
}) => { }) => {
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set()); const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
// Clear selection when switching playlists
useEffect(() => {
if (isSwitchingPlaylist) {
setSelectedSongs(new Set());
}
}, [isSwitchingPlaylist]);
// Helper function to get all playlists (excluding folders) from the playlist tree // Helper function to get all playlists (excluding folders) from the playlist tree
const getAllPlaylists = useCallback((nodes: PlaylistNode[]): PlaylistNode[] => { const getAllPlaylists = useCallback((nodes: PlaylistNode[]): PlaylistNode[] => {
let result: PlaylistNode[] = []; let result: PlaylistNode[] = [];
@ -270,7 +279,7 @@ export const SongList: React.FC<SongListProps> = ({
fontSize={depth > 0 ? "xs" : "sm"} fontSize={depth > 0 ? "xs" : "sm"}
color={selectedSongId === song.id ? "gray.300" : "gray.500"} color={selectedSongId === song.id ? "gray.300" : "gray.500"}
> >
{song.artist} {formatDuration(song.totalTime)} {song.artist} {formatDuration(song.totalTime)}{song.tonality ? ` - ${song.tonality}` : ''}
</Text> </Text>
{song.location && ( {song.location && (
<Text <Text

View File

@ -266,7 +266,7 @@ export function Configuration() {
{/* Song Matching Tab */} {/* Song Matching Tab */}
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700"> <TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
<VStack spacing={6} align="stretch"> <VStack spacing={6} align="stretch">
<Heading size="md" color="white">Sync and Matching</Heading> <Heading size="md" color="white">Sync and Matching ({storageProvider})</Heading>
<HStack spacing={3}> <HStack spacing={3}>
<Button <Button
leftIcon={<FiRefreshCw />} leftIcon={<FiRefreshCw />}
@ -274,7 +274,7 @@ export function Configuration() {
variant="solid" variant="solid"
onClick={() => api.startStorageSync()} onClick={() => api.startStorageSync()}
> >
Sync {storageProvider} (incremental) Sync (incremental)
</Button> </Button>
<Button <Button
leftIcon={<FiRefreshCw />} leftIcon={<FiRefreshCw />}
@ -282,7 +282,7 @@ export function Configuration() {
variant="outline" variant="outline"
onClick={() => api.startStorageSync({ force: true })} onClick={() => api.startStorageSync({ force: true })}
> >
Force {storageProvider} Sync (rescan all) Force Sync (rescan all)
</Button> </Button>
<Button <Button
leftIcon={<FiTrash2 />} leftIcon={<FiTrash2 />}
@ -290,7 +290,7 @@ export function Configuration() {
variant="outline" variant="outline"
onClick={() => api.startStorageSync({ clearLinks: true, force: true })} onClick={() => api.startStorageSync({ clearLinks: true, force: true })}
> >
Clear Links + Force {storageProvider} Sync Clear Links + Force Sync
</Button> </Button>
</HStack> </HStack>
<SongMatching /> <SongMatching />