mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
Merge pull request #384 from AdenMGB/theme-store
Online Enhanced Theme store
This commit is contained in:
+189
-38
@@ -14,52 +14,203 @@ function reloadSeqtaPages() {
|
|||||||
result.then(open, console.error);
|
result.then(open, console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
/** Callback for sending a response back to the message sender */
|
||||||
browser.runtime.onMessage.addListener(
|
type MessageSender = { (response?: unknown): void };
|
||||||
(request: any, _: any, sendResponse: (response?: any) => void) => {
|
|
||||||
switch (request.type) {
|
|
||||||
case "reloadTabs":
|
|
||||||
reloadSeqtaPages();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "extensionPages":
|
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
|
||||||
browser.tabs.query({}).then(function (tabs) {
|
const { token } = request;
|
||||||
for (let tab of tabs) {
|
const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
|
||||||
if (tab.url?.includes("chrome-extension://")) {
|
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
|
||||||
browser.tabs.sendMessage(tab.id!, request);
|
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 });
|
||||||
});
|
});
|
||||||
break;
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
case "currentTab":
|
function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boolean {
|
||||||
browser.tabs
|
const { themeId, token } = request;
|
||||||
.query({ active: true, currentWindow: true })
|
if (!themeId || typeof themeId !== "string") {
|
||||||
.then(function (tabs) {
|
sendResponse({ success: false, error: "Missing themeId" });
|
||||||
browser.tabs
|
return false;
|
||||||
.sendMessage(tabs[0].id!, request)
|
}
|
||||||
.then(function (response) {
|
const headers: Record<string, string> = {};
|
||||||
sendResponse(response);
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
});
|
fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers })
|
||||||
});
|
.then((r) => r.json())
|
||||||
return true;
|
.then(sendResponse)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[Background] fetchThemeDetails error:", err);
|
||||||
|
sendResponse({ success: false, error: err?.message });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
case "githubTab":
|
function handleFetchFromUrl(request: any, sendResponse: MessageSender): boolean {
|
||||||
browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
|
const { url } = request;
|
||||||
break;
|
if (!url || typeof url !== "string") {
|
||||||
|
sendResponse({ error: "Missing url" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fetch(url, { cache: "no-store" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => sendResponse({ data }))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[Background] fetchFromUrl error:", err);
|
||||||
|
sendResponse({ error: err?.message });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
case "setDefaultStorage":
|
async function parseJsonResponse(r: Response): Promise<any> {
|
||||||
SetStorageValue(getDefaultValues());
|
const text = await r.text();
|
||||||
break;
|
try {
|
||||||
|
return text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "sendNews":
|
function handleCloudReserveClient(request: any, sendResponse: MessageSender): boolean {
|
||||||
fetchNews(request.source ?? "australia", sendResponse);
|
const redirect_uri = request.redirect_uri ?? "https://accounts.betterseqta.org/auth/bsplus/callback";
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
|
||||||
console.log("Unknown request type");
|
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(
|
||||||
|
// @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;
|
||||||
}
|
}
|
||||||
|
console.log("Unknown request type");
|
||||||
return false;
|
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 './TabbedContainer.css';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>();
|
let { tabs, activeTab = $bindable(0) } = $props<{ tabs: { title: string, Content: any, props?: any }[]; activeTab?: number }>();
|
||||||
let activeTab = $state(0);
|
|
||||||
let containerRef: HTMLElement | null = null;
|
let containerRef: HTMLElement | null = null;
|
||||||
let tabWidth = $state(0);
|
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}
|
{#if coverThemes.length > 0}
|
||||||
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
|
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
|
||||||
<div
|
<div
|
||||||
class="w-full aspect-8/3"
|
class="w-full aspect-[5/1] max-h-[500px]"
|
||||||
use:emblaCarouselSvelte={{ options, plugins }}
|
use:emblaCarouselSvelte={{ options, plugins }}
|
||||||
onemblaInit={onInit}
|
onemblaInit={onInit}
|
||||||
>
|
>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import logoDark from '@/resources/icons/betterseqta-light-full.png';
|
import logoDark from '@/resources/icons/betterseqta-light-full.png';
|
||||||
import { closeStore } from '@/seqta/ui/renderStore'
|
import { closeStore } from '@/seqta/ui/renderStore'
|
||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
|
import CloudHeader from './CloudHeader.svelte';
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
|
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
|
||||||
@@ -39,6 +40,8 @@
|
|||||||
>
|
>
|
||||||
Backgrounds
|
Backgrounds
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<CloudHeader />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex relative gap-2">
|
<div class="flex relative gap-2">
|
||||||
|
|||||||
@@ -1,19 +1,110 @@
|
|||||||
<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';
|
||||||
|
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>
|
</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>
|
||||||
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white">
|
<!-- Menu dropdown -->
|
||||||
{theme.name}
|
<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>
|
||||||
<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>
|
||||||
|
|
||||||
|
{#if showSignInModal}
|
||||||
|
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import type { Theme } from '@/interface/types/Theme'
|
import type { Theme } from '@/interface/types/Theme'
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { animate } from 'motion';
|
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;
|
theme: Theme | null;
|
||||||
currentThemes: string[];
|
currentThemes: string[];
|
||||||
setDisplayTheme: (theme: Theme | null) => void;
|
setDisplayTheme: (theme: Theme | null) => void;
|
||||||
@@ -11,10 +12,21 @@
|
|||||||
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 showSignInModal = $state(false);
|
||||||
let modalElement: HTMLElement;
|
let modalElement: HTMLElement;
|
||||||
|
|
||||||
|
function handleFavoriteClick() {
|
||||||
|
if (isLoggedIn && toggleFavorite && theme) {
|
||||||
|
toggleFavorite(theme);
|
||||||
|
} else {
|
||||||
|
showSignInModal = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Function to get related themes
|
// Function to get related themes
|
||||||
function getRelatedThemes() {
|
function getRelatedThemes() {
|
||||||
return allThemes
|
return allThemes
|
||||||
@@ -73,35 +85,67 @@
|
|||||||
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">
|
||||||
{'\ued8a'}
|
<button class="p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
|
||||||
</button>
|
{'\ued8a'}
|
||||||
<h2 class="mb-4 text-2xl font-bold">
|
</button>
|
||||||
|
</div>
|
||||||
|
<h2 class="mb-2 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" />
|
<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">
|
<p class="mb-4 text-gray-700 dark:text-gray-300">
|
||||||
{theme.description}
|
{theme.description}
|
||||||
</p>
|
</p>
|
||||||
{#if currentThemes.includes(theme.id)}
|
<div class="flex flex-wrap gap-2 mt-4 justify-end items-center">
|
||||||
<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">
|
{#if toggleFavorite && theme}
|
||||||
{#if installing}
|
<button
|
||||||
<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">
|
type="button"
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
{/if}
|
{theme.is_favorited ? 'Favorited' : 'Favorite'}
|
||||||
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Remove</span>
|
</button>
|
||||||
</button>
|
{/if}
|
||||||
{:else}
|
{#if currentThemes.includes(theme.id)}
|
||||||
<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 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}
|
{#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">
|
<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"/>
|
<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"/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Install</span>
|
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Remove</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{:else}
|
||||||
|
<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"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<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>
|
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
|
||||||
|
|
||||||
@@ -116,7 +160,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}
|
||||||
@@ -124,3 +168,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showSignInModal}
|
||||||
|
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
|
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
|
||||||
import { onDestroy, onMount } from 'svelte'
|
import { onDestroy, onMount } from 'svelte'
|
||||||
|
import browser from 'webextension-polyfill'
|
||||||
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
||||||
import { OpenStorePage } from '@/seqta/ui/renderStore'
|
import { OpenStorePage } from '@/seqta/ui/renderStore'
|
||||||
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
|
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
|
||||||
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
|
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
|
||||||
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
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();
|
const themeManager = ThemeManager.getInstance();
|
||||||
|
|
||||||
@@ -13,6 +16,17 @@
|
|||||||
let { isEditMode } = $props<{ isEditMode: boolean }>();
|
let { isEditMode } = $props<{ isEditMode: boolean }>();
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
let tempTheme = $state(null);
|
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) => {
|
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
|
||||||
if (isEditMode) return;
|
if (isEditMode) return;
|
||||||
@@ -87,11 +101,55 @@
|
|||||||
themes: await themeManager.getAvailableThemes(),
|
themes: await themeManager.getAvailableThemes(),
|
||||||
selectedTheme: themeManager.getSelectedThemeId() || '',
|
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 () => {
|
onMount(async () => {
|
||||||
await fetchThemes();
|
await fetchThemes();
|
||||||
|
|
||||||
themeUpdates.addListener(fetchThemes);
|
themeUpdates.addListener(fetchThemes);
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -144,6 +202,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !isEditMode}
|
{#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
|
<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"
|
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() }}
|
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
|
||||||
@@ -211,3 +281,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showSignInModal}
|
||||||
|
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import { settingsPopup } from "../hooks/SettingsPopup";
|
import { settingsPopup } from "../hooks/SettingsPopup";
|
||||||
|
|
||||||
let devModeSequence = "";
|
let devModeSequence = "";
|
||||||
|
let settingsActiveTab = $state(0);
|
||||||
let showDisclaimerModal = $state(false);
|
let showDisclaimerModal = $state(false);
|
||||||
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
|
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
|
||||||
|
|
||||||
@@ -71,13 +72,14 @@
|
|||||||
showDisclaimerModal = true;
|
showDisclaimerModal = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(() => {
|
||||||
settingsPopup.addListener(() => {
|
settingsPopup.addListener(() => {
|
||||||
showColourPicker = false;
|
showColourPicker = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!standalone) return;
|
if (standalone) {
|
||||||
StandaloneStore.setStandalone(true);
|
StandaloneStore.setStandalone(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -275,6 +277,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabbedContainer
|
<TabbedContainer
|
||||||
|
bind:activeTab={settingsActiveTab}
|
||||||
tabs={[
|
tabs={[
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
|
|||||||
@@ -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,20 +52,57 @@
|
|||||||
activeTab = tab;
|
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 () => {
|
const fetchThemes = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${(new Date()).getTime()}`, { cache: 'no-store' });
|
const token = await cloudAuth.getStoredToken();
|
||||||
const data = await response.json();
|
const data = (await browser.runtime.sendMessage({
|
||||||
themes = data.themes;
|
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
|
// Shuffle for cover themes
|
||||||
const shuffled = [...themes].sort(() => 0.5 - Math.random());
|
const shuffled = [...themes].sort(() => 0.5 - Math.random());
|
||||||
coverThemes = shuffled.slice(0, 3);
|
coverThemes = shuffled.slice(0, 3);
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch themes', error);
|
console.error('Failed to fetch themes', err);
|
||||||
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs
|
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -91,6 +132,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' : ''}">
|
||||||
@@ -111,7 +163,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
|
||||||
@@ -120,6 +178,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);
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
export type Theme = {
|
export type Theme = {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
coverImage: string;
|
coverImage: string;
|
||||||
marqueeImage: string;
|
marqueeImage?: string;
|
||||||
id: string;
|
theme_json_url?: string;
|
||||||
|
is_favorited?: boolean;
|
||||||
|
favorite_count?: number;
|
||||||
|
download_count?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,12 +16,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permissions": ["tabs", "notifications", "storage"],
|
"permissions": ["tabs", "notifications", "storage"],
|
||||||
"host_permissions": ["https://newsapi.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'"
|
"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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import localforage from "localforage";
|
import localforage from "localforage";
|
||||||
|
import browser from "webextension-polyfill";
|
||||||
import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes";
|
import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import debounce from "@/seqta/utils/debounce";
|
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: {
|
public async downloadTheme(themeContent: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description?: string;
|
||||||
coverImage: string;
|
coverImage?: string;
|
||||||
|
theme_json_url?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
|
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
|
||||||
try {
|
try {
|
||||||
if (!themeContent.id) return;
|
if (!themeContent.id) return;
|
||||||
|
|
||||||
const response = await fetch(
|
let themeData: ThemeContent;
|
||||||
`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes/${themeContent.id}/theme.json`,
|
|
||||||
);
|
try {
|
||||||
const themeData = (await response.json()) as ThemeContent;
|
// 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);
|
await this.installTheme(themeData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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