mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(tracklist): column-header sorting for albums & playlists
Replaces dedicated sort buttons with clickable column headers in album and playlist detail views. Three-click cycle: asc → desc → natural order. Sortable by title, artist, album, favorite, rating, duration. Active column shown bold with ▲/▼ indicator.
This commit is contained in:
@@ -44,6 +44,8 @@ const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
|
|||||||
|
|
||||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type SortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration';
|
||||||
|
|
||||||
interface AlbumTrackListProps {
|
interface AlbumTrackListProps {
|
||||||
songs: SubsonicSong[];
|
songs: SubsonicSong[];
|
||||||
sorted?: boolean;
|
sorted?: boolean;
|
||||||
@@ -57,6 +59,9 @@ interface AlbumTrackListProps {
|
|||||||
onRate: (songId: string, rating: number) => void;
|
onRate: (songId: string, rating: number) => void;
|
||||||
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
||||||
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
|
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
|
||||||
|
sortKey?: SortKey;
|
||||||
|
sortDir?: 'asc' | 'desc';
|
||||||
|
onSort?: (key: SortKey) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── TrackRow (memoised) ───────────────────────────────────────────────────────
|
// ── TrackRow (memoised) ───────────────────────────────────────────────────────
|
||||||
@@ -262,6 +267,9 @@ export default function AlbumTrackList({
|
|||||||
onRate,
|
onRate,
|
||||||
onToggleSongStar,
|
onToggleSongStar,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
sortKey,
|
||||||
|
sortDir,
|
||||||
|
onSort,
|
||||||
}: AlbumTrackListProps) {
|
}: AlbumTrackListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -375,12 +383,33 @@ export default function AlbumTrackList({
|
|||||||
|
|
||||||
const currentTrackId = currentTrack?.id ?? null;
|
const currentTrackId = currentTrack?.id ?? null;
|
||||||
|
|
||||||
|
// ── Sortable columns ──────────────────────────────────────────────────────
|
||||||
|
const SORTABLE_COLS = new Set<ColKey | 'album'>(['title', 'artist', 'album', 'favorite', 'rating', 'duration']);
|
||||||
|
|
||||||
|
const isSortable = (key: ColKey | string): key is SortKey => SORTABLE_COLS.has(key as ColKey);
|
||||||
|
|
||||||
|
const handleHeaderClick = (key: ColKey | string) => {
|
||||||
|
if (!isSortable(key) || !onSort) return;
|
||||||
|
onSort(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSortIndicator = (key: SortKey) => {
|
||||||
|
if (sortKey !== key) return null;
|
||||||
|
return (
|
||||||
|
<span style={{ marginLeft: 4, fontSize: 10, opacity: 0.7 }}>
|
||||||
|
{sortDir === 'asc' ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Header cell renderer ──────────────────────────────────────────────────
|
// ── Header cell renderer ──────────────────────────────────────────────────
|
||||||
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
|
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
|
||||||
const key = colDef.key as ColKey;
|
const key = colDef.key as ColKey;
|
||||||
const isLastCol = colIndex === visibleCols.length - 1;
|
const isLastCol = colIndex === visibleCols.length - 1;
|
||||||
const isCentered = CENTERED_COLS.has(key);
|
const isCentered = CENTERED_COLS.has(key);
|
||||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
|
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
|
||||||
|
const canSort = isSortable(key) && onSort;
|
||||||
|
const isActive = canSort && sortKey === key;
|
||||||
|
|
||||||
if (key === 'num') {
|
if (key === 'num') {
|
||||||
return (
|
return (
|
||||||
@@ -398,9 +427,23 @@ export default function AlbumTrackList({
|
|||||||
if (key === 'title') {
|
if (key === 'title') {
|
||||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||||
return (
|
return (
|
||||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: canSort ? 'pointer' : 'default',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
onClick={() => handleHeaderClick(key)}
|
||||||
|
className={isActive ? 'tracklist-header-cell-active' : ''}
|
||||||
|
>
|
||||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isActive ? 600 : 400 }}>{label}</span>
|
||||||
|
{canSort && renderSortIndicator(key as SortKey)}
|
||||||
</div>
|
</div>
|
||||||
{hasNextCol && (
|
{hasNextCol && (
|
||||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
|
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
|
||||||
@@ -411,7 +454,20 @@ export default function AlbumTrackList({
|
|||||||
|
|
||||||
const isResizable = !isLastCol;
|
const isResizable = !isLastCol;
|
||||||
return (
|
return (
|
||||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: canSort ? 'pointer' : 'default',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
onClick={() => handleHeaderClick(key)}
|
||||||
|
className={isActive ? 'tracklist-header-cell-active' : ''}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', width: '100%', height: '100%', alignItems: 'center',
|
display: 'flex', width: '100%', height: '100%', alignItems: 'center',
|
||||||
@@ -419,7 +475,8 @@ export default function AlbumTrackList({
|
|||||||
paddingLeft: isCentered ? 0 : 12,
|
paddingLeft: isCentered ? 0 : 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isActive ? 600 : 400 }}>{label}</span>
|
||||||
|
{canSort && isSortable(key) && renderSortIndicator(key as SortKey)}
|
||||||
</div>
|
</div>
|
||||||
{isResizable && (
|
{isResizable && (
|
||||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||||
|
|||||||
+52
-26
@@ -58,8 +58,9 @@ export default function AlbumDetail() {
|
|||||||
|
|
||||||
const [albumEntityRating, setAlbumEntityRating] = useState(0);
|
const [albumEntityRating, setAlbumEntityRating] = useState(0);
|
||||||
const [filterText, setFilterText] = useState('');
|
const [filterText, setFilterText] = useState('');
|
||||||
const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist'>('natural');
|
const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'>('natural');
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||||
|
const [sortClickCount, setSortClickCount] = useState(0);
|
||||||
|
|
||||||
// Derive a stable albumId for the selectors below (empty string when not yet loaded).
|
// Derive a stable albumId for the selectors below (empty string when not yet loaded).
|
||||||
const albumId = album?.album.id ?? '';
|
const albumId = album?.album.id ?? '';
|
||||||
@@ -262,6 +263,31 @@ const handleEnqueueAll = () => {
|
|||||||
deleteAlbum(album.album.id, serverId);
|
deleteAlbum(album.album.id, serverId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (key: typeof sortKey) => {
|
||||||
|
if (key === 'natural') return;
|
||||||
|
if (sortKey === key) {
|
||||||
|
const nextCount = sortClickCount + 1;
|
||||||
|
if (nextCount >= 3) {
|
||||||
|
setSortKey('natural');
|
||||||
|
setSortDir('asc');
|
||||||
|
setSortClickCount(0);
|
||||||
|
} else {
|
||||||
|
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||||
|
setSortClickCount(nextCount);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDir('asc');
|
||||||
|
setSortClickCount(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Must be before early returns — hooks must be called unconditionally.
|
||||||
|
const mergedStarredSongs = useMemo(() => new Set([
|
||||||
|
...[...starredSongs].filter(id => starredOverrides[id] !== false),
|
||||||
|
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
|
||||||
|
]), [starredSongs, starredOverrides]);
|
||||||
|
|
||||||
const displayedSongs = useMemo(() => {
|
const displayedSongs = useMemo(() => {
|
||||||
if (!album) return [];
|
if (!album) return [];
|
||||||
const q = filterText.trim().toLowerCase();
|
const q = filterText.trim().toLowerCase();
|
||||||
@@ -270,13 +296,31 @@ const handleEnqueueAll = () => {
|
|||||||
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
|
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
|
||||||
if (sortKey !== 'natural') {
|
if (sortKey !== 'natural') {
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
const av = sortKey === 'title' ? a.title : (a.artist ?? '');
|
let av: string | number;
|
||||||
const bv = sortKey === 'title' ? b.title : (b.artist ?? '');
|
let bv: string | number;
|
||||||
return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
switch (sortKey) {
|
||||||
|
case 'title': av = a.title; bv = b.title; break;
|
||||||
|
case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break;
|
||||||
|
case 'album': av = a.album ?? ''; bv = b.album ?? ''; break;
|
||||||
|
case 'favorite':
|
||||||
|
av = mergedStarredSongs.has(a.id) ? 1 : 0;
|
||||||
|
bv = mergedStarredSongs.has(b.id) ? 1 : 0;
|
||||||
|
break;
|
||||||
|
case 'rating':
|
||||||
|
av = ratings[a.id] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0;
|
||||||
|
bv = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0;
|
||||||
|
break;
|
||||||
|
case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break;
|
||||||
|
default: av = a.title; bv = b.title;
|
||||||
|
}
|
||||||
|
if (typeof av === 'number' && typeof bv === 'number') {
|
||||||
|
return sortDir === 'asc' ? av - bv : bv - av;
|
||||||
|
}
|
||||||
|
return sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [album, filterText, sortKey, sortDir]);
|
}, [album, filterText, sortKey, sortDir, mergedStarredSongs, ratings, userRatingOverrides]);
|
||||||
|
|
||||||
// Hooks must be called unconditionally — derive from nullable album state.
|
// Hooks must be called unconditionally — derive from nullable album state.
|
||||||
// useMemo is required: buildCoverArtUrl generates a new salt on every call, so without
|
// useMemo is required: buildCoverArtUrl generates a new salt on every call, so without
|
||||||
@@ -286,12 +330,6 @@ const handleEnqueueAll = () => {
|
|||||||
const coverKey = useMemo(() => album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '', [album?.album.coverArt]);
|
const coverKey = useMemo(() => album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '', [album?.album.coverArt]);
|
||||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||||||
|
|
||||||
// Must be before early returns — hooks must be called unconditionally.
|
|
||||||
const mergedStarredSongs = useMemo(() => new Set([
|
|
||||||
...[...starredSongs].filter(id => starredOverrides[id] !== false),
|
|
||||||
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
|
|
||||||
]), [starredSongs, starredOverrides]);
|
|
||||||
|
|
||||||
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
|
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
|
||||||
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
|
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
|
||||||
|
|
||||||
@@ -354,21 +392,6 @@ const handleEnqueueAll = () => {
|
|||||||
>×</button>
|
>×</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
||||||
{(['natural', 'title', 'artist'] as const).map(key => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
className={`btn btn-sm ${sortKey === key ? 'btn-surface' : 'btn-ghost'}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (sortKey === key && key !== 'natural') setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
|
||||||
else { setSortKey(key); setSortDir('asc'); }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{key === 'natural' ? t('albumDetail.sortNatural') : key === 'title' ? t('albumDetail.sortByTitle') : t('albumDetail.sortByArtist')}
|
|
||||||
{sortKey === key && key !== 'natural' && <span style={{ marginLeft: 3 }}>{sortDir === 'asc' ? '↑' : '↓'}</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -385,6 +408,9 @@ const handleEnqueueAll = () => {
|
|||||||
onRate={handleRate}
|
onRate={handleRate}
|
||||||
onToggleSongStar={toggleSongStar}
|
onToggleSongStar={toggleSongStar}
|
||||||
onContextMenu={openContextMenu}
|
onContextMenu={openContextMenu}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{relatedAlbums.length > 0 && (
|
{relatedAlbums.length > 0 && (
|
||||||
|
|||||||
@@ -114,8 +114,9 @@ export default function PlaylistDetail() {
|
|||||||
const [editingMeta, setEditingMeta] = useState(false);
|
const [editingMeta, setEditingMeta] = useState(false);
|
||||||
const [customCoverId, setCustomCoverId] = useState<string | null>(null);
|
const [customCoverId, setCustomCoverId] = useState<string | null>(null);
|
||||||
const [filterText, setFilterText] = useState('');
|
const [filterText, setFilterText] = useState('');
|
||||||
const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist'>('natural');
|
const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'>('natural');
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||||
|
const [sortClickCount, setSortClickCount] = useState(0);
|
||||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||||
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
||||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||||
@@ -471,13 +472,27 @@ export default function PlaylistDetail() {
|
|||||||
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
|
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
|
||||||
if (sortKey !== 'natural') {
|
if (sortKey !== 'natural') {
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
const av = sortKey === 'title' ? a.title : (a.artist ?? '');
|
let av: string | number;
|
||||||
const bv = sortKey === 'title' ? b.title : (b.artist ?? '');
|
let bv: string | number;
|
||||||
return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
const effectiveRating = (s: SubsonicSong) => ratings[s.id] ?? userRatingOverrides[s.id] ?? s.userRating ?? 0;
|
||||||
|
const effectiveStarred = (s: SubsonicSong) => (s.id in starredOverrides ? starredOverrides[s.id] : starredSongs.has(s.id)) ? 1 : 0;
|
||||||
|
switch (sortKey) {
|
||||||
|
case 'title': av = a.title; bv = b.title; break;
|
||||||
|
case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break;
|
||||||
|
case 'album': av = a.album ?? ''; bv = b.album ?? ''; break;
|
||||||
|
case 'favorite': av = effectiveStarred(a); bv = effectiveStarred(b); break;
|
||||||
|
case 'rating': av = effectiveRating(a); bv = effectiveRating(b); break;
|
||||||
|
case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break;
|
||||||
|
default: av = a.title; bv = b.title;
|
||||||
|
}
|
||||||
|
if (typeof av === 'number' && typeof bv === 'number') {
|
||||||
|
return sortDir === 'asc' ? av - bv : bv - av;
|
||||||
|
}
|
||||||
|
return sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [songs, filterText, sortKey, sortDir]);
|
}, [songs, filterText, sortKey, sortDir, ratings, userRatingOverrides, starredOverrides, starredSongs]);
|
||||||
const displayedTracks = useMemo(
|
const displayedTracks = useMemo(
|
||||||
() => displayedSongs === songs ? tracks : displayedSongs.map(songToTrack),
|
() => displayedSongs === songs ? tracks : displayedSongs.map(songToTrack),
|
||||||
[displayedSongs, songs, tracks],
|
[displayedSongs, songs, tracks],
|
||||||
@@ -706,23 +721,6 @@ export default function PlaylistDetail() {
|
|||||||
>×</button>
|
>×</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
||||||
{(['natural', 'title', 'artist'] as const).map(key => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
className={`btn btn-sm ${sortKey === key ? 'btn-surface' : 'btn-ghost'}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (sortKey === key && key !== 'natural') setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
|
||||||
else { setSortKey(key); setSortDir('asc'); }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{key === 'natural' ? t('albumDetail.sortNatural')
|
|
||||||
: key === 'title' ? t('albumDetail.sortByTitle')
|
|
||||||
: t('albumDetail.sortByArtist')}
|
|
||||||
{sortKey === key && key !== 'natural' && <span style={{ marginLeft: 3 }}>{sortDir === 'asc' ? '↑' : '↓'}</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -777,6 +775,38 @@ export default function PlaylistDetail() {
|
|||||||
const isLastCol = colIndex === visibleCols.length - 1;
|
const isLastCol = colIndex === visibleCols.length - 1;
|
||||||
const isCentered = PL_CENTERED.has(key);
|
const isCentered = PL_CENTERED.has(key);
|
||||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
|
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
|
||||||
|
const sortableCols = new Set(['title', 'artist', 'favorite', 'rating', 'duration', 'album']);
|
||||||
|
const canSort = sortableCols.has(key);
|
||||||
|
const isSortActive = canSort && sortKey === key;
|
||||||
|
|
||||||
|
const handleSortClick = () => {
|
||||||
|
if (!canSort) return;
|
||||||
|
if (sortKey === key) {
|
||||||
|
const nextCount = sortClickCount + 1;
|
||||||
|
if (nextCount >= 3) {
|
||||||
|
setSortKey('natural');
|
||||||
|
setSortDir('asc');
|
||||||
|
setSortClickCount(0);
|
||||||
|
} else {
|
||||||
|
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||||
|
setSortClickCount(nextCount);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSortKey(key as typeof sortKey);
|
||||||
|
setSortDir('asc');
|
||||||
|
setSortClickCount(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSortIndicator = () => {
|
||||||
|
if (!isSortActive) return null;
|
||||||
|
return (
|
||||||
|
<span style={{ marginLeft: 4, fontSize: 10, opacity: 0.7 }}>
|
||||||
|
{sortDir === 'asc' ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (key === 'num') return (
|
if (key === 'num') return (
|
||||||
<div key="num" className="track-num">
|
<div key="num" className="track-num">
|
||||||
<span
|
<span
|
||||||
@@ -790,9 +820,23 @@ export default function PlaylistDetail() {
|
|||||||
if (key === 'title') {
|
if (key === 'title') {
|
||||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||||
return (
|
return (
|
||||||
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
<div
|
||||||
|
key="title"
|
||||||
|
onClick={handleSortClick}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: canSort ? 'pointer' : 'default',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
className={isSortActive ? 'tracklist-header-cell-active' : ''}
|
||||||
|
>
|
||||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
|
||||||
|
{canSort && renderSortIndicator()}
|
||||||
</div>
|
</div>
|
||||||
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
|
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -800,7 +844,20 @@ export default function PlaylistDetail() {
|
|||||||
}
|
}
|
||||||
if (key === 'delete') return <div key="delete" />;
|
if (key === 'delete') return <div key="delete" />;
|
||||||
return (
|
return (
|
||||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
<div
|
||||||
|
key={key}
|
||||||
|
onClick={handleSortClick}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: canSort ? 'pointer' : 'default',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
className={isSortActive ? 'tracklist-header-cell-active' : ''}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -811,7 +868,8 @@ export default function PlaylistDetail() {
|
|||||||
paddingLeft: isCentered ? 0 : 12,
|
paddingLeft: isCentered ? 0 : 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
|
||||||
|
{canSort && renderSortIndicator()}
|
||||||
</div>
|
</div>
|
||||||
{!isLastCol && key !== 'delete' && (
|
{!isLastCol && key !== 'delete' && (
|
||||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||||
|
|||||||
Reference in New Issue
Block a user