mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35: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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+57
-291
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user