Files
Psychotoxical-psysonic/src/pages/Artists.tsx
T
Psychotoxical f9df918c72 feat(themes): community Theme Store + semantic-token refactor (#1009)
* feat(themes): add semantic tokens for the theme-store contract (B0 P1)

Additive: define --highlight, --accent-2, --bg-deep, --bg-elevated and
--text-on-accent on the :root base as --ctp-* mappings. They resolve
per-theme automatically and nothing consumes them yet (zero behaviour
change) — groundwork for replacing direct --ctp-* use in components.

* refactor(themes): components consume semantic tokens, not --ctp-* (B0 P2)

Replace every direct --ctp-* reference in component/layout/track CSS and
TSX inline styles with the readable semantic token (--bg-app, --accent,
--highlight, --text-on-accent, …). --ctp-* now survives only as the
Catppuccin palette layer the base maps from, and as the deliberate
categorical rainbow in Composers/Genres/artistsHelpers (left untouched).

This is the readable contract surface for the community theme store.
Divergences (theme set a semantic var != its --ctp- source) are
corrections — the element now uses the theme's real semantic colour.

* feat(themes): add player-bar title/artist color tokens

New optional --player-title / --player-artist, defaulting to --text-primary / --text-secondary so nothing changes unless a theme overrides them. Lets a theme give the now-playing readout its own colour as a plain token.

* refactor(themes): token-only theme library (flatten, whitelist, one file per theme)

Turn every built-in theme into a single self-contained [data-theme] var block of semantic whitelist tokens (plus the internal --ctp-* palette layer):

- Flatten all themes: drop structural override rules, @keyframes, and global-token overrides (radius / shadow-elevation / transition / spacing / font / focus-ring). Signature player-bar readout colours are preserved via the new --player-title / --player-artist tokens.

- Normalize the var blocks to the semantic whitelist: drop the alternate token vocabulary (nav-active / scrollbar / bg-input / success / border-default / ...); rename --success -> --positive and --border-default -> --border where no whitelist equivalent was set. Migrate the few components that read those tokens to the whitelist equivalents.

- Split multi-theme files so each theme ships as its own file, making the built-in set 1:1 with the per-theme store packaging.

Kept as-is (built-in, not flattened): the two colour-blind-safe accessibility themes, plus the two curated core skins.

* chore(themes): remove seven themes retired after the token refactor

These themes leaned on heavy structural overrides and were dropped rather than flattened. Full removal each: the CSS file(s), the index.css import, the Theme type union, and the ThemePicker entry.

* feat(themes): granular tokens — track lists

Wire track rows to per-region tokens: row hover (--row-hover), the now-playing
row + indicator (--row-playing-bg / --row-playing-text), track title/artist/
number/duration text, column-header text, row dividers, and the resize-handle
active colour. Covers the desktop tracklist, the shared song-row (Tracks hub /
search), and the mobile tracklist. Drop a baked border fallback. Visual no-op.

* feat(themes): granular tokens — cards

Wire album and artist cards to per-region tokens (--card-hover-border,
--card-title, --card-subtitle, --card-placeholder-bg). Visual no-op.

* fix(themes): drop undefined/baked colour aliases

Replace the undefined --bg-surface (resolved to nothing — broken placeholder
backgrounds and filter input) with --card-placeholder-bg / --input-bg, and the
baked-hex aliases --color-error / --color-warning with --danger / --warning so
themes can actually recolour them.

* feat(themes): Spectrum demo theme + trim unused cascade tokens

Add a loud built-in demo theme that gives each region its own hue (sidebar
green, player pink, lists cyan, cards gold, menus red, controls blue) so the
per-region granularity is obvious when you switch to it. Drop two unused
cascade tokens (--sidebar-text-active, --row-active-bg) and the unused
on-media block (those media surfaces stay static by design).

* style(themes): make Spectrum demo brutally loud

Full-saturation neon per region (toxic green sidebar, magenta player, cyan
lists, acid-yellow cards, blood-red menus, electric-blue controls, purple
scrollbar) so the per-region separation is unmistakable. The earlier soft
tints were too subtle to read.

* feat(themes): name the granular demo theme Braindead

* feat(themes): granular per-region tokens — cascade layer + sidebar

Add an optional per-region token layer (semantic-cascade.css) so a theme can
recolour individual regions — sidebar hover, player controls, list rows,
menus, inputs, on-media surfaces — independently of the global tokens. Every
token defaults to its base token (or a media-safe literal), so this is a
visual no-op until a theme overrides one; it only adds control points.

Wire the sidebar region as the first consumer and drop the baked grey
fallbacks (--bg-tertiary, etc.) that no theme could reach.

* feat(themes): granular tokens — controls, menus, scrollbar

Wire inputs, buttons, sliders, the custom-select, context menus, submenus and
modals to per-region tokens, and tokenise the scrollbar. Complete B0 by
dropping the last direct --ctp-* references in the input/button/progress/
scrollbar utility CSS.

Fix three undefined-token bugs that fell back to nothing (so no theme could
reach them): --surface-2 (context-menu hover had no highlight), --bg-surface
(submenu create-input had no background), and the baked grey fallbacks in the
custom-select. Every other new token defaults to today's value — a visual
no-op that only adds override points.

* feat(themes): granular tokens — player bar

Wire the desktop player bar's transport controls, time toggle, and overflow
menu to per-region tokens (--player-control, --player-time-toggle-*, etc.).
Fix the undefined --surface-hover/--surface-active grey fallbacks on the time
toggle. Visual no-op; defaults match today's values.

* chore(themes): remove empty theme stub files

Six theme CSS files were reduced to comment-only stubs by the flatten
sweep but their files and @import lines remained. Five are empty
structural companions of now-flattened themes (morpheus, p-dvd,
aero-glass, luna-teal) and two are orphans of cut themes
(order-of-the-phoenix, pandora). Removed the files and their imports.

* feat(themes): runtime injection foundation for the theme store

Plumbing for installed community themes ahead of the in-app store UI,
nothing user-visible yet:

- installedThemesStore: persisted (localStorage) record of installed
  community themes incl. their CSS text, so an active community theme is
  available synchronously at startup (no flash, fully offline).
- themeInjection: reconcile <head> <style data-installed-theme> elements
  with the store; lightweight defense-in-depth sanitize on top of CI.
- themeRegistry: jsDelivr registry client with a 12h localStorage cache
  and stale-on-error fallback.
- App: inject installed themes before applying data-theme, in both webviews.
- themeStore: widen the Theme type to accept dynamic installed ids.

* feat(themes): dedicated Themes settings tab

Move theme selection and the day/night scheduler out of Appearance into
a new dedicated Themes tab — the future home of the community Theme Store.
Appearance keeps grid columns, visual options, UI scale, font and seekbar.

- ThemesTab: theme picker + scheduler (relocated verbatim).
- AppearanceTab: drop the two relocated sections + now-unused imports.
- Register the tab in settingsTabs (Tab union, resolveTab, search index)
  and Settings (tab bar, render, label map).
- i18n: settings.tabThemes in all 9 locales.

* feat(themes): community Theme Store browse + install

Add the Theme Store section to the Themes tab:

- Fetch the jsDelivr registry (12h cache, stale-on-error fallback).
- Search by name/author/description + filter by light/dark + refresh.
- Per-row CDN thumbnail, name, author, description and actions:
  Install / Apply / Update / Uninstall. Installing fetches the CSS,
  persists it (localStorage) and the runtime injection applies it;
  uninstalling the active theme falls back to the matching core.
- Rating slot left reserved (deferred).
- i18n: themeStore* keys in all 9 locales.

* feat(themes): slim bundle to fixed cores + flat Themes tab

Remove the 86 store palettes (incl. braindead) from the app bundle — the
CSS files, their index.css imports, the Theme union and the picker data —
leaving only the six fixed cores (Catppuccin Mocha/Latte, Kanagawa Wave,
Stark HUD, Vision Dark/Navy). Everything else installs from the store.

Themes tab is rebuilt flat (no collapsible accordions):
- "Your Themes": one card grid of the fixed cores + installed community
  themes; click to apply, uninstall on community ones (active theme falls
  back to the matching core). Catppuccin prefix on Mocha/Latte; a CVD-safe
  pill on the colour-blind-safe Vision themes.
- Scheduler day/night options include installed themes.
- Theme Store: alphabetical order, thumbnail lightbox, and a submit hint
  above the search linking to the themes repository.
- Nav order: Servers, Library, Audio, Themes, Appearance, Lyrics, …
- ThemePicker accordion removed; fixed-theme data moved to fixedThemes.ts.
- i18n for all new strings across 9 locales.

* feat(themes): reset removed-from-bundle themes to a bundled fallback

After slimming the bundle to the six fixed core themes, a profile upgraded
from an older build may have an active or scheduler theme that is now
store-only and not installed — it has no [data-theme] block and would render
as unstyled :root. Reset any theme/themeDay/themeNight that is neither bundled
nor installed to a bundled fallback: Mocha for the main + night slots, Latte
for the day slot. Runs synchronously in runPreReactBootstrap, rewriting the
persisted selection in localStorage before React mounts (no flash; Zustand
rehydrates after first paint). No auto-install and no network — the fallback
is always a bundled theme, so it works offline.

* feat(themes): floating back-to-top button on the Themes tab

The Themes tab can get long (theme grid + scheduler + full store list), so
add a floating back-to-top affordance that appears once the page is scrolled
and smooth-scrolls to the top. It is portalled into the route host and
positioned absolute against it — the main scroll viewport sets contain: paint,
which would otherwise make position: fixed resolve against the scrolling box
and drift with the content. Reusable component (scroll viewport id + threshold
props); i18n common.backToTop added in all nine locales.

* feat(themes): accessibility + state polish for the Theme Store

- Reuse the shared CoverLightbox for the thumbnail preview instead of a
  second inline dialog — gains a visible close button and a focus-managed,
  portalled dialog, and drops duplicated markup.
- Theme cards expose aria-pressed so assistive tech announces the selected
  theme, not just the visual check.
- Transient store messages get live-region roles (loading/empty/install
  failure = status, fetch error = alert).
- Thumbnails degrade gracefully when offline/missing (hide the broken-image
  glyph; the thumbnail button no longer stretches with the row, so its
  background can't show as letterbox bars).

* feat(themes): larger store-row thumbnails (120x75 -> 200x125)

The list previews were too small to make a theme out; bump the display size
(same 1.6 aspect). Thumbnails are now served at 720x450, so the larger
display stays crisp.

* fix(themes): bust thumbnail cache on registry change

jsDelivr serves theme thumbnails with a 7-day max-age, so when a thumbnail
is updated the webview keeps showing its cached old image (the path is
unchanged). Append the registry's generatedAt as a cache-busting query to
the thumbnail URLs (list + lightbox); it changes on every themes push, so a
registry refresh makes the webview re-fetch and reflect the current CDN
image instead of a stale one.

* docs(themes): changelog + credits for the Theme Store

Add the 1.48.0 "Themes — community Theme Store" changelog entry (PR #1009)
and the matching line in the Psychotoxical credits.

* fix(themes): address PR review (uninstall hygiene, validation, polish)

Uninstall/scheduler & validation:
- uninstallTheme() repairs every selection slot (active + day + night), not
  just the manual one, and is shared by both uninstall buttons (dedup).
- Validate theme CSS at install time and skip persisting CSS that won't inject
  (no more "installed/active but renders nothing" with no feedback).
- Harden the runtime validator: exactly one rule, scoped exactly to the
  theme's [data-theme='<id>'] selector (no unscoped/foreign selectors), no
  at-rules, url() only data:, no expression()/javascript:, size-capped.

Tokens & polish:
- Fix three dangling undefined tokens (--surface-2 x2, --bg-surface).
- Finish the warning/success token sweep (--warning / --positive, themeable).
- Apply the active theme synchronously before React mounts (no first-frame
  flash) and inject installed themes up front.
- One-time, dismissible notice when the slim-bundle migration reset a theme.
- Update badge uses semver, not string inequality.
- Offline/stale indicator in the store; cross-window theme sync; drop the now
  dead REMOVED_THEME_REMAP and Card.mode field.

Tests: themeInjection (validator + sync), themeRegistry (cache/force/stale/
malformed), uninstallTheme (slot repair), migration notice. i18n in all nine
locales. Full suite green (1755 tests).

* test(bootstrap): cover startup theme apply + cross-window sync

The review fixes added applyThemeAtStartup / installCrossWindowThemeSync to
bootstrap.ts (a hot-path file) without tests, dropping its coverage to 68.3%
and failing the frontend hot-path coverage gate (>=70%). Add unit tests for
both (and the no-op / malformed-storage paths); bootstrap.ts is back to ~98%.
2026-06-07 02:47:33 +02:00

477 lines
18 KiB
TypeScript

import { useEffect, useState, useCallback, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { LayoutGrid, List, Images, CheckSquare2 } from 'lucide-react';
import StarFilterButton from '../components/StarFilterButton';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { APP_MAIN_SCROLL_VIEWPORT_ID, ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight';
import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import {
ALL_SENTINEL,
ALPHABET,
OTHER_BUCKET,
ARTIST_LIST_LAST_IN_LETTER_EST,
ARTIST_LIST_LETTER_ROW_EST,
ARTIST_LIST_ROW_EST,
} from '../utils/componentHelpers/artistsHelpers';
import { useArtistsFiltering } from '../hooks/useArtistsFiltering';
import { useArtistsBrowseCatalog } from '../hooks/useArtistsBrowseCatalog';
import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch';
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
import { useClientSliceInfiniteScroll } from '../hooks/useClientSliceInfiniteScroll';
import { useInpageScrollSentinel } from '../hooks/useInpageScrollSentinel';
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
import { ArtistsGridView } from '../components/artists/ArtistsGridView';
import { ArtistsListView } from '../components/artists/ArtistsListView';
import InpageScrollSentinel from '../components/InpageScrollSentinel';
import { useArtistsBrowseFilters, type ArtistBrowseScrollSnapshot } from '../hooks/useArtistsBrowseFilters';
import { useArtistsBrowseScrollRestore } from '../hooks/useArtistsBrowseScrollRestore';
import { useArtistsBrowseScrollReset } from '../hooks/useArtistsBrowseScrollReset';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { peekArtistBrowseScrollRestore } from '../store/artistBrowseSessionStore';
import { readArtistBrowseRestore } from '../utils/navigation/albumDetailNavigation';
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
export default function Artists() {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const scrollSnapshotRef = useRef<ArtistBrowseScrollSnapshot>({ scrollTop: 0, visibleCount: 0 });
const restoreVisibleCountRef = useRef<number | undefined>(
peekArtistBrowseScrollRestore(serverId)?.visibleCount,
);
const {
letterFilter,
setLetterFilter,
starredOnly,
setStarredOnly,
viewMode,
setViewMode,
} = useArtistsBrowseFilters(serverId, scrollSnapshotRef);
const artistsSearchQuery = useScopedBrowseSearchQuery('artists');
const {
scrollBodyEl: artistsScrollBodyEl,
bindScrollBody: bindArtistsScrollBody,
getScrollRoot: getArtistsScrollRoot,
} = useInpageScrollViewport();
const showArtistImages = useAuthStore(s => s.showArtistImages);
const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O
const navigateToArtist = useNavigateToArtist();
const location = useLocation();
const navigate = useNavigate();
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
const {
catalogArtists,
loading: catalogLoading,
catalogHasMore,
catalogLoadingMore,
browseMode,
loadCatalogChunk,
catalogLoadingRef,
} = useArtistsBrowseCatalog({
serverId,
indexEnabled,
starredOnly,
musicLibraryFilterVersion,
});
const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch(
artistsSearchQuery,
indexEnabled,
serverId,
);
const artists = textSearchArtists ?? catalogArtists;
const loading = catalogLoading || textSearchLoading;
const textSearchActive = textSearchArtists != null;
/** Scoped/plain text filter — canonical CSS grid, not row virtualization (small result sets). */
const artistBrowsePlainLayout =
perfFlags.disableMainstageVirtualLists
|| textSearchActive
|| artistsSearchQuery.trim().length > 0;
const {
visibleCount,
loadingMore: sliceLoadingMore,
loadMore: sliceLoadMore,
} = useClientSliceInfiniteScroll({
pageSize: PAGE_SIZE,
resetDeps: [artistsSearchQuery, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId],
getScrollRoot: getArtistsScrollRoot,
scrollRootEl: artistsScrollBodyEl,
restoreDisplayCount: restoreVisibleCountRef.current,
});
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
setSelectedIds(new Set());
};
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const selectedArtists = artists.filter(a => selectedIds.has(a.id));
const {
filtered, visible, hasMore, groups, letters, artistListFlatRows,
} = useArtistsFiltering({ artists, filter: effectiveFilter, letterFilter, starredOnly, visibleCount, viewMode });
const pendingLetterMatch =
browseMode === 'slice'
&& !textSearchActive
&& !starredOnly
&& letterFilter !== ALL_SENTINEL
&& filtered.length === 0
&& catalogHasMore;
const gridHasMore =
hasMore
|| (browseMode === 'slice' && !textSearchActive && !starredOnly && catalogHasMore);
const gridLoadingMore = sliceLoadingMore || catalogLoadingMore;
const loadMoreRef = useRef<() => void>(() => {});
const sentinelIntersectingRef = useRef(false);
const loadMoreGrid = useCallback(() => {
if (hasMore) {
sliceLoadMore();
return;
}
if (browseMode === 'slice' && !textSearchActive && !starredOnly && catalogHasMore && !catalogLoadingRef.current) {
void loadCatalogChunk(true);
}
}, [
hasMore,
sliceLoadMore,
browseMode,
textSearchActive,
starredOnly,
catalogHasMore,
loadCatalogChunk,
catalogLoadingRef,
]);
loadMoreRef.current = loadMoreGrid;
scrollSnapshotRef.current = {
scrollTop: artistsScrollBodyEl?.scrollTop ?? 0,
visibleCount,
};
const { isScrollRestorePending } = useArtistsBrowseScrollRestore({
serverId,
scrollBodyEl: artistsScrollBodyEl,
visibleCount,
loading: loading || pendingLetterMatch,
loadingMore: gridLoadingMore,
hasMore: gridHasMore,
loadMore: loadMoreGrid,
});
useEffect(() => {
if (isScrollRestorePending || !readArtistBrowseRestore(location.state)) return;
navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
}, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
useEffect(() => {
if (!pendingLetterMatch || catalogLoadingRef.current) return;
void loadCatalogChunk(true);
}, [pendingLetterMatch, loadCatalogChunk, catalogLoadingRef]);
useEffect(() => {
if (browseMode !== 'slice' || textSearchActive || starredOnly) return;
if (!sentinelIntersectingRef.current) return;
if (visibleCount < filtered.length - PAGE_SIZE) return;
if (!catalogHasMore || catalogLoadingRef.current) return;
void loadCatalogChunk(true);
}, [
browseMode,
textSearchActive,
starredOnly,
visibleCount,
filtered.length,
catalogHasMore,
loadCatalogChunk,
catalogLoadingRef,
PAGE_SIZE,
]);
const bindLoadMoreSentinel = useInpageScrollSentinel({
active: gridHasMore,
getScrollRoot: getArtistsScrollRoot,
scrollRootEl: artistsScrollBodyEl,
onIntersect: () => loadMoreRef.current(),
drainSignal: gridLoadingMore,
intersectingRef: sentinelIntersectingRef,
});
const mainstageHeaderTight = useMainstageInpageHeaderTight(artistsScrollBodyEl, [
artistsSearchQuery,
letterFilter,
starredOnly,
viewMode,
]);
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
const artistsInpageScrollHeight = useElementClientHeightForElement(
artistsScrollBodyEl,
mainScrollViewportHeight,
);
const getInpageScrollElement = useCallback(
() =>
getArtistsScrollRoot()
?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null),
[getArtistsScrollRoot],
);
const artistListOverscan = Math.max(
12,
Math.ceil(artistsInpageScrollHeight / ARTIST_LIST_ROW_EST),
);
const artistListWrapRef = useRef<HTMLDivElement>(null);
const artistListScrollMargin = useVirtualizerScrollMargin(
artistListWrapRef,
getInpageScrollElement,
{
active: !artistBrowsePlainLayout && viewMode === 'list',
deps: [artistListFlatRows.length],
},
);
const artistListVirtualizer = useVirtualizer({
count:
artistBrowsePlainLayout || viewMode !== 'list' ? 0 : artistListFlatRows.length,
getScrollElement: getInpageScrollElement,
estimateSize: index => {
const row = artistListFlatRows[index];
if (!row) return ARTIST_LIST_ROW_EST;
if (row.kind === 'letter') return ARTIST_LIST_LETTER_ROW_EST;
return row.isLastInLetter ? ARTIST_LIST_LAST_IN_LETTER_EST : ARTIST_LIST_ROW_EST;
},
getItemKey: index => {
const row = artistListFlatRows[index];
if (!row) return index;
if (row.kind === 'letter') return `letter:${row.letter}`;
return `artist:${row.artist.id}`;
},
overscan: artistListOverscan,
scrollMargin: artistListScrollMargin,
});
const browseScrollResetKey = [
artistsSearchQuery,
letterFilter,
starredOnly,
viewMode,
serverId,
musicLibraryFilterVersion,
textSearchArtists?.length ?? '',
textSearchArtists?.[0]?.id ?? '',
].join('\0');
useArtistsBrowseScrollReset({
scrollSnapshotRef,
getScrollRoot: getArtistsScrollRoot,
isScrollRestorePending,
resetKey: browseScrollResetKey,
viewMode,
listVirtualize: !artistBrowsePlainLayout,
listVirtualizer: artistListVirtualizer,
});
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">
<div className="mainstage-inpage-toolbar-row">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('artists.selectionCount', { count: selectedIds.size })
: t('artists.title')}
</h1>
{textSearchLoading && (
<div className="spinner" style={{ width: 16, height: 16, flexShrink: 0 }} />
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{!(selectionMode && selectedIds.size > 0) && (<>
<StarFilterButton size="compact" active={starredOnly} onChange={setStarredOnly} />
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
data-tooltip-wrap
data-tooltip-pos="bottom"
>
<Images size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
data-tooltip-pos="bottom"
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
data-tooltip-pos="bottom"
>
<List size={20} />
</button>
</>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
</button>
</div>
</div>
<div className="mainstage-inpage-toolbar-alpha-row">
{ALPHABET.map(l => (
<button
key={l}
onClick={() => setLetterFilter(l)}
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
>
{l === ALL_SENTINEL ? t('artists.all') : l === OTHER_BUCKET ? t('artists.other') : l}
</button>
))}
</div>
</div>
</div>
<OverlayScrollArea
className="mainstage-inpage-scroll"
viewportClassName="mainstage-inpage-scroll__viewport"
viewportId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
viewportRef={bindArtistsScrollBody}
railInset="panel"
measureDeps={[
loading,
viewMode,
visible.length,
artistListFlatRows.length,
filtered.length,
gridHasMore,
selectionMode,
]}
>
<div style={{ position: 'relative' }}>
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
{!loading && pendingLetterMatch && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
</div>
)}
{!loading && !pendingLetterMatch && viewMode === 'grid' && (
<ArtistsGridView
visible={visible}
disableVirtualization={artistBrowsePlainLayout}
layoutKey={browseScrollResetKey}
selectionMode={selectionMode}
selectedIds={selectedIds}
selectedArtists={selectedArtists}
showArtistImages={showArtistImages}
toggleSelect={toggleSelect}
onOpenArtist={navigateToArtist}
openContextMenu={openContextMenu}
t={t}
/>
)}
{!loading && !pendingLetterMatch && viewMode === 'list' && (
<ArtistsListView
virtualized={!artistBrowsePlainLayout}
groups={groups}
letters={letters}
artistListFlatRows={artistListFlatRows}
artistListVirtualizer={artistListVirtualizer}
artistListWrapRef={artistListWrapRef}
artistListScrollMargin={artistListScrollMargin}
selectionMode={selectionMode}
selectedIds={selectedIds}
selectedArtists={selectedArtists}
showArtistImages={showArtistImages}
toggleSelect={toggleSelect}
onOpenArtist={navigateToArtist}
openContextMenu={openContextMenu}
t={t}
/>
)}
{!loading && gridHasMore && (
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={gridLoadingMore} />
)}
{!loading && !pendingLetterMatch && filtered.length === 0 && (
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
{t('artists.notFound')}
</div>
)}
</div>
{isScrollRestorePending && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<div className="spinner" />
</div>
)}
</div>
</OverlayScrollArea>
</div>
);
}