mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
d31291a463
The previous genre grid mounted ~60 Lucide SVG cards per page (with Watermark icon, gradient bg, infinite scroll) and froze the WebKitGTK renderer for several seconds on libraries with many genres. The new layout flows all genres as compact pills with log-scaled font size based on albumCount — one <span>-equivalent button per genre, no SVGs, no pagination needed. Pill colour is dimly tinted by the same deterministic hash-to-CTP palette used before; text picks up the genre colour on hover only, so the page reads calmly at rest. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
109 lines
4.0 KiB
TypeScript
109 lines
4.0 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Tags } from 'lucide-react';
|
|
import { getGenres, SubsonicGenre } from '../api/subsonic';
|
|
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
|
|
|
const CTP_COLORS = [
|
|
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
|
|
'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
|
|
'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)',
|
|
'var(--ctp-blue)', 'var(--ctp-lavender)',
|
|
];
|
|
|
|
function genreColor(name: string): string {
|
|
let h = 0;
|
|
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
|
return CTP_COLORS[h % CTP_COLORS.length];
|
|
}
|
|
|
|
const SCROLL_KEY = 'genres-scroll';
|
|
const FONT_MIN_REM = 0.78;
|
|
const FONT_MAX_REM = 1.7;
|
|
|
|
export default function Genres() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const [rawGenres, setRawGenres] = useState<SubsonicGenre[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
getGenres()
|
|
.then(data => setRawGenres(data))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
const genres = useMemo(
|
|
() => [...rawGenres].sort((a, b) => b.albumCount - a.albumCount),
|
|
[rawGenres],
|
|
);
|
|
|
|
// Log-scale font sizing — flattens the long tail (a 1000-album genre and a
|
|
// 50-album genre look distinct, but a 1-album genre still has a readable size).
|
|
const maxLog = useMemo(() => {
|
|
if (genres.length === 0) return 1;
|
|
return Math.log(Math.max(2, genres[0].albumCount));
|
|
}, [genres]);
|
|
|
|
useEffect(() => {
|
|
if (loading || genres.length === 0) return;
|
|
const saved = sessionStorage.getItem(SCROLL_KEY);
|
|
if (!saved) return;
|
|
const pos = parseInt(saved, 10);
|
|
sessionStorage.removeItem(SCROLL_KEY);
|
|
requestAnimationFrame(() => {
|
|
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
|
if (el) el.scrollTop = pos;
|
|
});
|
|
}, [loading, genres.length]);
|
|
|
|
const handleGenreClick = (genreValue: string) => {
|
|
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
|
if (el) sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop));
|
|
navigate(`/genres/${encodeURIComponent(genreValue)}`);
|
|
};
|
|
|
|
return (
|
|
<div className="content-body animate-fade-in">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
|
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('genres.title')}</h1>
|
|
{!loading && genres.length > 0 && (
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
|
|
<Tags size={14} style={{ color: 'var(--accent)' }} />
|
|
{genres.length} {t('genres.genreCount')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{loading && <p className="loading-text">{t('genres.loading')}</p>}
|
|
{!loading && genres.length === 0 && <p className="loading-text">{t('genres.empty')}</p>}
|
|
|
|
{!loading && genres.length > 0 && (
|
|
<div className="genre-cloud">
|
|
{genres.map(genre => {
|
|
const ratio = Math.log(Math.max(2, genre.albumCount)) / maxLog;
|
|
const fontRem = FONT_MIN_REM + ratio * (FONT_MAX_REM - FONT_MIN_REM);
|
|
const color = genreColor(genre.value);
|
|
return (
|
|
<button
|
|
key={genre.value}
|
|
type="button"
|
|
className="genre-pill"
|
|
style={{
|
|
'--genre-color': color,
|
|
fontSize: `${fontRem.toFixed(3)}rem`,
|
|
} as React.CSSProperties}
|
|
onClick={() => handleGenreClick(genre.value)}
|
|
data-tooltip={t('genres.albumCount', { count: genre.albumCount })}
|
|
>
|
|
{genre.value}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|