From d64962147a861165549e965ce948cec1024554bc Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 20 Feb 2026 10:27:17 +1030 Subject: [PATCH 01/11] feat: implement cloud store --- src/background.ts | 30 ++++++++++++++- src/interface/pages/store.svelte | 18 ++++++--- src/interface/types/Theme.ts | 5 ++- src/manifests/manifest.json | 4 +- src/plugins/built-in/themes/theme-manager.ts | 40 +++++++++++++++++--- 5 files changed, 80 insertions(+), 17 deletions(-) diff --git a/src/background.ts b/src/background.ts index 0aa331b8..911dbdf2 100644 --- a/src/background.ts +++ b/src/background.ts @@ -14,8 +14,8 @@ function reloadSeqtaPages() { result.then(open, console.error); } -// @ts-ignore browser.runtime.onMessage.addListener( + // @ts-ignore - OnMessageListener expects literal true for async, we return boolean (request: any, _: any, sendResponse: (response?: any) => void) => { switch (request.type) { case "reloadTabs": @@ -56,6 +56,34 @@ browser.runtime.onMessage.addListener( fetchNews(request.source ?? "australia", sendResponse); return true; + case "fetchThemes": { + const url = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`; + fetch(url, { cache: "no-store" }) + .then((r) => r.json()) + .then(sendResponse) + .catch((err) => { + console.error("[Background] fetchThemes error:", err); + sendResponse({ success: false, error: err?.message }); + }); + return true; + } + + case "fetchFromUrl": { + const { url } = request; + if (!url || typeof url !== "string") { + sendResponse({ error: "Missing url" }); + return false; + } + fetch(url, { cache: "no-store" }) + .then((r) => r.json()) + .then((data) => sendResponse({ data })) + .catch((err) => { + console.error("[Background] fetchFromUrl error:", err); + sendResponse({ error: err?.message }); + }); + return true; + } + default: console.log("Unknown request type"); } diff --git a/src/interface/pages/store.svelte b/src/interface/pages/store.svelte index f3387af0..6d4c0345 100644 --- a/src/interface/pages/store.svelte +++ b/src/interface/pages/store.svelte @@ -48,20 +48,26 @@ activeTab = tab; }; - // Fetch themes and initialize app + // Fetch themes via background script (avoids CORS when store runs inside SEQTA page) const fetchThemes = async () => { try { - const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${(new Date()).getTime()}`, { cache: 'no-store' }); - const data = await response.json(); - themes = data.themes; + const data = (await browser.runtime.sendMessage({ type: 'fetchThemes' })) as { + success?: boolean; + data?: { themes: Theme[] }; + error?: string; + }; + if (!data?.success || !data?.data?.themes) { + throw new Error(data?.error || 'Failed to fetch themes'); + } + themes = data.data.themes; // Shuffle for cover themes const shuffled = [...themes].sort(() => 0.5 - Math.random()); coverThemes = shuffled.slice(0, 3); loading = false; - } catch (error) { - console.error('Failed to fetch themes', error); + } catch (err) { + console.error('Failed to fetch themes', err); setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs } }; diff --git a/src/interface/types/Theme.ts b/src/interface/types/Theme.ts index b68a5ba1..853760cb 100644 --- a/src/interface/types/Theme.ts +++ b/src/interface/types/Theme.ts @@ -1,7 +1,8 @@ export type Theme = { + id: string; name: string; description: string; coverImage: string; - marqueeImage: string; - id: string; + marqueeImage?: string; + theme_json_url?: string; }; diff --git a/src/manifests/manifest.json b/src/manifests/manifest.json index 92736dce..1ef79abb 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -16,12 +16,12 @@ } }, "permissions": ["tabs", "notifications", "storage"], - "host_permissions": ["https://newsapi.org/", "*://*/*"], + "host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "*://*/*"], "background": { "service_worker": "background.ts" }, "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://betterseqta.org" }, "content_scripts": [ { diff --git a/src/plugins/built-in/themes/theme-manager.ts b/src/plugins/built-in/themes/theme-manager.ts index 69e6ff23..0ac37ddb 100644 --- a/src/plugins/built-in/themes/theme-manager.ts +++ b/src/plugins/built-in/themes/theme-manager.ts @@ -1,4 +1,5 @@ import localforage from "localforage"; +import browser from "webextension-polyfill"; import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import debounce from "@/seqta/utils/debounce"; @@ -470,23 +471,50 @@ export class ThemeManager { } } + private readonly THEME_API_BASE = 'https://betterseqta.org/api'; + + /** + * Fetch JSON from a URL via background script (avoids CORS when running inside SEQTA page) + */ + private async fetchFromUrl(url: string): Promise { + const result = (await browser.runtime.sendMessage({ + type: 'fetchFromUrl', + url, + })) as { data?: unknown; error?: string }; + if (result?.error) throw new Error(result.error); + return result?.data; + } + /** * Download and install a theme from the store */ public async downloadTheme(themeContent: { id: string; name: string; - description: string; - coverImage: string; + description?: string; + coverImage?: string; + theme_json_url?: string; }): Promise { console.debug("[ThemeManager] Downloading theme:", themeContent.name); try { if (!themeContent.id) return; - const response = await fetch( - `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes/${themeContent.id}/theme.json`, - ); - const themeData = (await response.json()) as ThemeContent; + let themeJsonUrl: string; + + // Use theme_json_url if provided (from API list), otherwise call download endpoint + if (themeContent.theme_json_url) { + themeJsonUrl = themeContent.theme_json_url; + } else { + const downloadData = await this.fetchFromUrl( + `${this.THEME_API_BASE}/themes/${themeContent.id}/download` + ) as { success?: boolean; data?: { theme_json_url: string } }; + if (!downloadData?.success || !downloadData?.data?.theme_json_url) { + throw new Error("Failed to get theme download URL"); + } + themeJsonUrl = downloadData.data.theme_json_url; + } + + const themeData = (await this.fetchFromUrl(themeJsonUrl)) as ThemeContent; await this.installTheme(themeData); } catch (error) { From f24292868238f834ae08eb6667368370154352b5 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 20 Feb 2026 10:27:53 +1030 Subject: [PATCH 02/11] feat: query the download api for download counts --- src/plugins/built-in/themes/theme-manager.ts | 23 ++++++++------------ 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/plugins/built-in/themes/theme-manager.ts b/src/plugins/built-in/themes/theme-manager.ts index 0ac37ddb..d077491d 100644 --- a/src/plugins/built-in/themes/theme-manager.ts +++ b/src/plugins/built-in/themes/theme-manager.ts @@ -486,7 +486,8 @@ export class ThemeManager { } /** - * Download and install a theme from the store + * Download and install a theme from the store. + * Always calls the download API first to increment download_count on the server. */ public async downloadTheme(themeContent: { id: string; @@ -499,20 +500,14 @@ export class ThemeManager { try { if (!themeContent.id) return; - let themeJsonUrl: string; - - // Use theme_json_url if provided (from API list), otherwise call download endpoint - if (themeContent.theme_json_url) { - themeJsonUrl = themeContent.theme_json_url; - } else { - const downloadData = await this.fetchFromUrl( - `${this.THEME_API_BASE}/themes/${themeContent.id}/download` - ) as { success?: boolean; data?: { theme_json_url: string } }; - if (!downloadData?.success || !downloadData?.data?.theme_json_url) { - throw new Error("Failed to get theme download URL"); - } - themeJsonUrl = downloadData.data.theme_json_url; + // Always call download endpoint to increment download_count + const downloadData = (await this.fetchFromUrl( + `${this.THEME_API_BASE}/themes/${themeContent.id}/download` + )) as { success?: boolean; data?: { theme_json_url: string } }; + if (!downloadData?.success || !downloadData?.data?.theme_json_url) { + throw new Error("Failed to get theme download URL"); } + const themeJsonUrl = downloadData.data.theme_json_url; const themeData = (await this.fetchFromUrl(themeJsonUrl)) as ThemeContent; From 4b251e0ea4a2d9d75f4caac4cac53c5818e72fa6 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 20 Feb 2026 10:28:13 +1030 Subject: [PATCH 03/11] feat: add github fallback --- src/background.ts | 15 ++++++++--- src/manifests/manifest.json | 2 +- src/plugins/built-in/themes/theme-manager.ts | 28 +++++++++++++------- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/background.ts b/src/background.ts index 911dbdf2..0370f35e 100644 --- a/src/background.ts +++ b/src/background.ts @@ -57,13 +57,20 @@ browser.runtime.onMessage.addListener( return true; case "fetchThemes": { - const url = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`; - fetch(url, { cache: "no-store" }) + const apiUrl = `https://betterseqta.org/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()}`; + fetch(apiUrl, { cache: "no-store" }) .then((r) => r.json()) .then(sendResponse) .catch((err) => { - console.error("[Background] fetchThemes error:", err); - sendResponse({ success: false, error: err?.message }); + 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 ?? [] } })) + .catch((fallbackErr) => { + console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr); + sendResponse({ success: false, error: fallbackErr?.message }); + }); }); return true; } diff --git a/src/manifests/manifest.json b/src/manifests/manifest.json index 1ef79abb..04b9f344 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -21,7 +21,7 @@ "service_worker": "background.ts" }, "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://betterseqta.org" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://betterseqta.org https://raw.githubusercontent.com" }, "content_scripts": [ { diff --git a/src/plugins/built-in/themes/theme-manager.ts b/src/plugins/built-in/themes/theme-manager.ts index d077491d..488e376b 100644 --- a/src/plugins/built-in/themes/theme-manager.ts +++ b/src/plugins/built-in/themes/theme-manager.ts @@ -472,6 +472,7 @@ export class ThemeManager { } private readonly THEME_API_BASE = 'https://betterseqta.org/api'; + private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes'; /** * Fetch JSON from a URL via background script (avoids CORS when running inside SEQTA page) @@ -487,7 +488,7 @@ export class ThemeManager { /** * Download and install a theme from the store. - * Always calls the download API first to increment download_count on the server. + * Uses API first (increments download_count), falls back to GitHub if unreachable. */ public async downloadTheme(themeContent: { id: string; @@ -500,16 +501,23 @@ export class ThemeManager { try { if (!themeContent.id) return; - // Always call download endpoint to increment download_count - const downloadData = (await this.fetchFromUrl( - `${this.THEME_API_BASE}/themes/${themeContent.id}/download` - )) as { success?: boolean; data?: { theme_json_url: string } }; - if (!downloadData?.success || !downloadData?.data?.theme_json_url) { - throw new Error("Failed to get theme download URL"); - } - const themeJsonUrl = downloadData.data.theme_json_url; + let themeData: ThemeContent; - const themeData = (await this.fetchFromUrl(themeJsonUrl)) as ThemeContent; + try { + // Try API first (increments download_count) + const downloadData = (await this.fetchFromUrl( + `${this.THEME_API_BASE}/themes/${themeContent.id}/download` + )) as { success?: boolean; data?: { theme_json_url: string } }; + if (!downloadData?.success || !downloadData?.data?.theme_json_url) { + throw new Error("Failed to get theme download URL"); + } + themeData = (await this.fetchFromUrl(downloadData.data.theme_json_url)) as ThemeContent; + } catch (apiError) { + // Fallback to GitHub if API is unreachable + console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError); + const githubUrl = `${this.GITHUB_THEMES_BASE}/${themeContent.id}/theme.json`; + themeData = (await this.fetchFromUrl(githubUrl)) as ThemeContent; + } await this.installTheme(themeData); } catch (error) { From 7a70b008c82e3215be4f58927341962b5a12ccbb Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 20 Feb 2026 10:49:38 +1030 Subject: [PATCH 04/11] feat: betterseqta cloud for favouriting items and future stuff --- src/background.ts | 128 ++++++++++- .../components/store/CoverSwiper.svelte | 2 +- .../components/store/ThemeCard.svelte | 79 ++++++- .../components/store/ThemeGrid.svelte | 15 +- .../components/store/ThemeModal.svelte | 36 +++- src/interface/pages/settings.svelte | 2 + src/interface/pages/settings/cloud.svelte | 103 +++++++++ src/interface/pages/store.svelte | 51 ++++- src/interface/types/Theme.ts | 2 + src/manifests/manifest.json | 4 +- src/seqta/utils/CloudAuth.ts | 198 ++++++++++++++++++ src/types/storage.ts | 6 + 12 files changed, 606 insertions(+), 20 deletions(-) create mode 100644 src/interface/pages/settings/cloud.svelte create mode 100644 src/seqta/utils/CloudAuth.ts diff --git a/src/background.ts b/src/background.ts index 0370f35e..52941557 100644 --- a/src/background.ts +++ b/src/background.ts @@ -57,9 +57,12 @@ browser.runtime.onMessage.addListener( return true; case "fetchThemes": { + const { token } = request; const apiUrl = `https://betterseqta.org/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()}`; - fetch(apiUrl, { cache: "no-store" }) + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch(apiUrl, { cache: "no-store", headers }) .then((r) => r.json()) .then(sendResponse) .catch((err) => { @@ -91,6 +94,129 @@ browser.runtime.onMessage.addListener( return true; } + case "cloudReserveClient": { + const redirect_uri = + request.redirect_uri ?? "https://accounts.betterseqta.org/auth/bsplus/callback"; + fetch("https://accounts.betterseqta.org/api/bsplus/client/reserve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ redirect_uri }), + }) + .then(async (r) => { + const text = await r.text(); + let data: any = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch { + sendResponse({ error: "Invalid response from server" }); + return; + } + if (!r.ok) { + sendResponse({ + error: data?.error ?? `Reserve failed (${r.status})`, + }); + } else { + sendResponse(data); + } + }) + .catch((err) => { + console.error("[Background] cloudReserveClient error:", err); + sendResponse({ error: err?.message ?? "Network error" }); + }); + return true; + } + + case "cloudLogin": { + const { client_id, redirect_uri, login, password } = request; + if (!client_id || !redirect_uri || !login || !password) { + sendResponse({ + error: "Missing client_id, redirect_uri, login, or password", + }); + return false; + } + fetch("https://accounts.betterseqta.org/api/bsplus/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id, + redirect_uri, + login, + password, + }), + }) + .then(async (r) => { + const text = await r.text(); + let data: any = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch { + sendResponse({ error: "Invalid response from server" }); + return; + } + if (!r.ok) { + sendResponse({ error: data?.error ?? "Login failed" }); + return; + } + sendResponse(data); + }) + .catch((err) => { + console.error("[Background] cloudLogin error:", err); + sendResponse({ error: err?.message ?? "Network error" }); + }); + return true; + } + + case "cloudRefresh": { + const { refresh_token, client_id } = request; + if (!refresh_token || !client_id) { + sendResponse({ error: "Missing refresh_token or client_id" }); + return false; + } + fetch("https://accounts.betterseqta.org/api/bsplus/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token, client_id }), + }) + .then(async (r) => { + const text = await r.text(); + let data: any = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch { + sendResponse({ error: "Invalid response from server" }); + return; + } + if (!r.ok) sendResponse({ error: data?.error ?? "Refresh failed" }); + else sendResponse(data); + }) + .catch((err) => { + console.error("[Background] cloudRefresh error:", err); + sendResponse({ error: err?.message ?? "Network error" }); + }); + return true; + } + + case "cloudFavorite": { + const { themeId, token, action } = request; + if (!themeId || !token) { + sendResponse({ success: false, error: "Theme ID and token required" }); + return false; + } + const isFavorite = action === "favorite"; + const url = `https://betterseqta.org/api/themes/${themeId}/favorite`; + fetch(url, { + 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; + } + default: console.log("Unknown request type"); } diff --git a/src/interface/components/store/CoverSwiper.svelte b/src/interface/components/store/CoverSwiper.svelte index 981eb4af..69889cc3 100644 --- a/src/interface/components/store/CoverSwiper.svelte +++ b/src/interface/components/store/CoverSwiper.svelte @@ -42,7 +42,7 @@ onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }} onclick={() => setDisplayTheme(theme)} > - Theme Preview + Theme Preview

{theme.name}

{theme.description}

diff --git a/src/interface/components/store/ThemeCard.svelte b/src/interface/components/store/ThemeCard.svelte index 178371eb..5ca16782 100644 --- a/src/interface/components/store/ThemeCard.svelte +++ b/src/interface/components/store/ThemeCard.svelte @@ -1,19 +1,86 @@ -
-
+
+
+ +
+ + {#if menuOpen} + + {/if} +
{theme.name}
- Theme Preview + Theme Preview
diff --git a/src/interface/components/store/ThemeGrid.svelte b/src/interface/components/store/ThemeGrid.svelte index 6f56120c..b3523f67 100644 --- a/src/interface/components/store/ThemeGrid.svelte +++ b/src/interface/components/store/ThemeGrid.svelte @@ -2,7 +2,13 @@ import type { Theme } from '@/interface/types/Theme' import ThemeCard from './ThemeCard.svelte'; - let { themes, searchTerm, setDisplayTheme } = $props<{ themes: Theme[]; searchTerm: string, setDisplayTheme: (theme: Theme) => void }>(); + let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn } = $props<{ + themes: Theme[]; + searchTerm: string; + setDisplayTheme: (theme: Theme) => void; + toggleFavorite: (theme: Theme) => void; + isLoggedIn: boolean; + }>(); let filteredThemes = $derived(themes.filter((theme: Theme) => theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase()) @@ -12,7 +18,12 @@
{#each filteredThemes as theme (theme.id)} - setDisplayTheme(theme)} /> + setDisplayTheme(theme)} + {toggleFavorite} + {isLoggedIn} + /> {/each} {#if filteredThemes.length !== 0} diff --git a/src/interface/components/store/ThemeModal.svelte b/src/interface/components/store/ThemeModal.svelte index e7752e70..14fc32c1 100644 --- a/src/interface/components/store/ThemeModal.svelte +++ b/src/interface/components/store/ThemeModal.svelte @@ -3,7 +3,7 @@ import { fade } from 'svelte/transition'; import { animate } from 'motion'; - let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme } = $props<{ + let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme, toggleFavorite, isLoggedIn } = $props<{ theme: Theme | null; currentThemes: string[]; setDisplayTheme: (theme: Theme | null) => void; @@ -11,6 +11,8 @@ onRemove: (themeId: string) => void; allThemes: Theme[]; displayTheme: Theme | null; + toggleFavorite?: (theme: Theme) => void; + isLoggedIn?: boolean; }>(); let installing = $state(false); let modalElement: HTMLElement; @@ -73,13 +75,35 @@ onkeydown={(e) => e.stopPropagation()} >
- +
+ {#if isLoggedIn && toggleFavorite && theme} + + {/if} + +

{theme.name}

- Theme Cover + Theme Cover

{theme.description}

@@ -116,7 +140,7 @@ {relatedTheme.name}
- Theme Preview + Theme Preview
{/each} diff --git a/src/interface/pages/settings.svelte b/src/interface/pages/settings.svelte index 3ecdad60..74c3a7d0 100644 --- a/src/interface/pages/settings.svelte +++ b/src/interface/pages/settings.svelte @@ -3,6 +3,7 @@ import Settings from "./settings/general.svelte"; import Shortcuts from "./settings/shortcuts.svelte"; import Theme from "./settings/theme.svelte"; + import Cloud from "./settings/cloud.svelte"; import browser from "webextension-polyfill"; import { standalone as StandaloneStore } from "../utils/standalone.svelte"; @@ -283,6 +284,7 @@ }, { title: "Shortcuts", Content: Shortcuts }, { title: "Themes", Content: Theme }, + { title: "BetterSEQTA Cloud", Content: Cloud }, ]} />
diff --git a/src/interface/pages/settings/cloud.svelte b/src/interface/pages/settings/cloud.svelte new file mode 100644 index 00000000..e951b58b --- /dev/null +++ b/src/interface/pages/settings/cloud.svelte @@ -0,0 +1,103 @@ + + +
+
+

BetterSEQTA Cloud

+

+ Sign in to favorite themes in the theme store. Your favorites sync across devices when logged in. +

+ + {#if cloudState.isLoggedIn} +
+

+ Signed in as + + {cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"} + +

+
+ {:else} +
{ + e.preventDefault(); + handleLogin(); + }} + > + + + {#if error} +

{error}

+ {/if} + +
+ {/if} +
+
diff --git a/src/interface/pages/store.svelte b/src/interface/pages/store.svelte index 6d4c0345..ba7835dc 100644 --- a/src/interface/pages/store.svelte +++ b/src/interface/pages/store.svelte @@ -15,8 +15,12 @@ import { loadBackground } from '@/seqta/ui/ImageBackgrounds' import Backgrounds from '../components/store/Backgrounds.svelte' + import { cloudAuth } from '@/seqta/utils/CloudAuth' const themeManager = ThemeManager.getInstance(); + let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn); + + cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; }); // State variables let searchTerm = $state(''); @@ -48,10 +52,34 @@ activeTab = tab; }; + const toggleFavorite = async (theme: Theme) => { + const token = await cloudAuth.getStoredToken(); + if (!token) return; + const isFavorite = !theme.is_favorited; + const result = (await browser.runtime.sendMessage({ + type: 'cloudFavorite', + themeId: theme.id, + token, + action: isFavorite ? 'favorite' : 'unfavorite', + })) as { success?: boolean }; + if (result?.success) { + themes = themes.map((t) => + t.id === theme.id ? { ...t, is_favorited: isFavorite } : t + ); + if (displayTheme?.id === theme.id) { + displayTheme = { ...displayTheme, is_favorited: isFavorite }; + } + } + }; + // Fetch themes via background script (avoids CORS when store runs inside SEQTA page) const fetchThemes = async () => { try { - const data = (await browser.runtime.sendMessage({ type: 'fetchThemes' })) as { + const token = await cloudAuth.getStoredToken(); + const data = (await browser.runtime.sendMessage({ + type: 'fetchThemes', + token: token ?? undefined, + })) as { success?: boolean; data?: { themes: Theme[] }; error?: string; @@ -97,6 +125,17 @@ console.error(error); } }); + + // Refetch themes when user logs in (from another tab) to get is_favorited + let lastLoggedIn = $state(false); + $effect(() => { + if (cloudLoggedIn && !lastLoggedIn) { + lastLoggedIn = true; + fetchThemes(); + } else if (!cloudLoggedIn) { + lastLoggedIn = false; + } + });
@@ -117,7 +156,13 @@ {/if} - + {#if displayTheme} { if (displayTheme) { await themeManager.downloadTheme(displayTheme); diff --git a/src/interface/types/Theme.ts b/src/interface/types/Theme.ts index 853760cb..1ec639b0 100644 --- a/src/interface/types/Theme.ts +++ b/src/interface/types/Theme.ts @@ -5,4 +5,6 @@ export type Theme = { coverImage: string; marqueeImage?: string; theme_json_url?: string; + is_favorited?: boolean; + favorite_count?: number; }; diff --git a/src/manifests/manifest.json b/src/manifests/manifest.json index 04b9f344..40091d86 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -16,12 +16,12 @@ } }, "permissions": ["tabs", "notifications", "storage"], - "host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "*://*/*"], + "host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"], "background": { "service_worker": "background.ts" }, "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://betterseqta.org https://raw.githubusercontent.com" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com" }, "content_scripts": [ { diff --git a/src/seqta/utils/CloudAuth.ts b/src/seqta/utils/CloudAuth.ts new file mode 100644 index 00000000..c661e8cb --- /dev/null +++ b/src/seqta/utils/CloudAuth.ts @@ -0,0 +1,198 @@ +import browser from "webextension-polyfill"; +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; + +const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback"; + +const STORAGE_KEYS = { + clientId: "bsplus_client_id", + accessToken: "bsplus_token", + refreshToken: "bsplus_refresh_token", + user: "bsplus_user", +} as const; + +export type CloudUser = { + id: string; + email?: string; + username?: string; + displayName?: string; + pfpUrl?: string; + admin_level?: number; +}; + +export type CloudAuthState = { + isLoggedIn: boolean; + user: CloudUser | null; +}; + +type Listener = (state: CloudAuthState) => void; + +class CloudAuthService { + private static instance: CloudAuthService; + private listeners = new Set(); + private _state: CloudAuthState = { isLoggedIn: false, user: null }; + + private constructor() { + void this.loadFromStorage(); + browser.storage.onChanged.addListener((changes, areaName) => { + if ( + areaName === "local" && + (changes[STORAGE_KEYS.accessToken] || + changes[STORAGE_KEYS.user] || + changes[STORAGE_KEYS.clientId]) + ) { + void this.loadFromStorage(); + } + }); + } + + public static getInstance(): CloudAuthService { + if (!CloudAuthService.instance) { + CloudAuthService.instance = new CloudAuthService(); + } + return CloudAuthService.instance; + } + + public get state(): CloudAuthState { + return this._state; + } + + public subscribe(listener: Listener): () => void { + this.listeners.add(listener); + listener(this._state); + return () => this.listeners.delete(listener); + } + + private async loadFromStorage(): Promise { + const result = await browser.storage.local.get([ + STORAGE_KEYS.accessToken, + STORAGE_KEYS.user, + ]); + const token = result[STORAGE_KEYS.accessToken] as string | undefined; + const user = result[STORAGE_KEYS.user] as CloudUser | undefined; + this._state = { + isLoggedIn: !!token, + user: user ?? null, + }; + this.notify(); + } + + private notify(): void { + for (const listener of this.listeners) { + listener(this._state); + } + } + + public async getStoredToken(): Promise { + const result = await browser.storage.local.get(STORAGE_KEYS.accessToken); + return (result[STORAGE_KEYS.accessToken] as string) ?? null; + } + + private async getClientId(): Promise { + let clientId = (settingsState as any)[STORAGE_KEYS.clientId] as string | undefined; + if (!clientId) { + const stored = await browser.storage.local.get(STORAGE_KEYS.clientId); + clientId = stored[STORAGE_KEYS.clientId] as string | undefined; + } + if (!clientId) { + const reserveResult = (await browser.runtime.sendMessage({ + type: "cloudReserveClient", + redirect_uri: REDIRECT_URI, + })) as { client_id?: string; error?: string }; + if (!reserveResult?.client_id) { + throw new Error(reserveResult?.error ?? "Failed to reserve client"); + } + clientId = reserveResult.client_id; + (settingsState as any).setKey(STORAGE_KEYS.clientId, clientId); + } + return clientId; + } + + public async login( + login: string, + password: string + ): Promise<{ success: boolean; error?: string }> { + try { + const clientId = await this.getClientId(); + const result = (await browser.runtime.sendMessage({ + type: "cloudLogin", + client_id: clientId, + redirect_uri: REDIRECT_URI, + login: login.trim(), + password, + })) as { + access_token?: string; + refresh_token?: string; + user?: CloudUser; + error?: string; + }; + if (result?.access_token && result?.refresh_token) { + (settingsState as any).setKey(STORAGE_KEYS.accessToken, result.access_token); + (settingsState as any).setKey(STORAGE_KEYS.refreshToken, result.refresh_token); + (settingsState as any).setKey(STORAGE_KEYS.user, result.user ?? null); + this._state = { + isLoggedIn: true, + user: result.user ?? null, + }; + this.notify(); + return { success: true }; + } + return { + success: false, + error: result?.error ?? "Login failed", + }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Login failed", + }; + } + } + + public async logout(): Promise { + await browser.storage.local.remove([ + STORAGE_KEYS.accessToken, + STORAGE_KEYS.refreshToken, + STORAGE_KEYS.user, + "cloudAccessToken", + "cloudUsername", + ]); + this._state = { isLoggedIn: false, user: null }; + this.notify(); + } + + public async refreshToken(): Promise { + const result = await browser.storage.local.get([ + STORAGE_KEYS.refreshToken, + STORAGE_KEYS.clientId, + ]); + const refreshToken = result[STORAGE_KEYS.refreshToken] as string | undefined; + const clientId = result[STORAGE_KEYS.clientId] as string | undefined; + if (!refreshToken || !clientId) return false; + + const refreshResult = (await browser.runtime.sendMessage({ + type: "cloudRefresh", + refresh_token: refreshToken, + client_id: clientId, + })) as { + access_token?: string; + refresh_token?: string; + user?: CloudUser; + error?: string; + }; + + if (refreshResult?.access_token && refreshResult?.refresh_token) { + (settingsState as any).setKey(STORAGE_KEYS.accessToken, refreshResult.access_token); + (settingsState as any).setKey(STORAGE_KEYS.refreshToken, refreshResult.refresh_token); + (settingsState as any).setKey(STORAGE_KEYS.user, refreshResult.user ?? null); + this._state = { + isLoggedIn: true, + user: refreshResult.user ?? null, + }; + this.notify(); + return true; + } + return false; + } +} + +export const cloudAuth = CloudAuthService.getInstance(); diff --git a/src/types/storage.ts b/src/types/storage.ts index d25cc192..92ee9e5f 100644 --- a/src/types/storage.ts +++ b/src/types/storage.ts @@ -47,6 +47,12 @@ export interface SettingsState { lettergrade: boolean; assessmentsAverage?: boolean; notificationCollector?: boolean; + + // BetterSEQTA Cloud (accounts.betterseqta.org) + bsplus_client_id?: string; + bsplus_token?: string; + bsplus_refresh_token?: string; + bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; admin_level?: number }; } interface ToggleItem { From 889175f3deb1ba134991c94a9db831d241580da9 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 20 Feb 2026 18:06:43 +1030 Subject: [PATCH 05/11] chore: appease codefactor --- src/seqta/utils/CloudAuth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seqta/utils/CloudAuth.ts b/src/seqta/utils/CloudAuth.ts index c661e8cb..08ff7a4e 100644 --- a/src/seqta/utils/CloudAuth.ts +++ b/src/seqta/utils/CloudAuth.ts @@ -24,7 +24,7 @@ export type CloudAuthState = { user: CloudUser | null; }; -type Listener = (state: CloudAuthState) => void; +type Listener = (_state: CloudAuthState) => void; class CloudAuthService { private static instance: CloudAuthService; From d21ce90a5ccb915b5b59d2696cf3d869d79253f9 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 20 Feb 2026 18:29:11 +1030 Subject: [PATCH 06/11] feat: download & like count + UI tweaks and cleanup --- src/background.ts | 21 +++ .../components/SignInToFavoriteModal.svelte | 77 ++++++++ .../components/TabbedContainer.svelte | 3 +- .../components/store/ThemeCard.svelte | 32 +++- .../components/store/ThemeModal.svelte | 102 ++++++----- .../components/themes/ThemeSelector.svelte | 76 +++++++- src/interface/pages/settings.svelte | 13 +- src/interface/pages/settings/cloud.svelte | 165 ++++++++++++------ src/interface/pages/store.svelte | 11 +- src/interface/types/Theme.ts | 1 + 10 files changed, 395 insertions(+), 106 deletions(-) create mode 100644 src/interface/components/SignInToFavoriteModal.svelte diff --git a/src/background.ts b/src/background.ts index 52941557..0adf7a5d 100644 --- a/src/background.ts +++ b/src/background.ts @@ -78,6 +78,27 @@ browser.runtime.onMessage.addListener( return true; } + case "fetchThemeDetails": { + const { themeId, token } = request; + if (!themeId || typeof themeId !== "string") { + sendResponse({ success: false, error: "Missing themeId" }); + return false; + } + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch(`https://betterseqta.org/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; + } + case "fetchFromUrl": { const { url } = request; if (!url || typeof url !== "string") { diff --git a/src/interface/components/SignInToFavoriteModal.svelte b/src/interface/components/SignInToFavoriteModal.svelte new file mode 100644 index 00000000..e3d5b29c --- /dev/null +++ b/src/interface/components/SignInToFavoriteModal.svelte @@ -0,0 +1,77 @@ + + +
{ + if (e.target === e.currentTarget) onClose(); + }} + onkeydown={(e) => { + if (e.key === "Escape") onClose(); + }} + role="button" + tabindex="-1" + transition:fade={{ duration: 150 }} +> + +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > +

+ Sign in to favorite themes +

+ +

+ Go to Settings → BetterSEQTA Cloud to sign in, or create an account to get started. +

+ +
+ + + Create account + + +
+
+
diff --git a/src/interface/components/TabbedContainer.svelte b/src/interface/components/TabbedContainer.svelte index 43a62349..d053f890 100644 --- a/src/interface/components/TabbedContainer.svelte +++ b/src/interface/components/TabbedContainer.svelte @@ -3,8 +3,7 @@ import './TabbedContainer.css'; import { onMount } from 'svelte'; - let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>(); - let activeTab = $state(0); + let { tabs, activeTab = $bindable(0) } = $props<{ tabs: { title: string, Content: any, props?: any }[]; activeTab?: number }>(); let containerRef: HTMLElement | null = null; let tabWidth = $state(0); diff --git a/src/interface/components/store/ThemeCard.svelte b/src/interface/components/store/ThemeCard.svelte index 5ca16782..7b28a504 100644 --- a/src/interface/components/store/ThemeCard.svelte +++ b/src/interface/components/store/ThemeCard.svelte @@ -2,6 +2,7 @@ import type { Theme } from '@/interface/types/Theme' import { fade } from 'svelte/transition'; import { onMount } from 'svelte'; + import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte'; let { theme, onClick, toggleFavorite, isLoggedIn } = $props<{ theme: Theme; @@ -10,6 +11,7 @@ isLoggedIn: boolean; }>(); let menuOpen = $state(false); + let showSignInModal = $state(false); let menuRef: HTMLDivElement; onMount(() => { @@ -29,7 +31,11 @@ function handleFavoriteClick(e: MouseEvent) { e.stopPropagation(); - if (isLoggedIn) toggleFavorite(theme); + if (isLoggedIn) { + toggleFavorite(theme); + } else { + showSignInModal = true; + } menuOpen = false; } @@ -55,7 +61,7 @@ >
-
- {theme.name} +
+ {theme.name} +
+ + + + + {(theme.download_count ?? 0).toLocaleString()} + + + + + + {(theme.favorite_count ?? 0).toLocaleString()} + +
@@ -84,3 +104,7 @@
+ +{#if showSignInModal} + (showSignInModal = false)} /> +{/if} diff --git a/src/interface/components/store/ThemeModal.svelte b/src/interface/components/store/ThemeModal.svelte index 14fc32c1..5deff576 100644 --- a/src/interface/components/store/ThemeModal.svelte +++ b/src/interface/components/store/ThemeModal.svelte @@ -2,6 +2,7 @@ import type { Theme } from '@/interface/types/Theme' import { fade } from 'svelte/transition'; import { animate } from 'motion'; + import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte'; let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme, toggleFavorite, isLoggedIn } = $props<{ theme: Theme | null; @@ -15,8 +16,17 @@ isLoggedIn?: boolean; }>(); let installing = $state(false); + let showSignInModal = $state(false); let modalElement: HTMLElement; + function handleFavoriteClick() { + if (isLoggedIn && toggleFavorite && theme) { + toggleFavorite(theme); + } else { + showSignInModal = true; + } + } + // Function to get related themes function getRelatedThemes() { return allThemes @@ -76,56 +86,66 @@ >
- {#if isLoggedIn && toggleFavorite && theme} - - {/if}
-

+

{theme.name}

+
+ + + + + {(theme.download_count ?? 0).toLocaleString()} downloads + + + + + + {(theme.favorite_count ?? 0).toLocaleString()} favorites + +
Theme Cover

{theme.description}

- {#if currentThemes.includes(theme.id)} - - {:else} - - {/if} + {theme.is_favorited ? 'Favorited' : 'Favorite'} + + {/if} + {#if currentThemes.includes(theme.id)} + + {:else} + + {/if} +
@@ -148,3 +168,7 @@
+ +{#if showSignInModal} + (showSignInModal = false)} /> +{/if} diff --git a/src/interface/components/themes/ThemeSelector.svelte b/src/interface/components/themes/ThemeSelector.svelte index e8606c6f..80be9e8d 100644 --- a/src/interface/components/themes/ThemeSelector.svelte +++ b/src/interface/components/themes/ThemeSelector.svelte @@ -1,11 +1,14 @@ @@ -276,6 +282,7 @@
-
-

BetterSEQTA Cloud

-

- Sign in to favorite themes in the theme store. Your favorites sync across devices when logged in. -

- - {#if cloudState.isLoggedIn} -
-

- Signed in as - - {cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"} - -

-
+ +
+ {#if cloudState.isLoggedIn} + +
+
+ {#if cloudState.user?.pfpUrl} + + {:else} +
+ {getInitials()} +
+ {/if} +
+

+ {cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"} +

+ {#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)} +

{cloudState.user.email}

+ {/if} +
+
+
- - {/if} + {:else} + +

+ Sign in to favorite themes in the store. Your favorites sync across devices when logged in. +

+
{ + e.preventDefault(); + handleLogin(); + }} + > +
+ + +
+
+ + +
+ {#if error} +

{error}

+ {/if} + +
+ {/if} +
diff --git a/src/interface/pages/store.svelte b/src/interface/pages/store.svelte index ba7835dc..67de3f78 100644 --- a/src/interface/pages/store.svelte +++ b/src/interface/pages/store.svelte @@ -63,11 +63,18 @@ action: isFavorite ? 'favorite' : 'unfavorite', })) as { success?: boolean }; if (result?.success) { + const delta = isFavorite ? 1 : -1; themes = themes.map((t) => - t.id === theme.id ? { ...t, is_favorited: isFavorite } : t + t.id === theme.id + ? { ...t, is_favorited: isFavorite, favorite_count: Math.max(0, (t.favorite_count ?? 0) + delta) } + : t ); if (displayTheme?.id === theme.id) { - displayTheme = { ...displayTheme, is_favorited: isFavorite }; + displayTheme = { + ...displayTheme, + is_favorited: isFavorite, + favorite_count: Math.max(0, (displayTheme.favorite_count ?? 0) + delta), + }; } } }; diff --git a/src/interface/types/Theme.ts b/src/interface/types/Theme.ts index 1ec639b0..28fb7918 100644 --- a/src/interface/types/Theme.ts +++ b/src/interface/types/Theme.ts @@ -7,4 +7,5 @@ export type Theme = { theme_json_url?: string; is_favorited?: boolean; favorite_count?: number; + download_count?: number; }; From 9d24d07c12b5f84afbb6409c0dd2f7afb3d73dbe Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 20 Feb 2026 18:43:02 +1030 Subject: [PATCH 07/11] chore: appease codefactor AGAIN --- src/background.ts | 415 ++++++++++++++++------------------- src/seqta/utils/CloudAuth.ts | 2 +- 2 files changed, 191 insertions(+), 226 deletions(-) diff --git a/src/background.ts b/src/background.ts index 0adf7a5d..c7a2d6c8 100644 --- a/src/background.ts +++ b/src/background.ts @@ -14,234 +14,199 @@ function reloadSeqtaPages() { result.then(open, console.error); } +type MessageSender = (response?: any) => void; + +function handleFetchThemes(request: any, sendResponse: MessageSender): boolean { + const { token } = request; + const apiUrl = `https://betterseqta.org/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((r) => r.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 ?? [] } })) + .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; + if (!themeId || typeof themeId !== "string") { + sendResponse({ success: false, error: "Missing themeId" }); + return false; + } + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch(`https://betterseqta.org/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 { + const { url } = request; + if (!url || typeof url !== "string") { + sendResponse({ error: "Missing url" }); + return false; + } + fetch(url, { cache: "no-store" }) + .then((r) => r.json()) + .then((data) => sendResponse({ data })) + .catch((err) => { + console.error("[Background] fetchFromUrl error:", err); + sendResponse({ error: err?.message }); + }); + return true; +} + +async function parseJsonResponse(r: Response): Promise { + const text = await r.text(); + try { + return text ? JSON.parse(text) : {}; + } catch { + return {}; + } +} + +function handleCloudReserveClient(request: any, sendResponse: MessageSender): boolean { + const redirect_uri = request.redirect_uri ?? "https://accounts.betterseqta.org/auth/bsplus/callback"; + fetch("https://accounts.betterseqta.org/api/bsplus/client/reserve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ redirect_uri }), + }) + .then(async (r) => { + const data = await parseJsonResponse(r); + if (!r.ok) sendResponse({ error: data?.error ?? `Reserve failed (${r.status})` }); + else sendResponse(data); + }) + .catch((err) => { + console.error("[Background] cloudReserveClient error:", err); + sendResponse({ error: err?.message ?? "Network error" }); + }); + return true; +} + +function handleCloudLogin(request: any, sendResponse: MessageSender): boolean { + const { client_id, redirect_uri, login, password } = request; + if (!client_id || !redirect_uri || !login || !password) { + sendResponse({ error: "Missing client_id, redirect_uri, login, or password" }); + return false; + } + fetch("https://accounts.betterseqta.org/api/bsplus/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_id, redirect_uri, login, password }), + }) + .then(async (r) => { + const data = await parseJsonResponse(r); + if (!r.ok) sendResponse({ error: data?.error ?? "Login failed" }); + else sendResponse(data); + }) + .catch((err) => { + console.error("[Background] cloudLogin error:", err); + sendResponse({ error: err?.message ?? "Network error" }); + }); + return true; +} + +function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean { + const { refresh_token, client_id } = request; + if (!refresh_token || !client_id) { + sendResponse({ error: "Missing refresh_token or client_id" }); + return false; + } + fetch("https://accounts.betterseqta.org/api/bsplus/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token, client_id }), + }) + .then(async (r) => { + const data = await parseJsonResponse(r); + if (!r.ok) sendResponse({ error: data?.error ?? "Refresh failed" }); + else sendResponse(data); + }) + .catch((err) => { + console.error("[Background] cloudRefresh error:", err); + sendResponse({ error: err?.message ?? "Network error" }); + }); + return true; +} + +function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean { + const { themeId, token, action } = request; + if (!themeId || !token) { + sendResponse({ success: false, error: "Theme ID and token required" }); + return false; + } + const isFavorite = action === "favorite"; + fetch(`https://betterseqta.org/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; +} + +const MESSAGE_HANDLERS: Record boolean | void> = { + reloadTabs: () => reloadSeqtaPages(), + extensionPages: (req) => { + browser.tabs.query({}).then((tabs) => { + for (const tab of tabs) { + if (tab.url?.includes("chrome-extension://")) browser.tabs.sendMessage(tab.id!, req); + } + }); + }, + currentTab: (req, sendResponse) => { + browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => { + browser.tabs.sendMessage(tabs[0].id!, req).then(sendResponse); + }); + return true; + }, + githubTab: () => { + void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" }); + }, + setDefaultStorage: () => SetStorageValue(getDefaultValues()), + sendNews: (req, sendResponse) => { + fetchNews(req.source ?? "australia", sendResponse); + return true; + }, + fetchThemes: handleFetchThemes, + fetchThemeDetails: handleFetchThemeDetails, + fetchFromUrl: handleFetchFromUrl, + cloudReserveClient: handleCloudReserveClient, + cloudLogin: handleCloudLogin, + cloudRefresh: handleCloudRefresh, + cloudFavorite: handleCloudFavorite, +}; + browser.runtime.onMessage.addListener( // @ts-ignore - OnMessageListener expects literal true for async, we return boolean - (request: any, _: any, sendResponse: (response?: any) => void) => { - switch (request.type) { - case "reloadTabs": - reloadSeqtaPages(); - break; - - case "extensionPages": - browser.tabs.query({}).then(function (tabs) { - for (let tab of tabs) { - if (tab.url?.includes("chrome-extension://")) { - browser.tabs.sendMessage(tab.id!, request); - } - } - }); - break; - - case "currentTab": - browser.tabs - .query({ active: true, currentWindow: true }) - .then(function (tabs) { - browser.tabs - .sendMessage(tabs[0].id!, request) - .then(function (response) { - sendResponse(response); - }); - }); - return true; - - case "githubTab": - browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" }); - break; - - case "setDefaultStorage": - SetStorageValue(getDefaultValues()); - break; - - case "sendNews": - fetchNews(request.source ?? "australia", sendResponse); - return true; - - case "fetchThemes": { - const { token } = request; - const apiUrl = `https://betterseqta.org/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((r) => r.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 ?? [] } })) - .catch((fallbackErr) => { - console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr); - sendResponse({ success: false, error: fallbackErr?.message }); - }); - }); - return true; - } - - case "fetchThemeDetails": { - const { themeId, token } = request; - if (!themeId || typeof themeId !== "string") { - sendResponse({ success: false, error: "Missing themeId" }); - return false; - } - const headers: Record = {}; - if (token) headers["Authorization"] = `Bearer ${token}`; - fetch(`https://betterseqta.org/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; - } - - case "fetchFromUrl": { - const { url } = request; - if (!url || typeof url !== "string") { - sendResponse({ error: "Missing url" }); - return false; - } - fetch(url, { cache: "no-store" }) - .then((r) => r.json()) - .then((data) => sendResponse({ data })) - .catch((err) => { - console.error("[Background] fetchFromUrl error:", err); - sendResponse({ error: err?.message }); - }); - return true; - } - - case "cloudReserveClient": { - const redirect_uri = - request.redirect_uri ?? "https://accounts.betterseqta.org/auth/bsplus/callback"; - fetch("https://accounts.betterseqta.org/api/bsplus/client/reserve", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ redirect_uri }), - }) - .then(async (r) => { - const text = await r.text(); - let data: any = {}; - try { - data = text ? JSON.parse(text) : {}; - } catch { - sendResponse({ error: "Invalid response from server" }); - return; - } - if (!r.ok) { - sendResponse({ - error: data?.error ?? `Reserve failed (${r.status})`, - }); - } else { - sendResponse(data); - } - }) - .catch((err) => { - console.error("[Background] cloudReserveClient error:", err); - sendResponse({ error: err?.message ?? "Network error" }); - }); - return true; - } - - case "cloudLogin": { - const { client_id, redirect_uri, login, password } = request; - if (!client_id || !redirect_uri || !login || !password) { - sendResponse({ - error: "Missing client_id, redirect_uri, login, or password", - }); - return false; - } - fetch("https://accounts.betterseqta.org/api/bsplus/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - client_id, - redirect_uri, - login, - password, - }), - }) - .then(async (r) => { - const text = await r.text(); - let data: any = {}; - try { - data = text ? JSON.parse(text) : {}; - } catch { - sendResponse({ error: "Invalid response from server" }); - return; - } - if (!r.ok) { - sendResponse({ error: data?.error ?? "Login failed" }); - return; - } - sendResponse(data); - }) - .catch((err) => { - console.error("[Background] cloudLogin error:", err); - sendResponse({ error: err?.message ?? "Network error" }); - }); - return true; - } - - case "cloudRefresh": { - const { refresh_token, client_id } = request; - if (!refresh_token || !client_id) { - sendResponse({ error: "Missing refresh_token or client_id" }); - return false; - } - fetch("https://accounts.betterseqta.org/api/bsplus/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh_token, client_id }), - }) - .then(async (r) => { - const text = await r.text(); - let data: any = {}; - try { - data = text ? JSON.parse(text) : {}; - } catch { - sendResponse({ error: "Invalid response from server" }); - return; - } - if (!r.ok) sendResponse({ error: data?.error ?? "Refresh failed" }); - else sendResponse(data); - }) - .catch((err) => { - console.error("[Background] cloudRefresh error:", err); - sendResponse({ error: err?.message ?? "Network error" }); - }); - return true; - } - - case "cloudFavorite": { - const { themeId, token, action } = request; - if (!themeId || !token) { - sendResponse({ success: false, error: "Theme ID and token required" }); - return false; - } - const isFavorite = action === "favorite"; - const url = `https://betterseqta.org/api/themes/${themeId}/favorite`; - fetch(url, { - 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; - } - - default: - console.log("Unknown request type"); + (request: any, _: any, sendResponse: MessageSender) => { + const handler = MESSAGE_HANDLERS[request.type]; + if (handler) { + const result = handler(request, sendResponse); + return result === true; } - + console.log("Unknown request type"); return false; }, ); diff --git a/src/seqta/utils/CloudAuth.ts b/src/seqta/utils/CloudAuth.ts index 08ff7a4e..c661e8cb 100644 --- a/src/seqta/utils/CloudAuth.ts +++ b/src/seqta/utils/CloudAuth.ts @@ -24,7 +24,7 @@ export type CloudAuthState = { user: CloudUser | null; }; -type Listener = (_state: CloudAuthState) => void; +type Listener = (state: CloudAuthState) => void; class CloudAuthService { private static instance: CloudAuthService; From 2faef2ae8db6c92a57658734bb90acde3cabb6cc Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 20 Feb 2026 19:08:36 +1030 Subject: [PATCH 08/11] fix: fix cf like too many times --- src/background.ts | 6 ++++-- src/seqta/utils/CloudAuth.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/background.ts b/src/background.ts index c7a2d6c8..5135fdc6 100644 --- a/src/background.ts +++ b/src/background.ts @@ -14,7 +14,7 @@ function reloadSeqtaPages() { result.then(open, console.error); } -type MessageSender = (response?: any) => void; +type MessageSender = (_?: unknown) => void; function handleFetchThemes(request: any, sendResponse: MessageSender): boolean { const { token } = request; @@ -166,7 +166,9 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean return true; } -const MESSAGE_HANDLERS: Record boolean | void> = { +type MessageHandler = (_req: any, _res: MessageSender) => boolean | void; + +const MESSAGE_HANDLERS: Record = { reloadTabs: () => reloadSeqtaPages(), extensionPages: (req) => { browser.tabs.query({}).then((tabs) => { diff --git a/src/seqta/utils/CloudAuth.ts b/src/seqta/utils/CloudAuth.ts index c661e8cb..ccff2f5a 100644 --- a/src/seqta/utils/CloudAuth.ts +++ b/src/seqta/utils/CloudAuth.ts @@ -24,7 +24,7 @@ export type CloudAuthState = { user: CloudUser | null; }; -type Listener = (state: CloudAuthState) => void; +type Listener = (_: CloudAuthState) => void; class CloudAuthService { private static instance: CloudAuthService; From 01f5e8f61d8dba660619fb86b5118c39939acd2f Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 20 Feb 2026 19:13:06 +1030 Subject: [PATCH 09/11] fix: cf is very annoying --- src/background.ts | 6 ++++-- src/seqta/utils/CloudAuth.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/background.ts b/src/background.ts index 5135fdc6..04da8de2 100644 --- a/src/background.ts +++ b/src/background.ts @@ -14,7 +14,8 @@ function reloadSeqtaPages() { result.then(open, console.error); } -type MessageSender = (_?: unknown) => void; +/** Callback for sending a response back to the message sender */ +type MessageSender = { (response?: unknown): void }; function handleFetchThemes(request: any, sendResponse: MessageSender): boolean { const { token } = request; @@ -166,7 +167,8 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean return true; } -type MessageHandler = (_req: any, _res: MessageSender) => boolean | void; +/** Handler for a message type; receives request and sendResponse callback */ +type MessageHandler = { (request: any, sendResponse: MessageSender): boolean | void }; const MESSAGE_HANDLERS: Record = { reloadTabs: () => reloadSeqtaPages(), diff --git a/src/seqta/utils/CloudAuth.ts b/src/seqta/utils/CloudAuth.ts index ccff2f5a..ff4f15f8 100644 --- a/src/seqta/utils/CloudAuth.ts +++ b/src/seqta/utils/CloudAuth.ts @@ -24,7 +24,8 @@ export type CloudAuthState = { user: CloudUser | null; }; -type Listener = (_: CloudAuthState) => void; +/** Callback invoked when auth state changes */ +type Listener = { (state: CloudAuthState): void }; class CloudAuthService { private static instance: CloudAuthService; From e50de00d0849102f83a9a17ddbccb158a6508ff6 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Wed, 25 Feb 2026 10:55:36 +1030 Subject: [PATCH 10/11] feat: move bs cloud to a more out of the way location and some theme store tweaks --- .../components/SignInToFavoriteModal.svelte | 2 +- .../components/store/CloudHeader.svelte | 203 ++++++++++++++++++ .../components/store/CoverSwiper.svelte | 2 +- src/interface/components/store/Header.svelte | 3 + src/interface/pages/settings.svelte | 6 - 5 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 src/interface/components/store/CloudHeader.svelte diff --git a/src/interface/components/SignInToFavoriteModal.svelte b/src/interface/components/SignInToFavoriteModal.svelte index e3d5b29c..24f21e1b 100644 --- a/src/interface/components/SignInToFavoriteModal.svelte +++ b/src/interface/components/SignInToFavoriteModal.svelte @@ -46,7 +46,7 @@

- Go to Settings → BetterSEQTA Cloud to sign in, or create an account to get started. + Sign in in the Theme Store to save favorites across devices, or create an account to get started.

diff --git a/src/interface/components/store/CloudHeader.svelte b/src/interface/components/store/CloudHeader.svelte new file mode 100644 index 00000000..7804ec2a --- /dev/null +++ b/src/interface/components/store/CloudHeader.svelte @@ -0,0 +1,203 @@ + + +
+ + + {#if open} + + +
e.stopPropagation()} + > +
+

BetterSEQTA Cloud

+

Sync favorites across devices

+
+
+ {#if cloudState.isLoggedIn} +
+
+ {#if cloudState.user?.pfpUrl} + + {:else} +
+ {getInitials()} +
+ {/if} +
+

+ {cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"} +

+ {#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)} +

{cloudState.user.email}

+ {/if} +
+
+ +
+ {:else} +

+ Sign in to favorite themes. Your favorites sync across devices when logged in. +

+
{ + e.preventDefault(); + handleLogin(); + }} + > + e.currentTarget.removeAttribute('readonly')} + class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200" + /> + e.currentTarget.removeAttribute('readonly')} + class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200" + /> + {#if error} +

{error}

+ {/if} + + + Create account + +
+ {/if} +
+
+ {/if} +
diff --git a/src/interface/components/store/CoverSwiper.svelte b/src/interface/components/store/CoverSwiper.svelte index 69889cc3..d5c35c1a 100644 --- a/src/interface/components/store/CoverSwiper.svelte +++ b/src/interface/components/store/CoverSwiper.svelte @@ -29,7 +29,7 @@ {#if coverThemes.length > 0}
diff --git a/src/interface/components/store/Header.svelte b/src/interface/components/store/Header.svelte index 720827af..4ee50781 100644 --- a/src/interface/components/store/Header.svelte +++ b/src/interface/components/store/Header.svelte @@ -3,6 +3,7 @@ import logoDark from '@/resources/icons/betterseqta-light-full.png'; import { closeStore } from '@/seqta/ui/renderStore' import browser from 'webextension-polyfill'; + import CloudHeader from './CloudHeader.svelte'; // Props let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{ @@ -39,6 +40,8 @@ > Backgrounds + +
diff --git a/src/interface/pages/settings.svelte b/src/interface/pages/settings.svelte index 5af86a90..53c00412 100644 --- a/src/interface/pages/settings.svelte +++ b/src/interface/pages/settings.svelte @@ -3,7 +3,6 @@ import Settings from "./settings/general.svelte"; import Shortcuts from "./settings/shortcuts.svelte"; import Theme from "./settings/theme.svelte"; - import Cloud from "./settings/cloud.svelte"; import browser from "webextension-polyfill"; import { standalone as StandaloneStore } from "../utils/standalone.svelte"; @@ -78,10 +77,6 @@ showColourPicker = false; }); - if (window.location.hash === "#cloud") { - settingsActiveTab = 3; - } - if (standalone) { StandaloneStore.setStandalone(true); } @@ -291,7 +286,6 @@ }, { title: "Shortcuts", Content: Shortcuts }, { title: "Themes", Content: Theme }, - { title: "BetterSEQTA Cloud", Content: Cloud }, ]} />
From 1d3643a1fc28af056eb0d5125ff82c3554436227 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Wed, 25 Feb 2026 12:46:36 +1030 Subject: [PATCH 11/11] chore: remove unused code --- src/interface/pages/settings/cloud.svelte | 158 ---------------------- 1 file changed, 158 deletions(-) delete mode 100644 src/interface/pages/settings/cloud.svelte diff --git a/src/interface/pages/settings/cloud.svelte b/src/interface/pages/settings/cloud.svelte deleted file mode 100644 index 2f2060c1..00000000 --- a/src/interface/pages/settings/cloud.svelte +++ /dev/null @@ -1,158 +0,0 @@ - - -
-
- -
-
- - - -
-
-

BetterSEQTA Cloud

-

Sync favorites across devices

-
-
- -
- {#if cloudState.isLoggedIn} - -
-
- {#if cloudState.user?.pfpUrl} - - {:else} -
- {getInitials()} -
- {/if} -
-

- {cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"} -

- {#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)} -

{cloudState.user.email}

- {/if} -
-
-
- {:else} - -

- Sign in to favorite themes in the store. Your favorites sync across devices when logged in. -

-
{ - e.preventDefault(); - handleLogin(); - }} - > -
- - -
-
- - -
- {#if error} -

{error}

- {/if} - -
- {/if} -
-
-