refactor(favorites): G.82 — extract Top Artists row + Radio favorites row + data hook + song-filtering hook (cluster) (#649)

Four-cut cluster opening the Favorites refactor. 1018 → 643 LOC
(−375).

TopFavoriteArtists — TopFavoriteArtistsRow (the horizontal-scroll
section with chevron nav buttons and resize-driven scroll-state)
plus the private TopFavoriteArtistCard with the cached avatar
image and selected-outline styling. Exports the
TopFavoriteArtist row-data shape.

RadioFavorites — RadioStationRow (same horizontal-scroll pattern
as the artists row) plus the private RadioFavCard with cover or
Cast-icon fallback, live-radio badge overlay when active, and an
unfavorite heart button.

useFavoritesData — owns the four data states (albums, artists,
songs, radioStations) + loading + the load-on-mount effect (calls
getStarred + reads radio favorites from localStorage + fetches
matching stations). Computes topFavoriteArtists memo (counts
favorited songs by artist, top 12). Exports unfavoriteStation
(removes from state + persists to localStorage).

useFavoritesSongFiltering — owns the filtering pipeline (drops
unfavorited, applies artist / genre / year-range filters) and
the three-state sort (asc → desc → reset). Returns
filteredSongs / visibleSongs plus handleSortClick /
getSortIndicator. Hook file uses .tsx because getSortIndicator
returns ArrowUp / ArrowDown JSX.

Favorites drops the inline definitions plus the now-unused direct
imports (getInternetRadioStations, getStarred, buildCoverArtUrl /
coverArtCacheKey, useAuthStore, Users / ArrowUp / ArrowDown
icons). Pure code move otherwise.
This commit is contained in:
Frank Stellmacher
2026-05-13 16:32:54 +02:00
committed by GitHub
parent 7482030a6b
commit a4b1b29dd6
5 changed files with 477 additions and 387 deletions
+116
View File
@@ -0,0 +1,116 @@
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Cast, ChevronLeft, ChevronRight, Heart, X } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
import type { InternetRadioStation } from '../../api/subsonicTypes';
import CachedImage from '../CachedImage';
interface RadioStationRowProps {
title: string;
stations: InternetRadioStation[];
currentRadio: InternetRadioStation | null;
isPlaying: boolean;
onPlay: (s: InternetRadioStation) => void;
onUnfavorite: (id: string) => void;
}
export function RadioStationRow({ title, stations, currentRadio, isPlaying, onPlay, onUnfavorite }: RadioStationRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
};
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
scrollRef.current.scrollBy({ left: dir === 'left' ? -scrollRef.current.clientWidth * 0.75 : scrollRef.current.clientWidth * 0.75, behavior: 'smooth' });
};
return (
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="album-row-nav">
<button className={`nav-btn${!showLeft ? ' disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
<ChevronLeft size={20} />
</button>
<button className={`nav-btn${!showRight ? ' disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{stations.map(s => (
<RadioFavCard
key={s.id}
station={s}
isActive={currentRadio?.id === s.id}
isPlaying={isPlaying}
onPlay={() => onPlay(s)}
onUnfavorite={() => onUnfavorite(s.id)}
/>
))}
</div>
</div>
</section>
);
}
interface RadioFavCardProps {
station: InternetRadioStation;
isActive: boolean;
isPlaying: boolean;
onPlay: () => void;
onUnfavorite: () => void;
}
function RadioFavCard({ station: s, isActive, isPlaying, onPlay, onUnfavorite }: RadioFavCardProps) {
const { t } = useTranslation();
return (
<div className={`album-card${isActive ? ' radio-card-active' : ''}`}>
<div className="album-card-cover">
{s.coverArt ? (
<CachedImage
src={buildCoverArtUrl(`ra-${s.id}`, 256)}
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)}
alt={s.name}
className="album-card-cover-img"
/>
) : (
<div className="album-card-cover-placeholder playlist-card-icon">
<Cast size={48} strokeWidth={1.2} />
</div>
)}
{isActive && isPlaying && (
<div className="radio-live-overlay">
<span className="radio-live-badge">{t('radio.live')}</span>
</div>
)}
<div className="album-card-play-overlay">
<button className="album-card-details-btn" onClick={onPlay}>
{isActive && isPlaying ? <X size={15} /> : <Cast size={14} />}
</button>
</div>
</div>
<div className="album-card-info">
<div className="album-card-title">{s.name}</div>
<div className="album-card-artist" style={{ display: 'flex', alignItems: 'center' }}>
<button
className="radio-favorite-btn active"
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', display: 'flex' }}
onClick={onUnfavorite}
data-tooltip={t('radio.unfavorite')}
>
<Heart size={12} fill="currentColor" />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,117 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
import CachedImage from '../CachedImage';
export interface TopFavoriteArtist {
id: string;
name: string;
count: number;
coverArtId: string;
}
interface TopFavoriteArtistsRowProps {
title: string;
artists: TopFavoriteArtist[];
selectedKey: string | null;
onToggle: (key: string) => void;
}
export function TopFavoriteArtistsRow({ title, artists, selectedKey, onToggle }: TopFavoriteArtistsRowProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
};
useEffect(() => {
handleScroll();
window.addEventListener('resize', handleScroll);
return () => window.removeEventListener('resize', handleScroll);
}, [artists]);
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
const amount = scrollRef.current.clientWidth * 0.75;
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
};
return (
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="album-row-nav">
<button className={`nav-btn ${!showLeft ? 'disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
<ChevronLeft size={20} />
</button>
<button className={`nav-btn ${!showRight ? 'disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{artists.map(a => (
<TopFavoriteArtistCard
key={a.id}
artist={a}
isSelected={selectedKey === a.id}
onClick={() => onToggle(a.id)}
songCountLabel={t('favorites.topArtistsSongCount', { count: a.count })}
/>
))}
</div>
</div>
</section>
);
}
interface TopFavoriteArtistCardProps {
artist: TopFavoriteArtist;
isSelected: boolean;
onClick: () => void;
songCountLabel: string;
}
function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) {
const coverId = artist.coverArtId;
const coverSrc = useMemo(() => coverId ? buildCoverArtUrl(coverId, 300) : '', [coverId]);
const coverCacheKey = useMemo(() => coverId ? coverArtCacheKey(coverId, 300) : '', [coverId]);
return (
<div
className={`artist-card${isSelected ? ' artist-card-selected' : ''}`}
onClick={onClick}
style={isSelected ? { outline: '2px solid var(--accent)', outlineOffset: '-2px', borderRadius: 12 } : undefined}
>
<div className="artist-card-avatar">
{coverId ? (
<CachedImage
src={coverSrc}
cacheKey={coverCacheKey}
alt={artist.name}
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement?.classList.add('fallback-visible');
}}
/>
) : (
<Users size={32} color="var(--text-muted)" />
)}
</div>
<div className="artist-card-info">
<span className="artist-card-name">{artist.name}</span>
<span className="artist-card-meta">{songCountLabel}</span>
</div>
</div>
);
}