mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(themes): sidebar notice when an installed theme has an update (#1041)
* feat(themes): sidebar notice when an installed theme has an update Adds a dismissible sidebar pill (sibling of the What's New banner) shown while an installed community theme has a newer version in the store. Clicking opens Settings -> Themes; dismiss hides it until a new update changes the set. The theme registry is now refreshed from source once per app launch instead of only when the Theme Store tab is opened, so newly published themes and updates surface without a manual refresh -- and feed this notice. * docs: add CHANGELOG entry for PR #1041 * feat(themes): in-place update control on installed theme cards Themes with a newer version in the store now show a centered update icon on their card in Settings -> Themes; clicking it fetches and reinstalls in place. Extracts the shared installThemeFromRegistry helper (fetch -> validate -> install) used by both the store list and the card control, and surfaces the full registry entry from useThemeUpdates so the card can update directly.
This commit is contained in:
+2
-1
@@ -56,7 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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), [#1036](https://github.com/Psychotoxical/psysonic/pull/1036), [#1038](https://github.com/Psychotoxical/psysonic/pull/1038)**
|
||||
**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), [#1038](https://github.com/Psychotoxical/psysonic/pull/1038), [#1041](https://github.com/Psychotoxical/psysonic/pull/1041)**
|
||||
|
||||
* 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.
|
||||
@@ -65,6 +65,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* 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.
|
||||
* The **Now Playing** page now follows the active theme end to end — light themes render it legibly instead of washed-out, with no per-theme tweaks needed.
|
||||
* The sidebar now shows a small notice when one of your installed themes has an update available — click it to jump straight to the Theme Store, or dismiss it until the next update. Themes with an update also show an update control right on their card under **Settings → Themes** to update them in place with one click, and the store refreshes once on startup so new themes and updates show up without hitting refresh.
|
||||
* 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).
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Paintbrush, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeUpdates, themeUpdateSignature } from '../hooks/useThemeUpdates';
|
||||
|
||||
interface Props {
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar pill shown above Now Playing while one or more installed community
|
||||
* themes have a newer version in the store. Clicking opens Settings → Themes;
|
||||
* X dismisses until a new update changes the set (see {@link themeUpdateSignature}).
|
||||
*
|
||||
* Sibling of {@link WhatsNewBanner}; reuses its `.whats-new-banner` styling.
|
||||
*/
|
||||
export default function ThemeUpdateBanner({ collapsed }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const updates = useThemeUpdates();
|
||||
const lastDismissed = useAuthStore(s => s.lastDismissedThemeUpdateSig);
|
||||
const setDismissed = useAuthStore(s => s.setLastDismissedThemeUpdateSig);
|
||||
|
||||
const count = updates.length;
|
||||
const sig = themeUpdateSignature(updates);
|
||||
if (count === 0 || sig === lastDismissed) return null;
|
||||
|
||||
const open = () => navigate('/settings', { state: { tab: 'themes' } });
|
||||
const dismiss = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDismissed(sig);
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="whats-new-banner whats-new-banner--collapsed theme-update-banner--collapsed"
|
||||
onClick={open}
|
||||
data-tooltip={t('sidebar.themeUpdatesTooltip')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<Paintbrush size={16} />
|
||||
<span className="theme-update-banner__count theme-update-banner__count--dot" aria-hidden>
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="whats-new-banner theme-update-banner" onClick={open}>
|
||||
<Paintbrush size={14} className="whats-new-banner__icon" />
|
||||
<span className="whats-new-banner__text">
|
||||
<span className="whats-new-banner__title">{t('sidebar.themeUpdatesTitle')}</span>
|
||||
</span>
|
||||
<span className="theme-update-banner__count" aria-hidden>{count}</span>
|
||||
<span
|
||||
className="whats-new-banner__dismiss"
|
||||
role="button"
|
||||
aria-label={t('sidebar.themeUpdatesDismiss')}
|
||||
onClick={dismiss}
|
||||
>
|
||||
<X size={12} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, RefreshCw, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { useInstalledThemesStore } from '../../store/installedThemesStore';
|
||||
import { uninstallTheme } from '../../utils/themes/uninstallTheme';
|
||||
import { installThemeFromRegistry } from '../../utils/themes/installThemeFromRegistry';
|
||||
import { useThemeUpdates } from '../../hooks/useThemeUpdates';
|
||||
import { useThemeAnimationRisk } from '../../hooks/useThemeAnimationRisk';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
import { AnimatedThemeBadge } from './AnimatedThemeBadge';
|
||||
import { FIXED_THEMES } from './fixedThemes';
|
||||
|
||||
@@ -43,6 +47,18 @@ export function InstalledThemes() {
|
||||
const setTheme = useThemeStore(s => s.setTheme);
|
||||
const installed = useInstalledThemesStore(s => s.themes);
|
||||
const animRisk = useThemeAnimationRisk();
|
||||
const updates = useThemeUpdates();
|
||||
const updateById = useMemo(() => new Map(updates.map(u => [u.id, u])), [updates]);
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
|
||||
const handleUpdate = async (id: string) => {
|
||||
const th = updateById.get(id);
|
||||
if (!th) return;
|
||||
setUpdatingId(id);
|
||||
const result = await installThemeFromRegistry(th);
|
||||
setUpdatingId(null);
|
||||
if (result !== 'ok') showToast(t('settings.themeStoreInstallFailed'), 4000, 'error');
|
||||
};
|
||||
|
||||
const cards: Card[] = [
|
||||
...FIXED_THEMES.map(f => ({ id: f.id, label: f.label, bg: f.bg, card: f.card, accent: f.accent, fixed: true, accessibility: !!f.accessibility, animated: false })),
|
||||
@@ -133,6 +149,38 @@ export function InstalledThemes() {
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
{updateById.has(c.id) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdate(c.id)}
|
||||
disabled={updatingId === c.id}
|
||||
data-tooltip={updatingId === c.id ? t('settings.themeStoreUpdating') : t('settings.themeStoreUpdate')}
|
||||
data-tooltip-pos="top"
|
||||
aria-label={t('settings.themeStoreUpdate')}
|
||||
// Big centered icon overlay across the whole 46px preview so an
|
||||
// available update is impossible to miss; click updates in place.
|
||||
// Icon (not text) so it fits the small card in any language.
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 46,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--text-on-accent, #fff)',
|
||||
cursor: updatingId === c.id ? 'default' : 'pointer',
|
||||
opacity: updatingId === c.id ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={20} strokeWidth={2.5} className={updatingId === c.id ? 'spin' : undefined} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -13,10 +13,9 @@ import { useInstalledThemesStore, type InstalledTheme } from '../../store/instal
|
||||
import {
|
||||
cdnUrl,
|
||||
fetchRegistry,
|
||||
fetchThemeCss,
|
||||
type RegistryTheme,
|
||||
} from '../../utils/themes/themeRegistry';
|
||||
import { validateThemeCss } from '../../utils/themes/themeInjection';
|
||||
import { installThemeFromRegistry } from '../../utils/themes/installThemeFromRegistry';
|
||||
import { uninstallTheme } from '../../utils/themes/uninstallTheme';
|
||||
import { isNewer } from '../../utils/componentHelpers/appUpdaterHelpers';
|
||||
|
||||
@@ -52,7 +51,6 @@ export function ThemeStoreSection() {
|
||||
const activeTheme = useThemeStore(s => s.theme);
|
||||
const setTheme = useThemeStore(s => s.setTheme);
|
||||
const installed = useInstalledThemesStore(s => s.themes);
|
||||
const install = useInstalledThemesStore(s => s.install);
|
||||
const animRisk = useThemeAnimationRisk();
|
||||
|
||||
const [themes, setThemes] = useState<RegistryTheme[] | null>(null);
|
||||
@@ -150,30 +148,9 @@ export function ThemeStoreSection() {
|
||||
const handleInstall = async (th: RegistryTheme) => {
|
||||
setBusyId(th.id);
|
||||
setFailedId(null);
|
||||
try {
|
||||
const css = await fetchThemeCss(th.css);
|
||||
// Don't persist CSS that won't inject — otherwise the theme would show as
|
||||
// installed/active but render nothing. Validate before storing.
|
||||
if (validateThemeCss(css, th.id) == null) {
|
||||
setFailedId(th.id);
|
||||
return;
|
||||
}
|
||||
install({
|
||||
id: th.id,
|
||||
name: th.name,
|
||||
author: th.author,
|
||||
version: th.version,
|
||||
description: th.description,
|
||||
mode: th.mode,
|
||||
tags: th.tags,
|
||||
css,
|
||||
installedAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
setFailedId(th.id);
|
||||
} finally {
|
||||
const result = await installThemeFromRegistry(th);
|
||||
if (result !== 'ok') setFailedId(th.id);
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AudioLines, ChevronRight, HardDriveDownload, PlayCircle, Settings, Spar
|
||||
import type { SidebarItemConfig } from '../../store/sidebarStore';
|
||||
import { ALL_NAV_ITEMS } from '../../config/navItems';
|
||||
import WhatsNewBanner from '../WhatsNewBanner';
|
||||
import ThemeUpdateBanner from '../ThemeUpdateBanner';
|
||||
import { displayPlaylistName, isSmartPlaylistName } from '../../utils/componentHelpers/sidebarHelpers';
|
||||
import SidebarLibraryPicker from './SidebarLibraryPicker';
|
||||
import SidebarActiveJobs from './SidebarActiveJobs';
|
||||
@@ -216,6 +217,9 @@ export default function SidebarNavBody(props: Props) {
|
||||
{/* What's New banner — only visible while the current release hasn't been seen. */}
|
||||
<WhatsNewBanner collapsed={isCollapsed} />
|
||||
|
||||
{/* Theme-update notice — only visible while an installed theme has an update. */}
|
||||
<ThemeUpdateBanner collapsed={isCollapsed} />
|
||||
|
||||
{/* Now Playing — pinned at the bottom unless the user moved it to the top. */}
|
||||
{!nowPlayingAtTop && nowPlayingLink}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../utils/themes/themeRegistry', () => ({
|
||||
fetchRegistry: vi.fn(),
|
||||
getCachedRegistry: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { fetchRegistry, type Registry } from '../utils/themes/themeRegistry';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
|
||||
import { useThemeUpdates, themeUpdateSignature } from './useThemeUpdates';
|
||||
|
||||
const fetchRegistryMock = vi.mocked(fetchRegistry);
|
||||
|
||||
function inst(id: string, version: string): InstalledTheme {
|
||||
return { id, name: id, author: 'x', version, description: '', mode: 'dark', css: '', installedAt: 0 };
|
||||
}
|
||||
|
||||
function registry(themes: { id: string; version: string }[]): Registry {
|
||||
return { themes: themes.map(t => ({ id: t.id, name: t.id, version: t.version })) } as unknown as Registry;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useInstalledThemesStore.setState({ themes: [] });
|
||||
fetchRegistryMock.mockReset();
|
||||
});
|
||||
|
||||
describe('useThemeUpdates', () => {
|
||||
it('lists only installed themes that have a newer registry version', async () => {
|
||||
useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0'), inst('b', '2.0.0'), inst('c', '1.5.0')] });
|
||||
fetchRegistryMock.mockResolvedValue({
|
||||
registry: registry([
|
||||
{ id: 'a', version: '1.1.0' }, // newer → update
|
||||
{ id: 'b', version: '2.0.0' }, // same → no
|
||||
{ id: 'c', version: '1.4.0' }, // older → no
|
||||
]),
|
||||
stale: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useThemeUpdates());
|
||||
await waitFor(() => expect(result.current).toHaveLength(1));
|
||||
expect(result.current[0].id).toBe('a');
|
||||
expect(result.current[0].version).toBe('1.1.0');
|
||||
});
|
||||
|
||||
it('returns nothing when nothing is outdated', async () => {
|
||||
useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0')] });
|
||||
fetchRegistryMock.mockResolvedValue({ registry: registry([{ id: 'a', version: '1.0.0' }]), stale: false });
|
||||
|
||||
const { result } = renderHook(() => useThemeUpdates());
|
||||
await waitFor(() => expect(fetchRegistryMock).toHaveBeenCalled());
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores registry themes the user has not installed', async () => {
|
||||
useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0')] });
|
||||
fetchRegistryMock.mockResolvedValue({ registry: registry([{ id: 'z', version: '9.0.0' }]), stale: false });
|
||||
|
||||
const { result } = renderHook(() => useThemeUpdates());
|
||||
await waitFor(() => expect(fetchRegistryMock).toHaveBeenCalled());
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('themeUpdateSignature', () => {
|
||||
it('is order-independent and encodes id@version', () => {
|
||||
const a = themeUpdateSignature([{ id: 'b', version: '2.0.0' }, { id: 'a', version: '1.1.0' }]);
|
||||
const b = themeUpdateSignature([{ id: 'a', version: '1.1.0' }, { id: 'b', version: '2.0.0' }]);
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe('a@1.1.0,b@2.0.0');
|
||||
});
|
||||
|
||||
it('changes when a version bumps so a dismissed notice can reappear', () => {
|
||||
expect(themeUpdateSignature([{ id: 'a', version: '1.1.0' }]))
|
||||
.not.toBe(themeUpdateSignature([{ id: 'a', version: '1.2.0' }]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { isNewer } from '../utils/componentHelpers/appUpdaterHelpers';
|
||||
import { fetchRegistry, getCachedRegistry, type Registry, type RegistryTheme } from '../utils/themes/themeRegistry';
|
||||
import { useInstalledThemesStore } from '../store/installedThemesStore';
|
||||
|
||||
// Refresh the registry from source once per app launch (not just from the
|
||||
// cache). This surfaces newly published themes and updates without the user
|
||||
// having to hit the manual refresh in the Theme Store, and it feeds the
|
||||
// sidebar update notice. Subsequent reads this session use the cache.
|
||||
let sessionRefreshStarted = false;
|
||||
|
||||
/**
|
||||
* Registry entries for installed community themes that have a newer version
|
||||
* available. Returns the full registry theme (css path, version, metadata) so a
|
||||
* caller can update in place. Seeds from the last-cached registry synchronously,
|
||||
* then revalidates (forced on the first call this session). Recomputes when the
|
||||
* installed set changes, so the list shrinks as the user updates themes.
|
||||
*/
|
||||
export function useThemeUpdates(): RegistryTheme[] {
|
||||
const installed = useInstalledThemesStore(s => s.themes);
|
||||
const [registry, setRegistry] = useState<Registry | null>(() => getCachedRegistry());
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const opts = sessionRefreshStarted ? undefined : { force: true };
|
||||
sessionRefreshStarted = true;
|
||||
fetchRegistry(opts)
|
||||
.then(r => { if (alive) setRegistry(r.registry); })
|
||||
.catch(() => { /* offline: keep whatever the cache gave us */ });
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!registry) return [];
|
||||
const installedVersionById = new Map(installed.map(t => [t.id, t.version]));
|
||||
return registry.themes.filter(rt => {
|
||||
const current = installedVersionById.get(rt.id);
|
||||
return current != null && isNewer(rt.version, current);
|
||||
});
|
||||
}, [registry, installed]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature of an update set, used to remember a dismissal: the sidebar
|
||||
* notice stays hidden until a new or bumped update changes this string.
|
||||
*/
|
||||
export function themeUpdateSignature(updates: Array<{ id: string; version: string }>): string {
|
||||
return updates.map(u => `${u.id}@${u.version}`).sort().join(',');
|
||||
}
|
||||
@@ -34,4 +34,8 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Playlists einklappen',
|
||||
more: 'Mehr',
|
||||
feelingLucky: 'Glücks-Mix',
|
||||
|
||||
themeUpdatesTitle: 'Theme-Updates',
|
||||
themeUpdatesTooltip: 'Theme-Updates verfügbar',
|
||||
themeUpdatesDismiss: 'Ausblenden',
|
||||
};
|
||||
|
||||
@@ -38,4 +38,8 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Collapse playlists',
|
||||
more: 'More',
|
||||
feelingLucky: 'Lucky Mix',
|
||||
|
||||
themeUpdatesTitle: 'Theme updates',
|
||||
themeUpdatesTooltip: 'Theme updates available',
|
||||
themeUpdatesDismiss: 'Dismiss',
|
||||
};
|
||||
|
||||
@@ -35,4 +35,8 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Colapsar listas',
|
||||
more: 'Más',
|
||||
feelingLucky: 'Mezcla Suerte',
|
||||
|
||||
themeUpdatesTitle: 'Actualizaciones de temas',
|
||||
themeUpdatesTooltip: 'Hay actualizaciones de temas',
|
||||
themeUpdatesDismiss: 'Descartar',
|
||||
};
|
||||
|
||||
@@ -34,4 +34,8 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Réduire les playlists',
|
||||
more: 'Plus',
|
||||
feelingLucky: 'Mix Chance',
|
||||
|
||||
themeUpdatesTitle: 'Mises à jour de thèmes',
|
||||
themeUpdatesTooltip: 'Mises à jour de thèmes disponibles',
|
||||
themeUpdatesDismiss: 'Masquer',
|
||||
};
|
||||
|
||||
@@ -34,4 +34,8 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Skjul spillelister',
|
||||
more: 'Mer',
|
||||
feelingLucky: 'Lykkemiks',
|
||||
|
||||
themeUpdatesTitle: 'Temaoppdateringer',
|
||||
themeUpdatesTooltip: 'Temaoppdateringer tilgjengelig',
|
||||
themeUpdatesDismiss: 'Lukk',
|
||||
};
|
||||
|
||||
@@ -34,4 +34,8 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Afspeellijsten inklappen',
|
||||
more: 'Meer',
|
||||
feelingLucky: 'Geluksmix',
|
||||
|
||||
themeUpdatesTitle: 'Thema-updates',
|
||||
themeUpdatesTooltip: 'Thema-updates beschikbaar',
|
||||
themeUpdatesDismiss: 'Sluiten',
|
||||
};
|
||||
|
||||
@@ -36,4 +36,8 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Restrânge playlisturi',
|
||||
more: 'Mai mult',
|
||||
feelingLucky: 'Mix Norocos',
|
||||
|
||||
themeUpdatesTitle: 'Actualizări de teme',
|
||||
themeUpdatesTooltip: 'Actualizări de teme disponibile',
|
||||
themeUpdatesDismiss: 'Închide',
|
||||
};
|
||||
|
||||
@@ -37,4 +37,8 @@ export const sidebar = {
|
||||
collapsePlaylists: 'Свернуть плейлисты',
|
||||
more: 'Ещё',
|
||||
feelingLucky: 'Мне повезёт',
|
||||
|
||||
themeUpdatesTitle: 'Обновления тем',
|
||||
themeUpdatesTooltip: 'Доступны обновления тем',
|
||||
themeUpdatesDismiss: 'Скрыть',
|
||||
};
|
||||
|
||||
@@ -34,4 +34,8 @@ export const sidebar = {
|
||||
collapsePlaylists: '收起播放列表',
|
||||
more: '更多',
|
||||
feelingLucky: '好运混音',
|
||||
|
||||
themeUpdatesTitle: '主题更新',
|
||||
themeUpdatesTooltip: '有可用的主题更新',
|
||||
themeUpdatesDismiss: '忽略',
|
||||
};
|
||||
|
||||
@@ -93,6 +93,7 @@ describe('trivial pass-through setters', () => {
|
||||
['setOfflineDownloadDir', 'offlineDownloadDir', '/tmp/offline'],
|
||||
['setHotCacheDownloadDir', 'hotCacheDownloadDir', '/tmp/hot'],
|
||||
['setLastSeenChangelogVersion', 'lastSeenChangelogVersion', '1.46.0'],
|
||||
['setLastDismissedThemeUpdateSig', 'lastDismissedThemeUpdateSig', 'theme-a@1.1.0'],
|
||||
['setDiscordTemplateDetails', 'discordTemplateDetails', '{artist} — {title}'],
|
||||
['setDiscordTemplateState', 'discordTemplateState', '{album}'],
|
||||
['setDiscordTemplateLargeText', 'discordTemplateLargeText', 'Hi'],
|
||||
|
||||
@@ -90,6 +90,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
sidebarLyricsStyle: 'classic',
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
lastDismissedThemeUpdateSig: '',
|
||||
advancedSettingsEnabled: false,
|
||||
seekbarStyle: 'truewave',
|
||||
queueNowPlayingCollapsed: false,
|
||||
|
||||
@@ -178,6 +178,9 @@ export interface AuthState {
|
||||
sidebarLyricsStyle: 'classic' | 'apple';
|
||||
showChangelogOnUpdate: boolean;
|
||||
lastSeenChangelogVersion: string;
|
||||
/** Signature of the installed-theme updates last dismissed in the sidebar
|
||||
* notice; the notice reappears once a new update changes the signature. */
|
||||
lastDismissedThemeUpdateSig: string;
|
||||
/** Reveals sub-sections marked `advanced` across all Settings tabs. */
|
||||
advancedSettingsEnabled: boolean;
|
||||
|
||||
@@ -348,6 +351,7 @@ export interface AuthState {
|
||||
setSidebarLyricsStyle: (v: 'classic' | 'apple') => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
setLastSeenChangelogVersion: (v: string) => void;
|
||||
setLastDismissedThemeUpdateSig: (v: string) => void;
|
||||
setAdvancedSettingsEnabled: (v: boolean) => void;
|
||||
setSeekbarStyle: (v: SeekbarStyle) => void;
|
||||
setQueueNowPlayingCollapsed: (v: boolean) => void;
|
||||
|
||||
@@ -30,6 +30,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
|
||||
| 'setSidebarLyricsStyle'
|
||||
| 'setShowChangelogOnUpdate'
|
||||
| 'setLastSeenChangelogVersion'
|
||||
| 'setLastDismissedThemeUpdateSig'
|
||||
| 'setAdvancedSettingsEnabled'
|
||||
> {
|
||||
return {
|
||||
@@ -51,6 +52,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
|
||||
setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||
setLastDismissedThemeUpdateSig: (v) => set({ lastDismissedThemeUpdateSig: v }),
|
||||
setAdvancedSettingsEnabled: (v) => set({ advancedSettingsEnabled: v }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,3 +80,32 @@
|
||||
justify-content: center;
|
||||
margin: 0 auto 6px;
|
||||
}
|
||||
|
||||
/* Theme-update sidebar notice — reuses the What's New banner styling, plus a
|
||||
count chip on the right (expanded) or a corner dot (collapsed). */
|
||||
.theme-update-banner__count {
|
||||
flex-shrink: 0;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9px;
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent, #fff);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.theme-update-banner__count--dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
border: 2px solid var(--bg-sidebar, var(--bg-app));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./themeRegistry', () => ({ fetchThemeCss: vi.fn() }));
|
||||
vi.mock('./themeInjection', () => ({ validateThemeCss: vi.fn() }));
|
||||
|
||||
import { fetchThemeCss, type RegistryTheme } from './themeRegistry';
|
||||
import { validateThemeCss } from './themeInjection';
|
||||
import { useInstalledThemesStore } from '../../store/installedThemesStore';
|
||||
import { installThemeFromRegistry } from './installThemeFromRegistry';
|
||||
|
||||
const fetchCss = vi.mocked(fetchThemeCss);
|
||||
const validate = vi.mocked(validateThemeCss);
|
||||
|
||||
const TH = {
|
||||
id: 'theme-a',
|
||||
name: 'Theme A',
|
||||
author: 'someone',
|
||||
version: '1.1.0',
|
||||
description: 'desc',
|
||||
mode: 'dark',
|
||||
tags: ['x'],
|
||||
css: 'themes/theme-a/theme.css',
|
||||
} as unknown as RegistryTheme;
|
||||
|
||||
beforeEach(() => {
|
||||
useInstalledThemesStore.setState({ themes: [] });
|
||||
fetchCss.mockReset();
|
||||
validate.mockReset();
|
||||
});
|
||||
|
||||
describe('installThemeFromRegistry', () => {
|
||||
it('installs the validated CSS and returns ok', async () => {
|
||||
fetchCss.mockResolvedValue('/* css */');
|
||||
validate.mockReturnValue('/* css */');
|
||||
|
||||
await expect(installThemeFromRegistry(TH)).resolves.toBe('ok');
|
||||
|
||||
const installed = useInstalledThemesStore.getState().getInstalled('theme-a');
|
||||
expect(installed?.version).toBe('1.1.0');
|
||||
expect(installed?.css).toBe('/* css */');
|
||||
});
|
||||
|
||||
it('does not persist CSS that fails the safety floor', async () => {
|
||||
fetchCss.mockResolvedValue('bad');
|
||||
validate.mockReturnValue(null);
|
||||
|
||||
await expect(installThemeFromRegistry(TH)).resolves.toBe('invalid');
|
||||
expect(useInstalledThemesStore.getState().isInstalled('theme-a')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns error when the fetch fails', async () => {
|
||||
fetchCss.mockRejectedValue(new Error('network'));
|
||||
|
||||
await expect(installThemeFromRegistry(TH)).resolves.toBe('error');
|
||||
expect(useInstalledThemesStore.getState().isInstalled('theme-a')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { fetchThemeCss, type RegistryTheme } from './themeRegistry';
|
||||
import { validateThemeCss } from './themeInjection';
|
||||
import { useInstalledThemesStore } from '../../store/installedThemesStore';
|
||||
|
||||
export type InstallResult = 'ok' | 'invalid' | 'error';
|
||||
|
||||
/**
|
||||
* Fetch a registry theme's CSS, validate it against the in-app safety floor,
|
||||
* and persist it (install or in-place update — the store replaces by id).
|
||||
* Shared by the Theme Store list and the "your themes" update chip so both go
|
||||
* through the same fetch → validate → install path.
|
||||
*
|
||||
* Never throws: returns `'invalid'` when the CSS fails the floor and `'error'`
|
||||
* on a network/fetch failure, so callers can surface it without a try/catch.
|
||||
*/
|
||||
export async function installThemeFromRegistry(th: RegistryTheme): Promise<InstallResult> {
|
||||
try {
|
||||
const css = await fetchThemeCss(th.css);
|
||||
// Don't persist CSS that won't inject — it would show as installed/active
|
||||
// but render nothing. Validate before storing.
|
||||
if (validateThemeCss(css, th.id) == null) return 'invalid';
|
||||
useInstalledThemesStore.getState().install({
|
||||
id: th.id,
|
||||
name: th.name,
|
||||
author: th.author,
|
||||
version: th.version,
|
||||
description: th.description,
|
||||
mode: th.mode,
|
||||
tags: th.tags,
|
||||
css,
|
||||
installedAt: Date.now(),
|
||||
});
|
||||
return 'ok';
|
||||
} catch {
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user