mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
+2
-1
@@ -56,13 +56,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Themes — community Theme Store
|
### Themes — community Theme Store
|
||||||
|
|
||||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016), [#1018](https://github.com/Psychotoxical/psysonic/pull/1018), [#1020](https://github.com/Psychotoxical/psysonic/pull/1020)**
|
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016), [#1018](https://github.com/Psychotoxical/psysonic/pull/1018), [#1020](https://github.com/Psychotoxical/psysonic/pull/1020), [#1036](https://github.com/Psychotoxical/psysonic/pull/1036)**
|
||||||
|
|
||||||
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
|
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
|
||||||
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
|
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
|
||||||
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is validated, you confirm its name and author, then it installs like any other community theme.
|
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is validated, you confirm its name and author, then it installs like any other community theme.
|
||||||
* Themes are **free-form** — beyond recolouring, they can add their own styling and animations and react to playback / fullscreen / sidebar / lyrics state. A safety floor (no network, no scripts) is always enforced; store themes are reviewed, and imported themes install at your own risk.
|
* Themes are **free-form** — beyond recolouring, they can add their own styling and animations and react to playback / fullscreen / sidebar / lyrics state. A safety floor (no network, no scripts) is always enforced; store themes are reviewed, and imported themes install at your own risk.
|
||||||
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
|
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
|
||||||
|
* Each store theme shows its **total downloads** and a **last-changed** date, and can be sorted by most popular, newest or name; the catalogue now has numbered page navigation. These stats refresh once a day.
|
||||||
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
|
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ pub fn set_nvidia_quirk_active(active: bool) {
|
|||||||
let _ = NVIDIA_QUIRK_ACTIVE.set(active);
|
let _ = NVIDIA_QUIRK_ACTIVE.set(active);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the Nvidia WebKit quirk was needed at startup. False when unrecorded
|
/// Whether the Nvidia WebKit quirk was needed at startup. Linux-only: read
|
||||||
/// (non-Linux, or GPU acceleration opted in via `PSYSONIC_WEBKIT_GPU_ACCEL`).
|
/// solely by the Linux branch of `theme_animation_risk`. False when unrecorded
|
||||||
|
/// (GPU acceleration opted in via `PSYSONIC_WEBKIT_GPU_ACCEL`). Gated so it is
|
||||||
|
/// not dead code on Windows/macOS, where the quirk never applies.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
pub(crate) fn nvidia_quirk_active() -> bool {
|
pub(crate) fn nvidia_quirk_active() -> bool {
|
||||||
NVIDIA_QUIRK_ACTIVE.get().copied().unwrap_or(false)
|
NVIDIA_QUIRK_ACTIVE.get().copied().unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 userEvent from '@testing-library/user-event';
|
||||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||||
import { ThemeStoreSection } from './ThemeStoreSection';
|
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.
|
// Control the registry the store browses so pagination/refresh are deterministic.
|
||||||
vi.mock('@/utils/themes/themeRegistry', () => ({
|
vi.mock('@/utils/themes/themeRegistry', () => ({
|
||||||
@@ -38,6 +38,40 @@ function makeRegistry(n: number): Registry {
|
|||||||
|
|
||||||
const rows = (container: HTMLElement) => container.querySelectorAll('.theme-store-row');
|
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', () => {
|
describe('ThemeStoreSection — pagination & refresh', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -52,7 +86,8 @@ describe('ThemeStoreSection — pagination & refresh', () => {
|
|||||||
await screen.findByText('Theme 01');
|
await screen.findByText('Theme 01');
|
||||||
// PAGE_SIZE is 12 → 30 themes span 3 pages.
|
// PAGE_SIZE is 12 → 30 themes span 3 pages.
|
||||||
expect(rows(container)).toHaveLength(12);
|
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.
|
// Page-2 themes are not rendered yet.
|
||||||
expect(screen.queryByText('Theme 13')).not.toBeInTheDocument();
|
expect(screen.queryByText('Theme 13')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -76,13 +111,13 @@ describe('ThemeStoreSection — pagination & refresh', () => {
|
|||||||
expect(screen.getByLabelText('Previous page')).toBeDisabled();
|
expect(screen.getByLabelText('Previous page')).toBeDisabled();
|
||||||
|
|
||||||
await user.click(screen.getByLabelText('Next page'));
|
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.getByText('Theme 13')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Theme 01')).not.toBeInTheDocument();
|
expect(screen.queryByText('Theme 01')).not.toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Previous page')).toBeEnabled();
|
expect(screen.getByLabelText('Previous page')).toBeEnabled();
|
||||||
|
|
||||||
await user.click(screen.getByLabelText('Next page'));
|
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(rows(container)).toHaveLength(6); // 30 - 24
|
||||||
expect(screen.getByLabelText('Next page')).toBeDisabled();
|
expect(screen.getByLabelText('Next page')).toBeDisabled();
|
||||||
});
|
});
|
||||||
@@ -107,12 +142,12 @@ describe('ThemeStoreSection — pagination & refresh', () => {
|
|||||||
|
|
||||||
await screen.findByText('Theme 01');
|
await screen.findByText('Theme 01');
|
||||||
await user.click(screen.getByLabelText('Next page'));
|
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
|
// '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.
|
// the page must snap back to 1 so the user isn't stranded past the end.
|
||||||
await user.type(screen.getByRole('searchbox'), 'theme');
|
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();
|
expect(screen.getByText('Theme 01')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,4 +188,43 @@ describe('ThemeStoreSection — pagination & refresh', () => {
|
|||||||
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
|
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Retry' })).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 25–30; 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { open as openUrl } from '@tauri-apps/plugin-shell';
|
|||||||
import CoverLightbox from '../CoverLightbox';
|
import CoverLightbox from '../CoverLightbox';
|
||||||
import { useThemeAnimationRisk } from '../../hooks/useThemeAnimationRisk';
|
import { useThemeAnimationRisk } from '../../hooks/useThemeAnimationRisk';
|
||||||
import { AnimatedThemeBadge } from './AnimatedThemeBadge';
|
import { AnimatedThemeBadge } from './AnimatedThemeBadge';
|
||||||
|
import { PopularityBar } from './PopularityBar';
|
||||||
|
import CustomSelect from '../CustomSelect';
|
||||||
|
import { formatRelativeTime } from '../../utils/format/relativeTime';
|
||||||
import { useThemeStore } from '../../store/themeStore';
|
import { useThemeStore } from '../../store/themeStore';
|
||||||
import { useInstalledThemesStore, type InstalledTheme } from '../../store/installedThemesStore';
|
import { useInstalledThemesStore, type InstalledTheme } from '../../store/installedThemesStore';
|
||||||
import {
|
import {
|
||||||
@@ -18,19 +21,34 @@ import { uninstallTheme } from '../../utils/themes/uninstallTheme';
|
|||||||
import { isNewer } from '../../utils/componentHelpers/appUpdaterHelpers';
|
import { isNewer } from '../../utils/componentHelpers/appUpdaterHelpers';
|
||||||
|
|
||||||
type ModeFilter = 'all' | 'dark' | 'light';
|
type ModeFilter = 'all' | 'dark' | 'light';
|
||||||
|
type SortMode = 'popular' | 'newest' | 'name';
|
||||||
|
|
||||||
const THEMES_REPO_URL = 'https://github.com/Psysonic/psysonic-themes';
|
const THEMES_REPO_URL = 'https://github.com/Psysonic/psysonic-themes';
|
||||||
|
|
||||||
/** Themes shown per page — the catalogue is large enough to paginate. */
|
/** Themes shown per page — the catalogue is large enough to paginate. */
|
||||||
const PAGE_SIZE = 12;
|
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
|
* The community Theme Store: browse the jsDelivr-hosted registry, filter by name
|
||||||
* and light/dark, install (fetch + persist + runtime inject), apply, update and
|
* 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.
|
* uninstall. Built-in themes are not in the registry, so they never appear here.
|
||||||
*/
|
*/
|
||||||
export function ThemeStoreSection() {
|
export function ThemeStoreSection() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const activeTheme = useThemeStore(s => s.theme);
|
const activeTheme = useThemeStore(s => s.theme);
|
||||||
const setTheme = useThemeStore(s => s.setTheme);
|
const setTheme = useThemeStore(s => s.setTheme);
|
||||||
const installed = useInstalledThemesStore(s => s.themes);
|
const installed = useInstalledThemesStore(s => s.themes);
|
||||||
@@ -45,6 +63,7 @@ export function ThemeStoreSection() {
|
|||||||
const [stale, setStale] = useState(false);
|
const [stale, setStale] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [mode, setMode] = useState<ModeFilter>('all');
|
const [mode, setMode] = useState<ModeFilter>('all');
|
||||||
|
const [sortMode, setSortMode] = useState<SortMode>('popular');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [failedId, setFailedId] = useState<string | null>(null);
|
const [failedId, setFailedId] = useState<string | null>(null);
|
||||||
@@ -81,10 +100,18 @@ export function ThemeStoreSection() {
|
|||||||
return m;
|
return m;
|
||||||
}, [installed]);
|
}, [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(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!themes) return [];
|
if (!themes) return [];
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
return themes.filter(th => {
|
const matched = themes.filter(th => {
|
||||||
if (mode !== 'all' && th.mode !== mode) return false;
|
if (mode !== 'all' && th.mode !== mode) return false;
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return (
|
return (
|
||||||
@@ -93,12 +120,20 @@ export function ThemeStoreSection() {
|
|||||||
th.description.toLowerCase().includes(q) ||
|
th.description.toLowerCase().includes(q) ||
|
||||||
(th.tags || []).some(tag => tag.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
|
// A changed filter can shrink the result set below the current page; reset to
|
||||||
// the first page whenever the query or mode filter changes.
|
// 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));
|
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
// Clamp defensively so a stale `page` (e.g. after the registry shrank) never
|
// 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') },
|
{ 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 (
|
return (
|
||||||
<div className="settings-card">
|
<div className="settings-card">
|
||||||
{/* Submit-your-own-theme hint */}
|
{/* Submit-your-own-theme hint */}
|
||||||
@@ -164,7 +205,7 @@ export function ThemeStoreSection() {
|
|||||||
|
|
||||||
{/* Network disclosure — the store reaches external services. */}
|
{/* Network disclosure — the store reaches external services. */}
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 1rem', lineHeight: 1.5 }}>
|
<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>
|
</p>
|
||||||
|
|
||||||
{/* Toolbar: search + mode filter + refresh. Hidden when offline with no
|
{/* Toolbar: search + mode filter + refresh. Hidden when offline with no
|
||||||
@@ -180,6 +221,23 @@ export function ThemeStoreSection() {
|
|||||||
aria-label={t('settings.themeStoreSearchPlaceholder')}
|
aria-label={t('settings.themeStoreSearchPlaceholder')}
|
||||||
style={{ flex: '1 1 180px', minWidth: 140 }}
|
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')}>
|
<div style={{ display: 'flex', gap: 4 }} role="group" aria-label={t('settings.themeStoreFilterMode')}>
|
||||||
{modeBtns.map(b => (
|
{modeBtns.map(b => (
|
||||||
<button
|
<button
|
||||||
@@ -249,7 +307,7 @@ export function ThemeStoreSection() {
|
|||||||
|
|
||||||
{!loading && !error && filtered.length > 0 && (
|
{!loading && !error && filtered.length > 0 && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
{pageItems.map(th => {
|
{pageItems.map((th, idx) => {
|
||||||
const inst = installedMap.get(th.id);
|
const inst = installedMap.get(th.id);
|
||||||
const isInstalled = !!inst;
|
const isInstalled = !!inst;
|
||||||
const updateAvailable = isInstalled && isNewer(th.version, inst!.version);
|
const updateAvailable = isInstalled && isNewer(th.version, inst!.version);
|
||||||
@@ -261,11 +319,13 @@ export function ThemeStoreSection() {
|
|||||||
className="theme-store-row"
|
className="theme-store-row"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
gap: 14,
|
gap: 14,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
border: `1px solid ${isActive ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
border: `1px solid ${isActive ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
borderRadius: 'var(--radius-md, 10px)',
|
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
|
<button
|
||||||
@@ -298,14 +358,11 @@ export function ThemeStoreSection() {
|
|||||||
)}
|
)}
|
||||||
{animRisk && th.animated && <AnimatedThemeBadge variant="inline" />}
|
{animRisk && th.animated && <AnimatedThemeBadge variant="inline" />}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.4, marginTop: 10 }}>
|
||||||
{t('settings.themeStoreByAuthor', { author: th.author })}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.4 }}>
|
|
||||||
{th.description}
|
{th.description}
|
||||||
</div>
|
</div>
|
||||||
{/* Rating slot reserved — see Theme Store roadmap (deferred). */}
|
{/* 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 && (
|
{!isInstalled && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
@@ -351,6 +408,31 @@ export function ThemeStoreSection() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -358,7 +440,11 @@ export function ThemeStoreSection() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && pageCount > 1 && (
|
{!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
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ padding: '4px 10px' }}
|
style={{ padding: '4px 10px' }}
|
||||||
@@ -370,9 +456,21 @@ export function ThemeStoreSection() {
|
|||||||
>
|
>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span role="status" style={{ fontSize: 12.5, color: 'var(--text-muted)', minWidth: 96, textAlign: 'center' }}>
|
{pageItemsList(safePage, pageCount).map((it, i) =>
|
||||||
{t('settings.themeStorePageStatus', { page: safePage, total: pageCount })}
|
it === 'gap' ? (
|
||||||
</span>
|
<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
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ padding: '4px 10px' }}
|
style={{ padding: '4px 10px' }}
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ const CONTRIBUTOR_ENTRIES = [
|
|||||||
'Performance Probe: Monitor/Toggles redesign, live CPU/RSS/thread metrics, overlay pins and sparklines, macOS snapshots (PR #890)',
|
'Performance Probe: Monitor/Toggles redesign, live CPU/RSS/thread metrics, overlay pins and sparklines, macOS snapshots (PR #890)',
|
||||||
'Performance Probe: opt-in thread-group CPU poll toggle and includeThreadGroups IPC fix (PR #891)',
|
'Performance Probe: opt-in thread-group CPU poll toggle and includeThreadGroups IPC fix (PR #891)',
|
||||||
'Queue: switchable display mode — Queue (upcoming only) vs Playlist (full list), with header toggle and settings entry (PR #922)',
|
'Queue: switchable display mode — Queue (upcoming only) vs Playlist (full list), with header toggle and settings entry (PR #922)',
|
||||||
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import, and free-form community themes (safety floor + state-reactive styling); 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012, #1013, #1014, #1015, #1016, #1018, #1020)',
|
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import, free-form community themes (safety floor + state-reactive styling), and per-theme download counts with popularity/newest/name sorting; 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012, #1013, #1014, #1015, #1016, #1018, #1020, #1036)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ export const settings = {
|
|||||||
themeStoreSubmitText: 'Eigenes Theme gebaut? Teil es mit der Community — mehr Infos im Themes-Repository.',
|
themeStoreSubmitText: 'Eigenes Theme gebaut? Teil es mit der Community — mehr Infos im Themes-Repository.',
|
||||||
themeStoreSubmitLink: 'Themes-Repository öffnen',
|
themeStoreSubmitLink: 'Themes-Repository öffnen',
|
||||||
themeStoreNetworkNotice: 'Der Theme-Store lädt Katalog und Vorschauen von externen Diensten (jsDelivr-CDN und GitHub). Es werden keine persönlichen Daten gesendet.',
|
themeStoreNetworkNotice: 'Der Theme-Store lädt Katalog und Vorschauen von externen Diensten (jsDelivr-CDN und GitHub). Es werden keine persönlichen Daten gesendet.',
|
||||||
|
themeStoreStatsNotice: 'Download- und „Zuletzt geändert"-Statistiken werden einmal täglich aktualisiert (gegen 04:17 UTC).',
|
||||||
themeStoreSearchPlaceholder: 'Themes suchen…',
|
themeStoreSearchPlaceholder: 'Themes suchen…',
|
||||||
themeStoreFilterMode: 'Nach Modus filtern',
|
themeStoreFilterMode: 'Nach Modus filtern',
|
||||||
themeStoreModeAll: 'Alle',
|
themeStoreModeAll: 'Alle',
|
||||||
@@ -342,7 +343,13 @@ export const settings = {
|
|||||||
themeStoreEmpty: 'Keine Themes passen zu deinen Filtern.',
|
themeStoreEmpty: 'Keine Themes passen zu deinen Filtern.',
|
||||||
themeStoreActive: 'Aktiv',
|
themeStoreActive: 'Aktiv',
|
||||||
themeStoreEnlarge: 'Vorschau vergrößern',
|
themeStoreEnlarge: 'Vorschau vergrößern',
|
||||||
themeStoreByAuthor: 'von {{author}}',
|
themeStoreSortPopular: 'Beliebteste',
|
||||||
|
themeStoreSortNewest: 'Neueste',
|
||||||
|
themeStoreSortName: 'Alphabetisch',
|
||||||
|
themeStoreAuthor: 'Autor',
|
||||||
|
themeStorePopularity: 'Beliebtheit',
|
||||||
|
themeStoreDownloads: 'Downloads gesamt',
|
||||||
|
themeStoreLastChanged: 'Zuletzt geändert',
|
||||||
themeMigrationNoticeTitle: 'Theme in den Store gezogen',
|
themeMigrationNoticeTitle: 'Theme in den Store gezogen',
|
||||||
themeMigrationNoticeBody: '{{themes}} ist nicht mehr in der App enthalten. Du kannst es im Theme-Store neu installieren (Einstellungen → Themes) oder ein anderes Theme wählen.',
|
themeMigrationNoticeBody: '{{themes}} ist nicht mehr in der App enthalten. Du kannst es im Theme-Store neu installieren (Einstellungen → Themes) oder ein anderes Theme wählen.',
|
||||||
themeMigrationNoticeOpen: 'Theme-Store öffnen',
|
themeMigrationNoticeOpen: 'Theme-Store öffnen',
|
||||||
|
|||||||
@@ -396,6 +396,7 @@ export const settings = {
|
|||||||
themeStoreSubmitText: 'Made your own theme? Share it with the community — more info in the themes repository.',
|
themeStoreSubmitText: 'Made your own theme? Share it with the community — more info in the themes repository.',
|
||||||
themeStoreSubmitLink: 'Open the themes repository',
|
themeStoreSubmitLink: 'Open the themes repository',
|
||||||
themeStoreNetworkNotice: 'The Theme Store loads its catalogue and previews from external services (the jsDelivr CDN and GitHub). No personal data is sent.',
|
themeStoreNetworkNotice: 'The Theme Store loads its catalogue and previews from external services (the jsDelivr CDN and GitHub). No personal data is sent.',
|
||||||
|
themeStoreStatsNotice: 'Download and last-changed stats refresh once a day (around 04:17 UTC).',
|
||||||
themeStoreSearchPlaceholder: 'Search themes…',
|
themeStoreSearchPlaceholder: 'Search themes…',
|
||||||
themeStoreFilterMode: 'Filter by mode',
|
themeStoreFilterMode: 'Filter by mode',
|
||||||
themeStoreModeAll: 'All',
|
themeStoreModeAll: 'All',
|
||||||
@@ -409,7 +410,13 @@ export const settings = {
|
|||||||
themeStoreEmpty: 'No themes match your filters.',
|
themeStoreEmpty: 'No themes match your filters.',
|
||||||
themeStoreActive: 'Active',
|
themeStoreActive: 'Active',
|
||||||
themeStoreEnlarge: 'Enlarge preview',
|
themeStoreEnlarge: 'Enlarge preview',
|
||||||
themeStoreByAuthor: 'by {{author}}',
|
themeStoreSortPopular: 'Most popular',
|
||||||
|
themeStoreSortNewest: 'Newest',
|
||||||
|
themeStoreSortName: 'Alphabetical',
|
||||||
|
themeStoreAuthor: 'Author',
|
||||||
|
themeStorePopularity: 'Popularity',
|
||||||
|
themeStoreDownloads: 'Total downloads',
|
||||||
|
themeStoreLastChanged: 'Last changed',
|
||||||
themeMigrationNoticeTitle: 'Theme moved to the Store',
|
themeMigrationNoticeTitle: 'Theme moved to the Store',
|
||||||
themeMigrationNoticeBody: '{{themes}} is no longer bundled with the app. You can reinstall it from the Theme Store (Settings → Themes), or pick another theme.',
|
themeMigrationNoticeBody: '{{themes}} is no longer bundled with the app. You can reinstall it from the Theme Store (Settings → Themes), or pick another theme.',
|
||||||
themeMigrationNoticeOpen: 'Open Theme Store',
|
themeMigrationNoticeOpen: 'Open Theme Store',
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ export const settings = {
|
|||||||
themeStoreSubmitText: '¿Has creado tu propio tema? Compártelo con la comunidad — más información en el repositorio de temas.',
|
themeStoreSubmitText: '¿Has creado tu propio tema? Compártelo con la comunidad — más información en el repositorio de temas.',
|
||||||
themeStoreSubmitLink: 'Abrir el repositorio de temas',
|
themeStoreSubmitLink: 'Abrir el repositorio de temas',
|
||||||
themeStoreNetworkNotice: 'La tienda de temas carga el catálogo y las vistas previas desde servicios externos (la CDN de jsDelivr y GitHub). No se envían datos personales.',
|
themeStoreNetworkNotice: 'La tienda de temas carga el catálogo y las vistas previas desde servicios externos (la CDN de jsDelivr y GitHub). No se envían datos personales.',
|
||||||
|
themeStoreStatsNotice: 'Las estadísticas de descargas y última modificación se actualizan una vez al día (sobre las 04:17 UTC).',
|
||||||
themeStoreSearchPlaceholder: 'Buscar temas…',
|
themeStoreSearchPlaceholder: 'Buscar temas…',
|
||||||
themeStoreFilterMode: 'Filtrar por modo',
|
themeStoreFilterMode: 'Filtrar por modo',
|
||||||
themeStoreModeAll: 'Todos',
|
themeStoreModeAll: 'Todos',
|
||||||
@@ -340,7 +341,13 @@ export const settings = {
|
|||||||
themeStoreEmpty: 'Ningún tema coincide con tus filtros.',
|
themeStoreEmpty: 'Ningún tema coincide con tus filtros.',
|
||||||
themeStoreActive: 'Activo',
|
themeStoreActive: 'Activo',
|
||||||
themeStoreEnlarge: 'Ampliar vista previa',
|
themeStoreEnlarge: 'Ampliar vista previa',
|
||||||
themeStoreByAuthor: 'por {{author}}',
|
themeStoreSortPopular: 'Más populares',
|
||||||
|
themeStoreSortNewest: 'Más recientes',
|
||||||
|
themeStoreSortName: 'Alfabético',
|
||||||
|
themeStoreAuthor: 'Autor',
|
||||||
|
themeStorePopularity: 'Popularidad',
|
||||||
|
themeStoreDownloads: 'Descargas totales',
|
||||||
|
themeStoreLastChanged: 'Última modificación',
|
||||||
themeMigrationNoticeTitle: 'Tema movido a la tienda',
|
themeMigrationNoticeTitle: 'Tema movido a la tienda',
|
||||||
themeMigrationNoticeBody: '{{themes}} ya no viene incluido en la app. Puedes reinstalarlo desde la Tienda de Temas (Ajustes → Temas) o elegir otro tema.',
|
themeMigrationNoticeBody: '{{themes}} ya no viene incluido en la app. Puedes reinstalarlo desde la Tienda de Temas (Ajustes → Temas) o elegir otro tema.',
|
||||||
themeMigrationNoticeOpen: 'Abrir Tienda de Temas',
|
themeMigrationNoticeOpen: 'Abrir Tienda de Temas',
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ export const settings = {
|
|||||||
themeStoreSubmitText: "Vous avez créé votre propre thème ? Partagez-le avec la communauté — plus d'infos dans le dépôt de thèmes.",
|
themeStoreSubmitText: "Vous avez créé votre propre thème ? Partagez-le avec la communauté — plus d'infos dans le dépôt de thèmes.",
|
||||||
themeStoreSubmitLink: 'Ouvrir le dépôt de thèmes',
|
themeStoreSubmitLink: 'Ouvrir le dépôt de thèmes',
|
||||||
themeStoreNetworkNotice: 'Le Theme Store charge son catalogue et ses aperçus depuis des services externes (le CDN jsDelivr et GitHub). Aucune donnée personnelle n’est envoyée.',
|
themeStoreNetworkNotice: 'Le Theme Store charge son catalogue et ses aperçus depuis des services externes (le CDN jsDelivr et GitHub). Aucune donnée personnelle n’est envoyée.',
|
||||||
|
themeStoreStatsNotice: 'Les statistiques de téléchargements et de dernière modification sont actualisées une fois par jour (vers 04h17 UTC).',
|
||||||
themeStoreSearchPlaceholder: 'Rechercher des thèmes…',
|
themeStoreSearchPlaceholder: 'Rechercher des thèmes…',
|
||||||
themeStoreFilterMode: 'Filtrer par mode',
|
themeStoreFilterMode: 'Filtrer par mode',
|
||||||
themeStoreModeAll: 'Tous',
|
themeStoreModeAll: 'Tous',
|
||||||
@@ -338,7 +339,13 @@ export const settings = {
|
|||||||
themeStoreEmpty: 'Aucun thème ne correspond à vos filtres.',
|
themeStoreEmpty: 'Aucun thème ne correspond à vos filtres.',
|
||||||
themeStoreActive: 'Actif',
|
themeStoreActive: 'Actif',
|
||||||
themeStoreEnlarge: "Agrandir l'aperçu",
|
themeStoreEnlarge: "Agrandir l'aperçu",
|
||||||
themeStoreByAuthor: 'par {{author}}',
|
themeStoreSortPopular: 'Les plus populaires',
|
||||||
|
themeStoreSortNewest: 'Plus récents',
|
||||||
|
themeStoreSortName: 'Alphabétique',
|
||||||
|
themeStoreAuthor: 'Auteur',
|
||||||
|
themeStorePopularity: 'Popularité',
|
||||||
|
themeStoreDownloads: 'Téléchargements totaux',
|
||||||
|
themeStoreLastChanged: 'Dernière modification',
|
||||||
themeMigrationNoticeTitle: 'Thème déplacé vers la boutique',
|
themeMigrationNoticeTitle: 'Thème déplacé vers la boutique',
|
||||||
themeMigrationNoticeBody: "{{themes}} n'est plus inclus dans l'application. Vous pouvez le réinstaller depuis la boutique de thèmes (Paramètres → Thèmes) ou choisir un autre thème.",
|
themeMigrationNoticeBody: "{{themes}} n'est plus inclus dans l'application. Vous pouvez le réinstaller depuis la boutique de thèmes (Paramètres → Thèmes) ou choisir un autre thème.",
|
||||||
themeMigrationNoticeOpen: 'Ouvrir la boutique de thèmes',
|
themeMigrationNoticeOpen: 'Ouvrir la boutique de thèmes',
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ export const settings = {
|
|||||||
themeStoreSubmitText: 'Laget ditt eget tema? Del det med fellesskapet — mer info i tema-repoet.',
|
themeStoreSubmitText: 'Laget ditt eget tema? Del det med fellesskapet — mer info i tema-repoet.',
|
||||||
themeStoreSubmitLink: 'Åpne tema-repoet',
|
themeStoreSubmitLink: 'Åpne tema-repoet',
|
||||||
themeStoreNetworkNotice: 'Theme Store laster katalogen og forhåndsvisninger fra eksterne tjenester (jsDelivr-CDN og GitHub). Ingen personopplysninger sendes.',
|
themeStoreNetworkNotice: 'Theme Store laster katalogen og forhåndsvisninger fra eksterne tjenester (jsDelivr-CDN og GitHub). Ingen personopplysninger sendes.',
|
||||||
|
themeStoreStatsNotice: 'Statistikk for nedlastinger og sist endret oppdateres én gang om dagen (rundt 04:17 UTC).',
|
||||||
themeStoreSearchPlaceholder: 'Søk i temaer…',
|
themeStoreSearchPlaceholder: 'Søk i temaer…',
|
||||||
themeStoreFilterMode: 'Filtrer etter modus',
|
themeStoreFilterMode: 'Filtrer etter modus',
|
||||||
themeStoreModeAll: 'Alle',
|
themeStoreModeAll: 'Alle',
|
||||||
@@ -341,7 +342,13 @@ export const settings = {
|
|||||||
themeStoreEmpty: 'Ingen temaer samsvarer med filtrene dine.',
|
themeStoreEmpty: 'Ingen temaer samsvarer med filtrene dine.',
|
||||||
themeStoreActive: 'Aktiv',
|
themeStoreActive: 'Aktiv',
|
||||||
themeStoreEnlarge: 'Forstørr forhåndsvisning',
|
themeStoreEnlarge: 'Forstørr forhåndsvisning',
|
||||||
themeStoreByAuthor: 'av {{author}}',
|
themeStoreSortPopular: 'Mest populære',
|
||||||
|
themeStoreSortNewest: 'Nyeste',
|
||||||
|
themeStoreSortName: 'Alfabetisk',
|
||||||
|
themeStoreAuthor: 'Forfatter',
|
||||||
|
themeStorePopularity: 'Popularitet',
|
||||||
|
themeStoreDownloads: 'Totalt nedlastinger',
|
||||||
|
themeStoreLastChanged: 'Sist endret',
|
||||||
themeMigrationNoticeTitle: 'Temaet er flyttet til butikken',
|
themeMigrationNoticeTitle: 'Temaet er flyttet til butikken',
|
||||||
themeMigrationNoticeBody: '{{themes}} følger ikke lenger med appen. Du kan installere det på nytt fra temabutikken (Innstillinger → Temaer), eller velge et annet tema.',
|
themeMigrationNoticeBody: '{{themes}} følger ikke lenger med appen. Du kan installere det på nytt fra temabutikken (Innstillinger → Temaer), eller velge et annet tema.',
|
||||||
themeMigrationNoticeOpen: 'Åpne temabutikken',
|
themeMigrationNoticeOpen: 'Åpne temabutikken',
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ export const settings = {
|
|||||||
themeStoreSubmitText: 'Eigen thema gemaakt? Deel het met de community — meer info in de themarepository.',
|
themeStoreSubmitText: 'Eigen thema gemaakt? Deel het met de community — meer info in de themarepository.',
|
||||||
themeStoreSubmitLink: 'Themarepository openen',
|
themeStoreSubmitLink: 'Themarepository openen',
|
||||||
themeStoreNetworkNotice: 'De Theme Store laadt de catalogus en previews van externe diensten (de jsDelivr-CDN en GitHub). Er worden geen persoonlijke gegevens verzonden.',
|
themeStoreNetworkNotice: 'De Theme Store laadt de catalogus en previews van externe diensten (de jsDelivr-CDN en GitHub). Er worden geen persoonlijke gegevens verzonden.',
|
||||||
|
themeStoreStatsNotice: 'Download- en laatst-gewijzigd-statistieken worden eenmaal per dag bijgewerkt (rond 04:17 UTC).',
|
||||||
themeStoreSearchPlaceholder: "Thema's zoeken…",
|
themeStoreSearchPlaceholder: "Thema's zoeken…",
|
||||||
themeStoreFilterMode: 'Filteren op modus',
|
themeStoreFilterMode: 'Filteren op modus',
|
||||||
themeStoreModeAll: 'Alle',
|
themeStoreModeAll: 'Alle',
|
||||||
@@ -338,7 +339,13 @@ export const settings = {
|
|||||||
themeStoreEmpty: "Geen thema's komen overeen met je filters.",
|
themeStoreEmpty: "Geen thema's komen overeen met je filters.",
|
||||||
themeStoreActive: 'Actief',
|
themeStoreActive: 'Actief',
|
||||||
themeStoreEnlarge: 'Voorbeeld vergroten',
|
themeStoreEnlarge: 'Voorbeeld vergroten',
|
||||||
themeStoreByAuthor: 'door {{author}}',
|
themeStoreSortPopular: 'Populairste',
|
||||||
|
themeStoreSortNewest: 'Nieuwste',
|
||||||
|
themeStoreSortName: 'Alfabetisch',
|
||||||
|
themeStoreAuthor: 'Auteur',
|
||||||
|
themeStorePopularity: 'Populariteit',
|
||||||
|
themeStoreDownloads: 'Totaal downloads',
|
||||||
|
themeStoreLastChanged: 'Laatst gewijzigd',
|
||||||
themeMigrationNoticeTitle: 'Thema verplaatst naar de store',
|
themeMigrationNoticeTitle: 'Thema verplaatst naar de store',
|
||||||
themeMigrationNoticeBody: '{{themes}} wordt niet meer met de app meegeleverd. Je kunt het opnieuw installeren via de Theme Store (Instellingen → Thema\'s) of een ander thema kiezen.',
|
themeMigrationNoticeBody: '{{themes}} wordt niet meer met de app meegeleverd. Je kunt het opnieuw installeren via de Theme Store (Instellingen → Thema\'s) of een ander thema kiezen.',
|
||||||
themeMigrationNoticeOpen: 'Theme Store openen',
|
themeMigrationNoticeOpen: 'Theme Store openen',
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ export const settings = {
|
|||||||
themeStoreSubmitText: 'Ți-ai creat propria temă? Împărtășește-o cu comunitatea — mai multe informații în depozitul de teme.',
|
themeStoreSubmitText: 'Ți-ai creat propria temă? Împărtășește-o cu comunitatea — mai multe informații în depozitul de teme.',
|
||||||
themeStoreSubmitLink: 'Deschide depozitul de teme',
|
themeStoreSubmitLink: 'Deschide depozitul de teme',
|
||||||
themeStoreNetworkNotice: 'Theme Store încarcă catalogul și previzualizările de la servicii externe (CDN-ul jsDelivr și GitHub). Nu se trimit date personale.',
|
themeStoreNetworkNotice: 'Theme Store încarcă catalogul și previzualizările de la servicii externe (CDN-ul jsDelivr și GitHub). Nu se trimit date personale.',
|
||||||
|
themeStoreStatsNotice: 'Statisticile de descărcări și ultima modificare se actualizează o dată pe zi (în jurul orei 04:17 UTC).',
|
||||||
themeStoreSearchPlaceholder: 'Caută teme…',
|
themeStoreSearchPlaceholder: 'Caută teme…',
|
||||||
themeStoreFilterMode: 'Filtrează după mod',
|
themeStoreFilterMode: 'Filtrează după mod',
|
||||||
themeStoreModeAll: 'Toate',
|
themeStoreModeAll: 'Toate',
|
||||||
@@ -344,7 +345,13 @@ export const settings = {
|
|||||||
themeStoreEmpty: 'Nicio temă nu corespunde filtrelor tale.',
|
themeStoreEmpty: 'Nicio temă nu corespunde filtrelor tale.',
|
||||||
themeStoreActive: 'Activă',
|
themeStoreActive: 'Activă',
|
||||||
themeStoreEnlarge: 'Mărește previzualizarea',
|
themeStoreEnlarge: 'Mărește previzualizarea',
|
||||||
themeStoreByAuthor: 'de {{author}}',
|
themeStoreSortPopular: 'Cele mai populare',
|
||||||
|
themeStoreSortNewest: 'Cele mai noi',
|
||||||
|
themeStoreSortName: 'Alfabetic',
|
||||||
|
themeStoreAuthor: 'Autor',
|
||||||
|
themeStorePopularity: 'Popularitate',
|
||||||
|
themeStoreDownloads: 'Descărcări totale',
|
||||||
|
themeStoreLastChanged: 'Ultima modificare',
|
||||||
themeMigrationNoticeTitle: 'Tema a fost mutată în magazin',
|
themeMigrationNoticeTitle: 'Tema a fost mutată în magazin',
|
||||||
themeMigrationNoticeBody: '{{themes}} nu mai este inclusă în aplicație. O poți reinstala din Magazinul de Teme (Setări → Teme) sau poți alege altă temă.',
|
themeMigrationNoticeBody: '{{themes}} nu mai este inclusă în aplicație. O poți reinstala din Magazinul de Teme (Setări → Teme) sau poți alege altă temă.',
|
||||||
themeMigrationNoticeOpen: 'Deschide Magazinul de Teme',
|
themeMigrationNoticeOpen: 'Deschide Magazinul de Teme',
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ export const settings = {
|
|||||||
themeStoreSubmitText: 'Создали свою тему? Поделитесь с сообществом — подробности в репозитории тем.',
|
themeStoreSubmitText: 'Создали свою тему? Поделитесь с сообществом — подробности в репозитории тем.',
|
||||||
themeStoreSubmitLink: 'Открыть репозиторий тем',
|
themeStoreSubmitLink: 'Открыть репозиторий тем',
|
||||||
themeStoreNetworkNotice: 'Магазин тем загружает каталог и превью с внешних сервисов (CDN jsDelivr и GitHub). Личные данные не отправляются.',
|
themeStoreNetworkNotice: 'Магазин тем загружает каталог и превью с внешних сервисов (CDN jsDelivr и GitHub). Личные данные не отправляются.',
|
||||||
|
themeStoreStatsNotice: 'Статистика загрузок и даты изменения обновляются раз в день (около 04:17 UTC).',
|
||||||
themeStoreSearchPlaceholder: 'Поиск тем…',
|
themeStoreSearchPlaceholder: 'Поиск тем…',
|
||||||
themeStoreFilterMode: 'Фильтр по режиму',
|
themeStoreFilterMode: 'Фильтр по режиму',
|
||||||
themeStoreModeAll: 'Все',
|
themeStoreModeAll: 'Все',
|
||||||
@@ -420,7 +421,13 @@ export const settings = {
|
|||||||
themeStoreEmpty: 'Нет тем, соответствующих фильтрам.',
|
themeStoreEmpty: 'Нет тем, соответствующих фильтрам.',
|
||||||
themeStoreActive: 'Активна',
|
themeStoreActive: 'Активна',
|
||||||
themeStoreEnlarge: 'Увеличить превью',
|
themeStoreEnlarge: 'Увеличить превью',
|
||||||
themeStoreByAuthor: 'от {{author}}',
|
themeStoreSortPopular: 'Популярные',
|
||||||
|
themeStoreSortNewest: 'Новые',
|
||||||
|
themeStoreSortName: 'По алфавиту',
|
||||||
|
themeStoreAuthor: 'Автор',
|
||||||
|
themeStorePopularity: 'Популярность',
|
||||||
|
themeStoreDownloads: 'Всего загрузок',
|
||||||
|
themeStoreLastChanged: 'Изменён',
|
||||||
themeMigrationNoticeTitle: 'Тема перенесена в магазин',
|
themeMigrationNoticeTitle: 'Тема перенесена в магазин',
|
||||||
themeMigrationNoticeBody: '{{themes}} больше не входит в приложение. Вы можете переустановить её из магазина тем (Настройки → Темы) или выбрать другую тему.',
|
themeMigrationNoticeBody: '{{themes}} больше не входит в приложение. Вы можете переустановить её из магазина тем (Настройки → Темы) или выбрать другую тему.',
|
||||||
themeMigrationNoticeOpen: 'Открыть магазин тем',
|
themeMigrationNoticeOpen: 'Открыть магазин тем',
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ export const settings = {
|
|||||||
themeStoreSubmitText: '做了自己的主题?与社区分享——更多信息见主题仓库。',
|
themeStoreSubmitText: '做了自己的主题?与社区分享——更多信息见主题仓库。',
|
||||||
themeStoreSubmitLink: '打开主题仓库',
|
themeStoreSubmitLink: '打开主题仓库',
|
||||||
themeStoreNetworkNotice: '主题商店从外部服务(jsDelivr CDN 和 GitHub)加载目录和预览。不会发送任何个人数据。',
|
themeStoreNetworkNotice: '主题商店从外部服务(jsDelivr CDN 和 GitHub)加载目录和预览。不会发送任何个人数据。',
|
||||||
|
themeStoreStatsNotice: '下载量和最后更新时间等统计数据每天更新一次(约 04:17 UTC)。',
|
||||||
themeStoreSearchPlaceholder: '搜索主题…',
|
themeStoreSearchPlaceholder: '搜索主题…',
|
||||||
themeStoreFilterMode: '按模式筛选',
|
themeStoreFilterMode: '按模式筛选',
|
||||||
themeStoreModeAll: '全部',
|
themeStoreModeAll: '全部',
|
||||||
@@ -337,7 +338,13 @@ export const settings = {
|
|||||||
themeStoreEmpty: '没有符合筛选条件的主题。',
|
themeStoreEmpty: '没有符合筛选条件的主题。',
|
||||||
themeStoreActive: '使用中',
|
themeStoreActive: '使用中',
|
||||||
themeStoreEnlarge: '放大预览',
|
themeStoreEnlarge: '放大预览',
|
||||||
themeStoreByAuthor: '作者:{{author}}',
|
themeStoreSortPopular: '最受欢迎',
|
||||||
|
themeStoreSortNewest: '最新',
|
||||||
|
themeStoreSortName: '按字母',
|
||||||
|
themeStoreAuthor: '作者',
|
||||||
|
themeStorePopularity: '热门度',
|
||||||
|
themeStoreDownloads: '总下载量',
|
||||||
|
themeStoreLastChanged: '最后更新',
|
||||||
themeMigrationNoticeTitle: '主题已移至商店',
|
themeMigrationNoticeTitle: '主题已移至商店',
|
||||||
themeMigrationNoticeBody: '{{themes}} 不再内置于应用中。你可以在主题商店(设置 → 主题)重新安装,或选择其他主题。',
|
themeMigrationNoticeBody: '{{themes}} 不再内置于应用中。你可以在主题商店(设置 → 主题)重新安装,或选择其他主题。',
|
||||||
themeMigrationNoticeOpen: '打开主题商店',
|
themeMigrationNoticeOpen: '打开主题商店',
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { formatRelativeTime } from '../format/relativeTime';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a relative time string like "3 hours ago" / "in 2 weeks" with
|
* Render a relative time string like "3 hours ago" / "in 2 weeks" with
|
||||||
* the supplied locale. Navidrome returns `"0001-01-01T00:00:00Z"` for
|
* the supplied locale. Navidrome returns `"0001-01-01T00:00:00Z"` for
|
||||||
@@ -10,14 +12,5 @@ export function formatLastSeen(iso: string | null | undefined, locale: string, n
|
|||||||
if (!iso) return neverLabel;
|
if (!iso) return neverLabel;
|
||||||
const t = new Date(iso).getTime();
|
const t = new Date(iso).getTime();
|
||||||
if (!Number.isFinite(t) || t < 1_000_000_000_000) return neverLabel;
|
if (!Number.isFinite(t) || t < 1_000_000_000_000) return neverLabel;
|
||||||
const diffSec = (t - Date.now()) / 1000;
|
return formatRelativeTime(t, locale);
|
||||||
const abs = Math.abs(diffSec);
|
|
||||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
|
||||||
if (abs < 60) return rtf.format(Math.round(diffSec), 'second');
|
|
||||||
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), 'minute');
|
|
||||||
if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour');
|
|
||||||
if (abs < 604800) return rtf.format(Math.round(diffSec / 86400), 'day');
|
|
||||||
if (abs < 2592000) return rtf.format(Math.round(diffSec / 604800), 'week');
|
|
||||||
if (abs < 31536000) return rtf.format(Math.round(diffSec / 2592000), 'month');
|
|
||||||
return rtf.format(Math.round(diffSec / 31536000), 'year');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatRelativeTime } from './relativeTime';
|
||||||
|
|
||||||
|
describe('formatRelativeTime', () => {
|
||||||
|
const SECOND = 1000;
|
||||||
|
const MINUTE = 60 * SECOND;
|
||||||
|
const HOUR = 60 * MINUTE;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
|
||||||
|
it('picks the largest sensible unit for past dates (en)', () => {
|
||||||
|
expect(formatRelativeTime(new Date(Date.now() - 5 * MINUTE), 'en')).toBe('5 minutes ago');
|
||||||
|
expect(formatRelativeTime(new Date(Date.now() - 3 * HOUR), 'en')).toBe('3 hours ago');
|
||||||
|
expect(formatRelativeTime(new Date(Date.now() - 3 * DAY), 'en')).toBe('3 days ago');
|
||||||
|
expect(formatRelativeTime(new Date(Date.now() - 14 * DAY), 'en')).toBe('2 weeks ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles future dates and respects the locale', () => {
|
||||||
|
expect(formatRelativeTime(new Date(Date.now() + 2 * DAY), 'en')).toBe('in 2 days');
|
||||||
|
expect(formatRelativeTime(new Date(Date.now() - 3 * DAY), 'de')).toBe('vor 3 Tagen');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Render a relative time like "3 hours ago" / "in 2 weeks" in the given locale,
|
||||||
|
* picking the largest sensible unit. Locale-aware via `Intl.RelativeTimeFormat`,
|
||||||
|
* so no per-string i18n keys are needed.
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(iso: string | number | Date, locale: string): string {
|
||||||
|
const diffSec = (new Date(iso).getTime() - Date.now()) / 1000;
|
||||||
|
const abs = Math.abs(diffSec);
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||||
|
if (abs < 60) return rtf.format(Math.round(diffSec), 'second');
|
||||||
|
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), 'minute');
|
||||||
|
if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour');
|
||||||
|
if (abs < 604800) return rtf.format(Math.round(diffSec / 86400), 'day');
|
||||||
|
if (abs < 2592000) return rtf.format(Math.round(diffSec / 604800), 'week');
|
||||||
|
if (abs < 31536000) return rtf.format(Math.round(diffSec / 2592000), 'month');
|
||||||
|
return rtf.format(Math.round(diffSec / 31536000), 'year');
|
||||||
|
}
|
||||||
@@ -28,6 +28,10 @@ export interface RegistryTheme {
|
|||||||
css: string;
|
css: string;
|
||||||
/** Repo-relative path to the thumbnail. */
|
/** Repo-relative path to the thumbnail. */
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
|
/** All-time CDN downloads of the theme's CSS (≈ installs); 0 until any land. */
|
||||||
|
installs?: number;
|
||||||
|
/** ISO date of the last commit touching the theme in the registry repo. */
|
||||||
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Registry {
|
export interface Registry {
|
||||||
|
|||||||
Reference in New Issue
Block a user