diff --git a/package.json b/package.json index a943c4d6..62156400 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betterseqtaplus", - "version": "3.7.2", + "version": "3.7.3", "type": "module", "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!", "browserslist": "> 0.5%, last 2 versions, not dead", diff --git a/src/SEQTA.ts b/src/SEQTA.ts index f4697a6e..2cbb3239 100644 --- a/src/SEQTA.ts +++ b/src/SEQTA.ts @@ -6,7 +6,7 @@ import documentLoadCSS from "@/css/documentload.scss?inline"; import icon48 from "@/resources/icons/icon-48.png?base64"; import browser from "webextension-polyfill"; -import * as plugins from "@/plugins"; +import { init as Monofile } from "@/plugins/monofile"; import { main } from "@/seqta/main"; import { delay } from "./seqta/utils/delay"; import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle"; @@ -112,10 +112,11 @@ async function init() { } await main(); - plugins.Monofile(); + Monofile(); if (settingsState.onoff) { - await plugins.initializePlugins(); + const { initializePlugins } = await import("@/plugins/runtime"); + await initializePlugins(); } if (settingsState.devMode) { diff --git a/src/background.ts b/src/background.ts index fd7aeb16..fa4bb494 100644 --- a/src/background.ts +++ b/src/background.ts @@ -10,8 +10,10 @@ import { performCloudSettingsUploadWithRetry, requestCloudSettingsDebouncedUpload, runCloudSettingsPoll, + withSuppressedCloudAutoUpload, } from "./background/cloudSettingsAutoSync"; import { getBsplusDeviceName } from "@/seqta/utils/bsplusDeviceName"; +import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl"; /** * Session-only dev-mode override of the content API base. @@ -46,6 +48,11 @@ function reloadSeqtaPages() { /** Callback for sending a response back to the message sender */ type MessageSender = { (response?: unknown): void }; +async function getAccessTokenFromStorage(): Promise { + const { bsplus_token } = await browser.storage.local.get("bsplus_token"); + return typeof bsplus_token === "string" && bsplus_token.length > 0 ? bsplus_token : null; +} + /** Accept API + GitHub fallback shapes; always return `{ success, data?: { themes } }`. */ function normalizeFetchThemesResponse(json: unknown): { success: boolean; @@ -80,66 +87,101 @@ function normalizeFetchThemesResponse(json: unknown): { } function handleFetchThemes(request: any, sendResponse: MessageSender): boolean { - const { token } = request; - const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`; - const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`; - const headers: Record = {}; - if (token) headers["Authorization"] = `Bearer ${token}`; - fetch(apiUrl, { cache: "no-store", headers }) - .then(async (r) => { - const json = await r.json(); - if (!r.ok) { - throw new Error( - (json && typeof json === "object" && "error" in json && typeof (json as { error?: string }).error === "string" - ? (json as { error: string }).error - : null) ?? `Themes API HTTP ${r.status}`, - ); - } - return normalizeFetchThemesResponse(json); - }) - .then(sendResponse) - .catch((err) => { - console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message); - fetch(githubUrl, { cache: "no-store" }) - .then(async (r) => { - if (!r.ok) throw new Error(`GitHub fallback HTTP ${r.status}`); - const data = await r.json(); - const themes = Array.isArray(data) ? data : (data?.themes ?? []); - return normalizeFetchThemesResponse({ success: true, data: { themes } }); - }) - .then(sendResponse) - .catch((fallbackErr) => { - console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr); - sendResponse({ success: false, error: fallbackErr?.message }); - }); - }); + void (async () => { + const token = await getAccessTokenFromStorage(); + const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`; + const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`; + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch(apiUrl, { cache: "no-store", headers }) + .then(async (r) => { + const json = await r.json(); + if (!r.ok) { + throw new Error( + (json && typeof json === "object" && "error" in json && typeof (json as { error?: string }).error === "string" + ? (json as { error: string }).error + : null) ?? `Themes API HTTP ${r.status}`, + ); + } + return normalizeFetchThemesResponse(json); + }) + .then(sendResponse) + .catch((err) => { + console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message); + fetch(githubUrl, { cache: "no-store" }) + .then(async (r) => { + if (!r.ok) throw new Error(`GitHub fallback HTTP ${r.status}`); + const data = await r.json(); + const themes = Array.isArray(data) ? data : (data?.themes ?? []); + return normalizeFetchThemesResponse({ success: true, data: { themes } }); + }) + .then(sendResponse) + .catch((fallbackErr) => { + console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr); + sendResponse({ success: false, error: fallbackErr?.message }); + }); + }); + })(); return true; } function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boolean { - const { themeId, token } = request; + const { themeId } = request; if (!themeId || typeof themeId !== "string") { sendResponse({ success: false, error: "Missing themeId" }); return false; } - const headers: Record = {}; - if (token) headers["Authorization"] = `Bearer ${token}`; - fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers }) - .then((r) => r.json()) - .then(sendResponse) - .catch((err) => { - console.error("[Background] fetchThemeDetails error:", err); - sendResponse({ success: false, error: err?.message }); - }); + void (async () => { + const token = await getAccessTokenFromStorage(); + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers }) + .then((r) => r.json()) + .then(sendResponse) + .catch((err) => { + console.error("[Background] fetchThemeDetails error:", err); + sendResponse({ success: false, error: err?.message }); + }); + })(); return true; } -function handleFetchFromUrl(request: any, sendResponse: MessageSender): boolean { +function isTrustedSender(sender?: browser.Runtime.MessageSender): boolean { + if (!sender) return false; + if (sender.id && sender.id !== browser.runtime.id) return false; + + const urls = [sender.url, sender.tab?.url].filter(Boolean) as string[]; + for (const pageUrl of urls) { + if (/^chrome-extension:\/\//.test(pageUrl) || /^moz-extension:\/\//.test(pageUrl)) { + return true; + } + try { + if (isSeqtaOrigin(new URL(pageUrl).origin)) return true; + } catch { + // try next URL + } + } + return false; +} + +function handleFetchFromUrl( + request: any, + sendResponse: MessageSender, + sender?: browser.Runtime.MessageSender, +): boolean { + if (!isTrustedSender(sender)) { + sendResponse({ error: "Unauthorized sender" }); + return false; + } const { url } = request; if (!url || typeof url !== "string") { sendResponse({ error: "Missing url" }); return false; } + if (!isAllowedFetchUrl(url)) { + sendResponse({ error: "URL not allowed" }); + return false; + } fetch(url, { cache: "no-store" }) .then((r) => r.json()) .then((data) => sendResponse({ data })) @@ -178,7 +220,15 @@ function handleCloudReserveClient(request: any, sendResponse: MessageSender): bo return true; } -function handleCloudLogin(request: any, sendResponse: MessageSender): boolean { +function handleCloudLogin( + request: any, + sendResponse: MessageSender, + sender?: browser.Runtime.MessageSender, +): boolean { + if (!isTrustedSender(sender)) { + sendResponse({ error: "Unauthorized sender" }); + return false; + } const { client_id, redirect_uri, login, password, device_name } = request; if (!client_id || !redirect_uri || !login || !password) { sendResponse({ error: "Missing client_id, redirect_uri, login, or password" }); @@ -300,10 +350,18 @@ function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean return true; } -function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): boolean { +function handleCloudSettingsUpload( + request: any, + sendResponse: MessageSender, + sender?: browser.Runtime.MessageSender, +): boolean { + if (!isTrustedSender(sender)) { + sendResponse({ success: false, error: "Unauthorized sender" }); + return false; + } void (async () => { try { - const token = request.token as string | undefined; + const token = await getAccessTokenFromStorage(); if (!token) { sendResponse({ success: false, error: "Not authenticated" }); return; @@ -325,10 +383,18 @@ function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): b return true; } -function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): boolean { +function handleCloudSettingsDownload( + request: any, + sendResponse: MessageSender, + sender?: browser.Runtime.MessageSender, +): boolean { + if (!isTrustedSender(sender)) { + sendResponse({ success: false, error: "Unauthorized sender" }); + return false; + } void (async () => { try { - const token = request.token as string | undefined; + const token = await getAccessTokenFromStorage(); if (!token) { sendResponse({ success: false, error: "Not authenticated" }); return; @@ -352,22 +418,29 @@ function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): } function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean { - const { themeId, token, action } = request; - if (!themeId || !token) { - sendResponse({ success: false, error: "Theme ID and token required" }); + const { themeId, action } = request; + if (!themeId) { + sendResponse({ success: false, error: "Theme ID required" }); return false; } - const isFavorite = action === "favorite"; - fetch(`${apiBase()}/api/themes/${themeId}/favorite`, { - method: isFavorite ? "POST" : "DELETE", - headers: { Authorization: `Bearer ${token}` }, - }) - .then((r) => r.json()) - .then(sendResponse) - .catch((err) => { - console.error("[Background] cloudFavorite error:", err); - sendResponse({ success: false, error: err?.message }); - }); + void (async () => { + const token = await getAccessTokenFromStorage(); + if (!token) { + sendResponse({ success: false, error: "Not authenticated" }); + return; + } + const isFavorite = action === "favorite"; + fetch(`${apiBase()}/api/themes/${themeId}/favorite`, { + method: isFavorite ? "POST" : "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }) + .then((r) => r.json()) + .then(sendResponse) + .catch((err) => { + console.error("[Background] cloudFavorite error:", err); + sendResponse({ success: false, error: err?.message }); + }); + })(); return true; } @@ -385,7 +458,12 @@ function isSeqtaOrigin(origin: string): boolean { } } -function handleSetDevApiBase(request: any): boolean { +function handleSetDevApiBase( + request: any, + _sendResponse: MessageSender, + sender?: browser.Runtime.MessageSender, +): boolean { + if (!isTrustedSender(sender)) return false; const url = typeof request?.url === "string" ? request.url.trim() : null; if (url && /^https?:\/\//.test(url)) { DEV_API_BASE = url.replace(/\/$/, ""); @@ -424,7 +502,11 @@ const MESSAGE_HANDLERS: Record = { }); return true; }, - sendNews: (req, sendResponse) => { + sendNews: (req, sendResponse, sender) => { + if (!isTrustedSender(sender)) { + sendResponse({ error: "Unauthorized sender" }); + return false; + } fetchNews(req.source ?? "australia", sendResponse); return true; }, @@ -501,10 +583,10 @@ function getDefaultValues(): SettingsState { return getDefaultSettingsState(); } -function SetStorageValue(object: any) { - for (var i in object) { - browser.storage.local.set({ [i]: object[i] }); - } +function SetStorageValue(object: SettingsState) { + void withSuppressedCloudAutoUpload(() => + browser.storage.local.set(object as Record), + ); } /** One-time migration for 3.6.5: opt upgraders into Global Search + indexing + transparency defaults. */ diff --git a/src/background/cloudSettingsAutoSync.ts b/src/background/cloudSettingsAutoSync.ts index 37224124..54661fdc 100644 --- a/src/background/cloudSettingsAutoSync.ts +++ b/src/background/cloudSettingsAutoSync.ts @@ -25,6 +25,11 @@ const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`; const UPLOAD_DEBOUNCE_MS = 2000; const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000; const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll"; +const FETCH_TIMEOUT_MS = 30_000; + +function fetchWithTimeout(url: string, init?: RequestInit): Promise { + return fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); +} type CloudSummaryResponse = { desqta?: unknown; @@ -35,6 +40,7 @@ let reloadSeqtaPagesFn: (() => void) | null = null; let suppressAutoUploadDuringRestore = false; let debounceTimer: ReturnType | null = null; let pollInFlight: Promise | null = null; +let autoSyncInitialized = false; function isAutoCloudSyncEnabled(all: Record): boolean { return all.autoCloudSettingsSync !== false; @@ -65,7 +71,7 @@ async function tryRefreshTokens(): Promise { if (!refresh_token || !client_id) return false; try { - const r = await fetch(REFRESH_URL, { + const r = await fetchWithTimeout(REFRESH_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token, client_id }), @@ -100,7 +106,7 @@ async function fetchCloudSummaryOnce( | { ok: false; unauthorized: boolean; error?: string } > { try { - const r = await fetch(CLOUD_SUMMARY_URL, { + const r = await fetchWithTimeout(CLOUD_SUMMARY_URL, { headers: { Authorization: `Bearer ${token}` }, cache: "no-store", }); @@ -177,7 +183,7 @@ async function putSettingsOnce(token: string): Promise { return { ok: true, skipped: true }; } - const r = await fetch(CLOUD_SETTINGS_SYNC_URL, { + const r = await fetchWithTimeout(CLOUD_SETTINGS_SYNC_URL, { method: "PUT", headers: { Authorization: `Bearer ${token}`, @@ -235,7 +241,7 @@ type GetResult = async function getSettingsAndApplyOnce(token: string): Promise { try { - const r = await fetch(CLOUD_SETTINGS_SYNC_URL, { + const r = await fetchWithTimeout(CLOUD_SETTINGS_SYNC_URL, { method: "GET", headers: { Authorization: `Bearer ${token}` }, cache: "no-store", @@ -373,8 +379,8 @@ export function runCloudSettingsPoll(): Promise { try { const { [POLL_THROTTLE_KEY]: last } = await browser.storage.local.get(POLL_THROTTLE_KEY); if (Date.now() - (Number(last) || 0) < POLL_THROTTLE_MS) return; - await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() }); await runCloudSettingsPollInner(); + await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() }); } catch (e) { console.error("[BS+ cloud sync] Poll error:", e); } finally { @@ -451,8 +457,21 @@ function onStorageChanged( })(); } +export async function withSuppressedCloudAutoUpload( + operation: () => T | Promise, +): Promise { + suppressAutoUploadDuringRestore = true; + try { + return await operation(); + } finally { + suppressAutoUploadDuringRestore = false; + } +} + export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void { reloadSeqtaPagesFn = deps.reloadSeqtaPages; + if (autoSyncInitialized) return; + autoSyncInitialized = true; browser.storage.onChanged.addListener(onStorageChanged); } diff --git a/src/background/news.ts b/src/background/news.ts index 70bcf1b2..d62a5378 100644 --- a/src/background/news.ts +++ b/src/background/news.ts @@ -1,5 +1,7 @@ import Parser from "rss-parser"; +const MAX_RATE_LIMIT_RETRIES = 3; + /** * Fetches news articles specifically for Australia from the NewsAPI. * @@ -13,15 +15,23 @@ import Parser from "rss-parser"; * to send the fetched news data back to the caller. * It's called with an object like `{ news: responseData }`. */ -const fetchAustraliaNews = async (url: string, sendResponse: any) => { +const fetchAustraliaNews = async ( + url: string, + sendResponse: any, + rateLimitRetryCount = 0, +) => { fetch(url) .then((result) => result.json()) .then((response) => { - if (response.code == "rateLimited") { - fetchAustraliaNews((url += "%00"), sendResponse); + if (response.code == "rateLimited" && rateLimitRetryCount < MAX_RATE_LIMIT_RETRIES) { + fetchAustraliaNews(`${url}%00`, sendResponse, rateLimitRetryCount + 1); } else { sendResponse({ news: response }); } + }) + .catch((error) => { + console.error("[BetterSEQTA+] Failed to fetch Australia news", error); + sendResponse({ news: { articles: [] } }); }); }; @@ -99,13 +109,14 @@ export async function fetchNews(source: string | undefined, sendResponse: any) { if (normalizedSource === "australia") { const date = new Date(); + date.setDate(date.getDate() - 5); const from = date.getFullYear() + "-" + - (date.getMonth() + 1) + + String(date.getMonth() + 1).padStart(2, "0") + "-" + - (date.getDate() - 5); + String(date.getDate()).padStart(2, "0"); const url = `https://newsapi.org/v2/everything?domains=abc.net.au&from=${from}&apiKey=17c0da766ba347c89d094449504e3080`; fetchAustraliaNews(url, sendResponse); @@ -115,7 +126,6 @@ export async function fetchNews(source: string | undefined, sendResponse: any) { const parser = new Parser(); let feeds: string[]; - console.log("fetchNews", normalizedSource); if (rssFeedsByCountry[normalizedSource.toLowerCase()]) { feeds = rssFeedsByCountry[normalizedSource.toLowerCase()]; @@ -129,6 +139,10 @@ export async function fetchNews(source: string | undefined, sendResponse: any) { const articlesPromises = feeds.map(async (feedUrl) => { try { const response = await fetch(feedUrl); + if (!response.ok) { + console.error(`Failed to fetch RSS feed: ${feedUrl} (${response.status})`); + return []; + } const feedString = await response.text(); const feed = await parser.parseString(feedString); diff --git a/src/interface/components/CloudPanel.svelte b/src/interface/components/CloudPanel.svelte index fb0ce6a5..7c3ef2e0 100644 --- a/src/interface/components/CloudPanel.svelte +++ b/src/interface/components/CloudPanel.svelte @@ -92,7 +92,7 @@ bind:this={background} class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50" onclick={handleBackgroundClick} - onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }} + onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick(e as unknown as MouseEvent) }} >
(null) let view: EditorView | null = null; + let unsubSettings: (() => void) | undefined; let editorTheme = new Compartment(); let { value, onChange, className } = $props<{value: string, onChange: (value: string) => void, className?: string}>() @@ -73,7 +74,7 @@ view = createEditorView(state, editor as HTMLElement); } - settingsState.subscribe((settings) => { + unsubSettings = settingsState.subscribe((settings) => { if (view) { view.dispatch({ effects: editorTheme.reconfigure( @@ -85,6 +86,7 @@ }); onDestroy(() => { + unsubSettings?.(); if (view) { view.destroy(); } diff --git a/src/interface/components/ColourPicker.svelte b/src/interface/components/ColourPicker.svelte index 34468642..5d0b035b 100644 --- a/src/interface/components/ColourPicker.svelte +++ b/src/interface/components/ColourPicker.svelte @@ -1,7 +1,6 @@ -
-
+
+
{#each tabs as { title }, index}
-
- -
- {#each tabs as { Content, props }, index} -
-
- -
- {/each} -
-
+
+ {#each tabs as { Content, props }, index (index)} + {#if activeTab === index} +
+
+ +
+ {/if} + {/each}
diff --git a/src/interface/components/store/Header.svelte b/src/interface/components/store/Header.svelte index 4ee50781..bfe322d9 100644 --- a/src/interface/components/store/Header.svelte +++ b/src/interface/components/store/Header.svelte @@ -1,10 +1,13 @@ diff --git a/src/interface/components/themes/ThemeSelector.svelte b/src/interface/components/themes/ThemeSelector.svelte index 9c6b54d7..1599bbae 100644 --- a/src/interface/components/themes/ThemeSelector.svelte +++ b/src/interface/components/themes/ThemeSelector.svelte @@ -2,8 +2,6 @@ import type { CustomTheme, ThemeList } from '@/types/CustomThemes' import { onDestroy, onMount } from 'svelte' import browser from 'webextension-polyfill' - import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator' - import { OpenStorePage } from '@/seqta/ui/renderStore' import { themeUpdates } from '@/interface/hooks/ThemeUpdates' import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup' import { ThemeManager } from '@/plugins/built-in/themes/theme-manager' @@ -22,11 +20,14 @@ let prevLoggedIn = $state(false); let showSignInModal = $state(false); - cloudAuth.subscribe((s) => { - const now = s.isLoggedIn; - if (now && !prevLoggedIn && themes) void fetchThemes(); - prevLoggedIn = now; - cloudLoggedIn = now; + $effect(() => { + const unsub = cloudAuth.subscribe((s) => { + const now = s.isLoggedIn; + if (now && !prevLoggedIn && themes) void fetchThemes(); + prevLoggedIn = now; + cloudLoggedIn = now; + }); + return unsub; }); const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => { @@ -103,17 +104,14 @@ selectedTheme: themeManager.getSelectedThemeId() || '', } if (themes && cloudLoggedIn) { - const token = await cloudAuth.getStoredToken(); - if (token) { - const status: Record = {}; - await Promise.all( - themes.themes.map(async (t) => { - try { - const res = (await browser.runtime.sendMessage({ - type: 'fetchThemeDetails', - themeId: t.id, - token, - })) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } }; + const status: Record = {}; + await Promise.all( + themes.themes.map(async (t) => { + try { + const res = (await browser.runtime.sendMessage({ + type: 'fetchThemeDetails', + themeId: t.id, + })) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } }; if (res?.success && res?.data?.theme) { status[t.id] = !!res.data.theme.is_favorited; } @@ -123,25 +121,32 @@ }) ); favoriteStatus = status; - } } else { favoriteStatus = {}; } } + const openStorePage = async () => { + const { OpenStorePage } = await import('@/seqta/ui/renderStore') + OpenStorePage() + } + + const openThemeCreator = async (themeId?: string) => { + const { OpenThemeCreator } = await import('@/plugins/built-in/themes/ThemeCreator') + OpenThemeCreator(themeId) + closeExtensionPopup() + } + const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => { e.stopPropagation(); if (!cloudLoggedIn) { showSignInModal = true; return; } - const token = await cloudAuth.getStoredToken(); - if (!token) return; const isFavorite = !favoriteStatus[theme.id]; const result = (await browser.runtime.sendMessage({ type: 'cloudFavorite', themeId: theme.id, - token, action: isFavorite ? 'favorite' : 'unfavorite', })) as { success?: boolean }; if (result?.success) { @@ -217,8 +222,8 @@
{ event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }} - onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') OpenThemeCreator(theme.id); closeExtensionPopup() }} + onclick={(event) => { event.stopPropagation(); void openThemeCreator(theme.id) }} + onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') void openThemeCreator(theme.id) }} role="button" tabindex="-1" > @@ -266,7 +271,7 @@ {/if}