refactor(playlists): G.81 — extract PlaylistsHeader + PlaylistCard + action utilities (cluster) (#648)

Three-cut cluster closing out the Playlists refactor. 516 → 273 LOC
(−243).

runPlaylistsActions — runPlaylistDelete (two-click confirm with
tooltip re-trigger), runPlaylistDeleteSelected (filters by
deletable, refreshes store, fires per-row error toasts), and
runPlaylistMergeSelected (collects unique songs across selected
playlists into the target, updatePlaylist + touchPlaylist + total
count toast). Each takes a deps object so all state-setters /
callbacks are explicit.

PlaylistsHeader — title row + creation controls (inline name
input with Enter / Escape handling, "New playlist" button,
"New smart" button gated on isNavidromeServer) + bulk delete
button + selection-mode toggle. The selection-mode title swaps
between t('playlists.title') and t('playlists.selectionCount').

PlaylistCard — full single-card render: cover area (smart-playlist
2×2 collage / cover image / fallback ListMusic icon + pending
clock badge), hover-only edit + delete buttons (delete with
two-click confirm), selection check overlay, play overlay button
with spinner state, and the info row (smart-playlist sparkle +
display name + song count + duration). Subscribes to
playerStore.openContextMenu directly.

Playlists drops the inline definitions + the now-unused direct
imports (deletePlaylist / updatePlaylist, buildCoverArtUrl /
coverArtCacheKey, CachedImage, StarRating, the cover image
helpers, most lucide icons, useMemo). Pure code move otherwise.
This commit is contained in:
Frank Stellmacher
2026-05-13 16:17:44 +02:00
committed by GitHub
parent 6e4ebca938
commit 7482030a6b
4 changed files with 461 additions and 291 deletions
+178
View File
@@ -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<string>;
selectedPlaylists: SubsonicPlaylist[];
toggleSelect: (id: string, opts?: { shiftKey?: boolean }) => void;
isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean;
deleteConfirmId: string | null;
setDeleteConfirmId: React.Dispatch<React.SetStateAction<string | null>>;
handleOpenSmartEditor: (pl: SubsonicPlaylist) => Promise<void>;
handleDelete: (e: React.MouseEvent, pl: SubsonicPlaylist) => void;
handlePlay: (e: React.MouseEvent, pl: SubsonicPlaylist) => void;
playingId: string | null;
smartCoverIdsByPlaylist: Record<string, string[]>;
pendingSmart: PendingSmartPlaylist[];
filteredSongCountByPlaylist: Record<string, number>;
filteredDurationByPlaylist: Record<string, number>;
}
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 (
<div
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' selected' : ''}`}
onClick={(e) => {
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 && (
<div className="playlist-card-actions">
{isPlaylistDeletable(pl) && (
<button
className="playlist-card-action playlist-card-action--edit"
onClick={(e) => {
e.stopPropagation();
if (isSmartPlaylistName(pl.name)) {
void handleOpenSmartEditor(pl);
return;
}
navigate(`/playlists/${pl.id}`, { state: { openEditMeta: true } });
}}
data-tooltip={t('playlists.editMeta')}
>
<Pencil size={13} />
</button>
)}
{isPlaylistDeletable(pl) && (
<button
className={`playlist-card-action playlist-card-action--delete${deleteConfirmId === pl.id ? ' playlist-card-action--delete-confirm' : ''}`}
onClick={(e) => handleDelete(e, pl)}
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('common.delete')}
>
<Trash2 size={13} />
</button>
)}
</div>
)}
{selectionMode && (
<div className={`album-card-select-check${selectedIds.has(pl.id) ? ' album-card-select-check--on' : ''}`}>
{selectedIds.has(pl.id) && <Check size={14} strokeWidth={3} />}
</div>
)}
{/* Cover area — server collage or fallback icon */}
<div className="album-card-cover">
{isSmartPlaylistName(pl.name) && (smartCoverIdsByPlaylist[pl.id]?.length ?? 0) > 0 ? (
<div className="playlist-cover-grid">
{Array.from({ length: 4 }, (_, i) => {
const id = smartCoverIdsByPlaylist[pl.id][i % smartCoverIdsByPlaylist[pl.id].length];
return id ? (
<PlaylistSmartCoverCell key={i} coverId={id} />
) : (
<div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
);
})}
</div>
) : pl.coverArt ? (
<PlaylistCardMainCover coverArt={pl.coverArt} alt={pl.name} />
) : (
<div className="album-card-cover-placeholder playlist-card-icon">
<ListMusic size={48} strokeWidth={1.2} />
</div>
)}
{pendingSmart.some(p => p.id === pl.id || p.name === pl.name) && (
<div
style={{
position: 'absolute',
top: 8,
left: 8,
width: 24,
height: 24,
borderRadius: 999,
background: 'rgba(0,0,0,0.45)',
border: '1px solid rgba(255,255,255,0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
zIndex: 8,
pointerEvents: 'none',
}}
data-tooltip={t('common.loading')}
>
<Clock3 size={13} />
</div>
)}
{/* Play overlay — same pattern as AlbumCard */}
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={(e) => handlePlay(e, pl)}
disabled={playingId === pl.id}
>
{playingId === pl.id
? <span className="spinner" style={{ width: 14, height: 14 }} />
: <Play size={15} fill="currentColor" />
}
</button>
</div>
</div>
<div className="album-card-info">
<div className="album-card-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{isSmartPlaylistName(pl.name) && <Sparkles size={14} style={{ color: 'var(--text-muted)', flex: '0 0 auto' }} />}
<span>{displayPlaylistName(pl.name)}</span>
</div>
<div className="album-card-artist">
{t('playlists.songs', { n: filteredSongCountByPlaylist[pl.id] ?? pl.songCount })}
{(filteredDurationByPlaylist[pl.id] ?? pl.duration) > 0 && (
<> · {formatHumanHoursMinutes(filteredDurationByPlaylist[pl.id] ?? pl.duration)}</>
)}
</div>
</div>
</div>
);
}
@@ -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<string>;
selectedPlaylists: SubsonicPlaylist[];
isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean;
toggleSelectionMode: () => void;
handleDeleteSelected: () => void;
creating: boolean;
setCreating: React.Dispatch<React.SetStateAction<boolean>>;
setCreatingSmart: React.Dispatch<React.SetStateAction<boolean>>;
newName: string;
setNewName: React.Dispatch<React.SetStateAction<string>>;
nameInputRef: React.RefObject<HTMLInputElement | null>;
handleCreate: () => Promise<void>;
isNavidromeServer: boolean;
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
setGenreQuery: React.Dispatch<React.SetStateAction<string>>;
}
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 (
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('playlists.selectionCount', { count: selectedIds.size })
: t('playlists.title')}
</h1>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{!(selectionMode && selectedIds.size > 0) && (<>
{creating ? (
<>
<input
ref={nameInputRef}
className="input"
style={{ width: 220 }}
placeholder={t('playlists.createName')}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="btn btn-primary" onClick={handleCreate}>
{t('playlists.create')}
</button>
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
{t('playlists.cancel')}
</button>
</>
) : (
<button className="btn btn-primary" onClick={() => { setCreatingSmart(false); setCreating(true); }}>
<Plus size={15} /> {t('playlists.newPlaylist')}
</button>
)}
{!creating && isNavidromeServer && (
<button className="btn btn-surface" onClick={() => {
setCreating(false);
setEditingSmartId(null);
setSmartFilters(defaultSmartFilters);
setGenreQuery('');
setCreatingSmart(v => !v);
}}>
<Plus size={15} /> {t('smartPlaylists.create')}
</button>
)}
</>
)}
{selectionMode && selectedIds.size > 0 && (() => {
const deletableCount = selectedPlaylists.filter(isPlaylistDeletable).length;
return (
<button
className="btn btn-danger"
onClick={handleDeleteSelected}
disabled={deletableCount === 0}
data-tooltip={deletableCount === selectedIds.size
? undefined
: t('playlists.deleteSelectedPartial', { n: deletableCount, total: selectedIds.size })}
data-tooltip-pos="bottom"
>
<Trash2 size={15} />
{t('playlists.deleteSelected')}
</button>
);
})()}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('playlists.cancelSelect') : t('playlists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
</button>
</div>
</div>
);
}
+57 -291
View File
@@ -1,33 +1,32 @@
import { deletePlaylist, getPlaylist, updatePlaylist } from '../api/subsonicPlaylists'; import { getPlaylist } from '../api/subsonicPlaylists';
import { getGenres } from '../api/subsonicGenres'; import { getGenres } from '../api/subsonicGenres';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { filterSongsToActiveLibrary } from '../api/subsonicLibrary'; import { filterSongsToActiveLibrary } from '../api/subsonicLibrary';
import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes'; import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes';
import { songToTrack } from '../utils/songToTrack'; 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 { useNavigate } from 'react-router-dom';
import { ListMusic, Play, Plus, Trash2, CheckSquare2, Check, Clock3, Sparkles, Pencil } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore'; import { usePlaylistStore } from '../store/playlistStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import CachedImage from '../components/CachedImage';
import StarRating from '../components/StarRating';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import { showToast } from '../utils/toast';
import { useRangeSelection } from '../hooks/useRangeSelection'; import { useRangeSelection } from '../hooks/useRangeSelection';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import { import {
defaultSmartFilters, displayPlaylistName, isSmartPlaylistName, defaultSmartFilters, isSmartPlaylistName,
type SmartFilters, type PendingSmartPlaylist, type SmartFilters, type PendingSmartPlaylist,
} from '../utils/playlistsSmart'; } from '../utils/playlistsSmart';
import { PlaylistSmartCoverCell, PlaylistCardMainCover } from '../components/playlists/PlaylistCoverImages';
import { useSmartCoverCollage } from '../hooks/useSmartCoverCollage'; import { useSmartCoverCollage } from '../hooks/useSmartCoverCollage';
import { usePlaylistsLibraryScopeCounts } from '../hooks/usePlaylistsLibraryScopeCounts'; import { usePlaylistsLibraryScopeCounts } from '../hooks/usePlaylistsLibraryScopeCounts';
import { usePendingSmartPolling } from '../hooks/usePendingSmartPolling'; import { usePendingSmartPolling } from '../hooks/usePendingSmartPolling';
import { runPlaylistsOpenSmartEditor } from '../utils/runPlaylistsOpenSmartEditor'; import { runPlaylistsOpenSmartEditor } from '../utils/runPlaylistsOpenSmartEditor';
import { runPlaylistsSaveSmart } from '../utils/runPlaylistsSaveSmart'; import { runPlaylistsSaveSmart } from '../utils/runPlaylistsSaveSmart';
import {
runPlaylistDelete, runPlaylistDeleteSelected, runPlaylistMergeSelected,
} from '../utils/runPlaylistsActions';
import PlaylistsSmartEditor from '../components/playlists/PlaylistsSmartEditor'; import PlaylistsSmartEditor from '../components/playlists/PlaylistsSmartEditor';
import PlaylistsHeader from '../components/playlists/PlaylistsHeader';
import PlaylistCard from '../components/playlists/PlaylistCard';
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
return formatHumanHoursMinutes(seconds); return formatHumanHoursMinutes(seconds);
@@ -147,80 +146,17 @@ export default function Playlists() {
setPlayingId(null); setPlayingId(null);
}; };
const handleDelete = async (e: React.MouseEvent, pl: SubsonicPlaylist) => { const handleDelete = (e: React.MouseEvent, pl: SubsonicPlaylist) => runPlaylistDelete({
e.stopPropagation(); e, pl, deleteConfirmId, setDeleteConfirmId, removeId, t,
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 handleDeleteSelected = async () => { const handleDeleteSelected = () => runPlaylistDeleteSelected({
const deletable = selectedPlaylists.filter(isPlaylistDeletable); selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t,
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 handleMergeSelected = async (targetPlaylist: SubsonicPlaylist) => { const handleMergeSelected = (targetPlaylist: SubsonicPlaylist) => runPlaylistMergeSelected({
if (selectedPlaylists.length === 0) return; targetPlaylist, selectedPlaylists, touchPlaylist, clearSelection, t,
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');
}
};
if (loading) { if (loading) {
return ( return (
@@ -276,83 +212,26 @@ export default function Playlists() {
} }
`}</style> `}</style>
{/* ── Header row ── */} <PlaylistsHeader
<div className="playlists-header"> selectionMode={selectionMode}
<h1 className="page-title" style={{ marginBottom: 0 }}> selectedIds={selectedIds}
{selectionMode && selectedIds.size > 0 selectedPlaylists={selectedPlaylists}
? t('playlists.selectionCount', { count: selectedIds.size }) isPlaylistDeletable={isPlaylistDeletable}
: t('playlists.title')} toggleSelectionMode={toggleSelectionMode}
</h1> handleDeleteSelected={handleDeleteSelected}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}> creating={creating}
{!(selectionMode && selectedIds.size > 0) && (<> setCreating={setCreating}
{creating ? ( setCreatingSmart={setCreatingSmart}
<> newName={newName}
<input setNewName={setNewName}
ref={nameInputRef} nameInputRef={nameInputRef}
className="input" handleCreate={handleCreate}
style={{ width: 220 }} isNavidromeServer={isNavidromeServer}
placeholder={t('playlists.createName')} setEditingSmartId={setEditingSmartId}
value={newName} setSmartFilters={setSmartFilters}
onChange={(e) => setNewName(e.target.value)} setGenreQuery={setGenreQuery}
onKeyDown={(e) => { />
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="btn btn-primary" onClick={handleCreate}>
{t('playlists.create')}
</button>
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
{t('playlists.cancel')}
</button>
</>
) : (
<button className="btn btn-primary" onClick={() => { setCreatingSmart(false); setCreating(true); }}>
<Plus size={15} /> {t('playlists.newPlaylist')}
</button>
)}
{!creating && isNavidromeServer && (
<button className="btn btn-surface" onClick={() => {
setCreating(false);
setEditingSmartId(null);
setSmartFilters(defaultSmartFilters);
setGenreQuery('');
setCreatingSmart(v => !v);
}}>
<Plus size={15} /> {t('smartPlaylists.create')}
</button>
)}
</>
)}
{selectionMode && selectedIds.size > 0 && (() => {
const deletableCount = selectedPlaylists.filter(isPlaylistDeletable).length;
return (
<button
className="btn btn-danger"
onClick={handleDeleteSelected}
disabled={deletableCount === 0}
data-tooltip={deletableCount === selectedIds.size
? undefined
: t('playlists.deleteSelectedPartial', { n: deletableCount, total: selectedIds.size })}
data-tooltip-pos="bottom"
>
<Trash2 size={15} />
{t('playlists.deleteSelected')}
</button>
);
})()}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('playlists.cancelSelect') : t('playlists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
</button>
</div>
</div>
{creatingSmart && ( {creatingSmart && (
<PlaylistsSmartEditor <PlaylistsSmartEditor
smartFilters={smartFilters} smartFilters={smartFilters}
@@ -374,143 +253,30 @@ export default function Playlists() {
) : ( ) : (
<div className="album-grid-wrap"> <div className="album-grid-wrap">
{playlists.map((pl) => ( {playlists.map((pl) => (
<div <PlaylistCard
key={pl.id} key={pl.id}
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' selected' : ''}`} pl={pl}
onClick={(e) => { selectionMode={selectionMode}
if (selectionMode) { selectedIds={selectedIds}
toggleSelect(pl.id, { shiftKey: e.shiftKey }); selectedPlaylists={selectedPlaylists}
} else { toggleSelect={toggleSelect}
navigate(`/playlists/${pl.id}`); isPlaylistDeletable={isPlaylistDeletable}
} deleteConfirmId={deleteConfirmId}
}} setDeleteConfirmId={setDeleteConfirmId}
onContextMenu={(e) => { handleOpenSmartEditor={handleOpenSmartEditor}
e.preventDefault(); handleDelete={handleDelete}
if (selectionMode && selectedIds.size > 0) { handlePlay={handlePlay}
openContextMenu(e.clientX, e.clientY, selectedPlaylists, 'multi-playlist'); playingId={playingId}
} else { smartCoverIdsByPlaylist={smartCoverIdsByPlaylist}
openContextMenu(e.clientX, e.clientY, pl, 'playlist'); pendingSmart={pendingSmart}
} filteredSongCountByPlaylist={filteredSongCountByPlaylist}
}} filteredDurationByPlaylist={filteredDurationByPlaylist}
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 && (
<div className="playlist-card-actions">
{isPlaylistDeletable(pl) && (
<button
className="playlist-card-action playlist-card-action--edit"
onClick={(e) => {
e.stopPropagation();
if (isSmartPlaylistName(pl.name)) {
void handleOpenSmartEditor(pl);
return;
}
navigate(`/playlists/${pl.id}`, { state: { openEditMeta: true } });
}}
data-tooltip={t('playlists.editMeta')}
>
<Pencil size={13} />
</button>
)}
{isPlaylistDeletable(pl) && (
<button
className={`playlist-card-action playlist-card-action--delete${deleteConfirmId === pl.id ? ' playlist-card-action--delete-confirm' : ''}`}
onClick={(e) => handleDelete(e, pl)}
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('common.delete')}
>
<Trash2 size={13} />
</button>
)}
</div>
)}
{selectionMode && (
<div className={`album-card-select-check${selectedIds.has(pl.id) ? ' album-card-select-check--on' : ''}`}>
{selectedIds.has(pl.id) && <Check size={14} strokeWidth={3} />}
</div>
)}
{/* Cover area — server collage or fallback icon */}
<div className="album-card-cover">
{isSmartPlaylistName(pl.name) && (smartCoverIdsByPlaylist[pl.id]?.length ?? 0) > 0 ? (
<div className="playlist-cover-grid">
{Array.from({ length: 4 }, (_, i) => {
const id = smartCoverIdsByPlaylist[pl.id][i % smartCoverIdsByPlaylist[pl.id].length];
return id ? (
<PlaylistSmartCoverCell key={i} coverId={id} />
) : (
<div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
);
})}
</div>
) : pl.coverArt ? (
<PlaylistCardMainCover coverArt={pl.coverArt} alt={pl.name} />
) : (
<div className="album-card-cover-placeholder playlist-card-icon">
<ListMusic size={48} strokeWidth={1.2} />
</div>
)}
{pendingSmart.some(p => p.id === pl.id || p.name === pl.name) && (
<div
style={{
position: 'absolute',
top: 8,
left: 8,
width: 24,
height: 24,
borderRadius: 999,
background: 'rgba(0,0,0,0.45)',
border: '1px solid rgba(255,255,255,0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
zIndex: 8,
pointerEvents: 'none',
}}
data-tooltip={t('common.loading')}
>
<Clock3 size={13} />
</div>
)}
{/* Play overlay — same pattern as AlbumCard */}
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={(e) => handlePlay(e, pl)}
disabled={playingId === pl.id}
>
{playingId === pl.id
? <span className="spinner" style={{ width: 14, height: 14 }} />
: <Play size={15} fill="currentColor" />
}
</button>
</div>
</div>
<div className="album-card-info">
<div className="album-card-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{isSmartPlaylistName(pl.name) && <Sparkles size={14} style={{ color: 'var(--text-muted)', flex: '0 0 auto' }} />}
<span>{displayPlaylistName(pl.name)}</span>
</div>
<div className="album-card-artist">
{t('playlists.songs', { n: filteredSongCountByPlaylist[pl.id] ?? pl.songCount })}
{(filteredDurationByPlaylist[pl.id] ?? pl.duration) > 0 && (
<> · {formatDuration(filteredDurationByPlaylist[pl.id] ?? pl.duration)}</>
)}
</div>
</div>
</div>
))} ))}
</div> </div>
)} )}
</div> </div>
); );
} }
+110
View File
@@ -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<React.SetStateAction<string | null>>;
removeId: (id: string) => void;
t: TFunction;
}
export async function runPlaylistDelete(deps: RunPlaylistDeleteDeps): Promise<void> {
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<string>;
isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean;
removeId: (id: string) => void;
clearSelection: () => void;
t: TFunction;
}
export async function runPlaylistDeleteSelected(deps: RunPlaylistDeleteSelectedDeps): Promise<void> {
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<void> {
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');
}
}