diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f2fbf9d..e17f6906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/src/components/ThemeUpdateBanner.tsx b/src/components/ThemeUpdateBanner.tsx new file mode 100644 index 00000000..2f09adbf --- /dev/null +++ b/src/components/ThemeUpdateBanner.tsx @@ -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 ( + + ); + } + + return ( + + ); +} diff --git a/src/components/settings/InstalledThemes.tsx b/src/components/settings/InstalledThemes.tsx index fb292772..2405a041 100644 --- a/src/components/settings/InstalledThemes.tsx +++ b/src/components/settings/InstalledThemes.tsx @@ -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(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() { )} + {updateById.has(c.id) && ( + + )} ); })} diff --git a/src/components/settings/ThemeStoreSection.tsx b/src/components/settings/ThemeStoreSection.tsx index 5e6026fc..e4bb3d3f 100644 --- a/src/components/settings/ThemeStoreSection.tsx +++ b/src/components/settings/ThemeStoreSection.tsx @@ -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(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 { - setBusyId(null); - } + const result = await installThemeFromRegistry(th); + if (result !== 'ok') setFailedId(th.id); + setBusyId(null); }; diff --git a/src/components/sidebar/SidebarNavBody.tsx b/src/components/sidebar/SidebarNavBody.tsx index 2eb7c8fc..2bcc8c02 100644 --- a/src/components/sidebar/SidebarNavBody.tsx +++ b/src/components/sidebar/SidebarNavBody.tsx @@ -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. */} + {/* Theme-update notice — only visible while an installed theme has an update. */} + + {/* Now Playing — pinned at the bottom unless the user moved it to the top. */} {!nowPlayingAtTop && nowPlayingLink} diff --git a/src/hooks/useThemeUpdates.test.ts b/src/hooks/useThemeUpdates.test.ts new file mode 100644 index 00000000..dbae00e7 --- /dev/null +++ b/src/hooks/useThemeUpdates.test.ts @@ -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' }])); + }); +}); diff --git a/src/hooks/useThemeUpdates.ts b/src/hooks/useThemeUpdates.ts new file mode 100644 index 00000000..cec9dd3e --- /dev/null +++ b/src/hooks/useThemeUpdates.ts @@ -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(() => 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(','); +} diff --git a/src/locales/de/sidebar.ts b/src/locales/de/sidebar.ts index b7d668ea..7d222c33 100644 --- a/src/locales/de/sidebar.ts +++ b/src/locales/de/sidebar.ts @@ -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', }; diff --git a/src/locales/en/sidebar.ts b/src/locales/en/sidebar.ts index b18ab1ce..837c1a18 100644 --- a/src/locales/en/sidebar.ts +++ b/src/locales/en/sidebar.ts @@ -38,4 +38,8 @@ export const sidebar = { collapsePlaylists: 'Collapse playlists', more: 'More', feelingLucky: 'Lucky Mix', + + themeUpdatesTitle: 'Theme updates', + themeUpdatesTooltip: 'Theme updates available', + themeUpdatesDismiss: 'Dismiss', }; diff --git a/src/locales/es/sidebar.ts b/src/locales/es/sidebar.ts index a44f44d8..89318d35 100644 --- a/src/locales/es/sidebar.ts +++ b/src/locales/es/sidebar.ts @@ -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', }; diff --git a/src/locales/fr/sidebar.ts b/src/locales/fr/sidebar.ts index 75b84073..f7b09f93 100644 --- a/src/locales/fr/sidebar.ts +++ b/src/locales/fr/sidebar.ts @@ -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', }; diff --git a/src/locales/nb/sidebar.ts b/src/locales/nb/sidebar.ts index 669bad6e..d4700a5d 100644 --- a/src/locales/nb/sidebar.ts +++ b/src/locales/nb/sidebar.ts @@ -34,4 +34,8 @@ export const sidebar = { collapsePlaylists: 'Skjul spillelister', more: 'Mer', feelingLucky: 'Lykkemiks', + + themeUpdatesTitle: 'Temaoppdateringer', + themeUpdatesTooltip: 'Temaoppdateringer tilgjengelig', + themeUpdatesDismiss: 'Lukk', }; diff --git a/src/locales/nl/sidebar.ts b/src/locales/nl/sidebar.ts index 747b6eac..b1bd1c67 100644 --- a/src/locales/nl/sidebar.ts +++ b/src/locales/nl/sidebar.ts @@ -34,4 +34,8 @@ export const sidebar = { collapsePlaylists: 'Afspeellijsten inklappen', more: 'Meer', feelingLucky: 'Geluksmix', + + themeUpdatesTitle: 'Thema-updates', + themeUpdatesTooltip: 'Thema-updates beschikbaar', + themeUpdatesDismiss: 'Sluiten', }; diff --git a/src/locales/ro/sidebar.ts b/src/locales/ro/sidebar.ts index d8109741..9fa29f03 100644 --- a/src/locales/ro/sidebar.ts +++ b/src/locales/ro/sidebar.ts @@ -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', }; diff --git a/src/locales/ru/sidebar.ts b/src/locales/ru/sidebar.ts index d6f011e9..a7dd7a19 100644 --- a/src/locales/ru/sidebar.ts +++ b/src/locales/ru/sidebar.ts @@ -37,4 +37,8 @@ export const sidebar = { collapsePlaylists: 'Свернуть плейлисты', more: 'Ещё', feelingLucky: 'Мне повезёт', + + themeUpdatesTitle: 'Обновления тем', + themeUpdatesTooltip: 'Доступны обновления тем', + themeUpdatesDismiss: 'Скрыть', }; diff --git a/src/locales/zh/sidebar.ts b/src/locales/zh/sidebar.ts index aa240882..a7ff630c 100644 --- a/src/locales/zh/sidebar.ts +++ b/src/locales/zh/sidebar.ts @@ -34,4 +34,8 @@ export const sidebar = { collapsePlaylists: '收起播放列表', more: '更多', feelingLucky: '好运混音', + + themeUpdatesTitle: '主题更新', + themeUpdatesTooltip: '有可用的主题更新', + themeUpdatesDismiss: '忽略', }; diff --git a/src/store/authStore.settings.test.ts b/src/store/authStore.settings.test.ts index c64da181..d8c3e8ff 100644 --- a/src/store/authStore.settings.test.ts +++ b/src/store/authStore.settings.test.ts @@ -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'], diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 0939cabb..724a685a 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -90,6 +90,7 @@ export const useAuthStore = create()( sidebarLyricsStyle: 'classic', showChangelogOnUpdate: true, lastSeenChangelogVersion: '', + lastDismissedThemeUpdateSig: '', advancedSettingsEnabled: false, seekbarStyle: 'truewave', queueNowPlayingCollapsed: false, diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts index c8cf29b9..9d098c16 100644 --- a/src/store/authStoreTypes.ts +++ b/src/store/authStoreTypes.ts @@ -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; diff --git a/src/store/authUiAppearanceActions.ts b/src/store/authUiAppearanceActions.ts index 5a485f72..60667c01 100644 --- a/src/store/authUiAppearanceActions.ts +++ b/src/store/authUiAppearanceActions.ts @@ -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 }), }; } diff --git a/src/styles/components/what-s-new-banner-page.css b/src/styles/components/what-s-new-banner-page.css index 76f1c2da..afe31a35 100644 --- a/src/styles/components/what-s-new-banner-page.css +++ b/src/styles/components/what-s-new-banner-page.css @@ -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)); +} diff --git a/src/utils/themes/installThemeFromRegistry.test.ts b/src/utils/themes/installThemeFromRegistry.test.ts new file mode 100644 index 00000000..7324ea3d --- /dev/null +++ b/src/utils/themes/installThemeFromRegistry.test.ts @@ -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); + }); +}); diff --git a/src/utils/themes/installThemeFromRegistry.ts b/src/utils/themes/installThemeFromRegistry.ts new file mode 100644 index 00000000..579553dd --- /dev/null +++ b/src/utils/themes/installThemeFromRegistry.ts @@ -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 { + 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'; + } +}