diff --git a/src/pages/Genres.tsx b/src/pages/Genres.tsx index 66b44e72..99f1656c 100644 --- a/src/pages/Genres.tsx +++ b/src/pages/Genres.tsx @@ -1,36 +1,10 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { - Headphones, Zap, Music2, Music, Cpu, Mic, Radio, Cloud, - Leaf, Heart, Sun, Flame, Film, Globe, BookOpen, Podcast, Star, - Tags, type LucideIcon, -} from 'lucide-react'; +import { Tags } from 'lucide-react'; import { getGenres, SubsonicGenre } from '../api/subsonic'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; -function getGenreIcon(name: string): LucideIcon { - const n = name.toLowerCase(); - if (/ambient|drone|new age/.test(n)) return Cloud; - if (/metal|hardcore|thrash|death|grind|doom/.test(n)) return Zap; - if (/rock/.test(n)) return Radio; - if (/jazz/.test(n)) return Music2; - if (/classical|orchestra|chamber|baroque|opera|symphon/.test(n)) return Music; - if (/electronic|techno|edm|house|trance|electro|synth/.test(n)) return Cpu; - if (/hip.?hop|rap/.test(n)) return Mic; - if (/pop/.test(n)) return Star; - if (/folk|country|bluegrass|americana/.test(n)) return Leaf; - if (/blues/.test(n)) return Music2; - if (/soul|r.?b|funk|gospel/.test(n)) return Heart; - if (/reggae|ska|dub/.test(n)) return Sun; - if (/punk/.test(n)) return Flame; - if (/soundtrack|score|ost|film|movie|cinema/.test(n)) return Film; - if (/world|latin|afro|celtic|tribal|traditional/.test(n)) return Globe; - if (/audiobook|spoken|hörbuch|speech|comedy/.test(n)) return BookOpen; - if (/podcast/.test(n)) return Podcast; - return Headphones; -} - 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)', @@ -45,63 +19,39 @@ function genreColor(name: string): string { } const SCROLL_KEY = 'genres-scroll'; -const VISIBLE_KEY = 'genres-visible'; -const PAGE_SIZE = 60; +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([]); const [loading, setLoading] = useState(true); - const [visibleCount, setVisibleCount] = useState(() => { - // Restore the previous visibleCount when navigating back from a detail - // page so scroll position lines up with rendered cards. - const saved = sessionStorage.getItem(VISIBLE_KEY); - return saved ? Math.max(PAGE_SIZE, parseInt(saved, 10)) : PAGE_SIZE; - }); - const observerTarget = useRef(null); useEffect(() => { getGenres() .then(data => setRawGenres(data)) .finally(() => setLoading(false)); - }, []); // getGenres is not folder-scoped — no dep on musicLibraryFilterVersion + }, []); - // Memoised sort — without this the page re-sorted 500+ entries on every - // unrelated re-render (e.g. theme change, sidebar toggle). const genres = useMemo( () => [...rawGenres].sort((a, b) => b.albumCount - a.albumCount), [rawGenres], ); - const visible = useMemo(() => genres.slice(0, visibleCount), [genres, visibleCount]); - const hasMore = visibleCount < genres.length; + // 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]); - // Infinite scroll — render the next batch when the user is ~1.5 screens - // away from the sentinel, so the rest of the watermarks never block first - // paint of the page. - useEffect(() => { - if (!hasMore) return; - const root = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); - const observer = new IntersectionObserver( - entries => { if (entries[0].isIntersecting) setVisibleCount(c => c + PAGE_SIZE); }, - { - root: root instanceof HTMLElement ? root : null, - rootMargin: '1500px', - }, - ); - if (observerTarget.current) observer.observe(observerTarget.current); - return () => observer.disconnect(); - }, [hasMore]); - - // Restore scroll position after genres are rendered 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); - sessionStorage.removeItem(VISIBLE_KEY); requestAnimationFrame(() => { const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); if (el) el.scrollTop = pos; @@ -110,10 +60,7 @@ export default function Genres() { const handleGenreClick = (genreValue: string) => { const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); - if (el) { - sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop)); - sessionStorage.setItem(VISIBLE_KEY, String(visibleCount)); - } + if (el) sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop)); navigate(`/genres/${encodeURIComponent(genreValue)}`); }; @@ -132,36 +79,29 @@ export default function Genres() { {loading &&

{t('genres.loading')}

} {!loading && genres.length === 0 &&

{t('genres.empty')}

} - {!loading && visible.length > 0 && ( - <> -
- {visible.map(genre => { - const Icon = getGenreIcon(genre.value); - const color = genreColor(genre.value); - return ( -
handleGenreClick(genre.value)} - role="button" - tabIndex={0} - onKeyDown={e => e.key === 'Enter' && handleGenreClick(genre.value)} - data-tooltip={genre.value} - > -
- -
-

{genre.value}

-

- {t('genres.albumCount', { count: genre.albumCount })} -

-
- ); - })} -
- {hasMore &&
} - + {!loading && genres.length > 0 && ( +
+ {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 ( + + ); + })} +
)}
); diff --git a/src/styles/components.css b/src/styles/components.css index 8d8368b6..9a9026c8 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -7144,55 +7144,41 @@ html.no-compositing .fs-seekbar-played { justify-content: flex-end; } -/* ─ Genre Cards ─ */ -.genre-card { - aspect-ratio: 1; - border-radius: 12px; - padding: 0.9rem; - cursor: pointer; - position: relative; - overflow: hidden; +/* ─ Genre Tag Cloud ─ */ +.genre-cloud { display: flex; - flex-direction: column; - justify-content: flex-end; - background: - linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 45%, transparent 70%), - var(--genre-color, var(--accent)); - transition: transform 0.15s ease, box-shadow 0.15s ease; + flex-wrap: wrap; + align-items: center; + gap: 0.55rem 0.6rem; + padding-bottom: 1rem; +} + +.genre-pill { + appearance: none; + border: 1px solid color-mix(in srgb, var(--genre-color, var(--accent)) 22%, transparent); + background: color-mix(in srgb, var(--genre-color, var(--accent)) 6%, transparent); + color: var(--text-primary); + border-radius: 999px; + padding: 0.28em 0.78em; + cursor: pointer; + font-weight: 600; + line-height: 1.15; + white-space: nowrap; user-select: none; + font-family: inherit; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease, transform 0.12s ease; } -.genre-card:hover { - transform: translateY(-3px) scale(1.02); - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32); +.genre-pill:hover { + background: color-mix(in srgb, var(--genre-color, var(--accent)) 14%, transparent); + border-color: color-mix(in srgb, var(--genre-color, var(--accent)) 45%, transparent); + color: var(--genre-color, var(--accent)); + transform: translateY(-1px); } -.genre-card-watermark { - position: absolute; - top: 50%; - right: -10px; - transform: translateY(-55%); - opacity: 0.22; - color: #fff; - pointer-events: none; -} - -.genre-card-name { - font-size: 0.86rem; - font-weight: 700; - color: #fff; - margin: 0; - line-height: 1.25; - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -.genre-card-count { - font-size: 0.72rem; - color: rgba(255, 255, 255, 0.85); - margin: 0.2rem 0 0; +.genre-pill:focus-visible { + outline: 2px solid var(--genre-color, var(--accent)); + outline-offset: 2px; } /* ─ Random Landing ─ */