From 7a70b008c82e3215be4f58927341962b5a12ccbb Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 20 Feb 2026 10:49:38 +1030 Subject: [PATCH] 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 {