mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(genre): co-locate genre browse pages into features/genre
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react';
|
||||
import { useLocation, useNavigationType } from 'react-router-dom';
|
||||
import { GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID, readInpageScrollTop } from '@/constants/appScroll';
|
||||
import {
|
||||
DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
|
||||
albumBrowseSortForServer,
|
||||
clearGenreDetailReturnStash,
|
||||
genreDetailGenreFromPath,
|
||||
isAlbumDetailPath,
|
||||
isGenreDetailPath,
|
||||
peekGenreDetailScrollRestore,
|
||||
stashGenreDetailReturnFilters,
|
||||
useAlbumBrowseSessionStore,
|
||||
} from '@/features/album';
|
||||
import { shouldRestoreAlbumBrowseSession } from '@/utils/navigation/albumDetailNavigation';
|
||||
import type { AlbumBrowseScrollSnapshot } from '@/features/album';
|
||||
|
||||
/** Genre detail: locked genre filter + leave/restore session (same contract as All Albums). */
|
||||
export function useGenreDetailBrowse(
|
||||
serverId: string,
|
||||
genreName: string,
|
||||
scrollSnapshotRef?: RefObject<AlbumBrowseScrollSnapshot>,
|
||||
) {
|
||||
const navigationType = useNavigationType();
|
||||
const location = useLocation();
|
||||
const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId));
|
||||
const restoredFromStashRef = useRef(false);
|
||||
const restoreKeyRef = useRef('');
|
||||
const restoreDisplayCountRef = useRef<number | undefined>(undefined);
|
||||
const restoreKey = `${serverId}:${genreName}`;
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
if (restoreKeyRef.current !== restoreKey) {
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
restoreKeyRef.current = restoreKey;
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
restoreDisplayCountRef.current = peekGenreDetailScrollRestore(serverId, genreName)?.displayCount;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
restoredFromStashRef.current = false;
|
||||
}, [serverId, genreName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || !genreName) return;
|
||||
|
||||
if (shouldRestoreAlbumBrowseSession(navigationType, location.state)) {
|
||||
restoredFromStashRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoredFromStashRef.current) return;
|
||||
|
||||
clearGenreDetailReturnStash(serverId, genreName);
|
||||
}, [serverId, genreName, navigationType, location.state]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!serverId || !genreName) return;
|
||||
const path = window.location.pathname;
|
||||
if (isAlbumDetailPath(path)) {
|
||||
// Read at cleanup time on purpose: we want the scroll snapshot as it is
|
||||
// at navigation-away. Copying it at effect setup would stash a stale value.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const snapshot = scrollSnapshotRef?.current;
|
||||
const scrollTop = Math.max(
|
||||
readInpageScrollTop(GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID),
|
||||
snapshot?.scrollTop ?? 0,
|
||||
);
|
||||
stashGenreDetailReturnFilters(serverId, genreName, {
|
||||
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
|
||||
selectedGenres: [genreName],
|
||||
scrollTop,
|
||||
displayCount: snapshot?.displayCount,
|
||||
});
|
||||
} else if (!isGenreDetailPath(path) || genreDetailGenreFromPath(path) !== genreName) {
|
||||
clearGenreDetailReturnStash(serverId, genreName);
|
||||
}
|
||||
};
|
||||
}, [serverId, genreName, scrollSnapshotRef]);
|
||||
|
||||
return {
|
||||
sort,
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
restoreDisplayCount: restoreDisplayCountRef.current,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Genre feature — the genre overview (`Genres`) and per-genre album browse
|
||||
* (`GenreDetail`) pages plus the genre-detail browse hook. The pages are
|
||||
* lazy-loaded by the router via their deep paths, so they are not re-exported
|
||||
* here; nothing else outside the feature consumes its modules.
|
||||
*
|
||||
* Stays OUT (library-core / shared, consumed by this feature, not owned): the
|
||||
* `lib/library/genre*` catalog/query helpers + `genreColor`, the album-side
|
||||
* `useGenreAlbumBrowse` browse hook (`features/album`), and the
|
||||
* `genreBrowsePlayback` queue builder (`features/playback`).
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Disc3, Play, ListPlus, Loader2 } from 'lucide-react';
|
||||
import { AlbumCard } from '@/features/album';
|
||||
import { LongPressWaveOverlay } from '@/components/LongPressWaveOverlay';
|
||||
import InpageScrollSentinel from '@/components/InpageScrollSentinel';
|
||||
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
||||
import { VirtualCardGrid } from '@/components/VirtualCardGrid';
|
||||
import { GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
|
||||
import { albumGridWarmCovers } from '@/cover/layoutSizes';
|
||||
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '@/features/album';
|
||||
import { useGenreAlbumBrowse } from '@/features/album';
|
||||
import { useAlbumBrowseScrollRestore } from '@/features/album';
|
||||
import { useGenreDetailBrowse } from '@/features/genre/hooks/useGenreDetailBrowse';
|
||||
import { useInpageScrollViewport } from '@/hooks/useInpageScrollViewport';
|
||||
import { useLongPressAction } from '@/lib/hooks/useLongPressAction';
|
||||
import { useMainstageInpageHeaderTight } from '@/hooks/useMainstageInpageHeaderTight';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import {
|
||||
fetchGenreAlbumCount,
|
||||
fetchGenreTracksForPlayback,
|
||||
} from '@/features/playback/utils/playback/genreBrowsePlayback';
|
||||
import { lookupGenreAlbumCount } from '@/lib/library/genreCatalogCountsCache';
|
||||
import { libraryScopeForServer } from '@/lib/api/subsonicClient';
|
||||
import {
|
||||
readAlbumBrowseRestore,
|
||||
readAlbumDetailReturnTo,
|
||||
} from '@/utils/navigation/albumDetailNavigation';
|
||||
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
|
||||
import { runBulkEnqueue, runBulkPlayAll, runBulkShuffle } from '@/features/playback/utils/playback/runBulkPlay';
|
||||
|
||||
export default function GenreDetail() {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const genre = decodeURIComponent(name ?? '');
|
||||
const { t } = useTranslation();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
|
||||
const scrollSnapshotRef = useRef<AlbumBrowseScrollSnapshot>({ scrollTop: 0, displayCount: 0 });
|
||||
|
||||
const { sort, restoreDisplayCount } = useGenreDetailBrowse(serverId, genre, scrollSnapshotRef);
|
||||
|
||||
const {
|
||||
scrollBodyEl,
|
||||
bindScrollBody: bindGenreDetailScrollBody,
|
||||
getScrollRoot,
|
||||
} = useInpageScrollViewport();
|
||||
|
||||
const {
|
||||
albums,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
displayAlbums,
|
||||
bindLoadMoreSentinel,
|
||||
loadMore,
|
||||
} = useGenreAlbumBrowse(
|
||||
serverId,
|
||||
genre,
|
||||
indexEnabled,
|
||||
sort,
|
||||
musicLibraryFilterVersion,
|
||||
getScrollRoot,
|
||||
scrollBodyEl,
|
||||
restoreDisplayCount,
|
||||
);
|
||||
|
||||
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, displayAlbums.length);
|
||||
|
||||
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
|
||||
serverId,
|
||||
genreName: genre,
|
||||
scrollBodyEl,
|
||||
displayAlbumsLength: displayAlbums.length,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrollRestorePending || !readAlbumBrowseRestore(location.state)) return;
|
||||
navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
||||
}, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
|
||||
|
||||
const [albumCount, setAlbumCount] = useState<number | null>(null);
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!genre || !serverId) return;
|
||||
const cached = lookupGenreAlbumCount(serverId, genre, libraryScopeForServer(serverId));
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (cached != null) setAlbumCount(cached);
|
||||
}, [serverId, genre, musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!genre || loading) return;
|
||||
const cached = lookupGenreAlbumCount(serverId, genre, libraryScopeForServer(serverId));
|
||||
if (cached != null) return;
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
void fetchGenreAlbumCount(serverId, genre, indexEnabled, sort).then(count => {
|
||||
if (!cancelled) setAlbumCount(count);
|
||||
});
|
||||
}, 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [serverId, genre, indexEnabled, sort, musicLibraryFilterVersion, loading]);
|
||||
|
||||
const fetchGenreTracks = useCallback(
|
||||
(shuffle?: boolean) => fetchGenreTracksForPlayback(serverId, genre, {
|
||||
shuffle,
|
||||
indexEnabled,
|
||||
}),
|
||||
[serverId, genre, indexEnabled],
|
||||
);
|
||||
|
||||
const handlePlayAll = useCallback(
|
||||
() => runBulkPlayAll({ fetchTracks: () => fetchGenreTracks(false), setLoading: setBulkLoading, playTrack }),
|
||||
[fetchGenreTracks, playTrack],
|
||||
);
|
||||
const handleShuffleAll = useCallback(
|
||||
() => runBulkShuffle({ fetchTracks: () => fetchGenreTracks(true), setLoading: setBulkLoading, playTrack }),
|
||||
[fetchGenreTracks, playTrack],
|
||||
);
|
||||
const handleEnqueueAll = useCallback(
|
||||
() => runBulkEnqueue({ fetchTracks: () => fetchGenreTracks(false), setLoading: setBulkLoading, enqueue }),
|
||||
[fetchGenreTracks, enqueue],
|
||||
);
|
||||
|
||||
const { isHolding, pressBind } = useLongPressAction({
|
||||
onShortPress: handlePlayAll,
|
||||
onLongPress: handleShuffleAll,
|
||||
});
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate(readAlbumDetailReturnTo(location.state) ?? '/genres');
|
||||
}, [navigate, location.state]);
|
||||
|
||||
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [genre, albumCount, bulkLoading]);
|
||||
|
||||
const headerCount = useMemo(() => {
|
||||
if (!loading && !hasMore && albums.length > 0) return albums.length;
|
||||
if (albumCount != null) return albumCount;
|
||||
if (loading) return null;
|
||||
return displayAlbums.length > 0 ? displayAlbums.length : null;
|
||||
}, [loading, hasMore, albums.length, albumCount, displayAlbums.length]);
|
||||
const showPlayback = !loading && (displayAlbums.length > 0 || (albumCount ?? 0) > 0);
|
||||
|
||||
return (
|
||||
<div className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
|
||||
<div className="mainstage-inpage-toolbar">
|
||||
<div className="page-sticky-header mainstage-inpage-toolbar-row">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={handleBack}
|
||||
aria-label={t('genres.back')}
|
||||
data-tooltip={t('genres.back')}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: '0.25rem' }}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span className="toolbar-btn-label">{t('genres.back')}</span>
|
||||
</button>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{genre}</h1>
|
||||
{headerCount != null && headerCount > 0 && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
|
||||
<Disc3 size={14} style={{ color: 'var(--accent)' }} />
|
||||
{t('genres.albumCount', { count: headerCount })}
|
||||
</span>
|
||||
)}
|
||||
{showPlayback && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: 'auto' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary long-press-play-btn"
|
||||
{...pressBind}
|
||||
disabled={bulkLoading}
|
||||
aria-label={t('genres.playTooltip')}
|
||||
data-tooltip={t('genres.playTooltip')}
|
||||
>
|
||||
<LongPressWaveOverlay active={isHolding} size="compact" />
|
||||
<span className="long-press-play-btn__icon" style={{ gap: '0.35rem' }}>
|
||||
{bulkLoading ? <Loader2 size={15} className="spin" /> : <Play size={15} fill="currentColor" />}
|
||||
<span className="toolbar-btn-label">{t('common.play')}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleEnqueueAll}
|
||||
disabled={bulkLoading}
|
||||
data-tooltip={t('genres.addToQueue')}
|
||||
>
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OverlayScrollArea
|
||||
className="mainstage-inpage-scroll"
|
||||
viewportClassName="mainstage-inpage-scroll__viewport"
|
||||
viewportId={GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
viewportRef={bindGenreDetailScrollBody}
|
||||
railInset="panel"
|
||||
measureDeps={[
|
||||
loading,
|
||||
displayAlbums.length,
|
||||
hasMore,
|
||||
genre,
|
||||
perfFlags.disableMainstageVirtualLists,
|
||||
]}
|
||||
>
|
||||
{loading && albums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : !loading && displayAlbums.length === 0 ? (
|
||||
<p className="loading-text" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{t('genres.albumsEmpty')}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
|
||||
<VirtualCardGrid
|
||||
items={displayAlbums}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={displayAlbums.length}
|
||||
scrollRootId={GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={album => (
|
||||
<AlbumCard
|
||||
album={album}
|
||||
observeScrollRootId={GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{hasMore && (
|
||||
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loadingMore} />
|
||||
)}
|
||||
</div>
|
||||
{isScrollRestorePending && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '3rem',
|
||||
background: 'var(--bg-app)',
|
||||
}}
|
||||
>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</OverlayScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { SubsonicGenre } from '@/lib/api/subsonicTypes';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tags } from 'lucide-react';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
|
||||
import { subscribeLibrarySyncIdle } from '@/lib/api/library';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { fetchGenreCatalog, filterGenresWithContent } from '@/features/playback/utils/playback/genreBrowsePlayback';
|
||||
import { libraryScopeForServer } from '@/lib/api/subsonicClient';
|
||||
import { peekGenreCatalogCache } from '@/lib/library/genreCatalogCountsCache';
|
||||
import { resolveIndexKey } from '@/utils/server/serverIndexKey';
|
||||
import { genreColor } from '@/lib/library/genreColor';
|
||||
|
||||
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 serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const libraryScope = libraryScopeForServer(serverId);
|
||||
const cachedGenres = serverId ? peekGenreCatalogCache(serverId, libraryScope, true) : null;
|
||||
const [rawGenres, setRawGenres] = useState<SubsonicGenre[]>(cachedGenres ?? []);
|
||||
const [loading, setLoading] = useState(!cachedGenres);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const scope = libraryScopeForServer(serverId);
|
||||
const cached = serverId ? peekGenreCatalogCache(serverId, scope, true) : null;
|
||||
if (cached) {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setRawGenres(cached);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
void fetchGenreCatalog(serverId, indexEnabled)
|
||||
.then(data => {
|
||||
if (!cancelled) setRawGenres(data);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [serverId, indexEnabled, musicLibraryFilterVersion]);
|
||||
|
||||
const genres = useMemo(
|
||||
() => filterGenresWithContent([...rawGenres]).sort((a, b) => b.albumCount - a.albumCount),
|
||||
[rawGenres],
|
||||
);
|
||||
|
||||
// After library resync the in-memory catalog cache is cleared, but this page
|
||||
// can still hold pre-sync genres until we refetch (issue #1162).
|
||||
useEffect(() => {
|
||||
if (!serverId || !indexEnabled) return;
|
||||
let cancelled = false;
|
||||
const indexKey = resolveIndexKey(serverId);
|
||||
let unlisten: (() => void) | undefined;
|
||||
void subscribeLibrarySyncIdle(payload => {
|
||||
if (!payload.ok) return;
|
||||
if (payload.serverId !== indexKey && payload.serverId !== serverId) return;
|
||||
void fetchGenreCatalog(serverId, indexEnabled).then(data => {
|
||||
if (!cancelled) setRawGenres(data);
|
||||
});
|
||||
}).then(fn => {
|
||||
unlisten = fn;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [serverId, indexEnabled]);
|
||||
|
||||
// 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)}`, { state: { returnTo: '/genres' } });
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user