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:
Psychotoxical
2026-06-09 01:02:32 +02:00
committed by GitHub
parent c6298d8c25
commit cfc9419de7
23 changed files with 422 additions and 29 deletions
+70
View File
@@ -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>
);
}
+49 -1
View File
@@ -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>
);
})}
+4 -27
View File
@@ -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 {
setBusyId(null);
}
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}