mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(themes): surface installed-theme updates in the store (#1240)
* feat(themes): show per-theme changelog and pin updatable themes Surface theme updates in the store: each card gains an expandable "What's new" built from the theme's optional manifest changelog (versions listed newest first), and installed themes with a pending update now float to the top of the list in both sort modes so they are easy to find. * i18n(settings): add themeStoreWhatsNew across locales New "What's new" label for the theme-store changelog disclosure, in all 13 locales. * docs(changelog): add theme store changelog and pinned-updates entry (#1240)
This commit is contained in:
@@ -47,6 +47,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* A dismissible banner inviting you to join the Psysonic community on Discord appears after 20 hours of accumulated app use. **Join** opens the invite; dismiss it for the session, or choose **Never show again** to hide it permanently.
|
||||
|
||||
|
||||
### Theme Store — per-theme changelogs and pinned updates
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1240](https://github.com/Psychotoxical/psysonic/pull/1240)**
|
||||
|
||||
* Each theme card now has an expandable **What's new** with per-version release notes, so you can see what a theme update changed — including non-visual fixes. Provided by theme authors; themes without notes just don't show the section.
|
||||
* Installed themes with an available update now appear at the top of the store list instead of wherever the sort placed them, so you don't have to hunt for them.
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Frontend restructure — feature-folder architecture and hardening
|
||||
|
||||
@@ -403,6 +403,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Themed window title bar on macOS — follows the active theme instead of the grey system bar, with the native window buttons floating over it (PR #1199)',
|
||||
'Frontend feature-folder restructure — CI-enforced layering guard, added unit/behavior-scenario/boot-smoke tests, and a compile-time frontend/backend IPC contract via tauri-specta (PR #1225)',
|
||||
'Completed the tauri-specta typed-IPC cutover — CI guards for bindings freshness and full command registration (PR #1230)',
|
||||
'Theme Store: per-theme changelogs shown as an expandable "What\'s new" on each card, plus installed themes with an available update pinned to the top of the list (PR #1240)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { ThemeStoreSection } from '@/features/settings/components/ThemeStoreSection';
|
||||
import { useInstalledThemesStore } from '@/store/installedThemesStore';
|
||||
import type { FetchRegistryResult, Registry, RegistryTheme } from '@/lib/themes/themeRegistry';
|
||||
|
||||
// Control the registry the store browses so pagination/refresh are deterministic.
|
||||
@@ -75,6 +76,7 @@ async function selectSort(
|
||||
describe('ThemeStoreSection — pagination & refresh', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useInstalledThemesStore.setState({ themes: [] });
|
||||
// jsdom has no layout engine; goToPage() scrolls the list back up.
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
@@ -221,4 +223,67 @@ describe('ThemeStoreSection — pagination & refresh', () => {
|
||||
expect(screen.queryByText('Theme 01')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '3', current: 'page' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an expandable changelog only for themes that ship one, newest version first', async () => {
|
||||
const themes = [
|
||||
mkTheme('with', 'With Log', {
|
||||
changelog: { '1.0.0': ['First release'], '1.2.0': ['Newer note', 'Another newer note'] },
|
||||
}),
|
||||
mkTheme('without', 'No Log'),
|
||||
];
|
||||
fetchRegistryMock.mockResolvedValue({ registry: registryOf(themes), stale: false });
|
||||
const { container } = renderWithProviders(<ThemeStoreSection />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await screen.findByText('With Log');
|
||||
|
||||
// Exactly one row (the one shipping a changelog) exposes the toggle.
|
||||
const toggles = screen.getAllByRole('button', { name: "What's new" });
|
||||
expect(toggles).toHaveLength(1);
|
||||
|
||||
// Collapsed by default — entry text is not rendered yet.
|
||||
const toggle = toggles[0];
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText('Newer note')).not.toBeInTheDocument();
|
||||
|
||||
// Expanding lists every version, newest first (1.2.0 before 1.0.0).
|
||||
await user.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
const panel = container.querySelector('#theme-changelog-with') as HTMLElement;
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel.textContent).toContain('Newer note');
|
||||
const i120 = panel.textContent!.indexOf('v1.2.0');
|
||||
const i100 = panel.textContent!.indexOf('v1.0.0');
|
||||
expect(i120).toBeGreaterThanOrEqual(0);
|
||||
expect(i100).toBeGreaterThan(i120);
|
||||
});
|
||||
|
||||
it('floats installed themes with a pending update to the top of the list', async () => {
|
||||
// Alpha is the newest by date and would normally lead; Zulu is older but has
|
||||
// an installed copy on an earlier version, so it should be pinned first.
|
||||
const themes = [
|
||||
mkTheme('alpha', 'Alpha', { updatedAt: '2026-06-10T00:00:00Z', version: '1.0.0' }),
|
||||
mkTheme('zulu', 'Zulu', { updatedAt: '2026-06-01T00:00:00Z', version: '2.0.0' }),
|
||||
];
|
||||
useInstalledThemesStore.setState({
|
||||
themes: [
|
||||
{
|
||||
id: 'zulu',
|
||||
name: 'Zulu',
|
||||
author: 'Tester',
|
||||
version: '1.0.0', // installed older than registry 2.0.0 → update available
|
||||
description: '',
|
||||
mode: 'dark',
|
||||
css: 'x',
|
||||
installedAt: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
fetchRegistryMock.mockResolvedValue({ registry: registryOf(themes), stale: false });
|
||||
const { container } = renderWithProviders(<ThemeStoreSection />);
|
||||
|
||||
await screen.findByText('Zulu');
|
||||
// Without the pin, newest-first would order Alpha (06-10) before Zulu (06-01).
|
||||
expect(rowNames(container)).toEqual(['Zulu', 'Alpha']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, ChevronLeft, ChevronRight, Download, RefreshCw, Trash2, WifiOff } from 'lucide-react';
|
||||
import { Check, ChevronDown, ChevronLeft, ChevronRight, Download, RefreshCw, Trash2, WifiOff } from 'lucide-react';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import CoverLightbox from '@/ui/CoverLightbox';
|
||||
import { useThemeAnimationRisk } from '@/features/settings/hooks/useThemeAnimationRisk';
|
||||
@@ -27,6 +27,11 @@ 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;
|
||||
|
||||
/** A theme's changelog as `[version, lines]` pairs, newest version first. */
|
||||
function sortedChangelog(changelog: Record<string, string[]>): [string, string[]][] {
|
||||
return Object.entries(changelog).sort(([a], [b]) => (isNewer(a, b) ? -1 : isNewer(b, a) ? 1 : 0));
|
||||
}
|
||||
|
||||
/** 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')[] {
|
||||
@@ -67,8 +72,17 @@ export function ThemeStoreSection() {
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [failedId, setFailedId] = useState<string | null>(null);
|
||||
const [lightbox, setLightbox] = useState<{ src: string; name: string } | null>(null);
|
||||
const [openChangelogIds, setOpenChangelogIds] = useState<Set<string>>(() => new Set());
|
||||
const topRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggleChangelog = (id: string) =>
|
||||
setOpenChangelogIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// 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`,
|
||||
@@ -119,9 +133,19 @@ export function ThemeStoreSection() {
|
||||
// Name is the stable tie-breaker — keeps ordering deterministic when many
|
||||
// themes share the same last-modified date.
|
||||
const byName = (a: RegistryTheme, b: RegistryTheme) => a.name.localeCompare(b.name);
|
||||
if (sortMode === 'name') return matched.sort(byName);
|
||||
return matched.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '') || byName(a, b));
|
||||
}, [themes, query, mode, sortMode, animFilter]);
|
||||
const bySort =
|
||||
sortMode === 'name'
|
||||
? byName
|
||||
: (a: RegistryTheme, b: RegistryTheme) =>
|
||||
(b.updatedAt || '').localeCompare(a.updatedAt || '') || byName(a, b);
|
||||
// Installed themes with a pending update float to the top (in either sort
|
||||
// mode) so the user finds them instead of hunting through the catalogue.
|
||||
const hasUpdate = (th: RegistryTheme) => {
|
||||
const inst = installedMap.get(th.id);
|
||||
return inst ? isNewer(th.version, inst.version) : false;
|
||||
};
|
||||
return matched.sort((a, b) => Number(hasUpdate(b)) - Number(hasUpdate(a)) || bySort(a, b));
|
||||
}, [themes, query, mode, sortMode, animFilter, installedMap]);
|
||||
|
||||
// A changed filter can shrink the result set below the current page; reset to
|
||||
// the first page whenever the query or mode filter changes.
|
||||
@@ -308,6 +332,8 @@ export function ThemeStoreSection() {
|
||||
const updateAvailable = isInstalled && isNewer(th.version, inst!.version);
|
||||
const isActive = activeTheme === th.id;
|
||||
const busy = busyId === th.id;
|
||||
const changelogEntries = th.changelog ? sortedChangelog(th.changelog) : [];
|
||||
const changelogOpen = openChangelogIds.has(th.id);
|
||||
return (
|
||||
<div
|
||||
key={th.id}
|
||||
@@ -370,6 +396,43 @@ export function ThemeStoreSection() {
|
||||
{t('settings.themeStoreLastChanged')}: {formatRelativeTime(th.updatedAt, i18n.language)}
|
||||
</div>
|
||||
)}
|
||||
{changelogEntries.length > 0 && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
aria-expanded={changelogOpen}
|
||||
aria-controls={`theme-changelog-${th.id}`}
|
||||
onClick={() => toggleChangelog(th.id)}
|
||||
style={{ fontSize: 12, padding: '2px 8px', display: 'inline-flex', alignItems: 'center', gap: 5 }}
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
style={{ transform: changelogOpen ? 'rotate(180deg)' : 'none', transition: 'transform 120ms' }}
|
||||
/>
|
||||
{t('settings.themeStoreWhatsNew')}
|
||||
</button>
|
||||
{changelogOpen && (
|
||||
<div
|
||||
id={`theme-changelog-${th.id}`}
|
||||
style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}
|
||||
>
|
||||
{changelogEntries.map(([version, lines]) => (
|
||||
<div key={version}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>
|
||||
v{version}
|
||||
</div>
|
||||
<ul style={{ margin: '2px 0 0', paddingInlineStart: 18, fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||
{lines.map((line, i) => (
|
||||
<li key={i}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 12 }}>
|
||||
{!isInstalled && (
|
||||
<button
|
||||
|
||||
@@ -30,6 +30,12 @@ export interface RegistryTheme {
|
||||
thumbnail: string;
|
||||
/** ISO date of the last commit touching the theme in the registry repo. */
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* Optional per-version release notes, keyed by `X.Y.Z`. Rendered as the
|
||||
* expandable "What's new" on the theme card. Author-provided in the manifest;
|
||||
* absent for themes that don't ship one.
|
||||
*/
|
||||
changelog?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface Registry {
|
||||
|
||||
@@ -463,6 +463,7 @@ export const settings = {
|
||||
themeStoreAnimStatic: 'Статични',
|
||||
themeStoreByAuthor: 'от {{author}}',
|
||||
themeStoreLastChanged: 'Последна промяна',
|
||||
themeStoreWhatsNew: 'Какво ново',
|
||||
themeMigrationNoticeTitle: 'Темата беше преместена в Магазина',
|
||||
themeMigrationNoticeBody: '{{themes}} вече не се доставя вградено с приложението. Можеш да я преинсталираш от Магазина за теми (Настройки → Теми) или да избереш друга тема.',
|
||||
themeMigrationNoticeOpen: 'Отвори Магазина за теми',
|
||||
|
||||
@@ -419,6 +419,7 @@ export const settings = {
|
||||
themeStoreAnimStatic: 'Statisch',
|
||||
themeStoreByAuthor: 'von {{author}}',
|
||||
themeStoreLastChanged: 'Zuletzt geändert',
|
||||
themeStoreWhatsNew: 'Was ist neu',
|
||||
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.',
|
||||
themeMigrationNoticeOpen: 'Theme-Store öffnen',
|
||||
|
||||
@@ -463,6 +463,7 @@ export const settings = {
|
||||
themeStoreAnimStatic: 'Static',
|
||||
themeStoreByAuthor: 'by {{author}}',
|
||||
themeStoreLastChanged: 'Last changed',
|
||||
themeStoreWhatsNew: "What's new",
|
||||
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.',
|
||||
themeMigrationNoticeOpen: 'Open Theme Store',
|
||||
|
||||
@@ -417,6 +417,7 @@ export const settings = {
|
||||
themeStoreAnimStatic: 'Estáticos',
|
||||
themeStoreByAuthor: 'por {{author}}',
|
||||
themeStoreLastChanged: 'Última modificación',
|
||||
themeStoreWhatsNew: 'Novedades',
|
||||
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.',
|
||||
themeMigrationNoticeOpen: 'Abrir Tienda de Temas',
|
||||
|
||||
@@ -405,6 +405,7 @@ export const settings = {
|
||||
themeStoreAnimStatic: 'Statiques',
|
||||
themeStoreByAuthor: 'par {{author}}',
|
||||
themeStoreLastChanged: 'Dernière modification',
|
||||
themeStoreWhatsNew: 'Nouveautés',
|
||||
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.",
|
||||
themeMigrationNoticeOpen: 'Ouvrir la boutique de thèmes',
|
||||
|
||||
@@ -463,6 +463,7 @@ export const settings = {
|
||||
themeStoreAnimStatic: 'Statikus',
|
||||
themeStoreByAuthor: 'készítette: {{author}}',
|
||||
themeStoreLastChanged: 'Utoljára módosítva',
|
||||
themeStoreWhatsNew: 'Újdonságok',
|
||||
themeMigrationNoticeTitle: 'A téma átkerült a Boltba',
|
||||
themeMigrationNoticeBody: 'A(z) {{themes}} már nincs az alkalmazáshoz csomagolva. Újratelepítheted a Témaboltból (Beállítások → Témák), vagy válassz másik témát.',
|
||||
themeMigrationNoticeOpen: 'Témabolt megnyitása',
|
||||
|
||||
@@ -457,6 +457,7 @@ export const settings = {
|
||||
themeStoreAnimStatic: '静的',
|
||||
themeStoreByAuthor: '{{author}} 作',
|
||||
themeStoreLastChanged: '最終変更',
|
||||
themeStoreWhatsNew: '新着情報',
|
||||
themeMigrationNoticeTitle: 'テーマはストアへ移動しました',
|
||||
themeMigrationNoticeBody: '{{themes}} はアプリに同梱されなくなりました。テーマストア (設定 → テーマ) から再インストールするか、別のテーマを選んでください。',
|
||||
themeMigrationNoticeOpen: 'テーマストアを開く',
|
||||
|
||||
@@ -408,6 +408,7 @@ export const settings = {
|
||||
themeStoreAnimStatic: 'Statiske',
|
||||
themeStoreByAuthor: 'av {{author}}',
|
||||
themeStoreLastChanged: 'Sist endret',
|
||||
themeStoreWhatsNew: 'Hva er nytt',
|
||||
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.',
|
||||
themeMigrationNoticeOpen: 'Åpne temabutikken',
|
||||
|
||||
@@ -405,6 +405,7 @@ export const settings = {
|
||||
themeStoreAnimStatic: 'Statisch',
|
||||
themeStoreByAuthor: 'door {{author}}',
|
||||
themeStoreLastChanged: 'Laatst gewijzigd',
|
||||
themeStoreWhatsNew: 'Wat is er nieuw',
|
||||
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.',
|
||||
themeMigrationNoticeOpen: 'Theme Store openen',
|
||||
|
||||
@@ -463,6 +463,7 @@ export const settings = {
|
||||
themeStoreAnimStatic: 'Statyczny',
|
||||
themeStoreByAuthor: 'autorstwa {{author}}',
|
||||
themeStoreLastChanged: 'Ostatnio zmieniony',
|
||||
themeStoreWhatsNew: 'Co nowego',
|
||||
themeMigrationNoticeTitle: 'Motyw przeniesiony do sklepu',
|
||||
themeMigrationNoticeBody: 'Motyw {{themes}} nie jest już dołączany do aplikacji. Możesz zainstalować go ponownie ze Sklepu motywów (Ustawienia → Motywy) albo wybrać inny motyw.',
|
||||
themeMigrationNoticeOpen: 'Otwórz sklep motywów',
|
||||
|
||||
@@ -421,6 +421,7 @@ export const settings = {
|
||||
themeStoreAnimStatic: 'Statice',
|
||||
themeStoreByAuthor: 'de {{author}}',
|
||||
themeStoreLastChanged: 'Ultima modificare',
|
||||
themeStoreWhatsNew: 'Noutăți',
|
||||
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ă.',
|
||||
themeMigrationNoticeOpen: 'Deschide Magazinul de Teme',
|
||||
|
||||
@@ -473,6 +473,7 @@ export const settings = {
|
||||
themeStoreAnimStatic: 'Статичные',
|
||||
themeStoreByAuthor: 'от {{author}}',
|
||||
themeStoreLastChanged: 'Изменён',
|
||||
themeStoreWhatsNew: 'Что нового',
|
||||
themeMigrationNoticeTitle: 'Тема перенесена в магазин',
|
||||
themeMigrationNoticeBody: '{{themes}} больше не входит в приложение. Вы можете переустановить её из магазина тем (Настройки → Темы) или выбрать другую тему.',
|
||||
themeMigrationNoticeOpen: 'Открыть магазин тем',
|
||||
|
||||
@@ -404,6 +404,7 @@ export const settings = {
|
||||
themeStoreAnimStatic: '静态',
|
||||
themeStoreByAuthor: '作者:{{author}}',
|
||||
themeStoreLastChanged: '最后更新',
|
||||
themeStoreWhatsNew: '更新内容',
|
||||
themeMigrationNoticeTitle: '主题已移至商店',
|
||||
themeMigrationNoticeBody: '{{themes}} 不再内置于应用中。你可以在主题商店(设置 → 主题)重新安装,或选择其他主题。',
|
||||
themeMigrationNoticeOpen: '打开主题商店',
|
||||
|
||||
Reference in New Issue
Block a user