feat(themes): Theme Store install counts, downloads & sort (#1036)

* feat(themes): install counts, downloads, popularity & sort in the Theme Store

Each registry theme now carries an install count and a last-changed date. Store
rows show them in a dedicated meta panel (author, popularity bar, total
downloads, last changed), plus a sort dropdown (most popular / newest / name),
zebra-striped rows, a numbered pager, and a note that the stats refresh daily.
Adds a shared formatRelativeTime helper (formatLastSeen now delegates to it).

* test(themes): cover sort modes, pager jump and relative-time formatting

* fix(build): gate nvidia_quirk_active to Linux

Its only caller is the Linux branch of theme_animation_risk, so on Windows and
macOS the function was dead code and tripped clippy's -D warnings.

* docs: CHANGELOG + credits for theme store download stats & sort (#1036)
This commit is contained in:
Psychotoxical
2026-06-08 22:05:41 +02:00
committed by GitHub
parent 68f0f09aae
commit 0878cbf308
19 changed files with 346 additions and 48 deletions
+24
View File
@@ -0,0 +1,24 @@
/**
* Five-segment popularity bar, filled relative to the most-downloaded theme in
* the catalogue (so the leader reads full and the rest scale against it). With
* few downloads it sits near-empty and fills in organically as counts grow.
* Decorative — the exact download number sits next to it as the real value.
*/
export function PopularityBar({ value, max }: { value: number; max: number }) {
const filled = max > 0 ? Math.round((Math.max(0, value) / max) * 5) : 0;
return (
<span style={{ display: 'inline-flex', gap: 3 }} aria-hidden="true">
{Array.from({ length: 5 }, (_, i) => (
<span
key={i}
style={{
width: 16,
height: 5,
borderRadius: 2,
background: i < filled ? 'var(--accent)' : 'var(--bg-hover)',
}}
/>
))}
</span>
);
}
@@ -1,9 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { screen, waitFor, fireEvent } 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';
import type { FetchRegistryResult, Registry, RegistryTheme } from '@/utils/themes/themeRegistry';
// Control the registry the store browses so pagination/refresh are deterministic.
vi.mock('@/utils/themes/themeRegistry', () => ({
@@ -38,6 +38,40 @@ function makeRegistry(n: number): Registry {
const rows = (container: HTMLElement) => container.querySelectorAll('.theme-store-row');
/** Visible theme names in row order (the first span in each row is the name). */
const rowNames = (container: HTMLElement) =>
[...container.querySelectorAll('.theme-store-row')].map(r => r.querySelector('span')?.textContent);
function mkTheme(id: string, name: string, extra: Partial<RegistryTheme> = {}): RegistryTheme {
return {
id,
name,
author: 'Tester',
version: '1.0.0',
description: `Description ${id}`,
mode: 'dark',
css: `themes/${id}/theme.css`,
thumbnail: `themes/${id}/thumb.webp`,
tags: [],
...extra,
};
}
function registryOf(themes: RegistryTheme[]): Registry {
return { schemaVersion: 1, generatedAt: '2026-06-08T00:00:00Z', themes };
}
/** Open the sort dropdown and pick an option by its visible label. */
async function selectSort(
user: ReturnType<typeof userEvent.setup>,
container: HTMLElement,
optionLabel: string,
) {
await user.click(container.querySelector('.custom-select-trigger') as HTMLElement);
// The option closes the list on mousedown, so fire it directly.
fireEvent.mouseDown(await screen.findByRole('option', { name: optionLabel }));
}
describe('ThemeStoreSection — pagination & refresh', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -52,7 +86,8 @@ describe('ThemeStoreSection — pagination & refresh', () => {
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();
expect(screen.getByRole('button', { name: '1', current: 'page' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '3' })).toBeInTheDocument();
// Page-2 themes are not rendered yet.
expect(screen.queryByText('Theme 13')).not.toBeInTheDocument();
});
@@ -76,13 +111,13 @@ describe('ThemeStoreSection — pagination & refresh', () => {
expect(screen.getByLabelText('Previous page')).toBeDisabled();
await user.click(screen.getByLabelText('Next page'));
expect(screen.getByText('Page 2 of 3')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '2', current: 'page' })).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(screen.getByRole('button', { name: '3', current: 'page' })).toBeInTheDocument();
expect(rows(container)).toHaveLength(6); // 30 - 24
expect(screen.getByLabelText('Next page')).toBeDisabled();
});
@@ -107,12 +142,12 @@ describe('ThemeStoreSection — pagination & refresh', () => {
await screen.findByText('Theme 01');
await user.click(screen.getByLabelText('Next page'));
expect(screen.getByText('Page 2 of 3')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '2', current: 'page' })).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());
await waitFor(() => expect(screen.getByRole('button', { name: '1', current: 'page' })).toBeInTheDocument());
expect(screen.getByText('Theme 01')).toBeInTheDocument();
});
@@ -153,4 +188,43 @@ describe('ThemeStoreSection — pagination & refresh', () => {
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
it('sorts by most popular, newest and name, and shows the download count', async () => {
const themes = [
mkTheme('a', 'Alpha', { installs: 10, updatedAt: '2026-06-01T00:00:00Z' }),
mkTheme('b', 'Bravo', { installs: 500, updatedAt: '2026-06-05T00:00:00Z' }),
mkTheme('c', 'Charlie', { installs: 0, updatedAt: '2026-06-03T00:00:00Z' }),
];
fetchRegistryMock.mockResolvedValue({ registry: registryOf(themes), stale: false });
const { container } = renderWithProviders(<ThemeStoreSection />);
const user = userEvent.setup();
await screen.findByText('Bravo');
// Default sort is most-downloaded first: Bravo (500) > Alpha (10) > Charlie (0).
expect(rowNames(container)).toEqual(['Bravo', 'Alpha', 'Charlie']);
// The download count renders in the meta panel.
expect(screen.getByText('500')).toBeInTheDocument();
// Newest first: Bravo (06-05) > Charlie (06-03) > Alpha (06-01).
await selectSort(user, container, 'Newest');
await waitFor(() => expect(rowNames(container)).toEqual(['Bravo', 'Charlie', 'Alpha']));
// Alphabetical.
await selectSort(user, container, 'Alphabetical');
await waitFor(() => expect(rowNames(container)).toEqual(['Alpha', 'Bravo', 'Charlie']));
});
it('jumps directly to a page via the numbered pager', async () => {
fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false });
renderWithProviders(<ThemeStoreSection />);
const user = userEvent.setup();
await screen.findByText('Theme 01');
await user.click(screen.getByRole('button', { name: '3' }));
// Page 3 holds items 2530; page 1 is gone and page 3 is marked current.
expect(screen.getByText('Theme 25')).toBeInTheDocument();
expect(screen.queryByText('Theme 01')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '3', current: 'page' })).toBeInTheDocument();
});
});
+116 -18
View File
@@ -5,6 +5,9 @@ import { open as openUrl } from '@tauri-apps/plugin-shell';
import CoverLightbox from '../CoverLightbox';
import { useThemeAnimationRisk } from '../../hooks/useThemeAnimationRisk';
import { AnimatedThemeBadge } from './AnimatedThemeBadge';
import { PopularityBar } from './PopularityBar';
import CustomSelect from '../CustomSelect';
import { formatRelativeTime } from '../../utils/format/relativeTime';
import { useThemeStore } from '../../store/themeStore';
import { useInstalledThemesStore, type InstalledTheme } from '../../store/installedThemesStore';
import {
@@ -18,19 +21,34 @@ import { uninstallTheme } from '../../utils/themes/uninstallTheme';
import { isNewer } from '../../utils/componentHelpers/appUpdaterHelpers';
type ModeFilter = 'all' | 'dark' | 'light';
type SortMode = 'popular' | 'newest' | 'name';
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;
/** Page numbers for the pager: all of them when there are few, otherwise the
* first and last page plus a window around the current one, with gaps. */
function pageItemsList(current: number, total: number): (number | 'gap')[] {
if (total <= 10) return Array.from({ length: total }, (_, i) => i + 1);
const out: (number | 'gap')[] = [1];
const lo = Math.max(2, current - 2);
const hi = Math.min(total - 1, current + 2);
if (lo > 2) out.push('gap');
for (let p = lo; p <= hi; p++) out.push(p);
if (hi < total - 1) out.push('gap');
out.push(total);
return out;
}
/**
* The community Theme Store: browse the jsDelivr-hosted registry, filter by name
* and light/dark, install (fetch + persist + runtime inject), apply, update and
* uninstall. Built-in themes are not in the registry, so they never appear here.
*/
export function ThemeStoreSection() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const activeTheme = useThemeStore(s => s.theme);
const setTheme = useThemeStore(s => s.setTheme);
const installed = useInstalledThemesStore(s => s.themes);
@@ -45,6 +63,7 @@ export function ThemeStoreSection() {
const [stale, setStale] = useState(false);
const [query, setQuery] = useState('');
const [mode, setMode] = useState<ModeFilter>('all');
const [sortMode, setSortMode] = useState<SortMode>('popular');
const [page, setPage] = useState(1);
const [busyId, setBusyId] = useState<string | null>(null);
const [failedId, setFailedId] = useState<string | null>(null);
@@ -81,10 +100,18 @@ export function ThemeStoreSection() {
return m;
}, [installed]);
// Scale the popularity bar against the most-downloaded theme across the whole
// catalogue (not the filtered view) so the bar means the same thing regardless
// of search/filter.
const maxInstalls = useMemo(
() => Math.max(1, ...(themes || []).map(th => th.installs ?? 0)),
[themes],
);
const filtered = useMemo(() => {
if (!themes) return [];
const q = query.trim().toLowerCase();
return themes.filter(th => {
const matched = themes.filter(th => {
if (mode !== 'all' && th.mode !== mode) return false;
if (!q) return true;
return (
@@ -93,12 +120,20 @@ export function ThemeStoreSection() {
th.description.toLowerCase().includes(q) ||
(th.tags || []).some(tag => tag.includes(q))
);
}).sort((a, b) => a.name.localeCompare(b.name));
}, [themes, query, mode]);
});
// Name is the stable tie-breaker — keeps ordering deterministic when many
// themes share the same (often 0) download count.
const byName = (a: RegistryTheme, b: RegistryTheme) => a.name.localeCompare(b.name);
if (sortMode === 'name') return matched.sort(byName);
if (sortMode === 'newest') {
return matched.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '') || byName(a, b));
}
return matched.sort((a, b) => (b.installs ?? 0) - (a.installs ?? 0) || byName(a, b));
}, [themes, query, mode, sortMode]);
// 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]);
useEffect(() => { setPage(1); }, [query, mode, sortMode]);
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
// Clamp defensively so a stale `page` (e.g. after the registry shrank) never
@@ -148,6 +183,12 @@ export function ThemeStoreSection() {
{ key: 'light', label: t('settings.themeStoreModeLight') },
];
const sortOptions = [
{ value: 'popular', label: t('settings.themeStoreSortPopular') },
{ value: 'newest', label: t('settings.themeStoreSortNewest') },
{ value: 'name', label: t('settings.themeStoreSortName') },
];
return (
<div className="settings-card">
{/* Submit-your-own-theme hint */}
@@ -164,7 +205,7 @@ export function ThemeStoreSection() {
{/* Network disclosure — the store reaches external services. */}
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 1rem', lineHeight: 1.5 }}>
{t('settings.themeStoreNetworkNotice')}
{t('settings.themeStoreNetworkNotice')}{' '}{t('settings.themeStoreStatsNotice')}
</p>
{/* Toolbar: search + mode filter + refresh. Hidden when offline with no
@@ -180,6 +221,23 @@ export function ThemeStoreSection() {
aria-label={t('settings.themeStoreSearchPlaceholder')}
style={{ flex: '1 1 180px', minWidth: 140 }}
/>
<CustomSelect
value={sortMode}
options={sortOptions}
onChange={v => setSortMode(v as SortMode)}
style={{
width: 170,
flexShrink: 0,
alignSelf: 'stretch',
boxSizing: 'border-box',
padding: '0 var(--space-4)',
background: 'var(--input-bg)',
border: '1px solid var(--input-border)',
borderRadius: 'var(--radius-md)',
fontSize: 14,
lineHeight: 1,
}}
/>
<div style={{ display: 'flex', gap: 4 }} role="group" aria-label={t('settings.themeStoreFilterMode')}>
{modeBtns.map(b => (
<button
@@ -249,7 +307,7 @@ export function ThemeStoreSection() {
{!loading && !error && filtered.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{pageItems.map(th => {
{pageItems.map((th, idx) => {
const inst = installedMap.get(th.id);
const isInstalled = !!inst;
const updateAvailable = isInstalled && isNewer(th.version, inst!.version);
@@ -261,11 +319,13 @@ export function ThemeStoreSection() {
className="theme-store-row"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 14,
padding: 12,
border: `1px solid ${isActive ? 'var(--accent)' : 'var(--border-subtle)'}`,
border: `1px solid ${isActive ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md, 10px)',
background: 'var(--bg-card)',
// Subtle zebra striping so adjacent rows read as distinct boxes.
background: idx % 2 === 1 ? 'var(--bg-hover)' : 'var(--bg-card)',
}}
>
<button
@@ -298,14 +358,11 @@ export function ThemeStoreSection() {
)}
{animRisk && th.animated && <AnimatedThemeBadge variant="inline" />}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.themeStoreByAuthor', { author: th.author })}
</div>
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.4 }}>
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.4, marginTop: 10 }}>
{th.description}
</div>
{/* Rating slot reserved — see Theme Store roadmap (deferred). */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 18 }}>
{!isInstalled && (
<button
className="btn btn-primary"
@@ -351,6 +408,31 @@ export function ThemeStoreSection() {
)}
</div>
</div>
<div
className="theme-store-meta"
style={{ flexShrink: 0, width: 190, display: 'flex', flexDirection: 'column', gap: 6, padding: '10px 12px', background: 'var(--bg-deep, var(--bg-elevated))', border: '1px solid var(--border)', borderRadius: 'var(--radius-md, 10px)' }}
>
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t('settings.themeStoreAuthor')}</div>
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{th.author}</div>
</div>
<div style={{ marginTop: 4, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>{t('settings.themeStorePopularity')}</div>
<PopularityBar value={th.installs ?? 0} max={maxInstalls} />
</div>
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t('settings.themeStoreDownloads')}</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 600 }}>{(th.installs ?? 0).toLocaleString(i18n.language)}</div>
</div>
{th.updatedAt && (
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t('settings.themeStoreLastChanged')}</div>
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)' }}>{formatRelativeTime(th.updatedAt, i18n.language)}</div>
</div>
)}
</div>
</div>
</div>
);
})}
@@ -358,7 +440,11 @@ export function ThemeStoreSection() {
)}
{!loading && !error && pageCount > 1 && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12, marginTop: 16 }}>
<div
role="navigation"
aria-label={t('settings.themeStorePageStatus', { page: safePage, total: pageCount })}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flexWrap: 'wrap', gap: 6, marginTop: 16 }}
>
<button
className="btn btn-ghost"
style={{ padding: '4px 10px' }}
@@ -370,9 +456,21 @@ export function ThemeStoreSection() {
>
<ChevronLeft size={16} />
</button>
<span role="status" style={{ fontSize: 12.5, color: 'var(--text-muted)', minWidth: 96, textAlign: 'center' }}>
{t('settings.themeStorePageStatus', { page: safePage, total: pageCount })}
</span>
{pageItemsList(safePage, pageCount).map((it, i) =>
it === 'gap' ? (
<span key={`gap-${i}`} style={{ color: 'var(--text-muted)', padding: '0 2px' }}></span>
) : (
<button
key={it}
className={`btn ${it === safePage ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12.5, padding: '4px 10px', minWidth: 34 }}
aria-current={it === safePage ? 'page' : undefined}
onClick={() => goToPage(it)}
>
{it}
</button>
)
)}
<button
className="btn btn-ghost"
style={{ padding: '4px 10px' }}