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 '@/ui/CoverLightbox'; import { useThemeAnimationRisk } from '@/features/settings/hooks/useThemeAnimationRisk'; import { AnimatedThemeBadge } from '@/features/settings/components/AnimatedThemeBadge'; import CustomSelect from '@/ui/CustomSelect'; import { formatRelativeTime } from '@/lib/format/relativeTime'; import { useThemeStore } from '@/store/themeStore'; import { useInstalledThemesStore, type InstalledTheme } from '@/store/installedThemesStore'; import { assetUrl, fetchRegistry, type RegistryTheme, } from '@/lib/themes/themeRegistry'; import { installThemeFromRegistry } from '@/lib/themes/installThemeFromRegistry'; import { uninstallTheme } from '@/lib/themes/uninstallTheme'; import { isNewer } from '@/utils/componentHelpers/appUpdaterHelpers'; type ModeFilter = 'all' | 'dark' | 'light'; type SortMode = 'newest' | 'name'; type AnimFilter = 'all' | 'animated' | 'static'; 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 GitHub-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(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 [sortMode, setSortMode] = useState('newest'); const [animFilter, setAnimFilter] = 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); }); }; // GitHub raw caches thumbnails only briefly, but the webview can still hold an // old copy; 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 ? `${assetUrl(rel)}?v=${encodeURIComponent(generatedAt)}` : assetUrl(rel); // React Compiler set-state-in-effect rule: state set from an async result resolved in this effect. // eslint-disable-next-line react-hooks/set-state-in-effect 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(); const matched = themes.filter(th => { if (mode !== 'all' && th.mode !== mode) return false; if (animFilter === 'animated' && !th.animated) return false; if (animFilter === 'static' && th.animated) 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 last-modified date. const byName = (a: RegistryTheme, b: RegistryTheme) => a.name.localeCompare(b.name); if (sortMode === 'name') return matched.sort(byName); return matched.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '') || byName(a, b)); }, [themes, query, mode, sortMode, animFilter]); // A changed filter can shrink the result set below the current page; reset to // the first page whenever the query or mode filter changes. // React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change. // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { setPage(1); }, [query, mode, sortMode, animFilter]); 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: 'newest', label: t('settings.themeStoreSortNewest') }, { value: 'name', label: t('settings.themeStoreSortName') }, ]; const animFilterOptions = [ { value: 'all', label: t('settings.themeStoreAnimAll') }, { value: 'animated', label: t('settings.themeStoreAnimAnimated') }, { value: 'static', label: t('settings.themeStoreAnimStatic') }, ]; return (
{/* Submit-your-own-theme hint */}
{t('settings.themeStoreSubmitText')}{' '}
{/* Network disclosure — the store reaches external services. */}

{t('settings.themeStoreNetworkNotice')}{' '}{t('settings.themeStoreStatsNotice')}

{/* 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 }} /> 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, }} /> setAnimFilter(v as AnimFilter)} style={{ width: 150, 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, }} />
{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, 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 (
{th.name} {isActive && ( {t('settings.themeStoreActive')} )} {animRisk && th.animated && }
{t('settings.themeStoreByAuthor', { author: th.author })} {' · '} {updateAvailable ? ( <>v{inst!.version} → v{th.version} ) : ( <>v{th.version} )}
{th.description}
{th.updatedAt && (
{t('settings.themeStoreLastChanged')}: {formatRelativeTime(th.updatedAt, i18n.language)}
)}
{!isInstalled && ( )} {isInstalled && !isActive && ( )} {updateAvailable && ( )} {isInstalled && ( )} {failedId === th.id && ( {t('settings.themeStoreInstallFailed')} )}
); })}
)} {!loading && !error && pageCount > 1 && (
{pageItemsList(safePage, pageCount).map((it, i) => it === 'gap' ? ( ) : ( ) )}
)} {lightbox && ( setLightbox(null)} /> )}
); }