refactor(artist-detail): G.85 — extract Hero + TopTracks + SimilarArtists + action utilities (cluster) (#652)

Four-cut cluster closing out the major ArtistDetail extraction.
707 → 339 LOC (−368).

ArtistDetailHero — the full hero header: back button, lightbox
trigger, avatar with hover upload overlay + camera/loader icon +
hidden file input, glow effect from extractCoverColors onLoad,
title + album count, entity-rating row, Last.fm + Wikipedia +
favourite link row, and the action button strip (play all,
shuffle, radio, share, offline cache with progress / done state).
Subscribes to useOfflineStore / useOfflineJobStore / useAuthStore
directly so the page doesn't have to thread bulk-progress through.

ArtistDetailTopTracks — the four-column tracklist with each row's
inline play-next + preview ring + track cover thumbnail + click
into playTopSongWithContinuation. Subscribes to playerStore /
previewStore / useOrbitSongRowBehavior directly.

ArtistDetailSimilarArtists — section header (with show-more toggle
on mobile), loading spinner, and the chip list (using
serverSimilarArtists vs. similarArtists depending on which path
fed it).

runArtistDetailActions — four parameterized async actions:
runArtistEntityRating (with full / track_only fallback +
saveFailed toast), runArtistToggleStar (optimistic state + revert
on error), runArtistShare (copy link + success / failure toast),
runArtistImageUpload (upload + invalidate cover-art cache + bump
revision).

ArtistDetail drops the inline definitions; formatDuration moves
into the TopTracks component. Pure code move otherwise.
This commit is contained in:
Frank Stellmacher
2026-05-13 17:26:05 +02:00
committed by GitHub
parent ba0bf8aa9d
commit bb0fe828bf
5 changed files with 607 additions and 361 deletions
+115
View File
@@ -0,0 +1,115 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { uploadArtistImage } from '../api/subsonicPlaylists';
import { setRating, star, unstar } from '../api/subsonicStarRating';
import type { SubsonicArtist } from '../api/subsonicTypes';
import { useAuthStore } from '../store/authStore';
import { copyEntityShareLink } from './copyEntityShareLink';
import { invalidateCoverArt } from './imageCache';
import { showToast } from './toast';
export interface RunArtistEntityRatingDeps {
artist: SubsonicArtist | null;
id: string | undefined;
rating: number;
artistEntityRatingSupport: string;
activeServerId: string;
t: TFunction;
setArtistEntityRating: (v: number) => void;
setArtist: React.Dispatch<React.SetStateAction<SubsonicArtist | null>>;
}
export async function runArtistEntityRating(deps: RunArtistEntityRatingDeps): Promise<void> {
const { artist, id, rating, artistEntityRatingSupport, activeServerId, t, setArtistEntityRating, setArtist } = deps;
if (!artist || artist.id !== id) return;
const artistId = artist.id;
const ratingAtStart = artist.userRating ?? 0;
setArtistEntityRating(rating);
if (artistEntityRatingSupport !== 'full') return;
try {
await setRating(artistId, rating);
setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a));
} catch (err) {
setArtistEntityRating(ratingAtStart);
useAuthStore.getState().setEntityRatingSupport(activeServerId, 'track_only');
showToast(
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
4500,
'error',
);
}
}
export interface RunArtistToggleStarDeps {
artist: SubsonicArtist | null;
isStarred: boolean;
setIsStarred: React.Dispatch<React.SetStateAction<boolean>>;
}
export async function runArtistToggleStar(deps: RunArtistToggleStarDeps): Promise<void> {
const { artist, isStarred, setIsStarred } = deps;
if (!artist) return;
const currentlyStarred = isStarred;
setIsStarred(!currentlyStarred);
try {
if (currentlyStarred) await unstar(artist.id, 'artist');
else await star(artist.id, 'artist');
} catch (e) {
console.error('Failed to toggle star', e);
setIsStarred(currentlyStarred);
}
}
export interface RunArtistShareDeps {
artist: SubsonicArtist;
t: TFunction;
}
export async function runArtistShare(deps: RunArtistShareDeps): Promise<void> {
const { artist, t } = deps;
try {
const ok = await copyEntityShareLink('artist', artist.id);
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
} catch {
showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
}
}
export interface RunArtistImageUploadDeps {
e: React.ChangeEvent<HTMLInputElement>;
artist: SubsonicArtist | null;
t: TFunction;
setUploading: (v: boolean) => void;
setCoverRevision: React.Dispatch<React.SetStateAction<number>>;
}
export async function runArtistImageUpload(deps: RunArtistImageUploadDeps): Promise<void> {
const { e, artist, t, setUploading, setCoverRevision } = deps;
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !artist) return;
setUploading(true);
try {
await uploadArtistImage(artist.id, file);
const coverId = artist.coverArt || artist.id;
await invalidateCoverArt(coverId);
// Also invalidate with bare artist.id in case coverArt differs
if (artist.coverArt && artist.coverArt !== artist.id) {
await invalidateCoverArt(artist.id);
}
setCoverRevision(r => r + 1);
showToast(t('artistDetail.uploadImage'));
} catch (err) {
showToast(
typeof err === 'string' ? err : err instanceof Error ? err.message : t('artistDetail.uploadImageError'),
4000,
'error',
);
} finally {
setUploading(false);
}
}