Merge pull request #384 from AdenMGB/theme-store

Online Enhanced Theme store
This commit is contained in:
Aden Lindsay
2026-02-25 12:59:59 +10:30
committed by GitHub
17 changed files with 1059 additions and 99 deletions
+194 -43
View File
@@ -14,52 +14,203 @@ function reloadSeqtaPages() {
result.then(open, console.error);
}
// @ts-ignore
/** 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;
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<string, string> = {};
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<string, string> = {};
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<any> {
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;
}
/** Handler for a message type; receives request and sendResponse callback */
type MessageHandler = { (request: any, sendResponse: MessageSender): boolean | void };
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
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(
(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);
// @ts-ignore - OnMessageListener expects literal true for async, we return boolean
(request: any, _: any, sendResponse: MessageSender) => {
const handler = MESSAGE_HANDLERS[request.type];
if (handler) {
const result = handler(request, sendResponse);
return result === true;
}
}
});
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;
default:
console.log("Unknown request type");
}
return false;
},
);
@@ -0,0 +1,77 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { animate } from "motion";
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
let { onClose } = $props<{ onClose: () => void }>();
let modalElement: HTMLElement;
$effect(() => {
if (modalElement) {
animate(modalElement, { scale: [0.9, 1], opacity: [0, 1] }, { type: "spring", stiffness: 300, damping: 25 });
}
});
function handleSignIn() {
onClose();
if (document.getElementById("ExtensionPopup")) {
closeExtensionPopup();
} else {
window.close();
}
}
</script>
<div
class="flex fixed inset-0 z-[10000] justify-center items-center bg-black/50"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onkeydown={(e) => {
if (e.key === "Escape") onClose();
}}
role="button"
tabindex="-1"
transition:fade={{ duration: 150 }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={modalElement}
class="p-4 mx-4 w-full max-w-md bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2 class="mb-3 text-xl font-bold text-zinc-900 dark:text-white">
Sign in to favorite themes
</h2>
<p class="mb-6 text-zinc-600 dark:text-zinc-400">
Sign in in the Theme Store to save favorites across devices, or create an account to get started.
</p>
<div class="flex flex-wrap gap-2 justify-end">
<button
type="button"
onclick={onClose}
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
OK
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
>
Create account
</a>
<button
type="button"
onclick={handleSignIn}
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in
</button>
</div>
</div>
</div>
@@ -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);
@@ -0,0 +1,203 @@
<script lang="ts">
import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let username = $state("");
let password = $state("");
let loading = $state(false);
let error = $state<string | null>(null);
let cloudState = $state(cloudAuth.state);
let open = $state(false);
let dropdownEl: HTMLElement;
onMount(() => {
const unsubscribe = cloudAuth.subscribe((state) => {
cloudState = state;
});
return unsubscribe;
});
function handleClickOutside(e: MouseEvent) {
if (dropdownEl && !dropdownEl.contains(e.target as Node)) {
open = false;
}
}
$effect(() => {
if (open) {
const timer = setTimeout(() => {
document.addEventListener("click", handleClickOutside);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener("click", handleClickOutside);
};
}
});
async function handleLogin() {
if (loading) return;
error = null;
if (!username.trim() || !password) {
error = "Please enter username and password";
return;
}
loading = true;
try {
const result = await cloudAuth.login(username.trim(), password);
if (result.success) {
password = "";
open = false;
} else {
error = result.error ?? "Login failed";
}
} finally {
loading = false;
}
}
async function handleLogout() {
await cloudAuth.logout();
open = false;
}
function getInitials(): string {
const u = cloudState.user;
if (!u) return "?";
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
if (u.username) return u.username.slice(0, 2).toUpperCase();
if (u.email) return u.email.slice(0, 2).toUpperCase();
return "?";
}
</script>
<div class="relative flex items-center" bind:this={dropdownEl}>
<button
type="button"
onclick={() => (open = !open)}
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-100/80 dark:bg-zinc-700/80 hover:bg-zinc-200/80 dark:hover:bg-zinc-600/80 transition-colors duration-200 text-base font-medium text-zinc-900 dark:text-white"
>
{#if cloudState.isLoggedIn}
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-8 h-8 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-sm">
{getInitials()}
</div>
{/if}
<span class="hidden max-w-24 truncate sm:inline text-base">
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 0012.75 15h-10.5z" />
</svg>
<span class="text-base font-medium">Sign in</span>
{/if}
</button>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute right-0 top-full mt-2 w-80 rounded-xl border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-xl z-[100] overflow-hidden"
onclick={(e) => e.stopPropagation()}
>
<div class="p-4 border-b border-zinc-200 dark:border-zinc-600">
<h3 class="text-xl font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
<p class="text-base text-zinc-500 dark:text-zinc-400">Sync favorites across devices</p>
</div>
<div class="p-4">
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()}
</div>
{/if}
<div class="min-w-0 flex-1">
<p class="text-base font-medium text-zinc-900 dark:text-white truncate">
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</p>
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
<p class="text-base text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
{/if}
</div>
</div>
<button
type="button"
onclick={handleLogout}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Sign out
</button>
</div>
{:else}
<p class="mb-4 text-base text-zinc-600 dark:text-zinc-400">
Sign in to favorite themes. Your favorites sync across devices when logged in.
</p>
<form
class="flex flex-col gap-3"
autocomplete="off"
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
<input
type="text"
name="betterseqta-cloud-username"
autocomplete="off"
placeholder="Email or username"
bind:value={username}
disabled={loading}
readonly
onfocus={(e) => 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"
/>
<input
type="password"
name="betterseqta-cloud-password"
autocomplete="new-password"
placeholder="Password"
bind:value={password}
disabled={loading}
readonly
onfocus={(e) => 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}
<p class="text-base text-red-600 dark:text-red-400">{error}</p>
{/if}
<button
type="submit"
disabled={loading}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200"
>
{loading ? "Signing in..." : "Sign in"}
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
>
Create account
</a>
</form>
{/if}
</div>
</div>
{/if}
</div>
@@ -29,7 +29,7 @@
{#if coverThemes.length > 0}
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
<div
class="w-full aspect-8/3"
class="w-full aspect-[5/1] max-h-[500px]"
use:emblaCarouselSvelte={{ options, plugins }}
onemblaInit={onInit}
>
@@ -42,7 +42,7 @@
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
onclick={() => setDisplayTheme(theme)}
>
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-full" />
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-full" />
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
<p class='text-lg text-white'>{theme.description}</p>
@@ -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
</button>
<CloudHeader />
</div>
<div class="flex relative gap-2">
@@ -1,19 +1,110 @@
<script lang="ts">
import type { Theme } from '@/interface/types/Theme'
let { theme, onClick } = $props<{ theme: Theme; onClick: () => void }>();
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte';
let { theme, onClick, toggleFavorite, isLoggedIn } = $props<{
theme: Theme;
onClick: () => void;
toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean;
}>();
let menuOpen = $state(false);
let showSignInModal = $state(false);
let menuRef: HTMLDivElement;
onMount(() => {
const closeMenu = (e: MouseEvent) => {
if (menuOpen && menuRef && !menuRef.contains(e.target as Node)) {
menuOpen = false;
}
};
document.addEventListener('click', closeMenu);
return () => document.removeEventListener('click', closeMenu);
});
function handleCardClick(e: MouseEvent) {
if ((e.target as HTMLElement).closest('[data-theme-menu]')) return;
onClick();
}
function handleFavoriteClick(e: MouseEvent) {
e.stopPropagation();
if (isLoggedIn) {
toggleFavorite(theme);
} else {
showSignInModal = true;
}
menuOpen = false;
}
</script>
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={onClick}>
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white">
{theme.name}
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={handleCardClick}>
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
<!-- Menu dropdown -->
<div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}>
<button
type="button"
class="flex justify-center items-center w-8 h-8 rounded-lg bg-black/40 hover:bg-black/60 text-white transition-all"
onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}
aria-label="Theme options"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
</button>
{#if menuOpen}
<div
class="absolute right-0 top-full mt-1 py-1 min-w-[140px] rounded-lg bg-white dark:bg-zinc-800 shadow-lg border border-zinc-200 dark:border-zinc-700"
role="menu"
>
<button
type="button"
class="flex gap-2 items-center w-full px-3 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700"
role="menuitem"
onclick={handleFavoriteClick}
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill={theme.is_favorited ? 'currentColor' : 'none'}
stroke="currentColor"
stroke-width="2"
class="w-5 h-5 {theme.is_favorited ? 'text-red-500' : ''}"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{theme.is_favorited ? 'Favorited' : 'Favorite'}
</button>
</div>
{/if}
</div>
<div class="absolute bottom-1 left-3 right-3 z-10 mb-1 flex flex-col gap-0.5">
<span class="text-xl font-bold text-white drop-shadow-md">{theme.name}</span>
<div class="flex gap-3 text-xs font-medium text-white/90 drop-shadow-sm">
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{(theme.download_count ?? 0).toLocaleString()}
</span>
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{(theme.favorite_count ?? 0).toLocaleString()}
</span>
</div>
</div>
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
<div class='w-full'>
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
</div>
</div>
</div>
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if}
@@ -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 @@
<div class="relative" >
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
{#each filteredThemes as theme (theme.id)}
<ThemeCard theme={theme} onClick={() => setDisplayTheme(theme)} />
<ThemeCard
{theme}
onClick={() => setDisplayTheme(theme)}
{toggleFavorite}
{isLoggedIn}
/>
{/each}
{#if filteredThemes.length !== 0}
@@ -2,8 +2,9 @@
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 } = $props<{
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme, toggleFavorite, isLoggedIn } = $props<{
theme: Theme | null;
currentThemes: string[];
setDisplayTheme: (theme: Theme | null) => void;
@@ -11,10 +12,21 @@
onRemove: (themeId: string) => void;
allThemes: Theme[];
displayTheme: Theme | null;
toggleFavorite?: (theme: Theme) => void;
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
@@ -73,18 +85,49 @@
onkeydown={(e) => e.stopPropagation()}
>
<div class="relative h-auto">
<button class="absolute top-0 right-0 p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
<div class="absolute top-0 right-0 flex gap-1 items-center">
<button class="p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
{'\ued8a'}
</button>
<h2 class="mb-4 text-2xl font-bold">
</div>
<h2 class="mb-2 text-2xl font-bold">
{theme.name}
</h2>
<img src={theme.marqueeImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" />
<div class="flex gap-4 mb-4 text-sm text-zinc-600 dark:text-zinc-400">
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{(theme.download_count ?? 0).toLocaleString()} downloads
</span>
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{(theme.favorite_count ?? 0).toLocaleString()} favorites
</span>
</div>
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" />
<p class="mb-4 text-gray-700 dark:text-gray-300">
{theme.description}
</p>
<div class="flex flex-wrap gap-2 mt-4 justify-end items-center">
{#if toggleFavorite && theme}
<button
type="button"
class="flex items-center gap-2 px-4 py-2 rounded-full transition-all duration-200 hover:scale-105 active:scale-95 {theme.is_favorited ? 'text-red-500 bg-red-500/10 dark:bg-red-500/20' : 'bg-zinc-200 dark:bg-zinc-700 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600'}"
onclick={handleFavoriteClick}
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
aria-label={theme.is_favorited ? 'Unfavorite' : 'Favorite'}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{theme.is_favorited ? 'Favorited' : 'Favorite'}
</button>
{/if}
{#if currentThemes.includes(theme.id)}
<button onclick={async () => {installing = true; await onRemove(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 mt-4 ml-auto w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200">
<button onclick={async () => {installing = true; await onRemove(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95">
{#if installing}
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
@@ -93,7 +136,7 @@
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Remove</span>
</button>
{:else}
<button onclick={async () => {installing = true; await onInstall(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 mt-4 ml-auto w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200">
<button onclick={async () => {installing = true; await onInstall(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95">
{#if installing}
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
@@ -102,6 +145,7 @@
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Install</span>
</button>
{/if}
</div>
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
@@ -116,7 +160,7 @@
{relatedTheme.name}
</div>
<div class="absolute bottom-0 z-0 w-full h-3/4 to-transparent from-black/80 bg-linear-to-t"></div>
<img src={relatedTheme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48" />
<img src={relatedTheme.marqueeImage || relatedTheme.coverImage} alt="Theme Preview" class="object-cover w-full h-48" />
</div>
</button>
{/each}
@@ -124,3 +168,7 @@
</div>
</div>
</div>
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if}
@@ -1,11 +1,14 @@
<script lang="ts">
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
import { onDestroy, onMount } from 'svelte'
import browser from 'webextension-polyfill'
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { OpenStorePage } from '@/seqta/ui/renderStore'
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte'
const themeManager = ThemeManager.getInstance();
@@ -13,6 +16,17 @@
let { isEditMode } = $props<{ isEditMode: boolean }>();
let isDragging = $state(false);
let tempTheme = $state(null);
let favoriteStatus = $state<Record<string, boolean>>({});
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
let prevLoggedIn = $state(false);
let showSignInModal = $state(false);
cloudAuth.subscribe((s) => {
const now = s.isLoggedIn;
if (now && !prevLoggedIn && themes) void fetchThemes();
prevLoggedIn = now;
cloudLoggedIn = now;
});
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
if (isEditMode) return;
@@ -87,11 +101,55 @@
themes: await themeManager.getAvailableThemes(),
selectedTheme: themeManager.getSelectedThemeId() || '',
}
if (themes && cloudLoggedIn) {
const token = await cloudAuth.getStoredToken();
if (token) {
const status: Record<string, boolean> = {};
await Promise.all(
themes.themes.map(async (t) => {
try {
const res = (await browser.runtime.sendMessage({
type: 'fetchThemeDetails',
themeId: t.id,
token,
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
if (res?.success && res?.data?.theme) {
status[t.id] = !!res.data.theme.is_favorited;
}
} catch {
// Theme may not exist on store (e.g. locally created)
}
})
);
favoriteStatus = status;
}
} else {
favoriteStatus = {};
}
}
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
e.stopPropagation();
if (!cloudLoggedIn) {
showSignInModal = true;
return;
}
const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !favoriteStatus[theme.id];
const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite',
themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean };
if (result?.success) {
favoriteStatus = { ...favoriteStatus, [theme.id]: isFavorite };
}
}
onMount(async () => {
await fetchThemes();
themeUpdates.addListener(fetchThemes);
})
@@ -144,6 +202,18 @@
{/if}
{#if !isEditMode}
<div
class="flex absolute right-24 top-1/4 z-20 place-items-center p-2 w-8 h-8 text-center rounded-full opacity-0 transition-all -translate-y-1/2 group-hover:opacity-100 group-hover:top-1/2 {(favoriteStatus[theme.id] ?? false) ? 'text-red-400' : 'text-white/80'} bg-black/50"
onclick={(event) => handleToggleFavorite(theme, event)}
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') handleToggleFavorite(theme, event as any) }}
role="button"
tabindex="-1"
title={cloudLoggedIn ? ((favoriteStatus[theme.id] ?? false) ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={(favoriteStatus[theme.id] ?? false) ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</div>
<div
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
@@ -211,3 +281,7 @@
</button>
</div>
</div>
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if}
+5 -2
View File
@@ -19,6 +19,7 @@
import { settingsPopup } from "../hooks/SettingsPopup";
let devModeSequence = "";
let settingsActiveTab = $state(0);
let showDisclaimerModal = $state(false);
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
@@ -71,13 +72,14 @@
showDisclaimerModal = true;
};
onMount(async () => {
onMount(() => {
settingsPopup.addListener(() => {
showColourPicker = false;
});
if (!standalone) return;
if (standalone) {
StandaloneStore.setStandalone(true);
}
});
</script>
@@ -275,6 +277,7 @@
</div>
<TabbedContainer
bind:activeTab={settingsActiveTab}
tabs={[
{
title: "Settings",
+67 -7
View File
@@ -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,20 +52,57 @@
activeTab = tab;
};
// Fetch themes and initialize app
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) {
const delta = isFavorite ? 1 : -1;
themes = themes.map((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,
favorite_count: Math.max(0, (displayTheme.favorite_count ?? 0) + delta),
};
}
}
};
// 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 token = await cloudAuth.getStoredToken();
const data = (await browser.runtime.sendMessage({
type: 'fetchThemes',
token: token ?? undefined,
})) 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
}
};
@@ -91,6 +132,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;
}
});
</script>
<div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}">
@@ -111,7 +163,13 @@
{/if}
<!-- ThemeGrid to display filtered themes -->
<ThemeGrid themes={filteredThemes} {searchTerm} {setDisplayTheme} />
<ThemeGrid
themes={filteredThemes}
{searchTerm}
{setDisplayTheme}
{toggleFavorite}
isLoggedIn={cloudLoggedIn}
/>
{#if displayTheme}
<ThemeModal
@@ -120,6 +178,8 @@
theme={displayTheme}
{displayTheme}
{setDisplayTheme}
{toggleFavorite}
isLoggedIn={cloudLoggedIn}
onInstall={async () => {
if (displayTheme) {
await themeManager.downloadTheme(displayTheme);
+6 -2
View File
@@ -1,7 +1,11 @@
export type Theme = {
id: string;
name: string;
description: string;
coverImage: string;
marqueeImage: string;
id: string;
marqueeImage?: string;
theme_json_url?: string;
is_favorited?: boolean;
favorite_count?: number;
download_count?: number;
};
+2 -2
View File
@@ -16,12 +16,12 @@
}
},
"permissions": ["tabs", "notifications", "storage"],
"host_permissions": ["https://newsapi.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'"
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com"
},
"content_scripts": [
{
+38 -7
View File
@@ -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,53 @@ 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';
/**
* Download and install a theme from the store
* Fetch JSON from a URL via background script (avoids CORS when running inside SEQTA page)
*/
private async fetchFromUrl(url: string): Promise<any> {
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.
* Uses API first (increments download_count), falls back to GitHub if unreachable.
*/
public async downloadTheme(themeContent: {
id: string;
name: string;
description: string;
coverImage: string;
description?: string;
coverImage?: string;
theme_json_url?: string;
}): Promise<void> {
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 themeData: 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) {
+199
View File
@@ -0,0 +1,199 @@
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;
};
/** Callback invoked when auth state changes */
type Listener = { (state: CloudAuthState): void };
class CloudAuthService {
private static instance: CloudAuthService;
private listeners = new Set<Listener>();
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<void> {
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<string | null> {
const result = await browser.storage.local.get(STORAGE_KEYS.accessToken);
return (result[STORAGE_KEYS.accessToken] as string) ?? null;
}
private async getClientId(): Promise<string> {
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<void> {
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<boolean> {
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();
+6
View File
@@ -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 {