mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
7c724a642f
* chore(eslint): add eslint toolchain and configs * fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …) * chore(eslint): clear unused vars in config, contexts, app and test * chore(eslint): clear unused vars in api and music-network * chore(eslint): clear unused vars in utils * chore(eslint): clear unused vars in store * chore(eslint): clear unused vars in cover and hooks * chore(eslint): clear unused vars in components * chore(eslint): clear unused vars in pages * chore(eslint): remove explicit any in src * chore(eslint): align react-hooks exhaustive-deps * chore(eslint): zero gradual config on src * chore(eslint): strict hook rules in store and utils * chore(eslint): strict hook rules in hooks and cover * chore(eslint): strict hook rules in components * chore(eslint): strict hook rules in pages and contexts * chore(eslint): document scripts ignore in eslint config * chore(eslint): add lint script and zero strict findings * chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons) * chore(eslint): tighten two set-state disable comments (review round 2 LOW) * chore(nix): sync npmDepsHash with package-lock.json * docs(changelog): add Under the Hood entry for ESLint setup (PR #1165) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
125 lines
4.3 KiB
TypeScript
125 lines
4.3 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
|
|
import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage';
|
|
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
|
import { coverServerScopeForServerId } from '../../cover/serverScope';
|
|
|
|
export interface TopFavoriteArtist {
|
|
id: string;
|
|
name: string;
|
|
count: number;
|
|
coverArtId: string;
|
|
/** Present when favorites are merged across servers. */
|
|
serverId?: string;
|
|
/** Raw artist id for song filtering (without server prefix). */
|
|
artistId?: 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 artistEntityId = artist.artistId ?? artist.coverArtId;
|
|
|
|
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 ? (
|
|
<ArtistCoverArtImage
|
|
artistId={artistEntityId}
|
|
coverArt={artist.coverArtId}
|
|
serverScope={coverServerScopeForServerId(artist.serverId)}
|
|
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
|
surface="dense"
|
|
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>
|
|
);
|
|
}
|