refactor(album-detail): I.7 — split AlbumDetail.tsx 511 → 369 LOC across 5 files (#679)

* refactor(album-detail): extract sanitizeFilename + useAlbumDetailData

Move sanitizeFilename into utils/albumDetailHelpers.ts. Pull the album +
related-albums fetch and the starred state seeds (isStarred,
starredSongs) into hooks/useAlbumDetailData.ts.

AlbumDetail.tsx: 511 → 483 LOC.

* refactor(album-detail): extract useAlbumOfflineState hook

Move the four primitive-selector offline status reads (cache map +
job-status filters + progress totals) into hooks/useAlbumOfflineState.ts.
Keeps the re-render minimisation comment with the code that needs it.

AlbumDetail.tsx: 483 → 461 LOC.

* refactor(album-detail): extract useAlbumDetailSort hook

Pull sortKey/Dir/clickCount state, the 3-click natural-reset cycle in
handleSort, and the displayedSongs memo (filter + sort comparator) into
hooks/useAlbumDetailSort.ts. Rating comparator keeps the same priority
chain as the row renderer.

AlbumDetail.tsx: 461 → 414 LOC.

* refactor(album-detail): extract AlbumDetailToolbar subcomponent

Pull the search input + bulk-action cluster (selection count, add-to-
playlist popover, clear-selection button) into
components/albumDetail/AlbumDetailToolbar.tsx. Parent retains showPlPicker
to coordinate the popover close with selection clears.

AlbumDetail.tsx: 414 → 369 LOC.
This commit is contained in:
Frank Stellmacher
2026-05-14 01:19:58 +02:00
committed by GitHub
parent 3dc50a2ef0
commit ff99b10faa
6 changed files with 337 additions and 170 deletions
+95
View File
@@ -0,0 +1,95 @@
import { useMemo, useState } from 'react';
import type { SubsonicSong } from '../api/subsonicTypes';
export type AlbumSortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration';
interface UseAlbumDetailSortArgs {
songs: SubsonicSong[] | undefined;
filterText: string;
starredSongs: Set<string>;
ratings: Record<string, number>;
userRatingOverrides: Record<string, number>;
}
interface UseAlbumDetailSortResult {
sortKey: AlbumSortKey;
sortDir: 'asc' | 'desc';
handleSort: (key: AlbumSortKey) => void;
displayedSongs: SubsonicSong[];
}
/**
* Sort + text-filter pipeline for the album track list. Click cycle on a
* header column: asc → desc → off (back to `natural` order). Other
* columns reset to asc on first click.
*
* Rating reads use the same priority as the row renderer
* (`ratings[id] ?? userRatingOverrides[id] ?? song.userRating`) so the
* sort matches the visible stars during optimistic updates.
*/
export function useAlbumDetailSort({
songs,
filterText,
starredSongs,
ratings,
userRatingOverrides,
}: UseAlbumDetailSortArgs): UseAlbumDetailSortResult {
const [sortKey, setSortKey] = useState<AlbumSortKey>('natural');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [sortClickCount, setSortClickCount] = useState(0);
const handleSort = (key: AlbumSortKey) => {
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);
}
};
const displayedSongs = useMemo(() => {
if (!songs) return [];
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;
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 = starredSongs.has(a.id) ? 1 : 0;
bv = starredSongs.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;
}, [songs, filterText, sortKey, sortDir, starredSongs, ratings, userRatingOverrides]);
return { sortKey, sortDir, handleSort, displayedSongs };
}