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 { useThemeStore } from '../../store/themeStore'; import { useInstalledThemesStore, type InstalledTheme } from '../../store/installedThemesStore'; import { cdnUrl, fetchRegistry, fetchThemeCss, type RegistryTheme, } from '../../utils/themes/themeRegistry'; import { validateThemeCss } from '../../utils/themes/themeInjection'; import { uninstallTheme } from '../../utils/themes/uninstallTheme'; import { isNewer } from '../../utils/componentHelpers/appUpdaterHelpers'; type ModeFilter = 'all' | 'dark' | 'light'; 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; /** * 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 } = useTranslation(); 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 [themes, setThemes] = useState(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('all'); const [page, setPage] = useState(1); const [busyId, setBusyId] = useState(null); const [failedId, setFailedId] = useState(null); const [lightbox, setLightbox] = useState<{ src: string; name: string } | null>(null); const topRef = useRef(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(); for (const it of installed) m.set(it.id, it); return m; }, [installed]); const filtered = useMemo(() => { if (!themes) return []; const q = query.trim().toLowerCase(); return 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)) ); }).sort((a, b) => a.name.localeCompare(b.name)); }, [themes, query, mode]); // 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]); 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); 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 modeBtns: { key: ModeFilter; label: string }[] = [ { key: 'all', label: t('settings.themeStoreModeAll') }, { key: 'dark', label: t('settings.themeStoreModeDark') }, { key: 'light', label: t('settings.themeStoreModeLight') }, ]; return (
{/* Submit-your-own-theme hint */}
{t('settings.themeStoreSubmitText')}{' '}
{/* Network disclosure — the store reaches external services. */}

{t('settings.themeStoreNetworkNotice')}

{/* Toolbar: search + mode filter + refresh. Hidden when offline with no catalogue to browse — the offline banner below stands in for it. */} {!error && (
setQuery(e.target.value)} placeholder={t('settings.themeStoreSearchPlaceholder')} aria-label={t('settings.themeStoreSearchPlaceholder')} style={{ flex: '1 1 180px', minWidth: 140 }} />
{modeBtns.map(b => ( ))}
)} {!loading && stale && (
{t('settings.themeStoreOffline')}
)} {loading && (

{t('settings.themeStoreLoading')}

)} {!loading && error && (
)} {!loading && !error && filtered.length === 0 && (

{t('settings.themeStoreEmpty')}

)} {!loading && !error && filtered.length > 0 && (
{pageItems.map(th => { 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 (
{th.name} {isActive && ( {t('settings.themeStoreActive')} )}
{t('settings.themeStoreByAuthor', { author: th.author })}
{th.description}
{/* Rating slot reserved — see Theme Store roadmap (deferred). */}
{!isInstalled && ( )} {isInstalled && !isActive && ( )} {updateAvailable && ( )} {isInstalled && ( )} {failedId === th.id && ( {t('settings.themeStoreInstallFailed')} )}
); })}
)} {!loading && !error && pageCount > 1 && (
{t('settings.themeStorePageStatus', { page: safePage, total: pageCount })}
)} {lightbox && ( setLightbox(null)} /> )}
); }