Files
Psychotoxical-psysonic/src/components/playlists/PlaylistCard.tsx
T
cucadmuh f3a0b3f7af fix(artist-detail): Last.fm/Wikipedia/Favorite hover keeps button border (#966)
* fix(artist-detail): keep ext-link border visible on hover

Hover used --border-subtle, which on Catppuccin matches --bg-card and
visually erased the rim while only the fill changed. Match btn-surface:
--ctp-surface1 border, --ctp-overlay0 on hover.

* docs(changelog): credit zunoz on Psysonic Discord for PR #966

* fix(playlists): tooltips on Play/Add Songs and song count pluralization

Add data-tooltip to Play and Add Songs in playlist hero; switch
playlists.songs to count-based _one/_other forms (was {{n}} without
i18next plural suffix, breaking spacing and singular).

* fix(playlists): render BPM and optional cols in Suggested Songs rows

PlaylistSuggestions shared column headers with the main tracklist but
its row switch omitted bpm, genre, playCount, and lastPlayed.
2026-06-03 23:19:51 +03:00

173 lines
6.7 KiB
TypeScript

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/playlist/playlistsSmart';
import { formatHumanHoursMinutes } from '../../utils/format/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) ? ' album-card--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); }}
>
{!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: 'var(--radius-sm)',
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', { count: filteredSongCountByPlaylist[pl.id] ?? pl.songCount })}
{(filteredDurationByPlaylist[pl.id] ?? pl.duration) > 0 && (
<> · {formatHumanHoursMinutes(filteredDurationByPlaylist[pl.id] ?? pl.duration)}</>
)}
</div>
</div>
</div>
);
}