Files
Psychotoxical-psysonic/src/components/playlists/PlaylistsHeader.tsx
T
Frank Stellmacher 7482030a6b 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.
2026-05-13 16:17:44 +02:00

117 lines
4.6 KiB
TypeScript

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>
);
}