From 9fb0d5638bb89bb5fc82637719ce0064f9c837ee Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:14:46 +0200 Subject: [PATCH] feat(themes): paginate theme store and keep scroll on refresh (#1011) The community Theme Store grew large enough that browsing it meant scrolling through the whole catalogue. Paginate it (12 per page) with a Prev / "Page X of Y" / Next pager that resets to page 1 on filter changes and scrolls back to the top of the list when paging. Also fix the refresh button resetting the scroll position. It set the top-level loading state, which unmounted the list and collapsed the scroll viewport so it clamped back to the top. Refreshing now keeps the existing list mounted and only spins the refresh icon; the full-page loading and error placeholders are reserved for the initial load. Adds themeStorePagePrev / PageNext / PageStatus across all nine locales and a ThemeStoreSection test covering pagination, filtering, page reset, and the refresh-keeps-scroll behaviour. --- .../settings/ThemeStoreSection.test.tsx | 146 ++++++++++++++++++ src/components/settings/ThemeStoreSection.tsx | 76 +++++++-- src/locales/de/settings.ts | 3 + src/locales/en/settings.ts | 3 + src/locales/es/settings.ts | 3 + src/locales/fr/settings.ts | 3 + src/locales/nb/settings.ts | 3 + src/locales/nl/settings.ts | 3 + src/locales/ro/settings.ts | 3 + src/locales/ru/settings.ts | 3 + src/locales/zh/settings.ts | 3 + 11 files changed, 240 insertions(+), 9 deletions(-) create mode 100644 src/components/settings/ThemeStoreSection.test.tsx diff --git a/src/components/settings/ThemeStoreSection.test.tsx b/src/components/settings/ThemeStoreSection.test.tsx new file mode 100644 index 00000000..5cf8e343 --- /dev/null +++ b/src/components/settings/ThemeStoreSection.test.tsx @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/helpers/renderWithProviders'; +import { ThemeStoreSection } from './ThemeStoreSection'; +import type { FetchRegistryResult, Registry } from '@/utils/themes/themeRegistry'; + +// Control the registry the store browses so pagination/refresh are deterministic. +vi.mock('@/utils/themes/themeRegistry', () => ({ + fetchRegistry: vi.fn(), + fetchThemeCss: vi.fn(async () => 'css'), + cdnUrl: (p: string) => `https://cdn.example/${p}`, +})); + +import { fetchRegistry } from '@/utils/themes/themeRegistry'; + +const fetchRegistryMock = vi.mocked(fetchRegistry); + +/** A registry with `n` themes named `Theme 01`…`Theme NN` (zero-padded so the + * component's alphabetical sort matches numeric order). */ +function makeRegistry(n: number): Registry { + const themes = Array.from({ length: n }, (_, i) => { + const num = String(i + 1).padStart(2, '0'); + return { + id: `t${num}`, + name: `Theme ${num}`, + author: 'Tester', + version: '1.0.0', + description: `Description ${num}`, + mode: (i % 2 === 0 ? 'dark' : 'light') as 'dark' | 'light', + css: `themes/t${num}/theme.css`, + thumbnail: `themes/t${num}/thumb.png`, + tags: [], + }; + }); + return { schemaVersion: 1, generatedAt: '2026-06-07T00:00:00Z', themes }; +} + +const rows = (container: HTMLElement) => container.querySelectorAll('.theme-store-row'); + +describe('ThemeStoreSection — pagination & refresh', () => { + beforeEach(() => { + vi.clearAllMocks(); + // jsdom has no layout engine; goToPage() scrolls the list back up. + Element.prototype.scrollIntoView = vi.fn(); + }); + + it('shows only one page of themes and a pager when the catalogue is large', async () => { + fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false }); + const { container } = renderWithProviders(); + + await screen.findByText('Theme 01'); + // PAGE_SIZE is 12 → 30 themes span 3 pages. + expect(rows(container)).toHaveLength(12); + expect(screen.getByText('Page 1 of 3')).toBeInTheDocument(); + // Page-2 themes are not rendered yet. + expect(screen.queryByText('Theme 13')).not.toBeInTheDocument(); + }); + + it('does not paginate when everything fits on one page', async () => { + fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(8), stale: false }); + const { container } = renderWithProviders(); + + await screen.findByText('Theme 01'); + expect(rows(container)).toHaveLength(8); + expect(screen.queryByLabelText('Next page')).not.toBeInTheDocument(); + expect(screen.queryByText(/page 1 of/i)).not.toBeInTheDocument(); + }); + + it('navigates between pages and disables prev/next at the bounds', async () => { + fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false }); + const { container } = renderWithProviders(); + const user = userEvent.setup(); + + await screen.findByText('Theme 01'); + expect(screen.getByLabelText('Previous page')).toBeDisabled(); + + await user.click(screen.getByLabelText('Next page')); + expect(screen.getByText('Page 2 of 3')).toBeInTheDocument(); + expect(screen.getByText('Theme 13')).toBeInTheDocument(); + expect(screen.queryByText('Theme 01')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Previous page')).toBeEnabled(); + + await user.click(screen.getByLabelText('Next page')); + expect(screen.getByText('Page 3 of 3')).toBeInTheDocument(); + expect(rows(container)).toHaveLength(6); // 30 - 24 + expect(screen.getByLabelText('Next page')).toBeDisabled(); + }); + + it('filters the catalogue by the search query', async () => { + fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false }); + const { container } = renderWithProviders(); + const user = userEvent.setup(); + + await screen.findByText('Theme 01'); + await user.type(screen.getByRole('searchbox'), 'Theme 05'); + + await waitFor(() => expect(rows(container)).toHaveLength(1)); + expect(screen.getByText('Theme 05')).toBeInTheDocument(); + expect(screen.queryByText('Theme 04')).not.toBeInTheDocument(); + }); + + it('resets to the first page when the search query changes', async () => { + fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false }); + renderWithProviders(); + const user = userEvent.setup(); + + await screen.findByText('Theme 01'); + await user.click(screen.getByLabelText('Next page')); + expect(screen.getByText('Page 2 of 3')).toBeInTheDocument(); + + // 'theme' matches all 30 names, so the catalogue is unchanged in size — but + // the page must snap back to 1 so the user isn't stranded past the end. + await user.type(screen.getByRole('searchbox'), 'theme'); + await waitFor(() => expect(screen.getByText('Page 1 of 3')).toBeInTheDocument()); + expect(screen.getByText('Theme 01')).toBeInTheDocument(); + }); + + it('keeps the list mounted while refreshing (no scroll-resetting unmount)', async () => { + const reg = makeRegistry(30); + let resolveRefresh!: (v: FetchRegistryResult) => void; + const pending = new Promise(res => { resolveRefresh = res; }); + fetchRegistryMock + .mockResolvedValueOnce({ registry: reg, stale: false }) // initial load + .mockReturnValueOnce(pending); // manual refresh, held pending + + const { container } = renderWithProviders(); + const user = userEvent.setup(); + + await screen.findByText('Theme 01'); + expect(container.querySelector('.animate-spin')).toBeNull(); + + await user.click(screen.getByLabelText('Refresh')); + + // The whole point of the fix: refreshing must NOT swap the list for the + // full-page loading placeholder (which collapses the scroll viewport and + // jumps it to the top). The rows stay; only the icon spins. + expect(rows(container)).toHaveLength(12); + expect(screen.queryByText(/loading themes/i)).not.toBeInTheDocument(); + expect(container.querySelector('.animate-spin')).not.toBeNull(); + + resolveRefresh({ registry: reg, stale: false }); + await waitFor(() => expect(container.querySelector('.animate-spin')).toBeNull()); + expect(rows(container)).toHaveLength(12); + }); +}); diff --git a/src/components/settings/ThemeStoreSection.tsx b/src/components/settings/ThemeStoreSection.tsx index db35ab32..0ca205ec 100644 --- a/src/components/settings/ThemeStoreSection.tsx +++ b/src/components/settings/ThemeStoreSection.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Check, Download, RefreshCw, Trash2 } from 'lucide-react'; +import { Check, ChevronLeft, ChevronRight, Download, RefreshCw, Trash2 } from 'lucide-react'; import { open as openUrl } from '@tauri-apps/plugin-shell'; import CoverLightbox from '../CoverLightbox'; import { useThemeStore } from '../../store/themeStore'; @@ -19,6 +19,9 @@ type ModeFilter = 'all' | 'dark' | 'light'; const THEMES_REPO_URL = 'https://github.com/Psysonic/psysonic-themes'; +/** Themes shown per page — the catalogue is large enough to paginate. */ +const PAGE_SIZE = 12; + /** * The community Theme Store: browse the jsDelivr-hosted registry, filter by name * and light/dark, install (fetch + persist + runtime inject), apply, update and @@ -34,21 +37,30 @@ export function ThemeStoreSection() { const [themes, setThemes] = useState(null); const [generatedAt, setGeneratedAt] = useState(''); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(false); const [stale, setStale] = useState(false); const [query, setQuery] = useState(''); const [mode, setMode] = useState('all'); + const [page, setPage] = useState(1); const [busyId, setBusyId] = useState(null); const [failedId, setFailedId] = useState(null); const [lightbox, setLightbox] = useState<{ src: string; name: string } | null>(null); + const topRef = useRef(null); + // A manual refresh must not unmount the list: blanking it collapses the + // scroll viewport's content height, which clamps scrollTop to 0 — i.e. the + // page jumps to the top. So keep the existing list mounted (`refreshing`, + // shown only via the spinning icon) and reserve the full-page loading/error + // placeholders for the initial load, when there is nothing to show anyway. const load = (force = false) => { - setLoading(true); + if (force) setRefreshing(true); + else setLoading(true); setError(false); fetchRegistry({ force }) .then(r => { setThemes(r.registry.themes); setGeneratedAt(r.registry.generatedAt); setStale(r.stale); }) - .catch(() => setError(true)) - .finally(() => setLoading(false)); + .catch(() => { if (force) setStale(true); else setError(true); }) + .finally(() => { setLoading(false); setRefreshing(false); }); }; // Thumbnails live at a stable CDN path, so the webview caches them hard @@ -81,6 +93,22 @@ export function ThemeStoreSection() { }).sort((a, b) => a.name.localeCompare(b.name)); }, [themes, query, mode]); + // A changed filter can shrink the result set below the current page; reset to + // the first page whenever the query or mode filter changes. + useEffect(() => { setPage(1); }, [query, mode]); + + const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + // Clamp defensively so a stale `page` (e.g. after the registry shrank) never + // points past the end and shows a blank list. + const safePage = Math.min(page, pageCount); + const pageItems = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE); + + const goToPage = (n: number) => { + setPage(Math.min(Math.max(1, n), pageCount)); + // Start the new page from the top of the store instead of mid-scroll. + topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + const handleInstall = async (th: RegistryTheme) => { setBusyId(th.id); setFailedId(null); @@ -132,7 +160,7 @@ export function ThemeStoreSection() { {/* Toolbar: search + mode filter + refresh */} -
+
load(true)} - disabled={loading} + disabled={loading || refreshing} aria-label={t('settings.themeStoreRefresh')} data-tooltip={t('settings.themeStoreRefresh')} data-tooltip-pos="left" > - +
@@ -191,7 +219,7 @@ export function ThemeStoreSection() { {!loading && !error && filtered.length > 0 && (
- {filtered.map(th => { + {pageItems.map(th => { const inst = installedMap.get(th.id); const isInstalled = !!inst; const updateAvailable = isInstalled && isNewer(th.version, inst!.version); @@ -298,6 +326,36 @@ export function ThemeStoreSection() {
)} + {!loading && !error && pageCount > 1 && ( +
+ + + {t('settings.themeStorePageStatus', { page: safePage, total: pageCount })} + + +
+ )} + {lightbox && ( setLightbox(null)} /> )} diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index d0fb7d22..690bb959 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -351,6 +351,9 @@ export const settings = { themeStoreUpdating: 'Aktualisiere…', themeStoreUninstall: 'Deinstallieren', themeStoreInstallFailed: 'Installation fehlgeschlagen', + themeStorePagePrev: 'Vorherige Seite', + themeStorePageNext: 'Nächste Seite', + themeStorePageStatus: 'Seite {{page}} von {{total}}', tabLibrary: 'Bibliothek', tabServers: 'Server', tabLyrics: 'Songtexte', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index cd6fe5be..d1b71e4e 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -418,6 +418,9 @@ export const settings = { themeStoreUpdating: 'Updating…', themeStoreUninstall: 'Uninstall', themeStoreInstallFailed: 'Install failed', + themeStorePagePrev: 'Previous page', + themeStorePageNext: 'Next page', + themeStorePageStatus: 'Page {{page}} of {{total}}', tabLibrary: 'Library', tabServers: 'Servers', tabLyrics: 'Lyrics', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 0f281b9b..230dccd4 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -349,6 +349,9 @@ export const settings = { themeStoreUpdating: 'Actualizando…', themeStoreUninstall: 'Desinstalar', themeStoreInstallFailed: 'Error al instalar', + themeStorePagePrev: 'Página anterior', + themeStorePageNext: 'Página siguiente', + themeStorePageStatus: 'Página {{page}} de {{total}}', tabLibrary: 'Biblioteca', tabServers: 'Servidores', tabLyrics: 'Letras', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index 5107cf6a..cbf0306c 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -347,6 +347,9 @@ export const settings = { themeStoreUpdating: 'Mise à jour…', themeStoreUninstall: 'Désinstaller', themeStoreInstallFailed: "Échec de l'installation", + themeStorePagePrev: 'Page précédente', + themeStorePageNext: 'Page suivante', + themeStorePageStatus: 'Page {{page}} sur {{total}}', tabLibrary: 'Bibliothèque', tabServers: 'Serveurs', tabLyrics: 'Paroles', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 268b8157..9da0ace9 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -350,6 +350,9 @@ export const settings = { themeStoreUpdating: 'Oppdaterer…', themeStoreUninstall: 'Avinstaller', themeStoreInstallFailed: 'Installasjonen mislyktes', + themeStorePagePrev: 'Forrige side', + themeStorePageNext: 'Neste side', + themeStorePageStatus: 'Side {{page}} av {{total}}', tabStorage: 'Frakoblet & Cache', inputKeybindingsTitle: 'Tastatursnarveier', aboutContributorsCount_one: '{{count}} bidrag', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 118b02ef..a5760ffc 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -347,6 +347,9 @@ export const settings = { themeStoreUpdating: 'Bijwerken…', themeStoreUninstall: 'Verwijderen', themeStoreInstallFailed: 'Installatie mislukt', + themeStorePagePrev: 'Vorige pagina', + themeStorePageNext: 'Volgende pagina', + themeStorePageStatus: 'Pagina {{page}} van {{total}}', tabLibrary: 'Bibliotheek', tabServers: 'Servers', tabLyrics: 'Songteksten', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 0896c35d..87164dff 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -353,6 +353,9 @@ export const settings = { themeStoreUpdating: 'Se actualizează…', themeStoreUninstall: 'Dezinstalează', themeStoreInstallFailed: 'Instalarea a eșuat', + themeStorePagePrev: 'Pagina anterioară', + themeStorePageNext: 'Pagina următoare', + themeStorePageStatus: 'Pagina {{page}} din {{total}}', tabLibrary: 'Librărie', tabServers: 'Servere', tabLyrics: 'Versuri', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 855459f4..51dcf99c 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -429,6 +429,9 @@ export const settings = { themeStoreUpdating: 'Обновление…', themeStoreUninstall: 'Удалить', themeStoreInstallFailed: 'Ошибка установки', + themeStorePagePrev: 'Предыдущая страница', + themeStorePageNext: 'Следующая страница', + themeStorePageStatus: 'Страница {{page}} из {{total}}', tabLibrary: 'Библиотека', tabServers: 'Серверы', tabLyrics: 'Тексты песен', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index b09447ef..a00e7321 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -346,6 +346,9 @@ export const settings = { themeStoreUpdating: '正在更新…', themeStoreUninstall: '卸载', themeStoreInstallFailed: '安装失败', + themeStorePagePrev: '上一页', + themeStorePageNext: '下一页', + themeStorePageStatus: '第 {{page}} 页,共 {{total}} 页', tabLibrary: '媒体库', tabServers: '服务器', tabLyrics: '歌词',