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: '歌词',