mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
cfc9419de7
* 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.
471 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|