Files
Psychotoxical-psysonic/src/components/settings/ThemeStoreSection.tsx
T
Psychotoxical cfc9419de7 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.
2026-06-09 01:02:32 +02:00

471 lines
21 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, ChevronLeft, ChevronRight, Download, RefreshCw, Trash2, WifiOff } from 'lucide-react';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import CoverLightbox from '../CoverLightbox';
import { useThemeAnimationRisk } from '../../hooks/useThemeAnimationRisk';
import { AnimatedThemeBadge } from './AnimatedThemeBadge';
import { PopularityBar } from './PopularityBar';
import CustomSelect from '../CustomSelect';
import { formatRelativeTime } from '../../utils/format/relativeTime';
import { useThemeStore } from '../../store/themeStore';
import { useInstalledThemesStore, type InstalledTheme } from '../../store/installedThemesStore';
import {
cdnUrl,
fetchRegistry,
type RegistryTheme,
} from '../../utils/themes/themeRegistry';
import { installThemeFromRegistry } from '../../utils/themes/installThemeFromRegistry';
import { uninstallTheme } from '../../utils/themes/uninstallTheme';
import { isNewer } from '../../utils/componentHelpers/appUpdaterHelpers';
type ModeFilter = 'all' | 'dark' | 'light';
type SortMode = 'popular' | 'newest' | 'name';
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;
/** 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
* 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.
*/
export function ThemeStoreSection() {
const { t, i18n } = useTranslation();
const activeTheme = useThemeStore(s => s.theme);
const setTheme = useThemeStore(s => s.setTheme);
const installed = useInstalledThemesStore(s => s.themes);
const animRisk = useThemeAnimationRisk();
const [themes, setThemes] = useState<RegistryTheme[] | null>(null);
const [generatedAt, setGeneratedAt] = useState('');
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(false);
const [stale, setStale] = useState(false);
const [query, setQuery] = useState('');
const [mode, setMode] = useState<ModeFilter>('all');
const [sortMode, setSortMode] = useState<SortMode>('popular');
const [page, setPage] = useState(1);
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 topRef = useRef<HTMLDivElement>(null);
// 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`,
// shown only via the spinning icon) and reserve the full-page loading/error
// placeholders for the initial load, when there is nothing to show anyway.
const load = (force = false) => {
if (force) setRefreshing(true);
else setLoading(true);
setError(false);
fetchRegistry({ force })
.then(r => { setThemes(r.registry.themes); setGeneratedAt(r.registry.generatedAt); setStale(r.stale); })
.catch(() => { if (force) setStale(true); else setError(true); })
.finally(() => { setLoading(false); setRefreshing(false); });
};
// Thumbnails live at a stable CDN path, so the webview caches them hard
// (jsDelivr sends max-age 7d). Tie a cache-buster to the registry's
// generatedAt — it changes on every themes push — so refreshed thumbnails
// show up after a registry refresh instead of being stuck on the old image.
const thumbUrl = (rel: string) =>
generatedAt ? `${cdnUrl(rel)}?v=${encodeURIComponent(generatedAt)}` : cdnUrl(rel);
useEffect(() => { load(false); }, []);
const installedMap = useMemo(() => {
const m = new Map<string, InstalledTheme>();
for (const it of installed) m.set(it.id, it);
return m;
}, [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(() => {
if (!themes) return [];
const q = query.trim().toLowerCase();
const matched = themes.filter(th => {
if (mode !== 'all' && th.mode !== mode) return false;
if (!q) return true;
return (
th.name.toLowerCase().includes(q) ||
th.author.toLowerCase().includes(q) ||
th.description.toLowerCase().includes(q) ||
(th.tags || []).some(tag => tag.includes(q))
);
});
// 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
// the first page whenever the query or mode filter changes.
useEffect(() => { setPage(1); }, [query, mode, sortMode]);
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
// Clamp defensively so a stale `page` (e.g. after the registry shrank) never
// points past the end and shows a blank list.
const safePage = Math.min(page, pageCount);
const pageItems = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const goToPage = (n: number) => {
setPage(Math.min(Math.max(1, n), pageCount));
// Start the new page from the top of the store instead of mid-scroll.
topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleInstall = async (th: RegistryTheme) => {
setBusyId(th.id);
setFailedId(null);
const result = await installThemeFromRegistry(th);
if (result !== 'ok') setFailedId(th.id);
setBusyId(null);
};
const modeBtns: { key: ModeFilter; label: string }[] = [
{ key: 'all', label: t('settings.themeStoreModeAll') },
{ key: 'dark', label: t('settings.themeStoreModeDark') },
{ 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 (
<div className="settings-card">
{/* Submit-your-own-theme hint */}
<div className="settings-hint settings-hint-info" style={{ marginBottom: '1rem' }}>
{t('settings.themeStoreSubmitText')}{' '}
<button
type="button"
onClick={() => void openUrl(THEMES_REPO_URL)}
style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', color: 'var(--accent)', cursor: 'pointer', textDecoration: 'underline' }}
>
{t('settings.themeStoreSubmitLink')}
</button>
</div>
{/* Network disclosure — the store reaches external services. */}
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 1rem', lineHeight: 1.5 }}>
{t('settings.themeStoreNetworkNotice')}{' '}{t('settings.themeStoreStatsNotice')}
</p>
{/* Toolbar: search + mode filter + refresh. Hidden when offline with no
catalogue to browse — the offline banner below stands in for it. */}
{!error && (
<div ref={topRef} style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: '1rem', scrollMarginTop: 8 }}>
<input
type="search"
className="input"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('settings.themeStoreSearchPlaceholder')}
aria-label={t('settings.themeStoreSearchPlaceholder')}
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')}>
{modeBtns.map(b => (
<button
key={b.key}
className={`btn ${mode === b.key ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '4px 10px' }}
aria-pressed={mode === b.key}
onClick={() => setMode(b.key)}
>
{b.label}
</button>
))}
</div>
<button
className="btn btn-ghost"
style={{ padding: '4px 10px' }}
onClick={() => load(true)}
disabled={loading || refreshing}
aria-label={t('settings.themeStoreRefresh')}
data-tooltip={t('settings.themeStoreRefresh')}
data-tooltip-pos="left"
>
<RefreshCw size={15} className={refreshing ? 'animate-spin' : ''} />
</button>
</div>
)}
{!loading && stale && (
<div className="settings-hint settings-hint-info" role="status" style={{ marginBottom: '0.75rem' }}>
{t('settings.themeStoreOffline')}
</div>
)}
{loading && (
<p role="status" style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('settings.themeStoreLoading')}</p>
)}
{!loading && error && (
<div
role="alert"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: 12,
padding: '28px 16px',
border: '1px dashed var(--border)',
borderRadius: 'var(--radius-md, 10px)',
background: 'var(--bg-elevated)',
}}
>
<WifiOff size={28} style={{ color: 'var(--text-muted)' }} aria-hidden="true" />
<div>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 4 }}>{t('settings.themeStoreOfflineTitle')}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('settings.themeStoreError')}</div>
</div>
<button className="btn btn-ghost" onClick={() => load(true)} disabled={refreshing}>
{t('settings.themeStoreRetry')}
</button>
</div>
)}
{!loading && !error && filtered.length === 0 && (
<p role="status" style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('settings.themeStoreEmpty')}</p>
)}
{!loading && !error && filtered.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{pageItems.map((th, idx) => {
const inst = installedMap.get(th.id);
const isInstalled = !!inst;
const updateAvailable = isInstalled && isNewer(th.version, inst!.version);
const isActive = activeTheme === th.id;
const busy = busyId === th.id;
return (
<div
key={th.id}
className="theme-store-row"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 14,
padding: 12,
border: `1px solid ${isActive ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md, 10px)',
// Subtle zebra striping so adjacent rows read as distinct boxes.
background: idx % 2 === 1 ? 'var(--bg-hover)' : 'var(--bg-card)',
}}
>
<button
type="button"
onClick={() => setLightbox({ src: thumbUrl(th.thumbnail), name: th.name })}
aria-label={t('settings.themeStoreEnlarge')}
data-tooltip={t('settings.themeStoreEnlarge')}
data-tooltip-pos="right"
style={{ padding: 0, border: 'none', background: 'none', cursor: 'zoom-in', flexShrink: 0, alignSelf: 'flex-start', lineHeight: 0, borderRadius: 6 }}
>
<img
src={thumbUrl(th.thumbnail)}
alt=""
loading="lazy"
width={200}
height={112}
// Offline / missing thumbnail: hide the broken-image glyph; the
// image's own neutral background stands in as a placeholder.
onError={e => { e.currentTarget.style.opacity = '0'; }}
style={{ width: 200, height: 112, objectFit: 'cover', borderRadius: 6, background: 'var(--bg-deep)' }}
/>
</button>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 600 }}>{th.name}</span>
{isActive && (
<span style={{ fontSize: 11, color: 'var(--accent)', display: 'inline-flex', alignItems: 'center', gap: 3 }}>
<Check size={12} /> {t('settings.themeStoreActive')}
</span>
)}
{animRisk && th.animated && <AnimatedThemeBadge variant="inline" />}
</div>
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.4, marginTop: 10 }}>
{th.description}
</div>
{/* Rating slot reserved — see Theme Store roadmap (deferred). */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 18 }}>
{!isInstalled && (
<button
className="btn btn-primary"
style={{ fontSize: 12, padding: '4px 12px', display: 'inline-flex', alignItems: 'center', gap: 5 }}
onClick={() => handleInstall(th)}
disabled={busy}
>
<Download size={14} /> {busy ? t('settings.themeStoreInstalling') : t('settings.themeStoreInstall')}
</button>
)}
{isInstalled && !isActive && (
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 12px' }}
onClick={() => setTheme(th.id)}
>
{t('settings.themeStoreApply')}
</button>
)}
{updateAvailable && (
<button
className="btn btn-primary"
style={{ fontSize: 12, padding: '4px 12px' }}
onClick={() => handleInstall(th)}
disabled={busy}
>
{busy ? t('settings.themeStoreUpdating') : t('settings.themeStoreUpdate')}
</button>
)}
{isInstalled && (
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 12px', display: 'inline-flex', alignItems: 'center', gap: 5 }}
onClick={() => uninstallTheme(th.id)}
>
<Trash2 size={14} /> {t('settings.themeStoreUninstall')}
</button>
)}
{failedId === th.id && (
<span role="status" style={{ fontSize: 12, color: 'var(--danger)', alignSelf: 'center' }}>
{t('settings.themeStoreInstallFailed')}
</span>
)}
</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>
)}
{!loading && !error && pageCount > 1 && (
<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
className="btn btn-ghost"
style={{ padding: '4px 10px' }}
onClick={() => goToPage(safePage - 1)}
disabled={safePage <= 1}
aria-label={t('settings.themeStorePagePrev')}
data-tooltip={t('settings.themeStorePagePrev')}
data-tooltip-pos="top"
>
<ChevronLeft size={16} />
</button>
{pageItemsList(safePage, pageCount).map((it, i) =>
it === 'gap' ? (
<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
className="btn btn-ghost"
style={{ padding: '4px 10px' }}
onClick={() => goToPage(safePage + 1)}
disabled={safePage >= pageCount}
aria-label={t('settings.themeStorePageNext')}
data-tooltip={t('settings.themeStorePageNext')}
data-tooltip-pos="top"
>
<ChevronRight size={16} />
</button>
</div>
)}
{lightbox && (
<CoverLightbox src={lightbox.src} alt={lightbox.name} onClose={() => setLightbox(null)} />
)}
</div>
);
}