mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
refactor(album-track-list): I.2 — split AlbumTrackList.tsx 662 → 187 LOC across 7 files (#672)
* refactor(album-track-list): extract helpers + types Pull formatDuration / codecLabel, the COLUMNS / CENTERED_COLS / SORTABLE_COLS tables, ColKey / SortKey types, and the isSortable type guard into utils/albumTrackListHelpers.ts. SortKey is re-exported from AlbumTrackList.tsx so existing imports stay valid. AlbumTrackList.tsx: 662 → 633 LOC. * refactor(album-track-list): extract TrackRow subcomponent Move the memoised tracklist row (~220 LOC including renderCell switch and mouse handlers) into components/albumTrackList/TrackRow.tsx. It still subscribes to its own selection + preview state via primitive selectors, so per-row re-render scope is unchanged. AlbumTrackList.tsx: 633 → 404 LOC. * refactor(album-track-list): extract AlbumTrackListMobile subcomponent Move the narrow-viewport branch (compact tracklist with disc separators and no column grid) into components/albumTrackList/AlbumTrackListMobile.tsx. AlbumTrackList.tsx: 404 → 376 LOC. * refactor(album-track-list): extract TracklistColumnPicker subcomponent The column visibility dropdown lives outside .tracklist to avoid the overflow box clipping its menu — pull the wrapper + button + popover into components/albumTrackList/TracklistColumnPicker.tsx. AlbumTrackList.tsx: 376 → 347 LOC. * refactor(album-track-list): extract TracklistHeaderRow subcomponent The fixed header (sortable + resizable per-column with the bulk-select toggle on the num cell) moves into components/albumTrackList/TracklistHeaderRow.tsx, taking 85+ LOC of cell rendering with it. AlbumTrackList.tsx: 347 → 254 LOC. * refactor(album-track-list): extract useAlbumTrackListSelection hook Pull bulk-selection state (selectedIds-size subscription, shift-range toggle, click-outside-clear, song-list-change clear) and the drag-start dispatcher (single vs multi-song drag) into hooks/useAlbumTrackListSelection.ts. AlbumTrackList.tsx: 254 → 187 LOC.
This commit is contained in:
committed by
GitHub
parent
b591a1cb5f
commit
f14c8f21e6
@@ -1,53 +1,22 @@
|
|||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Play, ChevronRight, Heart, ChevronDown, Check, RotateCcw, Square, AudioLines } from 'lucide-react';
|
import { useTracklistColumns } from '../utils/useTracklistColumns';
|
||||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useDragDrop } from '../contexts/DragDropContext';
|
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import StarRating from './StarRating';
|
|
||||||
import { useSelectionStore } from '../store/selectionStore';
|
import { useSelectionStore } from '../store/selectionStore';
|
||||||
import { useThemeStore } from '../store/themeStore';
|
import {
|
||||||
import { usePreviewStore } from '../store/previewStore';
|
COLUMNS,
|
||||||
|
type SortKey,
|
||||||
|
} from '../utils/albumTrackListHelpers';
|
||||||
|
import { useAlbumTrackListSelection } from '../hooks/useAlbumTrackListSelection';
|
||||||
|
import { TrackRow } from './albumTrackList/TrackRow';
|
||||||
|
import { AlbumTrackListMobile } from './albumTrackList/AlbumTrackListMobile';
|
||||||
|
import { TracklistColumnPicker } from './albumTrackList/TracklistColumnPicker';
|
||||||
|
import { TracklistHeaderRow } from './albumTrackList/TracklistHeaderRow';
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
export type { SortKey } from '../utils/albumTrackListHelpers';
|
||||||
const h = Math.floor(seconds / 3600);
|
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
|
||||||
const s = Math.floor(seconds % 60);
|
|
||||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function codecLabel(song: { suffix?: string; bitRate?: number }, showBitrate: boolean): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
|
||||||
if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
|
|
||||||
return parts.join(' · ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Column configuration ──────────────────────────────────────────────────────
|
|
||||||
const COLUMNS: readonly ColDef[] = [
|
|
||||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
|
||||||
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
|
||||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
|
||||||
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
|
|
||||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
|
||||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
|
||||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
|
||||||
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
|
|
||||||
|
|
||||||
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
|
|
||||||
|
|
||||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type SortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration';
|
|
||||||
|
|
||||||
interface AlbumTrackListProps {
|
interface AlbumTrackListProps {
|
||||||
songs: SubsonicSong[];
|
songs: SubsonicSong[];
|
||||||
@@ -69,231 +38,6 @@ interface AlbumTrackListProps {
|
|||||||
onSort?: (key: SortKey) => void;
|
onSort?: (key: SortKey) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── TrackRow (memoised) ───────────────────────────────────────────────────────
|
|
||||||
// Subscribes only to its own boolean in the selection store → O(1) re-render on toggle.
|
|
||||||
|
|
||||||
interface TrackRowProps {
|
|
||||||
song: SubsonicSong;
|
|
||||||
globalIdx: number;
|
|
||||||
visibleCols: readonly ColDef[];
|
|
||||||
gridStyle: React.CSSProperties;
|
|
||||||
currentTrackId: string | null;
|
|
||||||
isPlaying: boolean;
|
|
||||||
ratingValue: number;
|
|
||||||
isStarred: boolean;
|
|
||||||
inSelectMode: boolean;
|
|
||||||
isContextMenuSong: boolean;
|
|
||||||
onPlaySong: (song: SubsonicSong) => void;
|
|
||||||
onDoubleClickSong?: (song: SubsonicSong) => void;
|
|
||||||
onRate: (songId: string, rating: number) => void;
|
|
||||||
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
|
||||||
onContextMenu: AlbumTrackListProps['onContextMenu'];
|
|
||||||
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
|
|
||||||
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
|
|
||||||
setContextMenuSongId: (id: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TrackRow = React.memo(function TrackRow({
|
|
||||||
song,
|
|
||||||
globalIdx,
|
|
||||||
visibleCols,
|
|
||||||
gridStyle,
|
|
||||||
currentTrackId,
|
|
||||||
isPlaying,
|
|
||||||
ratingValue,
|
|
||||||
isStarred,
|
|
||||||
inSelectMode,
|
|
||||||
isContextMenuSong,
|
|
||||||
onPlaySong,
|
|
||||||
onDoubleClickSong,
|
|
||||||
onRate,
|
|
||||||
onToggleSongStar,
|
|
||||||
onContextMenu,
|
|
||||||
onToggleSelect,
|
|
||||||
onDragStart,
|
|
||||||
setContextMenuSongId,
|
|
||||||
}: TrackRowProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const showBitrate = useThemeStore(s => s.showBitrate);
|
|
||||||
// Fine-grained: only re-renders when THIS row's selection boolean flips.
|
|
||||||
const isSelected = useSelectionStore(s => s.selectedIds.has(song.id));
|
|
||||||
const isActive = currentTrackId === song.id;
|
|
||||||
// Primitive selector: row only re-renders when *this song's* preview state flips.
|
|
||||||
const isPreviewing = usePreviewStore(s => s.previewingId === song.id);
|
|
||||||
const isPreviewAudioStarted = usePreviewStore(s => s.previewingId === song.id && s.audioStarted);
|
|
||||||
|
|
||||||
const renderCell = (colDef: ColDef) => {
|
|
||||||
const key = colDef.key as ColKey;
|
|
||||||
switch (key) {
|
|
||||||
case 'num':
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key="num"
|
|
||||||
className={`track-num${isActive ? ' track-num-active' : ''}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
|
||||||
onClick={e => { e.stopPropagation(); onToggleSelect(song.id, globalIdx, e.shiftKey); }}
|
|
||||||
/>
|
|
||||||
{isActive && isPlaying ? (
|
|
||||||
<span className="track-num-eq">
|
|
||||||
<AudioLines className="eq-bars" size={14} />
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="track-num-number">{song.track ?? '—'}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'title':
|
|
||||||
return (
|
|
||||||
<div key="title" className="track-info track-info-suggestion">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="playlist-suggestion-play-btn"
|
|
||||||
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
|
|
||||||
onDoubleClick={onDoubleClickSong ? e => { e.stopPropagation(); onDoubleClickSong(song); } : undefined}
|
|
||||||
data-tooltip={t('common.play')}
|
|
||||||
aria-label={t('common.play')}
|
|
||||||
>
|
|
||||||
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'albums');
|
|
||||||
}}
|
|
||||||
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
|
||||||
aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
|
||||||
>
|
|
||||||
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
|
|
||||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
|
|
||||||
</svg>
|
|
||||||
{isPreviewing
|
|
||||||
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
|
|
||||||
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
|
||||||
</button>
|
|
||||||
<span className="track-title">{song.title}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'artist': {
|
|
||||||
const artistRefs = song.artists && song.artists.length > 0
|
|
||||||
? song.artists
|
|
||||||
: [{ id: song.artistId, name: song.artist }];
|
|
||||||
return (
|
|
||||||
<div key="artist" className="track-artist-cell">
|
|
||||||
{artistRefs.map((a, i) => (
|
|
||||||
<React.Fragment key={a.id ?? a.name ?? i}>
|
|
||||||
{i > 0 && <span className="track-artist-sep"> · </span>}
|
|
||||||
<span
|
|
||||||
className={`track-artist${a.id ? ' track-artist-link' : ''}`}
|
|
||||||
style={{ cursor: a.id ? 'pointer' : 'default' }}
|
|
||||||
onClick={e => { if (a.id) { e.stopPropagation(); navigate(`/artist/${a.id}`); } }}
|
|
||||||
>
|
|
||||||
{a.name ?? song.artist}
|
|
||||||
</span>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'favorite':
|
|
||||||
return (
|
|
||||||
<div key="favorite" className="track-star-cell">
|
|
||||||
<button
|
|
||||||
className={`btn btn-ghost track-star-btn${isStarred ? ' is-starred' : ''}`}
|
|
||||||
onClick={e => onToggleSongStar(song, e)}
|
|
||||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
|
||||||
>
|
|
||||||
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'rating':
|
|
||||||
return (
|
|
||||||
<StarRating
|
|
||||||
key="rating"
|
|
||||||
value={ratingValue}
|
|
||||||
onChange={r => onRate(song.id, r)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'duration':
|
|
||||||
return (
|
|
||||||
<div key="duration" className="track-duration">
|
|
||||||
{formatDuration(song.duration)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'format':
|
|
||||||
return (
|
|
||||||
<div key="format" className="track-meta">
|
|
||||||
{(song.suffix || (showBitrate && song.bitRate)) && (
|
|
||||||
<span className="track-codec">{codecLabel(song, showBitrate)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'genre':
|
|
||||||
return (
|
|
||||||
<div key="genre" className="track-genre">
|
|
||||||
{song.genre ?? '—'}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`track-row track-row-va track-row-with-actions${isActive ? ' active' : ''}${isContextMenuSong ? ' context-active' : ''}${isSelected ? ' bulk-selected' : ''}`}
|
|
||||||
style={gridStyle}
|
|
||||||
onClick={e => {
|
|
||||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
onToggleSelect(song.id, globalIdx, false);
|
|
||||||
} else if (inSelectMode) {
|
|
||||||
onToggleSelect(song.id, globalIdx, e.shiftKey);
|
|
||||||
} else {
|
|
||||||
onPlaySong(song);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDoubleClick={onDoubleClickSong ? e => {
|
|
||||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
|
||||||
if (e.ctrlKey || e.metaKey || inSelectMode) return;
|
|
||||||
onDoubleClickSong(song);
|
|
||||||
} : undefined}
|
|
||||||
onContextMenu={e => {
|
|
||||||
e.preventDefault();
|
|
||||||
setContextMenuSongId(song.id);
|
|
||||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
|
||||||
}}
|
|
||||||
role="row"
|
|
||||||
onMouseDown={e => {
|
|
||||||
if (e.button !== 0) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const sx = e.clientX, sy = e.clientY;
|
|
||||||
const onMove = (me: MouseEvent) => {
|
|
||||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
|
||||||
document.removeEventListener('mousemove', onMove);
|
|
||||||
document.removeEventListener('mouseup', onUp);
|
|
||||||
onDragStart(song, me);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onUp = () => {
|
|
||||||
document.removeEventListener('mousemove', onMove);
|
|
||||||
document.removeEventListener('mouseup', onUp);
|
|
||||||
};
|
|
||||||
document.addEventListener('mousemove', onMove);
|
|
||||||
document.addEventListener('mouseup', onUp);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{visibleCols.map(colDef => renderCell(colDef))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── AlbumTrackList ────────────────────────────────────────────────────────────
|
// ── AlbumTrackList ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AlbumTrackList({
|
export default function AlbumTrackList({
|
||||||
@@ -318,87 +62,21 @@ export default function AlbumTrackList({
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||||
const psyDrag = useDragDrop();
|
|
||||||
|
|
||||||
// Selection state lives in selectionStore — only the toggled row re-renders (O(1)).
|
|
||||||
const selectedCount = useSelectionStore(s => s.selectedIds.size);
|
|
||||||
const inSelectMode = selectedCount > 0;
|
|
||||||
const allSelected = selectedCount === songs.length && songs.length > 0;
|
|
||||||
const lastSelectedIdxRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
// ── Column state ──────────────────────────────────────────────────────────
|
|
||||||
const {
|
const {
|
||||||
colVisible, visibleCols, gridStyle,
|
colVisible, visibleCols, gridStyle,
|
||||||
startResize, toggleColumn, resetColumns,
|
startResize, toggleColumn, resetColumns,
|
||||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||||
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
|
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
|
||||||
|
|
||||||
// Clear selection when the song list changes (different album / filter applied).
|
const {
|
||||||
useEffect(() => {
|
inSelectMode, allSelected, onToggleSelect, onDragStart, toggleAll,
|
||||||
useSelectionStore.getState().clearAll();
|
} = useAlbumTrackListSelection({ songs, tracklistRef });
|
||||||
lastSelectedIdxRef.current = null;
|
|
||||||
}, [songs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||||
}, [contextMenuOpen]);
|
}, [contextMenuOpen]);
|
||||||
|
|
||||||
// Clear selection on click outside the tracklist (header, album art, etc.)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!inSelectMode) return;
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) {
|
|
||||||
useSelectionStore.getState().clearAll();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handler);
|
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
|
||||||
}, [inSelectMode, tracklistRef]);
|
|
||||||
|
|
||||||
// ── Stable callbacks passed to memoised TrackRow ──────────────────────────
|
|
||||||
|
|
||||||
const onToggleSelect = useCallback((id: string, globalIdx: number, shift: boolean) => {
|
|
||||||
useSelectionStore.getState().setSelectedIds(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (shift && lastSelectedIdxRef.current !== null) {
|
|
||||||
const from = Math.min(lastSelectedIdxRef.current, globalIdx);
|
|
||||||
const to = Math.max(lastSelectedIdxRef.current, globalIdx);
|
|
||||||
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
|
||||||
} else {
|
|
||||||
next.has(id) ? next.delete(id) : next.add(id);
|
|
||||||
}
|
|
||||||
lastSelectedIdxRef.current = globalIdx;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [songs]);
|
|
||||||
|
|
||||||
// Drag: if the dragged song is part of the selection, drag all selected songs.
|
|
||||||
const onDragStart = useCallback((song: SubsonicSong, me: MouseEvent) => {
|
|
||||||
const { selectedIds } = useSelectionStore.getState();
|
|
||||||
if (selectedIds.has(song.id) && selectedIds.size > 1) {
|
|
||||||
const tracks = songs
|
|
||||||
.filter(s => selectedIds.has(s.id))
|
|
||||||
.map(s => songToTrack(s));
|
|
||||||
psyDrag.startDrag(
|
|
||||||
{ data: JSON.stringify({ type: 'songs', tracks }), label: `${tracks.length} Songs` },
|
|
||||||
me.clientX, me.clientY,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
psyDrag.startDrag(
|
|
||||||
{ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title },
|
|
||||||
me.clientX, me.clientY,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [songs, psyDrag]);
|
|
||||||
|
|
||||||
const toggleAll = useCallback(() => {
|
|
||||||
if (allSelected) {
|
|
||||||
useSelectionStore.getState().clearAll();
|
|
||||||
} else {
|
|
||||||
useSelectionStore.getState().setSelectedIds(() => new Set(songs.map(s => s.id)));
|
|
||||||
}
|
|
||||||
}, [allSelected, songs]);
|
|
||||||
|
|
||||||
// ── Disc grouping ─────────────────────────────────────────────────────────
|
// ── Disc grouping ─────────────────────────────────────────────────────────
|
||||||
const discs = new Map<number, SubsonicSong[]>();
|
const discs = new Map<number, SubsonicSong[]>();
|
||||||
if (!sorted) {
|
if (!sorted) {
|
||||||
@@ -415,192 +93,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 ──────────────────────────────────────────────────
|
|
||||||
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
|
|
||||||
const key = colDef.key as ColKey;
|
|
||||||
const isLastCol = colIndex === visibleCols.length - 1;
|
|
||||||
const isCentered = CENTERED_COLS.has(key);
|
|
||||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
|
|
||||||
const canSort = isSortable(key) && onSort;
|
|
||||||
const isActive = canSort && sortKey === key;
|
|
||||||
|
|
||||||
if (key === 'num') {
|
|
||||||
return (
|
|
||||||
<div key={key} className="track-num">
|
|
||||||
<span
|
|
||||||
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
|
||||||
onClick={e => { e.stopPropagation(); toggleAll(); }}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
/>
|
|
||||||
<span className="track-num-number">#</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'title') {
|
|
||||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
|
||||||
return (
|
|
||||||
<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 }}>
|
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isActive ? 600 : 400 }}>{label}</span>
|
|
||||||
{canSort && renderSortIndicator(key as SortKey)}
|
|
||||||
</div>
|
|
||||||
{hasNextCol && (
|
|
||||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isResizable = !isLastCol;
|
|
||||||
return (
|
|
||||||
<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: isCentered ? 'center' : 'flex-start',
|
|
||||||
paddingLeft: isCentered ? 0 : 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isActive ? 600 : 400 }}>{label}</span>
|
|
||||||
{canSort && isSortable(key) && renderSortIndicator(key as SortKey)}
|
|
||||||
</div>
|
|
||||||
{isResizable && (
|
|
||||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Mobile tracklist ──────────────────────────────────────────────────────
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<div className="tracklist-mobile">
|
<AlbumTrackListMobile
|
||||||
{discNums.map(discNum => (
|
discNums={discNums}
|
||||||
<div key={discNum}>
|
discs={discs}
|
||||||
{isMultiDisc && (
|
isMultiDisc={isMultiDisc}
|
||||||
<div className="disc-header">
|
currentTrackId={currentTrackId}
|
||||||
<span className="disc-icon">💿</span> CD {discNum}
|
isPlaying={isPlaying}
|
||||||
</div>
|
contextMenuSongId={contextMenuSongId}
|
||||||
)}
|
setContextMenuSongId={setContextMenuSongId}
|
||||||
{discs.get(discNum)!.map(song => {
|
onPlaySong={onPlaySong}
|
||||||
const isActive = currentTrackId === song.id;
|
onContextMenu={onContextMenu}
|
||||||
return (
|
/>
|
||||||
<div
|
|
||||||
key={song.id}
|
|
||||||
className={`tracklist-mobile-row${isActive ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
|
||||||
onClick={() => onPlaySong(song)}
|
|
||||||
onContextMenu={e => {
|
|
||||||
e.preventDefault();
|
|
||||||
setContextMenuSongId(song.id);
|
|
||||||
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="tracklist-mobile-main">
|
|
||||||
{isActive && isPlaying ? (
|
|
||||||
<span className="tracklist-mobile-eq">
|
|
||||||
<AudioLines className="eq-bars" size={14} />
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="tracklist-mobile-num">{song.track ?? ''}</span>
|
|
||||||
)}
|
|
||||||
<span className="tracklist-mobile-title">{song.title}</span>
|
|
||||||
</div>
|
|
||||||
<span className="tracklist-mobile-duration">{formatDuration(song.duration)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Column visibility picker - outside .tracklist to avoid overflow cutoff */}
|
<TracklistColumnPicker
|
||||||
<div className="tracklist-col-picker-wrapper" ref={pickerRef}>
|
pickerRef={pickerRef}
|
||||||
<div className="tracklist-col-picker">
|
pickerOpen={pickerOpen}
|
||||||
<button
|
setPickerOpen={setPickerOpen}
|
||||||
className="tracklist-col-picker-btn"
|
colVisible={colVisible}
|
||||||
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
|
toggleColumn={toggleColumn}
|
||||||
data-tooltip={t('albumDetail.columns')}
|
resetColumns={resetColumns}
|
||||||
>
|
t={t}
|
||||||
<ChevronDown size={14} />
|
/>
|
||||||
</button>
|
|
||||||
{pickerOpen && (
|
|
||||||
<div className="tracklist-col-picker-menu">
|
|
||||||
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
|
||||||
{COLUMNS.filter(c => !c.required).map(c => {
|
|
||||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key;
|
|
||||||
const isOn = colVisible.has(c.key);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={c.key}
|
|
||||||
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
|
|
||||||
onClick={() => toggleColumn(c.key)}
|
|
||||||
>
|
|
||||||
<span className="tracklist-col-picker-check">
|
|
||||||
{isOn && <Check size={13} />}
|
|
||||||
</span>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="tracklist-col-picker-divider" />
|
|
||||||
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
|
|
||||||
<RotateCcw size={13} />
|
|
||||||
{t('albumDetail.resetColumns')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="tracklist"
|
className="tracklist"
|
||||||
@@ -611,12 +130,18 @@ export default function AlbumTrackList({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
||||||
{/* ── Header ── */}
|
<TracklistHeaderRow
|
||||||
<div className="tracklist-header-wrapper">
|
visibleCols={visibleCols}
|
||||||
<div className="tracklist-header" style={gridStyle}>
|
gridStyle={gridStyle}
|
||||||
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
|
sortKey={sortKey}
|
||||||
</div>
|
sortDir={sortDir}
|
||||||
</div>
|
onSort={onSort}
|
||||||
|
allSelected={allSelected}
|
||||||
|
inSelectMode={inSelectMode}
|
||||||
|
toggleAll={toggleAll}
|
||||||
|
startResize={startResize}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ── Tracks ── */}
|
{/* ── Tracks ── */}
|
||||||
{discNums.map(discNum => (
|
{discNums.map(discNum => (
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AudioLines } from 'lucide-react';
|
||||||
|
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||||
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
|
import { songToTrack } from '../../utils/songToTrack';
|
||||||
|
import { formatDuration } from '../../utils/albumTrackListHelpers';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
discNums: number[];
|
||||||
|
discs: Map<number, SubsonicSong[]>;
|
||||||
|
isMultiDisc: boolean;
|
||||||
|
currentTrackId: string | null;
|
||||||
|
isPlaying: boolean;
|
||||||
|
contextMenuSongId: string | null;
|
||||||
|
setContextMenuSongId: (id: string | null) => void;
|
||||||
|
onPlaySong: (song: SubsonicSong) => void;
|
||||||
|
onContextMenu: (
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
track: Track,
|
||||||
|
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song',
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact tracklist for narrow viewports. Drops the column grid + column
|
||||||
|
* picker + drag selection entirely — just a one-line row per song with
|
||||||
|
* disc group separators. Play on tap, context menu on long-press / right
|
||||||
|
* click.
|
||||||
|
*/
|
||||||
|
export function AlbumTrackListMobile({
|
||||||
|
discNums,
|
||||||
|
discs,
|
||||||
|
isMultiDisc,
|
||||||
|
currentTrackId,
|
||||||
|
isPlaying,
|
||||||
|
contextMenuSongId,
|
||||||
|
setContextMenuSongId,
|
||||||
|
onPlaySong,
|
||||||
|
onContextMenu,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="tracklist-mobile">
|
||||||
|
{discNums.map(discNum => (
|
||||||
|
<div key={discNum}>
|
||||||
|
{isMultiDisc && (
|
||||||
|
<div className="disc-header">
|
||||||
|
<span className="disc-icon">💿</span> CD {discNum}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{discs.get(discNum)!.map(song => {
|
||||||
|
const isActive = currentTrackId === song.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={song.id}
|
||||||
|
className={`tracklist-mobile-row${isActive ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||||
|
onClick={() => onPlaySong(song)}
|
||||||
|
onContextMenu={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
setContextMenuSongId(song.id);
|
||||||
|
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="tracklist-mobile-main">
|
||||||
|
{isActive && isPlaying ? (
|
||||||
|
<span className="tracklist-mobile-eq">
|
||||||
|
<AudioLines className="eq-bars" size={14} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="tracklist-mobile-num">{song.track ?? ''}</span>
|
||||||
|
)}
|
||||||
|
<span className="tracklist-mobile-title">{song.title}</span>
|
||||||
|
</div>
|
||||||
|
<span className="tracklist-mobile-duration">{formatDuration(song.duration)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AudioLines, ChevronRight, Heart, Play, Square } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { ColDef } from '../../utils/useTracklistColumns';
|
||||||
|
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||||
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
|
import { songToTrack } from '../../utils/songToTrack';
|
||||||
|
import { useSelectionStore } from '../../store/selectionStore';
|
||||||
|
import { useThemeStore } from '../../store/themeStore';
|
||||||
|
import { usePreviewStore } from '../../store/previewStore';
|
||||||
|
import StarRating from '../StarRating';
|
||||||
|
import { codecLabel, formatDuration, type ColKey } from '../../utils/albumTrackListHelpers';
|
||||||
|
|
||||||
|
type ContextMenuFn = (
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
track: Track,
|
||||||
|
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song',
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
interface TrackRowProps {
|
||||||
|
song: SubsonicSong;
|
||||||
|
globalIdx: number;
|
||||||
|
visibleCols: readonly ColDef[];
|
||||||
|
gridStyle: React.CSSProperties;
|
||||||
|
currentTrackId: string | null;
|
||||||
|
isPlaying: boolean;
|
||||||
|
ratingValue: number;
|
||||||
|
isStarred: boolean;
|
||||||
|
inSelectMode: boolean;
|
||||||
|
isContextMenuSong: boolean;
|
||||||
|
onPlaySong: (song: SubsonicSong) => void;
|
||||||
|
onDoubleClickSong?: (song: SubsonicSong) => void;
|
||||||
|
onRate: (songId: string, rating: number) => void;
|
||||||
|
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
||||||
|
onContextMenu: ContextMenuFn;
|
||||||
|
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
|
||||||
|
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
|
||||||
|
setContextMenuSongId: (id: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoised tracklist row. Subscribes to its own selection + preview state
|
||||||
|
* via primitive selectors so only this row re-renders when the user
|
||||||
|
* toggles selection or starts/stops a preview.
|
||||||
|
*/
|
||||||
|
export const TrackRow = React.memo(function TrackRow({
|
||||||
|
song,
|
||||||
|
globalIdx,
|
||||||
|
visibleCols,
|
||||||
|
gridStyle,
|
||||||
|
currentTrackId,
|
||||||
|
isPlaying,
|
||||||
|
ratingValue,
|
||||||
|
isStarred,
|
||||||
|
inSelectMode,
|
||||||
|
isContextMenuSong,
|
||||||
|
onPlaySong,
|
||||||
|
onDoubleClickSong,
|
||||||
|
onRate,
|
||||||
|
onToggleSongStar,
|
||||||
|
onContextMenu,
|
||||||
|
onToggleSelect,
|
||||||
|
onDragStart,
|
||||||
|
setContextMenuSongId,
|
||||||
|
}: TrackRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const showBitrate = useThemeStore(s => s.showBitrate);
|
||||||
|
const isSelected = useSelectionStore(s => s.selectedIds.has(song.id));
|
||||||
|
const isActive = currentTrackId === song.id;
|
||||||
|
const isPreviewing = usePreviewStore(s => s.previewingId === song.id);
|
||||||
|
const isPreviewAudioStarted = usePreviewStore(s => s.previewingId === song.id && s.audioStarted);
|
||||||
|
|
||||||
|
const renderCell = (colDef: ColDef) => {
|
||||||
|
const key = colDef.key as ColKey;
|
||||||
|
switch (key) {
|
||||||
|
case 'num':
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key="num"
|
||||||
|
className={`track-num${isActive ? ' track-num-active' : ''}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||||
|
onClick={e => { e.stopPropagation(); onToggleSelect(song.id, globalIdx, e.shiftKey); }}
|
||||||
|
/>
|
||||||
|
{isActive && isPlaying ? (
|
||||||
|
<span className="track-num-eq">
|
||||||
|
<AudioLines className="eq-bars" size={14} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="track-num-number">{song.track ?? '—'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'title':
|
||||||
|
return (
|
||||||
|
<div key="title" className="track-info track-info-suggestion">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="playlist-suggestion-play-btn"
|
||||||
|
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
|
||||||
|
onDoubleClick={onDoubleClickSong ? e => { e.stopPropagation(); onDoubleClickSong(song); } : undefined}
|
||||||
|
data-tooltip={t('common.play')}
|
||||||
|
aria-label={t('common.play')}
|
||||||
|
>
|
||||||
|
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'albums');
|
||||||
|
}}
|
||||||
|
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
|
aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
|
>
|
||||||
|
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
|
||||||
|
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
|
||||||
|
</svg>
|
||||||
|
{isPreviewing
|
||||||
|
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
|
||||||
|
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
||||||
|
</button>
|
||||||
|
<span className="track-title">{song.title}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'artist': {
|
||||||
|
const artistRefs = song.artists && song.artists.length > 0
|
||||||
|
? song.artists
|
||||||
|
: [{ id: song.artistId, name: song.artist }];
|
||||||
|
return (
|
||||||
|
<div key="artist" className="track-artist-cell">
|
||||||
|
{artistRefs.map((a, i) => (
|
||||||
|
<React.Fragment key={a.id ?? a.name ?? i}>
|
||||||
|
{i > 0 && <span className="track-artist-sep"> · </span>}
|
||||||
|
<span
|
||||||
|
className={`track-artist${a.id ? ' track-artist-link' : ''}`}
|
||||||
|
style={{ cursor: a.id ? 'pointer' : 'default' }}
|
||||||
|
onClick={e => { if (a.id) { e.stopPropagation(); navigate(`/artist/${a.id}`); } }}
|
||||||
|
>
|
||||||
|
{a.name ?? song.artist}
|
||||||
|
</span>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'favorite':
|
||||||
|
return (
|
||||||
|
<div key="favorite" className="track-star-cell">
|
||||||
|
<button
|
||||||
|
className={`btn btn-ghost track-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||||
|
onClick={e => onToggleSongStar(song, e)}
|
||||||
|
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||||
|
>
|
||||||
|
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'rating':
|
||||||
|
return (
|
||||||
|
<StarRating
|
||||||
|
key="rating"
|
||||||
|
value={ratingValue}
|
||||||
|
onChange={r => onRate(song.id, r)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'duration':
|
||||||
|
return (
|
||||||
|
<div key="duration" className="track-duration">
|
||||||
|
{formatDuration(song.duration)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'format':
|
||||||
|
return (
|
||||||
|
<div key="format" className="track-meta">
|
||||||
|
{(song.suffix || (showBitrate && song.bitRate)) && (
|
||||||
|
<span className="track-codec">{codecLabel(song, showBitrate)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'genre':
|
||||||
|
return (
|
||||||
|
<div key="genre" className="track-genre">
|
||||||
|
{song.genre ?? '—'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`track-row track-row-va track-row-with-actions${isActive ? ' active' : ''}${isContextMenuSong ? ' context-active' : ''}${isSelected ? ' bulk-selected' : ''}`}
|
||||||
|
style={gridStyle}
|
||||||
|
onClick={e => {
|
||||||
|
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
onToggleSelect(song.id, globalIdx, false);
|
||||||
|
} else if (inSelectMode) {
|
||||||
|
onToggleSelect(song.id, globalIdx, e.shiftKey);
|
||||||
|
} else {
|
||||||
|
onPlaySong(song);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDoubleClick={onDoubleClickSong ? e => {
|
||||||
|
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||||
|
if (e.ctrlKey || e.metaKey || inSelectMode) return;
|
||||||
|
onDoubleClickSong(song);
|
||||||
|
} : undefined}
|
||||||
|
onContextMenu={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
setContextMenuSongId(song.id);
|
||||||
|
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||||
|
}}
|
||||||
|
role="row"
|
||||||
|
onMouseDown={e => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const sx = e.clientX, sy = e.clientY;
|
||||||
|
const onMove = (me: MouseEvent) => {
|
||||||
|
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
onDragStart(song, me);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibleCols.map(colDef => renderCell(colDef))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Check, ChevronDown, RotateCcw } from 'lucide-react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import { COLUMNS } from '../../utils/albumTrackListHelpers';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pickerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
pickerOpen: boolean;
|
||||||
|
setPickerOpen: (updater: (v: boolean) => boolean) => void;
|
||||||
|
colVisible: Set<string>;
|
||||||
|
toggleColumn: (key: string) => void;
|
||||||
|
resetColumns: () => void;
|
||||||
|
t: TFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The column visibility dropdown that sits outside `.tracklist` so the
|
||||||
|
* popover menu can grow without being clipped by the tracklist's overflow
|
||||||
|
* box. Lists every non-required column and offers a reset-to-defaults
|
||||||
|
* button.
|
||||||
|
*/
|
||||||
|
export function TracklistColumnPicker({
|
||||||
|
pickerRef,
|
||||||
|
pickerOpen,
|
||||||
|
setPickerOpen,
|
||||||
|
colVisible,
|
||||||
|
toggleColumn,
|
||||||
|
resetColumns,
|
||||||
|
t,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="tracklist-col-picker-wrapper" ref={pickerRef}>
|
||||||
|
<div className="tracklist-col-picker">
|
||||||
|
<button
|
||||||
|
className="tracklist-col-picker-btn"
|
||||||
|
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
|
||||||
|
data-tooltip={t('albumDetail.columns')}
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
{pickerOpen && (
|
||||||
|
<div className="tracklist-col-picker-menu">
|
||||||
|
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||||
|
{COLUMNS.filter(c => !c.required).map(c => {
|
||||||
|
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key;
|
||||||
|
const isOn = colVisible.has(c.key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.key}
|
||||||
|
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
|
||||||
|
onClick={() => toggleColumn(c.key)}
|
||||||
|
>
|
||||||
|
<span className="tracklist-col-picker-check">
|
||||||
|
{isOn && <Check size={13} />}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="tracklist-col-picker-divider" />
|
||||||
|
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
|
||||||
|
<RotateCcw size={13} />
|
||||||
|
{t('albumDetail.resetColumns')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { ColDef } from '../../utils/useTracklistColumns';
|
||||||
|
import { CENTERED_COLS, isSortable, type ColKey, type SortKey } from '../../utils/albumTrackListHelpers';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visibleCols: readonly ColDef[];
|
||||||
|
gridStyle: React.CSSProperties;
|
||||||
|
sortKey?: SortKey;
|
||||||
|
sortDir?: 'asc' | 'desc';
|
||||||
|
onSort?: (key: SortKey) => void;
|
||||||
|
allSelected: boolean;
|
||||||
|
inSelectMode: boolean;
|
||||||
|
toggleAll: () => void;
|
||||||
|
startResize: (e: React.MouseEvent, colIndex: number, direction: 1 | -1) => void;
|
||||||
|
t: TFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fixed tracklist header row. Each cell is independently sortable
|
||||||
|
* (when the column is in `SORTABLE_COLS` and `onSort` is provided) and
|
||||||
|
* resizable via a 6px drop-target along its right edge.
|
||||||
|
*
|
||||||
|
* The `num` cell additionally hosts the bulk-selection toggle so that
|
||||||
|
* shift-click-style ranges anchor against the header.
|
||||||
|
*/
|
||||||
|
export function TracklistHeaderRow({
|
||||||
|
visibleCols,
|
||||||
|
gridStyle,
|
||||||
|
sortKey,
|
||||||
|
sortDir,
|
||||||
|
onSort,
|
||||||
|
allSelected,
|
||||||
|
inSelectMode,
|
||||||
|
toggleAll,
|
||||||
|
startResize,
|
||||||
|
t,
|
||||||
|
}: Props) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
|
||||||
|
const key = colDef.key as ColKey;
|
||||||
|
const isLastCol = colIndex === visibleCols.length - 1;
|
||||||
|
const isCentered = CENTERED_COLS.has(key);
|
||||||
|
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
|
||||||
|
const canSort = isSortable(key) && onSort;
|
||||||
|
const isActive = canSort && sortKey === key;
|
||||||
|
|
||||||
|
if (key === 'num') {
|
||||||
|
return (
|
||||||
|
<div key={key} className="track-num">
|
||||||
|
<span
|
||||||
|
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||||
|
onClick={e => { e.stopPropagation(); toggleAll(); }}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<span className="track-num-number">#</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'title') {
|
||||||
|
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||||
|
return (
|
||||||
|
<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 }}>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isActive ? 600 : 400 }}>{label}</span>
|
||||||
|
{canSort && renderSortIndicator(key as SortKey)}
|
||||||
|
</div>
|
||||||
|
{hasNextCol && (
|
||||||
|
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isResizable = !isLastCol;
|
||||||
|
return (
|
||||||
|
<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: isCentered ? 'center' : 'flex-start',
|
||||||
|
paddingLeft: isCentered ? 0 : 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isActive ? 600 : 400 }}>{label}</span>
|
||||||
|
{canSort && isSortable(key) && renderSortIndicator(key as SortKey)}
|
||||||
|
</div>
|
||||||
|
{isResizable && (
|
||||||
|
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tracklist-header-wrapper">
|
||||||
|
<div className="tracklist-header" style={gridStyle}>
|
||||||
|
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
|
import { useSelectionStore } from '../store/selectionStore';
|
||||||
|
import { useDragDrop } from '../contexts/DragDropContext';
|
||||||
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
|
|
||||||
|
interface UseAlbumTrackListSelectionArgs {
|
||||||
|
songs: SubsonicSong[];
|
||||||
|
tracklistRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAlbumTrackListSelectionResult {
|
||||||
|
inSelectMode: boolean;
|
||||||
|
allSelected: boolean;
|
||||||
|
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
|
||||||
|
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
|
||||||
|
toggleAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk selection + drag wiring for `AlbumTrackList`:
|
||||||
|
* - Clears selection whenever the song list changes (album switch or
|
||||||
|
* filter applied) and on mousedown outside the tracklist.
|
||||||
|
* - `onToggleSelect` supports shift-click ranges anchored against the
|
||||||
|
* last toggled row.
|
||||||
|
* - `onDragStart` promotes a single-row drag into a multi-row drag when
|
||||||
|
* the dragged song is part of the active selection.
|
||||||
|
*
|
||||||
|
* Subscribes only to `selectedIds.size` so the host component re-renders
|
||||||
|
* once when select-mode flips on/off; per-row state stays inside
|
||||||
|
* `TrackRow`'s own primitive selector for O(1) toggles.
|
||||||
|
*/
|
||||||
|
export function useAlbumTrackListSelection({
|
||||||
|
songs,
|
||||||
|
tracklistRef,
|
||||||
|
}: UseAlbumTrackListSelectionArgs): UseAlbumTrackListSelectionResult {
|
||||||
|
const psyDrag = useDragDrop();
|
||||||
|
const selectedCount = useSelectionStore(s => s.selectedIds.size);
|
||||||
|
const inSelectMode = selectedCount > 0;
|
||||||
|
const allSelected = selectedCount === songs.length && songs.length > 0;
|
||||||
|
const lastSelectedIdxRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
useSelectionStore.getState().clearAll();
|
||||||
|
lastSelectedIdxRef.current = null;
|
||||||
|
}, [songs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inSelectMode) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) {
|
||||||
|
useSelectionStore.getState().clearAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [inSelectMode, tracklistRef]);
|
||||||
|
|
||||||
|
const onToggleSelect = useCallback((id: string, globalIdx: number, shift: boolean) => {
|
||||||
|
useSelectionStore.getState().setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (shift && lastSelectedIdxRef.current !== null) {
|
||||||
|
const from = Math.min(lastSelectedIdxRef.current, globalIdx);
|
||||||
|
const to = Math.max(lastSelectedIdxRef.current, globalIdx);
|
||||||
|
songs.slice(from, to + 1).forEach(s => next.add(s.id));
|
||||||
|
} else {
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
}
|
||||||
|
lastSelectedIdxRef.current = globalIdx;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [songs]);
|
||||||
|
|
||||||
|
const onDragStart = useCallback((song: SubsonicSong, me: MouseEvent) => {
|
||||||
|
const { selectedIds } = useSelectionStore.getState();
|
||||||
|
if (selectedIds.has(song.id) && selectedIds.size > 1) {
|
||||||
|
const tracks = songs
|
||||||
|
.filter(s => selectedIds.has(s.id))
|
||||||
|
.map(s => songToTrack(s));
|
||||||
|
psyDrag.startDrag(
|
||||||
|
{ data: JSON.stringify({ type: 'songs', tracks }), label: `${tracks.length} Songs` },
|
||||||
|
me.clientX, me.clientY,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
psyDrag.startDrag(
|
||||||
|
{ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title },
|
||||||
|
me.clientX, me.clientY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [songs, psyDrag]);
|
||||||
|
|
||||||
|
const toggleAll = useCallback(() => {
|
||||||
|
if (allSelected) {
|
||||||
|
useSelectionStore.getState().clearAll();
|
||||||
|
} else {
|
||||||
|
useSelectionStore.getState().setSelectedIds(() => new Set(songs.map(s => s.id)));
|
||||||
|
}
|
||||||
|
}, [allSelected, songs]);
|
||||||
|
|
||||||
|
return { inSelectMode, allSelected, onToggleSelect, onDragStart, toggleAll };
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ColDef } from './useTracklistColumns';
|
||||||
|
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function codecLabel(song: { suffix?: string; bitRate?: number }, showBitrate: boolean): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||||
|
if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLUMNS: readonly ColDef[] = [
|
||||||
|
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
||||||
|
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
||||||
|
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
||||||
|
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
|
||||||
|
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||||
|
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||||
|
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
||||||
|
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
|
||||||
|
|
||||||
|
export const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
|
||||||
|
|
||||||
|
export type SortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration';
|
||||||
|
|
||||||
|
export const SORTABLE_COLS = new Set<ColKey | 'album'>(['title', 'artist', 'album', 'favorite', 'rating', 'duration']);
|
||||||
|
|
||||||
|
export function isSortable(key: ColKey | string): key is SortKey {
|
||||||
|
return SORTABLE_COLS.has(key as ColKey);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user