mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
feat: betterseqta cloud for favouriting items and future stuff
This commit is contained in:
+127
-1
@@ -57,9 +57,12 @@ browser.runtime.onMessage.addListener(
|
|||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "fetchThemes": {
|
case "fetchThemes": {
|
||||||
|
const { token } = request;
|
||||||
const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
|
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 githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
|
||||||
fetch(apiUrl, { cache: "no-store" })
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
fetch(apiUrl, { cache: "no-store", headers })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(sendResponse)
|
.then(sendResponse)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -91,6 +94,129 @@ browser.runtime.onMessage.addListener(
|
|||||||
return true;
|
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:
|
default:
|
||||||
console.log("Unknown request type");
|
console.log("Unknown request type");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
|
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
|
||||||
onclick={() => 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]'>
|
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
|
||||||
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
|
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
|
||||||
<p class='text-lg text-white'>{theme.description}</p>
|
<p class='text-lg text-white'>{theme.description}</p>
|
||||||
|
|||||||
@@ -1,19 +1,86 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Theme } from '@/interface/types/Theme'
|
import type { Theme } from '@/interface/types/Theme'
|
||||||
|
|
||||||
let { theme, onClick } = $props<{ theme: Theme; onClick: () => void }>();
|
|
||||||
|
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let { theme, onClick, toggleFavorite, isLoggedIn } = $props<{
|
||||||
|
theme: Theme;
|
||||||
|
onClick: () => void;
|
||||||
|
toggleFavorite: (theme: Theme) => void;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
}>();
|
||||||
|
let menuOpen = $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);
|
||||||
|
menuOpen = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={onClick}>
|
<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] 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="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 {!isLoggedIn ? 'opacity-50 cursor-not-allowed' : ''}"
|
||||||
|
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 z-10 mb-1 text-xl font-bold text-white">
|
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white">
|
||||||
{theme.name}
|
{theme.name}
|
||||||
</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='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
|
||||||
<div class='w-full'>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
import type { Theme } from '@/interface/types/Theme'
|
import type { Theme } from '@/interface/types/Theme'
|
||||||
import ThemeCard from './ThemeCard.svelte';
|
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) =>
|
let filteredThemes = $derived(themes.filter((theme: Theme) =>
|
||||||
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
|
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
@@ -12,7 +18,12 @@
|
|||||||
<div class="relative" >
|
<div class="relative" >
|
||||||
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each filteredThemes as theme (theme.id)}
|
{#each filteredThemes as theme (theme.id)}
|
||||||
<ThemeCard theme={theme} onClick={() => setDisplayTheme(theme)} />
|
<ThemeCard
|
||||||
|
{theme}
|
||||||
|
onClick={() => setDisplayTheme(theme)}
|
||||||
|
{toggleFavorite}
|
||||||
|
{isLoggedIn}
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if filteredThemes.length !== 0}
|
{#if filteredThemes.length !== 0}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { animate } from 'motion';
|
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;
|
theme: Theme | null;
|
||||||
currentThemes: string[];
|
currentThemes: string[];
|
||||||
setDisplayTheme: (theme: Theme | null) => void;
|
setDisplayTheme: (theme: Theme | null) => void;
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
onRemove: (themeId: string) => void;
|
onRemove: (themeId: string) => void;
|
||||||
allThemes: Theme[];
|
allThemes: Theme[];
|
||||||
displayTheme: Theme | null;
|
displayTheme: Theme | null;
|
||||||
|
toggleFavorite?: (theme: Theme) => void;
|
||||||
|
isLoggedIn?: boolean;
|
||||||
}>();
|
}>();
|
||||||
let installing = $state(false);
|
let installing = $state(false);
|
||||||
let modalElement: HTMLElement;
|
let modalElement: HTMLElement;
|
||||||
@@ -73,13 +75,35 @@
|
|||||||
onkeydown={(e) => e.stopPropagation()}
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div class="relative h-auto">
|
<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">
|
||||||
|
{#if isLoggedIn && toggleFavorite && theme}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-2 rounded-lg transition-all hover:bg-zinc-100 dark:hover:bg-zinc-700 {theme.is_favorited ? 'text-red-500' : 'text-gray-600 dark:text-gray-200'}"
|
||||||
|
onclick={() => toggleFavorite(theme)}
|
||||||
|
title={theme.is_favorited ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
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-6 h-6"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
|
||||||
{'\ued8a'}
|
{'\ued8a'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<h2 class="mb-4 text-2xl font-bold">
|
<h2 class="mb-4 text-2xl font-bold">
|
||||||
{theme.name}
|
{theme.name}
|
||||||
</h2>
|
</h2>
|
||||||
<img src={theme.marqueeImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" />
|
<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">
|
<p class="mb-4 text-gray-700 dark:text-gray-300">
|
||||||
{theme.description}
|
{theme.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -116,7 +140,7 @@
|
|||||||
{relatedTheme.name}
|
{relatedTheme.name}
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 z-0 w-full h-3/4 to-transparent from-black/80 bg-linear-to-t"></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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Settings from "./settings/general.svelte";
|
import Settings from "./settings/general.svelte";
|
||||||
import Shortcuts from "./settings/shortcuts.svelte";
|
import Shortcuts from "./settings/shortcuts.svelte";
|
||||||
import Theme from "./settings/theme.svelte";
|
import Theme from "./settings/theme.svelte";
|
||||||
|
import Cloud from "./settings/cloud.svelte";
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
import { standalone as StandaloneStore } from "../utils/standalone.svelte";
|
import { standalone as StandaloneStore } from "../utils/standalone.svelte";
|
||||||
@@ -283,6 +284,7 @@
|
|||||||
},
|
},
|
||||||
{ title: "Shortcuts", Content: Shortcuts },
|
{ title: "Shortcuts", Content: Shortcuts },
|
||||||
{ title: "Themes", Content: Theme },
|
{ title: "Themes", Content: Theme },
|
||||||
|
{ title: "BetterSEQTA Cloud", Content: Cloud },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Button from "../../components/Button.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);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const unsubscribe = cloudAuth.subscribe((state) => {
|
||||||
|
cloudState = state;
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = "";
|
||||||
|
} else {
|
||||||
|
error = result.error ?? "Login failed";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await cloudAuth.logout();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 p-1">
|
||||||
|
<div class="p-4 rounded-xl border border-zinc-200/50 dark:border-zinc-700/40 bg-zinc-50/50 dark:bg-zinc-900/30">
|
||||||
|
<h2 class="mb-2 text-lg font-bold">BetterSEQTA Cloud</h2>
|
||||||
|
<p class="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Sign in to favorite themes in the theme store. Your favorites sync across devices when logged in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if cloudState.isLoggedIn}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<p class="text-sm">
|
||||||
|
Signed in as
|
||||||
|
<span class="font-medium">
|
||||||
|
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleLogout} text="Sign out" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleLogin();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Email or username"
|
||||||
|
bind:value={username}
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full px-3 py-2 text-sm 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"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
bind:value={password}
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full px-3 py-2 text-sm 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"
|
||||||
|
/>
|
||||||
|
{#if error}
|
||||||
|
<p class="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleLogin}
|
||||||
|
text={loading ? "Signing in..." : "Sign in"}
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href="https://accounts.betterseqta.org/register"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center justify-center 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 hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-accent-ring focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -15,8 +15,12 @@
|
|||||||
|
|
||||||
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
|
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
|
||||||
import Backgrounds from '../components/store/Backgrounds.svelte'
|
import Backgrounds from '../components/store/Backgrounds.svelte'
|
||||||
|
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
||||||
|
|
||||||
const themeManager = ThemeManager.getInstance();
|
const themeManager = ThemeManager.getInstance();
|
||||||
|
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
|
||||||
|
|
||||||
|
cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
|
||||||
|
|
||||||
// State variables
|
// State variables
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
@@ -48,10 +52,34 @@
|
|||||||
activeTab = tab;
|
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)
|
// Fetch themes via background script (avoids CORS when store runs inside SEQTA page)
|
||||||
const fetchThemes = async () => {
|
const fetchThemes = async () => {
|
||||||
try {
|
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;
|
success?: boolean;
|
||||||
data?: { themes: Theme[] };
|
data?: { themes: Theme[] };
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -97,6 +125,17 @@
|
|||||||
console.error(error);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}">
|
<div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}">
|
||||||
@@ -117,7 +156,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ThemeGrid to display filtered themes -->
|
<!-- ThemeGrid to display filtered themes -->
|
||||||
<ThemeGrid themes={filteredThemes} {searchTerm} {setDisplayTheme} />
|
<ThemeGrid
|
||||||
|
themes={filteredThemes}
|
||||||
|
{searchTerm}
|
||||||
|
{setDisplayTheme}
|
||||||
|
{toggleFavorite}
|
||||||
|
isLoggedIn={cloudLoggedIn}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if displayTheme}
|
{#if displayTheme}
|
||||||
<ThemeModal
|
<ThemeModal
|
||||||
@@ -126,6 +171,8 @@
|
|||||||
theme={displayTheme}
|
theme={displayTheme}
|
||||||
{displayTheme}
|
{displayTheme}
|
||||||
{setDisplayTheme}
|
{setDisplayTheme}
|
||||||
|
{toggleFavorite}
|
||||||
|
isLoggedIn={cloudLoggedIn}
|
||||||
onInstall={async () => {
|
onInstall={async () => {
|
||||||
if (displayTheme) {
|
if (displayTheme) {
|
||||||
await themeManager.downloadTheme(displayTheme);
|
await themeManager.downloadTheme(displayTheme);
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ export type Theme = {
|
|||||||
coverImage: string;
|
coverImage: string;
|
||||||
marqueeImage?: string;
|
marqueeImage?: string;
|
||||||
theme_json_url?: string;
|
theme_json_url?: string;
|
||||||
|
is_favorited?: boolean;
|
||||||
|
favorite_count?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,12 +16,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permissions": ["tabs", "notifications", "storage"],
|
"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": {
|
"background": {
|
||||||
"service_worker": "background.ts"
|
"service_worker": "background.ts"
|
||||||
},
|
},
|
||||||
"content_security_policy": {
|
"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": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<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();
|
||||||
@@ -47,6 +47,12 @@ export interface SettingsState {
|
|||||||
lettergrade: boolean;
|
lettergrade: boolean;
|
||||||
assessmentsAverage?: boolean;
|
assessmentsAverage?: boolean;
|
||||||
notificationCollector?: 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 {
|
interface ToggleItem {
|
||||||
|
|||||||
Reference in New Issue
Block a user