mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
committed by
GitHub
parent
6e4ebca938
commit
7482030a6b
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user