diff --git a/src/background.ts b/src/background.ts index 074a4138..ad7ab92a 100644 --- a/src/background.ts +++ b/src/background.ts @@ -43,6 +43,39 @@ function reloadSeqtaPages() { /** Callback for sending a response back to the message sender */ type MessageSender = { (response?: unknown): void }; +/** Accept API + GitHub fallback shapes; always return `{ success, data?: { themes } }`. */ +function normalizeFetchThemesResponse(json: unknown): { + success: boolean; + data?: { themes: unknown[] }; + error?: string; +} { + if (!json || typeof json !== "object") { + return { success: false, error: "Invalid themes response" }; + } + const body = json as Record; + if (body.success === false) { + return { + success: false, + error: typeof body.error === "string" ? body.error : "Failed to fetch themes", + }; + } + const data = body.data; + let themes: unknown[] | null = null; + if (data && typeof data === "object" && !Array.isArray(data)) { + const nested = (data as Record).themes; + if (Array.isArray(nested)) themes = nested; + } else if (Array.isArray(data)) { + themes = data; + } + if (!themes && Array.isArray(body.themes)) { + themes = body.themes; + } + if (!themes) { + return { success: false, error: "Themes list missing from response" }; + } + return { success: true, data: { themes } }; +} + function handleFetchThemes(request: any, sendResponse: MessageSender): boolean { const { token } = request; const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`; @@ -50,13 +83,28 @@ function handleFetchThemes(request: any, sendResponse: MessageSender): boolean { const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; fetch(apiUrl, { cache: "no-store", headers }) - .then((r) => r.json()) + .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((r) => r.json()) - .then((data) => sendResponse({ success: true, data: { themes: data.themes ?? [] } })) + .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 }); diff --git a/src/interface/components/store/CoverSwiper.svelte b/src/interface/components/store/CoverSwiper.svelte index cd6eb126..1f1bb038 100644 --- a/src/interface/components/store/CoverSwiper.svelte +++ b/src/interface/components/store/CoverSwiper.svelte @@ -10,14 +10,18 @@ }>(); let emblaApi = $state(); - const options = { loop: true }; - const plugins = [ - Autoplay({ - delay: 5000, - stopOnInteraction: false, - stopOnMouseEnter: true, - }), - ]; + const options = $derived({ loop: slides.length > 1 }); + const plugins = $derived( + slides.length > 1 + ? [ + Autoplay({ + delay: 5000, + stopOnInteraction: false, + stopOnMouseEnter: true, + }), + ] + : [], + ); function onInit(event: CustomEvent) { emblaApi = event.detail; diff --git a/src/interface/components/store/ThemeGrid.svelte b/src/interface/components/store/ThemeGrid.svelte index db4c0fe1..5b219fe8 100644 --- a/src/interface/components/store/ThemeGrid.svelte +++ b/src/interface/components/store/ThemeGrid.svelte @@ -21,9 +21,12 @@ allStoreThemeRows?: Theme[]; }>(); - let filteredThemes = $derived(themes.filter((theme: Theme) => - theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase()) - )); + let filteredThemes = $derived(themes.filter((theme: Theme) => { + const q = searchTerm.toLowerCase(); + const name = (theme.name ?? '').toLowerCase(); + const description = (theme.description ?? '').toLowerCase(); + return name.includes(q) || description.includes(q); + }));
diff --git a/src/interface/components/store/ThemeModal.svelte b/src/interface/components/store/ThemeModal.svelte index 1a2c27de..8dd09da7 100644 --- a/src/interface/components/store/ThemeModal.svelte +++ b/src/interface/components/store/ThemeModal.svelte @@ -234,7 +234,7 @@ $effect(() => { - if (displayTheme) { + if (displayTheme && modalElement) { animate( diff --git a/src/interface/pages/store.svelte b/src/interface/pages/store.svelte index b1e6649c..54ef7845 100644 --- a/src/interface/pages/store.svelte +++ b/src/interface/pages/store.svelte @@ -7,7 +7,7 @@ import SkeletonLoader from '../components/SkeletonLoader.svelte'; import { settingsState } from '@/seqta/utils/listeners/SettingsState' import type { Theme } from '../types/Theme' - import { visibleStoreThemes, buildCoverSlidesForThemes } from '@/interface/utils/themeStoreFlavours' + import { visibleStoreThemes, buildCoverSlidesForThemes, normalizeStoreTheme } from '@/interface/utils/themeStoreFlavours' import browser from 'webextension-polyfill' import ThemeModal from '../components/store/ThemeModal.svelte' import Header from '../components/store/Header.svelte' @@ -41,9 +41,22 @@ let activeTab = $state('themes'); let error = $state(null); + let fetchAttempt = $state(0); let selectedBackground = $state(null); let showSignInOverlay = $state(false); + const MAX_FETCH_ATTEMPTS = 3; + const FETCH_MESSAGE_TIMEOUT_MS = 25_000; + + function sendMessageWithTimeout(message: object): Promise { + return Promise.race([ + browser.runtime.sendMessage(message) as Promise, + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Theme store request timed out — reload the SEQTA page after updating the extension.')), FETCH_MESSAGE_TIMEOUT_MS); + }), + ]); + } + const fetchCurrentThemes = async () => { const themes = await themeManager.getAvailableThemes(); currentThemes = themes.filter(theme => theme !== null).map(theme => theme.id); @@ -100,26 +113,42 @@ }; // Fetch themes via background script (avoids CORS when store runs inside SEQTA page) - const fetchThemes = async () => { + const fetchThemes = async (isRetry = false) => { + if (!isRetry) { + fetchAttempt = 0; + error = null; + } try { const token = await cloudAuth.getStoredToken(); - const data = (await browser.runtime.sendMessage({ + const data = await sendMessageWithTimeout<{ + success?: boolean; + data?: { themes: unknown[] }; + error?: string; + }>({ type: 'fetchThemes', token: token ?? undefined, - })) as { - success?: boolean; - data?: { themes: Theme[] }; - error?: string; - }; - if (!data?.success || !data?.data?.themes) { + }); + if (!data?.success || !Array.isArray(data?.data?.themes)) { throw new Error(data?.error || 'Failed to fetch themes'); } - themes = [...data.data.themes].sort(compareStoreThemes); - + themes = data.data.themes + .map((row) => normalizeStoreTheme(row as Record)) + .filter((t) => t.id.length > 0) + .sort(compareStoreThemes); + error = null; loading = false; } catch (err) { console.error('Failed to fetch themes', err); - setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs + fetchAttempt += 1; + if (fetchAttempt >= MAX_FETCH_ATTEMPTS) { + error = + err instanceof Error + ? err.message + : 'Could not load themes. Reload the SEQTA page, then open the store again.'; + loading = false; + return; + } + setTimeout(() => fetchThemes(true), 5000); } }; @@ -160,11 +189,12 @@ // Filter themes (list is already featured-first, then newest; filter preserves order) let filteredThemes = $derived( - listThemes.filter( - (theme) => - theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || - theme.description.toLowerCase().includes(searchTerm.toLowerCase()), - ), + listThemes.filter((theme) => { + const q = searchTerm.toLowerCase(); + const name = (theme.name ?? '').toLowerCase(); + const description = (theme.description ?? '').toLowerCase(); + return name.includes(q) || description.includes(q); + }), ); async function installThemeFromStore(themeId: string, meta: Theme) { @@ -221,7 +251,28 @@ {#if loading}
- + {#each Array(6) as _, i (i)} + + {/each} +
+ {:else if error} +
+

Couldn't load themes

+

{error}

+

+ After an extension update, reload your SEQTA tab so the new version can talk to the browser. +

+
{:else} diff --git a/src/interface/utils/themeStoreFlavours.ts b/src/interface/utils/themeStoreFlavours.ts index e6c55cd4..1dfa7767 100644 --- a/src/interface/utils/themeStoreFlavours.ts +++ b/src/interface/utils/themeStoreFlavours.ts @@ -4,9 +4,90 @@ export function isHiddenStoreTheme(theme: Theme): boolean { return theme.theme_role === "slave"; } +/** Coerce API / fallback rows into the store `Theme` shape (camelCase images, safe strings). */ +export function normalizeStoreTheme(raw: Record): Theme { + const flavours = Array.isArray(raw.flavours) + ? (raw.flavours as Record[]).map( + (f): ThemeFlavour => ({ + id: String(f.id ?? ""), + name: String(f.name ?? ""), + accent_color: String(f.accent_color ?? f.accentColor ?? ""), + cover_image: String(f.cover_image ?? f.coverImage ?? ""), + marquee_image: + typeof (f.marquee_image ?? f.marqueeImage) === "string" + ? String(f.marquee_image ?? f.marqueeImage) + : undefined, + download_count: + typeof f.download_count === "number" + ? f.download_count + : typeof f.downloadCount === "number" + ? f.downloadCount + : undefined, + }), + ) + : undefined; + + return { + id: String(raw.id ?? ""), + name: String(raw.name ?? "Untitled"), + description: String(raw.description ?? ""), + coverImage: String(raw.coverImage ?? raw.cover_image ?? ""), + marqueeImage: + typeof (raw.marqueeImage ?? raw.marquee_image) === "string" + ? String(raw.marqueeImage ?? raw.marquee_image) + : undefined, + theme_json_url: + typeof (raw.theme_json_url ?? raw.themeJsonUrl) === "string" + ? String(raw.theme_json_url ?? raw.themeJsonUrl) + : undefined, + is_favorited: raw.is_favorited === true || raw.isFavorited === true, + favorite_count: + typeof raw.favorite_count === "number" + ? raw.favorite_count + : typeof raw.favoriteCount === "number" + ? raw.favoriteCount + : undefined, + download_count: + typeof raw.download_count === "number" + ? raw.download_count + : typeof raw.downloadCount === "number" + ? raw.downloadCount + : undefined, + author: typeof raw.author === "string" ? raw.author : undefined, + featured: raw.featured === true, + tags: Array.isArray(raw.tags) ? (raw.tags as string[]) : undefined, + created_at: + typeof raw.created_at === "number" + ? raw.created_at + : typeof raw.createdAt === "number" + ? raw.createdAt + : undefined, + updated_at: + typeof raw.updated_at === "number" + ? raw.updated_at + : typeof raw.updatedAt === "number" + ? raw.updatedAt + : undefined, + theme_role: + raw.theme_role === "master" || raw.theme_role === "slave" || raw.theme_role === "standard" + ? raw.theme_role + : undefined, + master_id: + typeof (raw.master_id ?? raw.masterId) === "string" + ? String(raw.master_id ?? raw.masterId) + : undefined, + flavours, + }; +} + /** Grid and search: omit slave rows (when API sends a flattened list). */ export function visibleStoreThemes(themes: Theme[]): Theme[] { - return themes.filter((t) => !isHiddenStoreTheme(t)); + const visible = themes.filter((t) => !isHiddenStoreTheme(t)); + // If every row is a slave (bad/migration payload), avoid an empty grid. + if (visible.length === 0 && themes.length > 0) { + return themes; + } + return visible; } function marqueeOrCoverUrl(t: { marqueeImage?: string; coverImage: string }): string { diff --git a/src/seqta/ui/renderStore.ts b/src/seqta/ui/renderStore.ts index 6f7e3703..33b292dd 100644 --- a/src/seqta/ui/renderStore.ts +++ b/src/seqta/ui/renderStore.ts @@ -15,6 +15,9 @@ export function renderStore() { throw new Error("Container not found"); } + // Avoid stacking multiple store roots if opened repeatedly without close. + document.getElementById("store")?.remove(); + const child = document.createElement("div"); child.id = "store"; container!.appendChild(child);