From 0b84a199e41e3cd5e645ed6c59f9ac84bd839585 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 13:09:00 +0200 Subject: [PATCH] =?UTF-8?q?refactor(playlist):=20G.65=20=E2=80=94=20extrac?= =?UTF-8?q?t=20Tracklist=20+=20FilterToolbar=20+=20displayedSongs=20(clust?= =?UTF-8?q?er)=20(#632)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-cut cluster on PlaylistDetail.tsx. 1065 → 742 LOC (−323). PlaylistTracklist — the big one. Owns the bulk-action bar, column visibility picker, sortable header (3-click cycle: asc → desc → natural, plus arrow indicator + drag-resize handles between columns), empty state with "add first song" button, and the song rows themselves (drag-over indicators, current-track highlight, bulk-selection check, ctrl/meta/shift selection vs. play vs. orbit-queue-hint, inline play-next + preview buttons in the title cell, artist/album links, star/rating cells, format/duration/delete columns). Subscribes directly to playerStore (currentTrack / isPlaying / playTrack / openContextMenu / starredOverrides / userRatingOverrides), previewStore (previewingId / audioStarted), themeStore (showBitrate), useDragDrop (isDragging), useOrbitSongRowBehavior — so the parent doesn't have to thread any of that through props. PL_CENTERED moves to the component because the only remaining tracklist header lives there now. PlaylistFilterToolbar — small filter input with clear-X. playlistDisplayedSongs — pure `getDisplayedSongs(songs, opts)` that returns the filtered + sorted song list. Exports PlaylistSortKey / PlaylistSortDir types so PlaylistDetail's sortKey/sortDir state and PlaylistTracklist's props share the same union types. PlaylistDetail's import list loses the icons/components that only the tracklist used (AudioLines, ChevronDown, Check, Heart, RotateCcw, StarRating, AddToPlaylistSubmenu) — they followed the component to the new file. Pure code move otherwise. --- .../playlist/PlaylistFilterToolbar.tsx | 35 ++ src/components/playlist/PlaylistTracklist.tsx | 435 +++++++++++++++++ src/pages/PlaylistDetail.tsx | 436 +++--------------- src/utils/playlistDisplayedSongs.ts | 43 ++ 4 files changed, 569 insertions(+), 380 deletions(-) create mode 100644 src/components/playlist/PlaylistFilterToolbar.tsx create mode 100644 src/components/playlist/PlaylistTracklist.tsx create mode 100644 src/utils/playlistDisplayedSongs.ts diff --git a/src/components/playlist/PlaylistFilterToolbar.tsx b/src/components/playlist/PlaylistFilterToolbar.tsx new file mode 100644 index 00000000..28851bbb --- /dev/null +++ b/src/components/playlist/PlaylistFilterToolbar.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Search, X } from 'lucide-react'; + +interface Props { + filterText: string; + setFilterText: (v: string) => void; +} + +export default function PlaylistFilterToolbar({ filterText, setFilterText }: Props) { + const { t } = useTranslation(); + return ( +
+
+ + setFilterText(e.target.value)} + /> + {filterText && ( + + )} +
+
+ ); +} diff --git a/src/components/playlist/PlaylistTracklist.tsx b/src/components/playlist/PlaylistTracklist.tsx new file mode 100644 index 00000000..5b0a466a --- /dev/null +++ b/src/components/playlist/PlaylistTracklist.tsx @@ -0,0 +1,435 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { + AudioLines, Check, ChevronDown, ChevronRight, Heart, ListPlus, Play, RotateCcw, Search, Square, Trash2, X, +} from 'lucide-react'; +import type { ColDef } from '../../utils/useTracklistColumns'; +import type { SubsonicSong } from '../../api/subsonicTypes'; +import type { Track } from '../../store/playerStoreTypes'; +import { usePlayerStore } from '../../store/playerStore'; +import { usePreviewStore } from '../../store/previewStore'; +import { useThemeStore } from '../../store/themeStore'; +import { useDragDrop } from '../../contexts/DragDropContext'; +import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior'; +import { songToTrack } from '../../utils/songToTrack'; +import { codecLabel, formatDuration } from '../../utils/playlistDetailHelpers'; +import type { PlaylistSortKey, PlaylistSortDir } from '../../utils/playlistDisplayedSongs'; +import StarRating from '../StarRating'; +import { AddToPlaylistSubmenu } from '../ContextMenu'; + +const PL_CENTERED = new Set(['favorite', 'rating', 'duration']); + +interface Props { + // Column config / picker + allColumns: readonly ColDef[]; + visibleCols: ColDef[]; + gridStyle: React.CSSProperties; + colVisible: Set; + toggleColumn: (key: string) => void; + resetColumns: () => void; + pickerOpen: boolean; + setPickerOpen: React.Dispatch>; + pickerRef: React.RefObject; + startResize: (e: React.MouseEvent, colIndex: number, direction?: 1 | -1) => void; + tracklistRef: React.RefObject; + + // Data + songs: SubsonicSong[]; + displayedSongs: SubsonicSong[]; + displayedTracks: Track[]; + isFiltered: boolean; + id: string | undefined; + + // Sort + sortKey: PlaylistSortKey; + setSortKey: React.Dispatch>; + sortDir: PlaylistSortDir; + setSortDir: React.Dispatch>; + sortClickCount: number; + setSortClickCount: React.Dispatch>; + + // Selection + selectedIds: Set; + setSelectedIds: React.Dispatch>>; + allSelected: boolean; + toggleAll: () => void; + toggleSelect: (id: string, idx: number, shift: boolean) => void; + showBulkPlPicker: boolean; + setShowBulkPlPicker: React.Dispatch>; + bulkRemove: () => void; + + // Context menu + DnD visual + contextMenuSongId: string | null; + setContextMenuSongId: React.Dispatch>; + dropTargetIdx: { idx: number; before: boolean } | null; + + // Rating / star / row mouse / delete + ratings: Record; + starredSongs: Set; + handleRate: (songId: string, rating: number) => void; + handleToggleStar: (song: SubsonicSong, e: React.MouseEvent) => void; + handleRowMouseDown: (e: React.MouseEvent, idx: number) => void; + handleRowMouseEnter: (idx: number, e: React.MouseEvent) => void; + removeSong: (idx: number) => void; + + // Empty state + setSearchOpen: React.Dispatch>; +} + +export default function PlaylistTracklist({ + allColumns, visibleCols, gridStyle, colVisible, toggleColumn, resetColumns, + pickerOpen, setPickerOpen, pickerRef, startResize, tracklistRef, + songs, displayedSongs, displayedTracks, isFiltered, id, + sortKey, setSortKey, sortDir, setSortDir, sortClickCount, setSortClickCount, + selectedIds, setSelectedIds, allSelected, toggleAll, toggleSelect, + showBulkPlPicker, setShowBulkPlPicker, bulkRemove, + contextMenuSongId, setContextMenuSongId, dropTargetIdx, + ratings, starredSongs, handleRate, handleToggleStar, + handleRowMouseDown, handleRowMouseEnter, removeSong, + setSearchOpen, +}: Props) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const currentTrack = usePlayerStore(s => s.currentTrack); + const isPlaying = usePlayerStore(s => s.isPlaying); + const playTrack = usePlayerStore(s => s.playTrack); + const openContextMenu = usePlayerStore(s => s.openContextMenu); + const starredOverrides = usePlayerStore(s => s.starredOverrides); + const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); + const previewingId = usePreviewStore(s => s.previewingId); + const previewAudioStarted = usePreviewStore(s => s.audioStarted); + const showBitrate = useThemeStore(s => s.showBitrate); + const { isDragging } = useDragDrop(); + const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); + + return ( +
+ + {/* Bulk action bar */} + {selectedIds.size > 0 && ( +
+ + {t('common.bulkSelected', { count: selectedIds.size })} + +
+ + {showBulkPlPicker && ( + { setShowBulkPlPicker(false); setSelectedIds(new Set()); }} + dropDown + /> + )} +
+ + +
+ )} + + {/* Column visibility picker */} +
+
+ + {pickerOpen && ( +
+
{t('albumDetail.columns')}
+ {allColumns.filter(c => !c.required).map(c => { + const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key; + const isOn = colVisible.has(c.key); + return ( + + ); + })} +
+ +
+ )} +
+
+ + {/* Header */} +
+
+ {visibleCols.map((colDef, colIndex) => { + const key = colDef.key; + const isLastCol = colIndex === visibleCols.length - 1; + const isCentered = PL_CENTERED.has(key); + 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 PlaylistSortKey); + setSortDir('asc'); + setSortClickCount(1); + } + }; + + const renderSortIndicator = () => { + if (!isSortActive) return null; + return ( + + {sortDir === 'asc' ? '▲' : '▼'} + + ); + }; + + if (key === 'num') return ( +
+ 0 ? ' bulk-check-visible' : ''}`} + onClick={e => { e.stopPropagation(); toggleAll(); }} + style={{ cursor: 'pointer' }} + /> + # +
+ ); + if (key === 'title') { + const hasNextCol = colIndex + 1 < visibleCols.length; + return ( +
+
+ {label} + {canSort && renderSortIndicator()} +
+ {hasNextCol &&
startResize(e, colIndex + 1, -1)} />} +
+ ); + } + if (key === 'delete') return
; + return ( +
+
+ {label} + {canSort && renderSortIndicator()} +
+ {!isLastCol && key !== 'delete' && ( +
startResize(e, colIndex, 1)} /> + )} +
+ ); + })} +
+
+ + {songs.length === 0 && ( +
+ {t('playlists.emptyPlaylist')} + +
+ )} + + {displayedSongs.map((song, i) => { + const realIdx = isFiltered ? songs.indexOf(song) : i; + return ( + + {!isFiltered && isDragging && dropTargetIdx?.idx === i && dropTargetIdx.before && ( +
+ )} +
!isFiltered && handleRowMouseEnter(i, e)} + onMouseDown={e => handleRowMouseDown(e, realIdx)} + onClick={e => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + if (e.ctrlKey || e.metaKey) { + toggleSelect(song.id, i, false); + } else if (selectedIds.size > 0) { + toggleSelect(song.id, i, e.shiftKey); + } else if (orbitActive) { + queueHint(); + } else { + playTrack(displayedTracks[i], displayedTracks); + } + }} + onDoubleClick={orbitActive ? e => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + if (e.ctrlKey || e.metaKey || selectedIds.size > 0) return; + addTrackToOrbit(song.id); + } : undefined} + onContextMenu={e => { + e.preventDefault(); + setContextMenuSongId(song.id); + openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song', undefined, id, realIdx); + }} + > + {visibleCols.map(colDef => { + const inSelectMode = selectedIds.size > 0; + switch (colDef.key) { + case 'num': return ( +
+ { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} /> + {currentTrack?.id === song.id && isPlaying ? ( + + ) : ( + {i + 1} + )} +
+ ); + case 'title': return ( +
+ + + {song.title} +
+ ); + case 'artist': return ( +
+ { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist} +
+ ); + case 'album': return ( +
+ { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album} +
+ ); + case 'favorite': return ( +
+ +
+ ); + case 'rating': return handleRate(song.id, r)} />; + case 'duration': return
{formatDuration(song.duration ?? 0)}
; + case 'format': return ( +
+ {(song.suffix || (showBitrate && song.bitRate)) && {codecLabel(song, showBitrate)}} +
+ ); + case 'delete': return ( +
+ +
+ ); + default: return null; + } + })} +
+ {!isFiltered && isDragging && dropTargetIdx?.idx === i && !dropTargetIdx.before && ( +
+ )} + + ); + })} + + +
+ ); +} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 0745093f..c70adae7 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -44,6 +44,9 @@ import CsvImportReportModal from '../components/playlist/CsvImportReportModal'; import PlaylistSongSearchPanel from '../components/playlist/PlaylistSongSearchPanel'; import PlaylistSuggestions from '../components/playlist/PlaylistSuggestions'; import PlaylistHero from '../components/playlist/PlaylistHero'; +import PlaylistTracklist from '../components/playlist/PlaylistTracklist'; +import PlaylistFilterToolbar from '../components/playlist/PlaylistFilterToolbar'; +import { getDisplayedSongs, type PlaylistSortKey, type PlaylistSortDir } from '../utils/playlistDisplayedSongs'; // ── Column configuration ────────────────────────────────────────────────────── const PL_COLUMNS: readonly ColDef[] = [ @@ -58,8 +61,6 @@ const PL_COLUMNS: readonly ColDef[] = [ { key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true }, ]; -const PL_CENTERED = new Set(['favorite', 'rating', 'duration']); - export default function PlaylistDetail() { const { id } = useParams<{ id: string }>(); const { t } = useTranslation(); @@ -113,8 +114,8 @@ export default function PlaylistDetail() { const [editingMeta, setEditingMeta] = useState(false); const [customCoverId, setCustomCoverId] = useState(null); const [filterText, setFilterText] = useState(''); - const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'>('natural'); - const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); + const [sortKey, setSortKey] = useState('natural'); + const [sortDir, setSortDir] = useState('asc'); const [sortClickCount, setSortClickCount] = useState(0); const [starredSongs, setStarredSongs] = useState>(new Set()); const [hoveredSuggestionId, setHoveredSuggestionId] = useState(null); @@ -536,34 +537,13 @@ export default function PlaylistDetail() { const existingIds = useMemo(() => new Set(songs.map(s => s.id)), [songs]); const tracks = useMemo(() => songs.map(songToTrack), [songs]); - const displayedSongs = useMemo(() => { - const q = filterText.trim().toLowerCase(); - if (!q && sortKey === 'natural') return songs; - let result = [...songs]; - if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q)); - if (sortKey !== 'natural') { - result.sort((a, b) => { - let av: string | number; - let bv: string | number; - 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; - }, [songs, filterText, sortKey, sortDir, ratings, userRatingOverrides, starredOverrides, starredSongs]); + const displayedSongs = useMemo( + () => getDisplayedSongs(songs, { + filterText, sortKey, sortDir, + ratings, userRatingOverrides, starredOverrides, starredSongs, + }), + [songs, filterText, sortKey, sortDir, ratings, userRatingOverrides, starredOverrides, starredSongs], + ); const displayedTracks = useMemo( () => displayedSongs === songs ? tracks : displayedSongs.map(songToTrack), [displayedSongs, songs, tracks], @@ -671,357 +651,53 @@ export default function PlaylistDetail() { {/* ── Filter / sort toolbar ── */} {songs.length > 0 && ( -
-
- - setFilterText(e.target.value)} - /> - {filterText && ( - - )} -
-
+ )} {/* ── Tracklist ── */} -
- - {/* Bulk action bar */} - {selectedIds.size > 0 && ( -
- - {t('common.bulkSelected', { count: selectedIds.size })} - -
- - {showBulkPlPicker && ( - { setShowBulkPlPicker(false); setSelectedIds(new Set()); }} - dropDown - /> - )} -
- - -
- )} - - {/* Column visibility picker */} -
-
- - {pickerOpen && ( -
-
{t('albumDetail.columns')}
- {PL_COLUMNS.filter(c => !c.required).map(c => { - const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key; - const isOn = colVisible.has(c.key); - return ( - - ); - })} -
- -
- )} -
-
- - {/* Header */} -
-
- {visibleCols.map((colDef, colIndex) => { - const key = colDef.key; - const isLastCol = colIndex === visibleCols.length - 1; - const isCentered = PL_CENTERED.has(key); - 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 ( - - {sortDir === 'asc' ? '▲' : '▼'} - - ); - }; - - if (key === 'num') return ( -
- 0 ? ' bulk-check-visible' : ''}`} - onClick={e => { e.stopPropagation(); toggleAll(); }} - style={{ cursor: 'pointer' }} - /> - # -
- ); - if (key === 'title') { - const hasNextCol = colIndex + 1 < visibleCols.length; - return ( -
-
- {label} - {canSort && renderSortIndicator()} -
- {hasNextCol &&
startResize(e, colIndex + 1, -1)} />} -
- ); - } - if (key === 'delete') return
; - return ( -
-
- {label} - {canSort && renderSortIndicator()} -
- {!isLastCol && key !== 'delete' && ( -
startResize(e, colIndex, 1)} /> - )} -
- ); - })} -
-
- - {songs.length === 0 && ( -
- {t('playlists.emptyPlaylist')} - -
- )} - - {displayedSongs.map((song, i) => { - const realIdx = isFiltered ? songs.indexOf(song) : i; - return ( - - {!isFiltered && isDragging && dropTargetIdx?.idx === i && dropTargetIdx.before && ( -
- )} -
!isFiltered && handleRowMouseEnter(i, e)} - onMouseDown={e => handleRowMouseDown(e, realIdx)} - onClick={e => { - if ((e.target as HTMLElement).closest('button, a, input')) return; - if (e.ctrlKey || e.metaKey) { - toggleSelect(song.id, i, false); - } else if (selectedIds.size > 0) { - toggleSelect(song.id, i, e.shiftKey); - } else if (orbitActive) { - queueHint(); - } else { - playTrack(displayedTracks[i], displayedTracks); - } - }} - onDoubleClick={orbitActive ? e => { - if ((e.target as HTMLElement).closest('button, a, input')) return; - if (e.ctrlKey || e.metaKey || selectedIds.size > 0) return; - addTrackToOrbit(song.id); - } : undefined} - onContextMenu={e => { - e.preventDefault(); - setContextMenuSongId(song.id); - openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song', undefined, id, realIdx); - }} - > - {visibleCols.map(colDef => { - const inSelectMode = selectedIds.size > 0; - switch (colDef.key) { - case 'num': return ( -
- { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} /> - {currentTrack?.id === song.id && isPlaying ? ( - - ) : ( - {i + 1} - )} -
- ); - case 'title': return ( -
- - - {song.title} -
- ); - case 'artist': return ( -
- { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist} -
- ); - case 'album': return ( -
- { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album} -
- ); - case 'favorite': return ( -
- -
- ); - case 'rating': return handleRate(song.id, r)} />; - case 'duration': return
{formatDuration(song.duration ?? 0)}
; - case 'format': return ( -
- {(song.suffix || (showBitrate && song.bitRate)) && {codecLabel(song, showBitrate)}} -
- ); - case 'delete': return ( -
- -
- ); - default: return null; - } - })} -
- {!isFiltered && isDragging && dropTargetIdx?.idx === i && !dropTargetIdx.before && ( -
- )} - - ); - })} - - -
+ {/* ── Suggestions ── */} ; + userRatingOverrides: Record; + starredOverrides: Record; + starredSongs: Set; +} + +export function getDisplayedSongs(songs: SubsonicSong[], opts: DisplayedSongsOptions): SubsonicSong[] { + const q = opts.filterText.trim().toLowerCase(); + if (!q && opts.sortKey === 'natural') return songs; + let result = [...songs]; + if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q)); + if (opts.sortKey !== 'natural') { + result.sort((a, b) => { + let av: string | number; + let bv: string | number; + const effectiveRating = (s: SubsonicSong) => opts.ratings[s.id] ?? opts.userRatingOverrides[s.id] ?? s.userRating ?? 0; + const effectiveStarred = (s: SubsonicSong) => (s.id in opts.starredOverrides ? opts.starredOverrides[s.id] : opts.starredSongs.has(s.id)) ? 1 : 0; + switch (opts.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 opts.sortDir === 'asc' ? av - bv : bv - av; + } + return opts.sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string); + }); + } + return result; +}