mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
fix: theme store stuck on loading skeleton after fetch failures
Harden theme list fetching with normalized API responses, timeouts, retries, and a visible error state so the store no longer stays blank when messaging or payloads fail (common after extension updates without a SEQTA tab reload).
This commit is contained in:
+51
-3
@@ -43,6 +43,39 @@ function reloadSeqtaPages() {
|
|||||||
/** Callback for sending a response back to the message sender */
|
/** Callback for sending a response back to the message sender */
|
||||||
type MessageSender = { (response?: unknown): void };
|
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<string, unknown>;
|
||||||
|
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<string, unknown>).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 {
|
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
|
||||||
const { token } = request;
|
const { token } = request;
|
||||||
const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
|
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<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
fetch(apiUrl, { cache: "no-store", headers })
|
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)
|
.then(sendResponse)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
|
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
|
||||||
fetch(githubUrl, { cache: "no-store" })
|
fetch(githubUrl, { cache: "no-store" })
|
||||||
.then((r) => r.json())
|
.then(async (r) => {
|
||||||
.then((data) => sendResponse({ success: true, data: { themes: data.themes ?? [] } }))
|
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) => {
|
.catch((fallbackErr) => {
|
||||||
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
|
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
|
||||||
sendResponse({ success: false, error: fallbackErr?.message });
|
sendResponse({ success: false, error: fallbackErr?.message });
|
||||||
|
|||||||
@@ -10,14 +10,18 @@
|
|||||||
}>();
|
}>();
|
||||||
let emblaApi = $state();
|
let emblaApi = $state();
|
||||||
|
|
||||||
const options = { loop: true };
|
const options = $derived({ loop: slides.length > 1 });
|
||||||
const plugins = [
|
const plugins = $derived(
|
||||||
Autoplay({
|
slides.length > 1
|
||||||
delay: 5000,
|
? [
|
||||||
stopOnInteraction: false,
|
Autoplay({
|
||||||
stopOnMouseEnter: true,
|
delay: 5000,
|
||||||
}),
|
stopOnInteraction: false,
|
||||||
];
|
stopOnMouseEnter: true,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
function onInit(event: CustomEvent) {
|
function onInit(event: CustomEvent) {
|
||||||
emblaApi = event.detail;
|
emblaApi = event.detail;
|
||||||
|
|||||||
@@ -21,9 +21,12 @@
|
|||||||
allStoreThemeRows?: Theme[];
|
allStoreThemeRows?: Theme[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let filteredThemes = $derived(themes.filter((theme: Theme) =>
|
let filteredThemes = $derived(themes.filter((theme: Theme) => {
|
||||||
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
|
const q = searchTerm.toLowerCase();
|
||||||
));
|
const name = (theme.name ?? '').toLowerCase();
|
||||||
|
const description = (theme.description ?? '').toLowerCase();
|
||||||
|
return name.includes(q) || description.includes(q);
|
||||||
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative" >
|
<div class="relative" >
|
||||||
|
|||||||
@@ -234,7 +234,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
||||||
if (displayTheme) {
|
if (displayTheme && modalElement) {
|
||||||
|
|
||||||
animate(
|
animate(
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import SkeletonLoader from '../components/SkeletonLoader.svelte';
|
import SkeletonLoader from '../components/SkeletonLoader.svelte';
|
||||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||||
import type { Theme } from '../types/Theme'
|
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 browser from 'webextension-polyfill'
|
||||||
import ThemeModal from '../components/store/ThemeModal.svelte'
|
import ThemeModal from '../components/store/ThemeModal.svelte'
|
||||||
import Header from '../components/store/Header.svelte'
|
import Header from '../components/store/Header.svelte'
|
||||||
@@ -41,9 +41,22 @@
|
|||||||
let activeTab = $state('themes');
|
let activeTab = $state('themes');
|
||||||
|
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
let fetchAttempt = $state(0);
|
||||||
let selectedBackground = $state<string | null>(null);
|
let selectedBackground = $state<string | null>(null);
|
||||||
let showSignInOverlay = $state(false);
|
let showSignInOverlay = $state(false);
|
||||||
|
|
||||||
|
const MAX_FETCH_ATTEMPTS = 3;
|
||||||
|
const FETCH_MESSAGE_TIMEOUT_MS = 25_000;
|
||||||
|
|
||||||
|
function sendMessageWithTimeout<T>(message: object): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
browser.runtime.sendMessage(message) as Promise<T>,
|
||||||
|
new Promise<T>((_, 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 fetchCurrentThemes = async () => {
|
||||||
const themes = await themeManager.getAvailableThemes();
|
const themes = await themeManager.getAvailableThemes();
|
||||||
currentThemes = themes.filter(theme => theme !== null).map(theme => theme.id);
|
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)
|
// 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 {
|
try {
|
||||||
const token = await cloudAuth.getStoredToken();
|
const token = await cloudAuth.getStoredToken();
|
||||||
const data = (await browser.runtime.sendMessage({
|
const data = await sendMessageWithTimeout<{
|
||||||
|
success?: boolean;
|
||||||
|
data?: { themes: unknown[] };
|
||||||
|
error?: string;
|
||||||
|
}>({
|
||||||
type: 'fetchThemes',
|
type: 'fetchThemes',
|
||||||
token: token ?? undefined,
|
token: token ?? undefined,
|
||||||
})) as {
|
});
|
||||||
success?: boolean;
|
if (!data?.success || !Array.isArray(data?.data?.themes)) {
|
||||||
data?: { themes: Theme[] };
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
if (!data?.success || !data?.data?.themes) {
|
|
||||||
throw new Error(data?.error || 'Failed to fetch 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<string, unknown>))
|
||||||
|
.filter((t) => t.id.length > 0)
|
||||||
|
.sort(compareStoreThemes);
|
||||||
|
error = null;
|
||||||
loading = false;
|
loading = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch themes', 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)
|
// Filter themes (list is already featured-first, then newest; filter preserves order)
|
||||||
let filteredThemes = $derived(
|
let filteredThemes = $derived(
|
||||||
listThemes.filter(
|
listThemes.filter((theme) => {
|
||||||
(theme) =>
|
const q = searchTerm.toLowerCase();
|
||||||
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const name = (theme.name ?? '').toLowerCase();
|
||||||
theme.description.toLowerCase().includes(searchTerm.toLowerCase()),
|
const description = (theme.description ?? '').toLowerCase();
|
||||||
),
|
return name.includes(q) || description.includes(q);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
async function installThemeFromStore(themeId: string, meta: Theme) {
|
async function installThemeFromStore(themeId: string, meta: Theme) {
|
||||||
@@ -221,7 +251,28 @@
|
|||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<SkeletonLoader width="100%" height="200px" />
|
{#each Array(6) as _, i (i)}
|
||||||
|
<SkeletonLoader width="100%" height="200px" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex flex-col items-center justify-center py-24 text-center max-w-lg mx-auto">
|
||||||
|
<h2 class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">Couldn't load themes</h2>
|
||||||
|
<p class="mt-3 text-zinc-600 dark:text-zinc-300">{error}</p>
|
||||||
|
<p class="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
After an extension update, reload your SEQTA tab so the new version can talk to the browser.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-6 px-4 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700"
|
||||||
|
onclick={() => {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
void fetchThemes();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Themes Tab Content -->
|
<!-- Themes Tab Content -->
|
||||||
|
|||||||
@@ -4,9 +4,90 @@ export function isHiddenStoreTheme(theme: Theme): boolean {
|
|||||||
return theme.theme_role === "slave";
|
return theme.theme_role === "slave";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Coerce API / fallback rows into the store `Theme` shape (camelCase images, safe strings). */
|
||||||
|
export function normalizeStoreTheme(raw: Record<string, unknown>): Theme {
|
||||||
|
const flavours = Array.isArray(raw.flavours)
|
||||||
|
? (raw.flavours as Record<string, unknown>[]).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). */
|
/** Grid and search: omit slave rows (when API sends a flattened list). */
|
||||||
export function visibleStoreThemes(themes: Theme[]): Theme[] {
|
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 {
|
function marqueeOrCoverUrl(t: { marqueeImage?: string; coverImage: string }): string {
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export function renderStore() {
|
|||||||
throw new Error("Container not found");
|
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");
|
const child = document.createElement("div");
|
||||||
child.id = "store";
|
child.id = "store";
|
||||||
container!.appendChild(child);
|
container!.appendChild(child);
|
||||||
|
|||||||
Reference in New Issue
Block a user