diff --git a/src/components/playlists/PlaylistCard.tsx b/src/components/playlists/PlaylistCard.tsx new file mode 100644 index 00000000..90f46c15 --- /dev/null +++ b/src/components/playlists/PlaylistCard.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { Check, Clock3, ListMusic, Pencil, Play, Sparkles, Trash2 } from 'lucide-react'; +import type { SubsonicPlaylist } from '../../api/subsonicTypes'; +import { usePlayerStore } from '../../store/playerStore'; +import { + displayPlaylistName, isSmartPlaylistName, type PendingSmartPlaylist, +} from '../../utils/playlistsSmart'; +import { formatHumanHoursMinutes } from '../../utils/formatHumanDuration'; +import { PlaylistCardMainCover, PlaylistSmartCoverCell } from './PlaylistCoverImages'; + +interface Props { + pl: SubsonicPlaylist; + selectionMode: boolean; + selectedIds: Set; + selectedPlaylists: SubsonicPlaylist[]; + toggleSelect: (id: string, opts?: { shiftKey?: boolean }) => void; + isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean; + deleteConfirmId: string | null; + setDeleteConfirmId: React.Dispatch>; + handleOpenSmartEditor: (pl: SubsonicPlaylist) => Promise; + handleDelete: (e: React.MouseEvent, pl: SubsonicPlaylist) => void; + handlePlay: (e: React.MouseEvent, pl: SubsonicPlaylist) => void; + playingId: string | null; + smartCoverIdsByPlaylist: Record; + pendingSmart: PendingSmartPlaylist[]; + filteredSongCountByPlaylist: Record; + filteredDurationByPlaylist: Record; +} + +export default function PlaylistCard({ + pl, selectionMode, selectedIds, selectedPlaylists, + toggleSelect, isPlaylistDeletable, + deleteConfirmId, setDeleteConfirmId, + handleOpenSmartEditor, handleDelete, handlePlay, playingId, + smartCoverIdsByPlaylist, pendingSmart, + filteredSongCountByPlaylist, filteredDurationByPlaylist, +}: Props) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const openContextMenu = usePlayerStore(s => s.openContextMenu); + + return ( +
{ + if (selectionMode) { + toggleSelect(pl.id, { shiftKey: e.shiftKey }); + } else { + navigate(`/playlists/${pl.id}`); + } + }} + onContextMenu={(e) => { + e.preventDefault(); + if (selectionMode && selectedIds.size > 0) { + openContextMenu(e.clientX, e.clientY, selectedPlaylists, 'multi-playlist'); + } else { + openContextMenu(e.clientX, e.clientY, pl, 'playlist'); + } + }} + onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }} + style={selectionMode && selectedIds.has(pl.id) ? { + position: 'relative', + outline: '2px solid var(--accent)', + outlineOffset: '2px', + borderRadius: 'var(--radius-md)' + } : { position: 'relative' }} + > + {!selectionMode && ( +
+ {isPlaylistDeletable(pl) && ( + + )} + {isPlaylistDeletable(pl) && ( + + )} +
+ )} + {selectionMode && ( +
+ {selectedIds.has(pl.id) && } +
+ )} + {/* Cover area — server collage or fallback icon */} +
+ {isSmartPlaylistName(pl.name) && (smartCoverIdsByPlaylist[pl.id]?.length ?? 0) > 0 ? ( +
+ {Array.from({ length: 4 }, (_, i) => { + const id = smartCoverIdsByPlaylist[pl.id][i % smartCoverIdsByPlaylist[pl.id].length]; + return id ? ( + + ) : ( +
+ ); + })} +
+ ) : pl.coverArt ? ( + + ) : ( +
+ +
+ )} + {pendingSmart.some(p => p.id === pl.id || p.name === pl.name) && ( +
+ +
+ )} + + {/* Play overlay — same pattern as AlbumCard */} +
+ +
+ +
+ +
+
+ {isSmartPlaylistName(pl.name) && } + {displayPlaylistName(pl.name)} +
+
+ {t('playlists.songs', { n: filteredSongCountByPlaylist[pl.id] ?? pl.songCount })} + {(filteredDurationByPlaylist[pl.id] ?? pl.duration) > 0 && ( + <> · {formatHumanHoursMinutes(filteredDurationByPlaylist[pl.id] ?? pl.duration)} + )} +
+
+
+ ); +} diff --git a/src/components/playlists/PlaylistsHeader.tsx b/src/components/playlists/PlaylistsHeader.tsx new file mode 100644 index 00000000..d24b0d07 --- /dev/null +++ b/src/components/playlists/PlaylistsHeader.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { CheckSquare2, Plus, Trash2 } from 'lucide-react'; +import type { SubsonicPlaylist } from '../../api/subsonicTypes'; +import { + defaultSmartFilters, type SmartFilters, +} from '../../utils/playlistsSmart'; + +interface Props { + selectionMode: boolean; + selectedIds: Set; + selectedPlaylists: SubsonicPlaylist[]; + isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean; + toggleSelectionMode: () => void; + handleDeleteSelected: () => void; + creating: boolean; + setCreating: React.Dispatch>; + setCreatingSmart: React.Dispatch>; + newName: string; + setNewName: React.Dispatch>; + nameInputRef: React.RefObject; + handleCreate: () => Promise; + isNavidromeServer: boolean; + setEditingSmartId: React.Dispatch>; + setSmartFilters: React.Dispatch>; + setGenreQuery: React.Dispatch>; +} + +export default function PlaylistsHeader({ + selectionMode, selectedIds, selectedPlaylists, isPlaylistDeletable, + toggleSelectionMode, handleDeleteSelected, + creating, setCreating, setCreatingSmart, + newName, setNewName, nameInputRef, handleCreate, + isNavidromeServer, setEditingSmartId, setSmartFilters, setGenreQuery, +}: Props) { + const { t } = useTranslation(); + + return ( +
+

+ {selectionMode && selectedIds.size > 0 + ? t('playlists.selectionCount', { count: selectedIds.size }) + : t('playlists.title')} +

+
+ {!(selectionMode && selectedIds.size > 0) && (<> + {creating ? ( + <> + setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreate(); + if (e.key === 'Escape') { setCreating(false); setNewName(''); } + }} + /> + + + + ) : ( + + )} + {!creating && isNavidromeServer && ( + + )} + + )} + {selectionMode && selectedIds.size > 0 && (() => { + const deletableCount = selectedPlaylists.filter(isPlaylistDeletable).length; + return ( + + ); + })()} + +
+
+ ); +} diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index 8f26b5cd..60b9c4b7 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -1,33 +1,32 @@ -import { deletePlaylist, getPlaylist, updatePlaylist } from '../api/subsonicPlaylists'; +import { getPlaylist } from '../api/subsonicPlaylists'; import { getGenres } from '../api/subsonicGenres'; -import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; import { filterSongsToActiveLibrary } from '../api/subsonicLibrary'; import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; -import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { ListMusic, Play, Plus, Trash2, CheckSquare2, Check, Clock3, Sparkles, Pencil } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { usePlaylistStore } from '../store/playlistStore'; import { useAuthStore } from '../store/authStore'; -import CachedImage from '../components/CachedImage'; -import StarRating from '../components/StarRating'; import { useTranslation } from 'react-i18next'; -import { formatHumanHoursMinutes } from '../utils/formatHumanDuration'; -import { showToast } from '../utils/toast'; import { useRangeSelection } from '../hooks/useRangeSelection'; +import { formatHumanHoursMinutes } from '../utils/formatHumanDuration'; import { - defaultSmartFilters, displayPlaylistName, isSmartPlaylistName, + defaultSmartFilters, isSmartPlaylistName, type SmartFilters, type PendingSmartPlaylist, } from '../utils/playlistsSmart'; -import { PlaylistSmartCoverCell, PlaylistCardMainCover } from '../components/playlists/PlaylistCoverImages'; import { useSmartCoverCollage } from '../hooks/useSmartCoverCollage'; import { usePlaylistsLibraryScopeCounts } from '../hooks/usePlaylistsLibraryScopeCounts'; import { usePendingSmartPolling } from '../hooks/usePendingSmartPolling'; import { runPlaylistsOpenSmartEditor } from '../utils/runPlaylistsOpenSmartEditor'; import { runPlaylistsSaveSmart } from '../utils/runPlaylistsSaveSmart'; +import { + runPlaylistDelete, runPlaylistDeleteSelected, runPlaylistMergeSelected, +} from '../utils/runPlaylistsActions'; import PlaylistsSmartEditor from '../components/playlists/PlaylistsSmartEditor'; +import PlaylistsHeader from '../components/playlists/PlaylistsHeader'; +import PlaylistCard from '../components/playlists/PlaylistCard'; function formatDuration(seconds: number): string { return formatHumanHoursMinutes(seconds); @@ -147,80 +146,17 @@ export default function Playlists() { setPlayingId(null); }; - const handleDelete = async (e: React.MouseEvent, pl: SubsonicPlaylist) => { - e.stopPropagation(); - if (deleteConfirmId !== pl.id) { - setDeleteConfirmId(pl.id); - const btn = e.currentTarget as HTMLElement; - requestAnimationFrame(() => { - btn.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); - }); - return; - } - try { - await deletePlaylist(pl.id); - removeId(pl.id); - usePlaylistStore.setState((s) => ({ - playlists: s.playlists.filter((p) => p.id !== pl.id), - })); - showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info'); - } catch { - showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error'); - } - setDeleteConfirmId(null); - }; + const handleDelete = (e: React.MouseEvent, pl: SubsonicPlaylist) => runPlaylistDelete({ + e, pl, deleteConfirmId, setDeleteConfirmId, removeId, t, + }); - const handleDeleteSelected = async () => { - const deletable = selectedPlaylists.filter(isPlaylistDeletable); - if (deletable.length === 0) return; - let deleted = 0; - for (const pl of deletable) { - try { - await deletePlaylist(pl.id); - removeId(pl.id); - deleted++; - } catch { - showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error'); - } - } - usePlaylistStore.setState((s) => ({ - playlists: s.playlists.filter((p) => !(selectedIds.has(p.id) && isPlaylistDeletable(p))), - })); - clearSelection(); - if (deleted > 0) { - showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info'); - } - }; + const handleDeleteSelected = () => runPlaylistDeleteSelected({ + selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t, + }); - const handleMergeSelected = async (targetPlaylist: SubsonicPlaylist) => { - if (selectedPlaylists.length === 0) return; - try { - const { songs: targetSongs } = await getPlaylist(targetPlaylist.id); - const targetIds = new Set(targetSongs.map(s => s.id)); - let totalAdded = 0; - - for (const pl of selectedPlaylists) { - if (pl.id === targetPlaylist.id) continue; - const { songs } = await getPlaylist(pl.id); - const newSongs = songs.filter(s => !targetIds.has(s.id)); - if (newSongs.length > 0) { - newSongs.forEach(s => targetIds.add(s.id)); - totalAdded += newSongs.length; - } - } - - if (totalAdded > 0) { - await updatePlaylist(targetPlaylist.id, Array.from(targetIds)); - touchPlaylist(targetPlaylist.id); - showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetPlaylist.name }), 3000, 'info'); - } else { - showToast(t('playlists.mergeNoNewSongs'), 3000, 'info'); - } - clearSelection(); - } catch { - showToast(t('playlists.mergeError'), 4000, 'error'); - } - }; + const handleMergeSelected = (targetPlaylist: SubsonicPlaylist) => runPlaylistMergeSelected({ + targetPlaylist, selectedPlaylists, touchPlaylist, clearSelection, t, + }); if (loading) { return ( @@ -276,83 +212,26 @@ export default function Playlists() { } `} - {/* ── Header row ── */} -
-

- {selectionMode && selectedIds.size > 0 - ? t('playlists.selectionCount', { count: selectedIds.size }) - : t('playlists.title')} -

-
- {!(selectionMode && selectedIds.size > 0) && (<> - {creating ? ( - <> - setNewName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleCreate(); - if (e.key === 'Escape') { setCreating(false); setNewName(''); } - }} - /> - - - - ) : ( - - )} - {!creating && isNavidromeServer && ( - - )} - - )} - {selectionMode && selectedIds.size > 0 && (() => { - const deletableCount = selectedPlaylists.filter(isPlaylistDeletable).length; - return ( - - ); - })()} - -
-
+ + {creatingSmart && ( {playlists.map((pl) => ( -
{ - if (selectionMode) { - toggleSelect(pl.id, { shiftKey: e.shiftKey }); - } else { - navigate(`/playlists/${pl.id}`); - } - }} - onContextMenu={(e) => { - e.preventDefault(); - if (selectionMode && selectedIds.size > 0) { - openContextMenu(e.clientX, e.clientY, selectedPlaylists, 'multi-playlist'); - } else { - openContextMenu(e.clientX, e.clientY, pl, 'playlist'); - } - }} - onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }} - style={selectionMode && selectedIds.has(pl.id) ? { - position: 'relative', - outline: '2px solid var(--accent)', - outlineOffset: '2px', - borderRadius: 'var(--radius-md)' - } : { position: 'relative' }} - > - {!selectionMode && ( -
- {isPlaylistDeletable(pl) && ( - - )} - {isPlaylistDeletable(pl) && ( - - )} -
- )} - {selectionMode && ( -
- {selectedIds.has(pl.id) && } -
- )} - {/* Cover area — server collage or fallback icon */} -
- {isSmartPlaylistName(pl.name) && (smartCoverIdsByPlaylist[pl.id]?.length ?? 0) > 0 ? ( -
- {Array.from({ length: 4 }, (_, i) => { - const id = smartCoverIdsByPlaylist[pl.id][i % smartCoverIdsByPlaylist[pl.id].length]; - return id ? ( - - ) : ( -
- ); - })} -
- ) : pl.coverArt ? ( - - ) : ( -
- -
- )} - {pendingSmart.some(p => p.id === pl.id || p.name === pl.name) && ( -
- -
- )} - - {/* Play overlay — same pattern as AlbumCard */} -
- -
- -
- -
-
- {isSmartPlaylistName(pl.name) && } - {displayPlaylistName(pl.name)} -
-
- {t('playlists.songs', { n: filteredSongCountByPlaylist[pl.id] ?? pl.songCount })} - {(filteredDurationByPlaylist[pl.id] ?? pl.duration) > 0 && ( - <> · {formatDuration(filteredDurationByPlaylist[pl.id] ?? pl.duration)} - )} -
-
-
+ pl={pl} + selectionMode={selectionMode} + selectedIds={selectedIds} + selectedPlaylists={selectedPlaylists} + toggleSelect={toggleSelect} + isPlaylistDeletable={isPlaylistDeletable} + deleteConfirmId={deleteConfirmId} + setDeleteConfirmId={setDeleteConfirmId} + handleOpenSmartEditor={handleOpenSmartEditor} + handleDelete={handleDelete} + handlePlay={handlePlay} + playingId={playingId} + smartCoverIdsByPlaylist={smartCoverIdsByPlaylist} + pendingSmart={pendingSmart} + filteredSongCountByPlaylist={filteredSongCountByPlaylist} + filteredDurationByPlaylist={filteredDurationByPlaylist} + /> ))}
)} +
); } diff --git a/src/utils/runPlaylistsActions.ts b/src/utils/runPlaylistsActions.ts new file mode 100644 index 00000000..7e93f99f --- /dev/null +++ b/src/utils/runPlaylistsActions.ts @@ -0,0 +1,110 @@ +import type React from 'react'; +import type { TFunction } from 'i18next'; +import { deletePlaylist, getPlaylist, updatePlaylist } from '../api/subsonicPlaylists'; +import type { SubsonicPlaylist } from '../api/subsonicTypes'; +import { usePlaylistStore } from '../store/playlistStore'; +import { showToast } from './toast'; + +export interface RunPlaylistDeleteDeps { + e: React.MouseEvent; + pl: SubsonicPlaylist; + deleteConfirmId: string | null; + setDeleteConfirmId: React.Dispatch>; + removeId: (id: string) => void; + t: TFunction; +} + +export async function runPlaylistDelete(deps: RunPlaylistDeleteDeps): Promise { + const { e, pl, deleteConfirmId, setDeleteConfirmId, removeId, t } = deps; + e.stopPropagation(); + if (deleteConfirmId !== pl.id) { + setDeleteConfirmId(pl.id); + const btn = e.currentTarget as HTMLElement; + requestAnimationFrame(() => { + btn.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + }); + return; + } + try { + await deletePlaylist(pl.id); + removeId(pl.id); + usePlaylistStore.setState((s) => ({ + playlists: s.playlists.filter((p) => p.id !== pl.id), + })); + showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info'); + } catch { + showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error'); + } + setDeleteConfirmId(null); +} + +export interface RunPlaylistDeleteSelectedDeps { + selectedPlaylists: SubsonicPlaylist[]; + selectedIds: Set; + isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean; + removeId: (id: string) => void; + clearSelection: () => void; + t: TFunction; +} + +export async function runPlaylistDeleteSelected(deps: RunPlaylistDeleteSelectedDeps): Promise { + const { selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t } = deps; + const deletable = selectedPlaylists.filter(isPlaylistDeletable); + if (deletable.length === 0) return; + let deleted = 0; + for (const pl of deletable) { + try { + await deletePlaylist(pl.id); + removeId(pl.id); + deleted++; + } catch { + showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error'); + } + } + usePlaylistStore.setState((s) => ({ + playlists: s.playlists.filter((p) => !(selectedIds.has(p.id) && isPlaylistDeletable(p))), + })); + clearSelection(); + if (deleted > 0) { + showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info'); + } +} + +export interface RunPlaylistMergeSelectedDeps { + targetPlaylist: SubsonicPlaylist; + selectedPlaylists: SubsonicPlaylist[]; + touchPlaylist: (id: string) => void; + clearSelection: () => void; + t: TFunction; +} + +export async function runPlaylistMergeSelected(deps: RunPlaylistMergeSelectedDeps): Promise { + const { targetPlaylist, selectedPlaylists, touchPlaylist, clearSelection, t } = deps; + if (selectedPlaylists.length === 0) return; + try { + const { songs: targetSongs } = await getPlaylist(targetPlaylist.id); + const targetIds = new Set(targetSongs.map(s => s.id)); + let totalAdded = 0; + + for (const pl of selectedPlaylists) { + if (pl.id === targetPlaylist.id) continue; + const { songs } = await getPlaylist(pl.id); + const newSongs = songs.filter(s => !targetIds.has(s.id)); + if (newSongs.length > 0) { + newSongs.forEach(s => targetIds.add(s.id)); + totalAdded += newSongs.length; + } + } + + if (totalAdded > 0) { + await updatePlaylist(targetPlaylist.id, Array.from(targetIds)); + touchPlaylist(targetPlaylist.id); + showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetPlaylist.name }), 3000, 'info'); + } else { + showToast(t('playlists.mergeNoNewSongs'), 3000, 'info'); + } + clearSelection(); + } catch { + showToast(t('playlists.mergeError'), 4000, 'error'); + } +}