mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 19:54:39 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcc856e798 | |||
| a0038ac871 | |||
| 49824e9eab | |||
| 01eeb18638 | |||
| 3702443ece | |||
| 87ba75ff41 | |||
| f9406fb469 | |||
| 690792fd62 | |||
| f6ac112329 | |||
| ec68cec0ca |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "betterseqtaplus",
|
||||
"version": "3.6.0",
|
||||
"version": "3.6.3",
|
||||
"type": "module",
|
||||
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
|
||||
"browserslist": "> 0.5%, last 2 versions, not dead",
|
||||
|
||||
@@ -133,6 +133,74 @@ function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleCloudStartLogin(request: any, sendResponse: MessageSender): boolean {
|
||||
const { client_id, redirect_uri } = request;
|
||||
if (!client_id || !redirect_uri) {
|
||||
sendResponse({ error: "Missing client_id or redirect_uri" });
|
||||
return true;
|
||||
}
|
||||
const authorizeUrl = `https://accounts.betterseqta.org/login?redirect=${encodeURIComponent(`/oauth/authorize?client_id=${client_id}&redirect_uri=${encodeURIComponent(redirect_uri)}`)}`;
|
||||
browser.tabs.create({ url: authorizeUrl }).then(() => {
|
||||
sendResponse({ success: true });
|
||||
}).catch((err) => {
|
||||
console.error("[Background] cloudStartLogin error:", err);
|
||||
sendResponse({ error: err?.message ?? "Failed to open login page" });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const CALLBACK_URL_PREFIX = "https://accounts.betterseqta.org/auth/bsplus/callback";
|
||||
|
||||
function initCloudLoginCallbackListener() {
|
||||
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||
if (changeInfo.url && changeInfo.url.startsWith(CALLBACK_URL_PREFIX)) {
|
||||
try {
|
||||
const url = new URL(changeInfo.url);
|
||||
const token = url.searchParams.get("token");
|
||||
const refreshToken = url.searchParams.get("refresh_token");
|
||||
const userId = url.searchParams.get("user_id");
|
||||
|
||||
if (token && refreshToken) {
|
||||
// Store tokens
|
||||
void (async () => {
|
||||
try {
|
||||
await browser.storage.local.set({
|
||||
bsplus_token: token,
|
||||
bsplus_refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
// Fetch full user info
|
||||
const userRes = await fetch("https://accounts.betterseqta.org/api/auth/me", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (userRes.ok) {
|
||||
const user = await userRes.json();
|
||||
await browser.storage.local.set({ bsplus_user: user });
|
||||
} else if (userId) {
|
||||
await browser.storage.local.set({ bsplus_user: { id: userId } });
|
||||
}
|
||||
|
||||
// Trigger cloud settings download
|
||||
void performCloudSettingsDownloadWithRetry(token).catch((err) => {
|
||||
console.warn("[Background] Cloud settings download after login:", err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[Background] Failed to process login callback:", err);
|
||||
}
|
||||
})();
|
||||
|
||||
// Close the callback tab
|
||||
void browser.tabs.remove(tabId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Background] Error parsing callback URL:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initCloudLoginCallbackListener();
|
||||
|
||||
function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
|
||||
const { refresh_token, client_id } = request;
|
||||
if (!refresh_token || !client_id) {
|
||||
@@ -269,6 +337,7 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
||||
fetchFromUrl: handleFetchFromUrl,
|
||||
cloudReserveClient: handleCloudReserveClient,
|
||||
cloudLogin: handleCloudLogin,
|
||||
cloudStartLogin: handleCloudStartLogin,
|
||||
cloudRefresh: handleCloudRefresh,
|
||||
cloudFavorite: handleCloudFavorite,
|
||||
cloudSettingsUpload: handleCloudSettingsUpload,
|
||||
|
||||
@@ -13,9 +13,9 @@ export const CLOUD_SUMMARY_URL = `${ACCOUNTS_BASE}/api/user/cloud-summary`;
|
||||
const CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`;
|
||||
const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`;
|
||||
|
||||
const ALARM_NAME = "bsplus_cloud_settings_auto_sync";
|
||||
const PERIOD_MINUTES = 60;
|
||||
const UPLOAD_DEBOUNCE_MS = 2000;
|
||||
const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000;
|
||||
const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll";
|
||||
|
||||
type CloudSummaryResponse = {
|
||||
desqta?: unknown;
|
||||
@@ -323,6 +323,9 @@ export function runCloudSettingsPoll(): Promise<void> {
|
||||
if (pollInFlight) return pollInFlight;
|
||||
pollInFlight = (async () => {
|
||||
try {
|
||||
const { [POLL_THROTTLE_KEY]: last } = await browser.storage.local.get(POLL_THROTTLE_KEY);
|
||||
if (Date.now() - (Number(last) || 0) < POLL_THROTTLE_MS) return;
|
||||
await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() });
|
||||
await runCloudSettingsPollInner();
|
||||
} catch (e) {
|
||||
console.error("[BS+ cloud sync] Poll error:", e);
|
||||
@@ -360,14 +363,11 @@ async function runDebouncedUploadJob(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAlarmWithStorage(): Promise<void> {
|
||||
async function syncAutoUploadWithStorage(): Promise<void> {
|
||||
const all = (await browser.storage.local.get()) as Record<string, unknown>;
|
||||
if (!isAutoCloudSyncEnabled(all)) {
|
||||
await browser.alarms.clear(ALARM_NAME);
|
||||
clearUploadDebounce();
|
||||
return;
|
||||
}
|
||||
await browser.alarms.create(ALARM_NAME, { periodInMinutes: PERIOD_MINUTES });
|
||||
}
|
||||
|
||||
function onStorageChanged(
|
||||
@@ -377,7 +377,7 @@ function onStorageChanged(
|
||||
if (area !== "local") return;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) {
|
||||
void syncAlarmWithStorage();
|
||||
void syncAutoUploadWithStorage();
|
||||
}
|
||||
|
||||
const keys = Object.keys(changes);
|
||||
@@ -392,15 +392,8 @@ function onStorageChanged(
|
||||
})();
|
||||
}
|
||||
|
||||
function onAlarm(alarm: browser.Alarms.Alarm): void {
|
||||
if (alarm.name !== ALARM_NAME) return;
|
||||
void runCloudSettingsPoll();
|
||||
}
|
||||
|
||||
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
|
||||
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
|
||||
browser.alarms.onAlarm.addListener(onAlarm);
|
||||
browser.storage.onChanged.addListener(onStorageChanged);
|
||||
void syncAlarmWithStorage();
|
||||
}
|
||||
|
||||
|
||||
@@ -1653,6 +1653,13 @@ html.transparencyEffects
|
||||
box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Smoothed by attachNotificationsPanelAnimation (matches #ExtensionPopup spring) */
|
||||
.bsplus-notifications-panel {
|
||||
transform-origin: top right;
|
||||
will-change: opacity, transform;
|
||||
filter: drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
|
||||
#menu li.active {
|
||||
color: #ffffff !important;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
@@ -4364,3 +4371,41 @@ h2.home-subtitle {
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.bsplus-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
max-width: 380px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 12px;
|
||||
background: var(--background-secondary, #fff);
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.bsplus-toast-content p {
|
||||
margin: 6px 0 0;
|
||||
opacity: 0.8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.bsplus-toast-close {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.bsplus-toast-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { animate } from "motion";
|
||||
import { delay } from "@/seqta/utils/delay.ts";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
|
||||
const { hidePanel } = $props<{
|
||||
hidePanel: () => void;
|
||||
}>();
|
||||
|
||||
let cloudState = $state(cloudAuth.state);
|
||||
let background = $state<HTMLDivElement | null>(null);
|
||||
let content = $state<HTMLDivElement | null>(null);
|
||||
let loginError = $state<string | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
const unsub = cloudAuth.subscribe((s) => {
|
||||
cloudState = s;
|
||||
});
|
||||
|
||||
if (background && content) {
|
||||
animate(
|
||||
background,
|
||||
{ opacity: [0, 1] },
|
||||
{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }
|
||||
);
|
||||
animate(
|
||||
content,
|
||||
{ scale: [0.4, 1], opacity: [0, 1] },
|
||||
{ type: "spring", stiffness: 400, damping: 30 }
|
||||
);
|
||||
}
|
||||
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closePanel();
|
||||
};
|
||||
document.addEventListener("keydown", handleEscapeKey);
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
document.removeEventListener("keydown", handleEscapeKey);
|
||||
};
|
||||
});
|
||||
|
||||
async function closePanel() {
|
||||
if (!background || !content) return;
|
||||
animate(
|
||||
content,
|
||||
{ scale: [1, 0.4], opacity: [1, 0] },
|
||||
{ type: "spring", stiffness: 400, damping: 30 }
|
||||
);
|
||||
animate(
|
||||
background,
|
||||
{ opacity: [1, 0] },
|
||||
{ ease: [0.4, 0, 0.2, 1] }
|
||||
);
|
||||
await delay(400);
|
||||
hidePanel();
|
||||
}
|
||||
|
||||
function handleBackgroundClick(event: MouseEvent) {
|
||||
if (event.target === background) closePanel();
|
||||
}
|
||||
|
||||
async function handleSignIn() {
|
||||
loginError = null;
|
||||
const result = await cloudAuth.startLogin();
|
||||
if (result.success) {
|
||||
closePanel();
|
||||
} else {
|
||||
loginError = result.error ?? "Failed to open login page";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await cloudAuth.logout();
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={background}
|
||||
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50"
|
||||
onclick={handleBackgroundClick}
|
||||
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }}
|
||||
>
|
||||
<div
|
||||
bind:this={content}
|
||||
class="p-5 w-[320px] bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
|
||||
>
|
||||
<h3 class="text-lg font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
|
||||
<p class="mt-0.5 text-sm text-zinc-500 dark:text-zinc-400">Account & sync</p>
|
||||
|
||||
<div class="mt-4">
|
||||
{#if cloudState.isLoggedIn}
|
||||
<div class="flex flex-col gap-4">
|
||||
<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-sm 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-xs 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-2.5 text-sm 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}
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Sign in to sync settings across devices, use your cloud profile picture, and more.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSignIn}
|
||||
class="w-full px-4 py-2.5 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 with BetterSEQTA Cloud
|
||||
</button>
|
||||
{#if loginError}
|
||||
<p class="text-xs text-red-600 dark:text-red-400">{loginError}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
|
||||
Opens accounts.betterseqta.org in a new tab
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,17 +2,17 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import DisclaimerModal from "./DisclaimerModal.svelte";
|
||||
import Button from "./Button.svelte";
|
||||
import Switch from "./Switch.svelte";
|
||||
|
||||
let { showDisclaimer } = $props<{
|
||||
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
|
||||
}>();
|
||||
|
||||
let cloudState = $state(cloudAuth.state);
|
||||
let busy = $state(false);
|
||||
let statusMessage = $state<string | null>(null);
|
||||
let statusError = $state<string | null>(null);
|
||||
let lastUploadAt = $state<string | null>(null);
|
||||
let lastDownloadAt = $state<string | null>(null);
|
||||
let showRestoreConfirm = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const unsub = cloudAuth.subscribe((s) => {
|
||||
@@ -21,13 +21,6 @@
|
||||
return unsub;
|
||||
});
|
||||
|
||||
function formatNow(): string {
|
||||
return new Date().toLocaleString(undefined, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
if (!token) return;
|
||||
@@ -40,8 +33,7 @@
|
||||
token,
|
||||
})) as { success?: boolean; error?: string };
|
||||
if (res?.success) {
|
||||
statusMessage = "Settings saved to the cloud.";
|
||||
lastUploadAt = formatNow();
|
||||
statusMessage = "Settings uploaded.";
|
||||
} else {
|
||||
statusError = res?.error ?? "Upload failed";
|
||||
}
|
||||
@@ -53,11 +45,10 @@
|
||||
}
|
||||
|
||||
function promptDownload() {
|
||||
showRestoreConfirm = true;
|
||||
showDisclaimer(confirmDownload, () => {});
|
||||
}
|
||||
|
||||
async function confirmDownload() {
|
||||
showRestoreConfirm = false;
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
if (!token) return;
|
||||
busy = true;
|
||||
@@ -69,8 +60,7 @@
|
||||
token,
|
||||
})) as { success?: boolean; error?: string; notFound?: boolean };
|
||||
if (res?.success) {
|
||||
statusMessage = "Settings restored from the cloud. SEQTA tabs were reloaded.";
|
||||
lastDownloadAt = formatNow();
|
||||
statusMessage = "Settings restored.";
|
||||
} else {
|
||||
statusError = res?.error ?? "Download failed";
|
||||
}
|
||||
@@ -82,22 +72,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="w-full rounded-xl border border-zinc-200/60 bg-zinc-50/80 px-4 py-2.5 dark:border-zinc-700/50 dark:bg-zinc-900/40"
|
||||
>
|
||||
<h3 class="text-xs font-bold text-zinc-800 dark:text-zinc-100">Cloud settings backup</h3>
|
||||
<p class="mt-0.5 text-[11px] leading-snug text-zinc-500 dark:text-zinc-400">
|
||||
Upload copies this browser’s BetterSEQTA+ settings to your account. Download replaces local settings with the
|
||||
cloud copy (your sign-in stays on this device).
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mt-2 flex flex-col gap-2 rounded-lg border border-zinc-200/50 bg-white/60 px-3 py-2.5 dark:border-zinc-600/40 dark:bg-zinc-800/40"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<p class="min-w-0 flex-1 pt-0.5 text-[11px] font-semibold leading-tight text-zinc-800 dark:text-zinc-100">
|
||||
Automatic sync
|
||||
</p>
|
||||
{#if cloudState.isLoggedIn}
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold text-zinc-800 dark:text-zinc-100">Automatic sync</p>
|
||||
<p class="text-[10px] text-zinc-500 dark:text-zinc-400">Syncs settings when SEQTA loads and when you make changes</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<Switch
|
||||
state={$settingsState.autoCloudSettingsSync !== false}
|
||||
@@ -105,62 +86,35 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] leading-snug text-zinc-500 dark:text-zinc-400">
|
||||
When signed in, each time SEQTA loads and also hourly, if the cloud backup is newer it will replace local
|
||||
settings. Settings you change will upload shortly after you adjust them.
|
||||
</p>
|
||||
<p class="text-[10px] leading-snug text-zinc-500 dark:text-zinc-400">
|
||||
Passwords, tokens, and other sensitive data are not included in the backup.
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
text={busy ? "Please wait\u2026" : "Upload"}
|
||||
onClick={upload}
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button
|
||||
text={busy ? "Please wait\u2026" : "Download"}
|
||||
onClick={promptDownload}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if statusMessage}
|
||||
<p class="text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
|
||||
{/if}
|
||||
{#if statusError}
|
||||
<p class="text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||
Passwords and tokens are never synced.
|
||||
<a
|
||||
href="https://betterseqta.org/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ml-0.5 inline font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 transition-all duration-200 hover:text-emerald-700 hover:decoration-emerald-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-emerald-400 dark:decoration-emerald-400/50 dark:hover:text-emerald-300 dark:focus-visible:ring-offset-zinc-800 rounded-sm"
|
||||
>
|
||||
Privacy policy
|
||||
</a>
|
||||
class="font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 rounded-sm"
|
||||
>Privacy policy</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
text={busy ? "Please wait…" : "Upload to cloud"}
|
||||
onClick={upload}
|
||||
disabled={busy || !cloudState.isLoggedIn}
|
||||
/>
|
||||
<Button
|
||||
text={busy ? "Please wait…" : "Download from cloud"}
|
||||
onClick={promptDownload}
|
||||
disabled={busy || !cloudState.isLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if !cloudState.isLoggedIn}
|
||||
<p class="mt-2 text-[11px] text-zinc-500 dark:text-zinc-400">
|
||||
Sign in from the BetterSEQTA Cloud header above to sync settings.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if statusMessage}
|
||||
<p class="mt-2 text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
|
||||
{/if}
|
||||
{#if statusError}
|
||||
<p class="mt-2 text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
|
||||
{/if}
|
||||
{#if lastUploadAt || lastDownloadAt}
|
||||
<p class="mt-1 text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||
{#if lastUploadAt}<span>Last upload: {lastUploadAt}</span>{/if}
|
||||
{#if lastUploadAt && lastDownloadAt}<span class="mx-1">·</span>{/if}
|
||||
{#if lastDownloadAt}<span>Last download: {lastDownloadAt}</span>{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showRestoreConfirm}
|
||||
<DisclaimerModal
|
||||
title="Restore from cloud?"
|
||||
message="This will replace BetterSEQTA+ settings in this browser with your cloud backup. Your BetterSEQTA Cloud sign-in on this device will be kept. Continue?"
|
||||
onConfirm={confirmDownload}
|
||||
onCancel={() => (showRestoreConfirm = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { animate } from "motion";
|
||||
import { onMount } from "svelte";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import CloudLoginForm from "@/interface/components/store/CloudLoginForm.svelte";
|
||||
|
||||
let { onClose } = $props<{ onClose: () => void }>();
|
||||
let modalElement: HTMLElement;
|
||||
@@ -23,6 +22,10 @@
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSignIn() {
|
||||
await cloudAuth.startLogin();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -52,7 +55,16 @@
|
||||
Sign in to the Theme Store to save favorites across devices, or create an account to get started.
|
||||
</p>
|
||||
|
||||
<CloudLoginForm compact onSuccess={onClose} />
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSignIn}
|
||||
class="w-full px-4 py-2.5 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 with BetterSEQTA Cloud
|
||||
</button>
|
||||
<p class="mt-2 text-xs text-center text-zinc-400 dark:text-zinc-500">
|
||||
Opens accounts.betterseqta.org in a new tab
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<button
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import CloudLoginForm from "./CloudLoginForm.svelte";
|
||||
|
||||
let { alwaysShowUserName = false } = $props<{
|
||||
/** When true (e.g. narrow extension popup), show display name below sm breakpoint */
|
||||
let { alwaysShowUserName = false, onClick = undefined } = $props<{
|
||||
alwaysShowUserName?: boolean;
|
||||
onClick?: () => void;
|
||||
}>();
|
||||
|
||||
let cloudState = $state(cloudAuth.state);
|
||||
@@ -42,6 +41,19 @@
|
||||
open = false;
|
||||
}
|
||||
|
||||
async function handleSignIn() {
|
||||
await cloudAuth.startLogin();
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleButtonClick() {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
} else {
|
||||
open = !open;
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(): string {
|
||||
const u = cloudState.user;
|
||||
if (!u) return "?";
|
||||
@@ -55,35 +67,35 @@
|
||||
<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"
|
||||
onclick={handleButtonClick}
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-[0.75rem] rounded-lg shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white transition-colors duration-200"
|
||||
>
|
||||
{#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"
|
||||
class="w-5 h-5 rounded-full object-cover ring-1 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">
|
||||
<div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
|
||||
{getInitials()}
|
||||
</div>
|
||||
{/if}
|
||||
<span
|
||||
class={alwaysShowUserName
|
||||
? "inline max-w-[10rem] truncate text-sm"
|
||||
: "hidden max-w-24 truncate sm:inline text-base"}
|
||||
? "inline max-w-[10rem] truncate text-[0.75rem]"
|
||||
: "hidden max-w-24 truncate sm:inline text-[0.75rem]"}
|
||||
>
|
||||
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xl font-IconFamily" aria-hidden="true">{'\ued53'}</span>
|
||||
<span class="text-base font-medium">Sign in</span>
|
||||
<span class="text-sm font-IconFamily" aria-hidden="true">{'\ued53'}</span>
|
||||
<span class="text-[0.75rem]">Sign in</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
{#if !onClick && open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
@@ -127,11 +139,21 @@
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<CloudLoginForm
|
||||
onSuccess={() => {
|
||||
open = false;
|
||||
}}
|
||||
/>
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Sign in to sync favorites across devices.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSignIn}
|
||||
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 transition-colors duration-200"
|
||||
>
|
||||
Sign in with BetterSEQTA Cloud
|
||||
</button>
|
||||
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
|
||||
Opens accounts.betterseqta.org in a new tab
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,14 +15,16 @@
|
||||
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
|
||||
|
||||
import ColourPicker from "../components/ColourPicker.svelte";
|
||||
import CloudPanel from "../components/CloudPanel.svelte";
|
||||
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
||||
import CloudHeader from "@/interface/components/store/CloudHeader.svelte";
|
||||
import { settingsPopup } from "../hooks/SettingsPopup";
|
||||
|
||||
let devModeSequence = "";
|
||||
let settingsActiveTab = $state(0);
|
||||
let showDisclaimerModal = $state(false);
|
||||
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
|
||||
let disclaimerTitle = $state("Confirm");
|
||||
let disclaimerMessage = $state("");
|
||||
|
||||
const handleDevModeToggle = () => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -67,15 +69,23 @@
|
||||
|
||||
let { standalone } = $props<{ standalone?: boolean }>();
|
||||
let showColourPicker = $state<boolean>(false);
|
||||
let showCloudPanel = $state<boolean>(false);
|
||||
|
||||
const showDisclaimer = (onConfirm: () => void, onCancel: () => void) => {
|
||||
const openCloudPanel = () => {
|
||||
showCloudPanel = true;
|
||||
};
|
||||
|
||||
const showDisclaimer = (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => {
|
||||
disclaimerCallbacks = { onConfirm, onCancel };
|
||||
disclaimerTitle = title ?? "Confirm";
|
||||
disclaimerMessage = message ?? "";
|
||||
showDisclaimerModal = true;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
settingsPopup.addListener(() => {
|
||||
showColourPicker = false;
|
||||
showCloudPanel = false;
|
||||
});
|
||||
|
||||
if (standalone) {
|
||||
@@ -277,25 +287,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-between gap-2 px-4 py-2.5 border-b border-zinc-200/40 dark:border-zinc-700/40"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-sm font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h2>
|
||||
<p class="text-xs text-zinc-500 dark:text-zinc-400">Account & sync</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<CloudHeader alwaysShowUserName />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabbedContainer
|
||||
bind:activeTab={settingsActiveTab}
|
||||
tabs={[
|
||||
{
|
||||
title: "Settings",
|
||||
Content: Settings,
|
||||
props: { showColourPicker: openColourPicker, showDisclaimer },
|
||||
props: { showColourPicker: openColourPicker, showDisclaimer, showCloudPanel: openCloudPanel },
|
||||
},
|
||||
{ title: "Shortcuts", Content: Shortcuts },
|
||||
{ title: "Themes", Content: Theme },
|
||||
@@ -310,19 +308,20 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showCloudPanel}
|
||||
<CloudPanel
|
||||
hidePanel={() => {
|
||||
showCloudPanel = false;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showDisclaimerModal && disclaimerCallbacks}
|
||||
<DisclaimerModal
|
||||
title="Assessment Averages Disclaimer"
|
||||
message="This feature calculates a simple average of your assessment grades. It does not take into account:
|
||||
• Assessment weightings
|
||||
• Different grading scales
|
||||
• Other factors used in official reports
|
||||
|
||||
The displayed average may be inaccurate compared to your actual marks found in reports.
|
||||
|
||||
Do you want to enable this feature?"
|
||||
title={disclaimerTitle}
|
||||
message={disclaimerMessage}
|
||||
onConfirm={() => {
|
||||
disclaimerCallbacks?.onConfirm();
|
||||
showDisclaimerModal = false;
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
|
||||
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
|
||||
import CloudSettingsSync from "@/interface/components/CloudSettingsSync.svelte"
|
||||
import CloudHeader from "@/interface/components/store/CloudHeader.svelte"
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth"
|
||||
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
|
||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
||||
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
|
||||
@@ -54,6 +56,12 @@
|
||||
const pluginSettings = getAllPluginSettings() as Plugin[];
|
||||
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({});
|
||||
|
||||
let cloudState = $state(cloudAuth.state);
|
||||
$effect(() => {
|
||||
const unsub = cloudAuth.subscribe((s) => { cloudState = s; });
|
||||
return unsub;
|
||||
});
|
||||
|
||||
async function loadPluginSettings() {
|
||||
for (const plugin of pluginSettings) {
|
||||
if (Object.keys(plugin.settings).length === 0) continue;
|
||||
@@ -95,9 +103,10 @@
|
||||
loadPluginSettings();
|
||||
})
|
||||
|
||||
const { showColourPicker, showDisclaimer } = $props<{
|
||||
const { showColourPicker, showDisclaimer, showCloudPanel } = $props<{
|
||||
showColourPicker: () => void;
|
||||
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
|
||||
showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void;
|
||||
showCloudPanel: () => void;
|
||||
}>();
|
||||
|
||||
async function exportCloudSettingsJsonToFile() {
|
||||
@@ -196,12 +205,11 @@
|
||||
},
|
||||
{
|
||||
title: "Default Page",
|
||||
description:
|
||||
"The page to load when SEQTA Learn or SEQTA Engage opens (uses the same #?page=/… URL as SEQTA). BetterSEQTA home on Engage only applies when Home is selected.",
|
||||
description: "Choose which page loads first when you open SEQTA",
|
||||
id: 10,
|
||||
Component: Select,
|
||||
props: {
|
||||
state: $settingsState.defaultPage,
|
||||
state: $settingsState.defaultPage ?? "home",
|
||||
onChange: (value: string) => (settingsState.defaultPage = value),
|
||||
options: [
|
||||
{ value: "home", label: "Home" },
|
||||
@@ -310,8 +318,9 @@
|
||||
async () => {
|
||||
await updatePluginSetting(plugin.pluginId, 'enabled', true);
|
||||
},
|
||||
() => {
|
||||
}
|
||||
() => {},
|
||||
"Assessment Averages Disclaimer",
|
||||
"This feature calculates a simple average of your assessment grades. It does not take into account:\n• Assessment weightings\n• Different grading scales\n• Other factors used in official reports\n\nThe displayed average may be inaccurate compared to your actual marks found in reports.\n\nDo you want to enable this feature?"
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -324,8 +333,8 @@
|
||||
|
||||
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
|
||||
{#each Object.entries(plugin.settings) as [key, setting]}
|
||||
<!-- Skip the 'enabled' setting if it's part of the settings object -->
|
||||
{#if key !== 'enabled'}
|
||||
<!-- Skip the 'enabled' setting and hide cloud-only settings when not signed in -->
|
||||
{#if key !== 'enabled' && !(key === 'useCloudPfp' && !cloudState.isLoggedIn)}
|
||||
<div class="flex justify-between items-center px-4 py-3">
|
||||
<div class="pr-4">
|
||||
<h2 class="text-sm font-bold">{setting.title || key}</h2>
|
||||
@@ -388,6 +397,25 @@
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="border-none">
|
||||
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
|
||||
<div class="flex justify-between items-center px-4 py-3">
|
||||
<div class="pr-4">
|
||||
<h2 class="text-sm font-bold">BetterSEQTA Cloud</h2>
|
||||
<p class="text-xs">Account & sync</p>
|
||||
</div>
|
||||
<div>
|
||||
<CloudHeader alwaysShowUserName onClick={showCloudPanel} />
|
||||
</div>
|
||||
</div>
|
||||
{#if cloudState.isLoggedIn}
|
||||
<div class="px-3 pb-3">
|
||||
<CloudSettingsSync showDisclaimer={(onConfirm, onCancel) => showDisclaimer(onConfirm, onCancel, "Restore from cloud?", "This will replace your local settings with the cloud backup. Continue?")} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-1 border-none"></div>
|
||||
|
||||
{@render Setting({
|
||||
@@ -401,10 +429,6 @@
|
||||
}
|
||||
})}
|
||||
|
||||
<div class="border-none py-3">
|
||||
<CloudSettingsSync />
|
||||
</div>
|
||||
|
||||
{#if $settingsState.devMode}
|
||||
<div class="flex-col p-1 my-1 bg-gradient-to-br from-white rounded-xl border shadow-sm to-zinc-100 border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
|
||||
<div class="flex justify-between items-center px-4 py-3">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"64": "resources/icons/icon-64.png"
|
||||
}
|
||||
},
|
||||
"permissions": ["tabs", "notifications", "storage", "alarms"],
|
||||
"permissions": ["tabs", "notifications", "storage"],
|
||||
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
|
||||
"background": {
|
||||
"service_worker": "background.ts"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Plugin } from "../../core/types";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { getAssessmentsData } from "./api";
|
||||
import { renderErrorState, renderSkeletonLoader } from "./ui";
|
||||
import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
|
||||
import styles from "./styles.css?inline";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
@@ -68,7 +68,6 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
|
||||
try {
|
||||
const data = await getAssessmentsData();
|
||||
const { renderGrid } = await import("./ui");
|
||||
renderGrid(container, data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load assessments:", err);
|
||||
|
||||
@@ -14,8 +14,7 @@ const settings = defineSettings({
|
||||
useCloudPfp: booleanSetting({
|
||||
default: false,
|
||||
title: "Use BetterSEQTA Cloud profile picture",
|
||||
description:
|
||||
"When enabled, uses the avatar from your BetterSEQTA Cloud account (sign in from the extension store). Otherwise uses the uploaded image below.",
|
||||
description: "Use your cloud account avatar instead of the uploaded image below",
|
||||
}),
|
||||
picture: componentSetting({
|
||||
title: "Profile Picture",
|
||||
|
||||
@@ -680,9 +680,17 @@ export class ThemeManager {
|
||||
* Compare installed store themes to GET /api/themes and refresh when the server is newer.
|
||||
* Skips themes with userEdited: true (theme creator / popup save, or custom accent vs default).
|
||||
*/
|
||||
private static STORE_CHECK_KEY = "bsplus_lastStoreThemeCheck";
|
||||
private static STORE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
public async checkStoreThemeUpdates(): Promise<void> {
|
||||
if (this.storeUpdateCheckRunning) return;
|
||||
|
||||
const lastCheck = Number(localStorage.getItem(ThemeManager.STORE_CHECK_KEY) || 0);
|
||||
if (Date.now() - lastCheck < ThemeManager.STORE_CHECK_INTERVAL_MS) return;
|
||||
|
||||
this.storeUpdateCheckRunning = true;
|
||||
localStorage.setItem(ThemeManager.STORE_CHECK_KEY, String(Date.now()));
|
||||
try {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
|
||||
@@ -63,7 +63,12 @@ function resetTimetableStyles(): void {
|
||||
}
|
||||
|
||||
async function handleTimetable(): Promise<void> {
|
||||
await waitForElm(".time", true, 10);
|
||||
// SEQTA uses `.times` blocks on entries, not necessarily `.time`; avoid infinite polling on a missing selector.
|
||||
try {
|
||||
await waitForElm(".timetablepage .times, .timetablepage .entry.class", true, 50, 200);
|
||||
} catch {
|
||||
/* timetable body may render after the shell */
|
||||
}
|
||||
|
||||
// Convert time format if needed
|
||||
if (settingsState.timeFormat == "12") {
|
||||
|
||||
@@ -271,7 +271,9 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
};
|
||||
|
||||
const syncQuickbarFromDOM = () => {
|
||||
const quickbar = document.querySelector(".timetablepage .quickbar.visible");
|
||||
const quickbar = document.querySelector(
|
||||
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
|
||||
);
|
||||
if (quickbar && quickbar.getAttribute("data-type") === "class") {
|
||||
const titleEl = quickbar.querySelector(".title");
|
||||
const roomEl = quickbar.querySelector(".meta .room");
|
||||
@@ -287,7 +289,9 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
if (!timetablePage || quickbarObserver) return;
|
||||
|
||||
quickbarObserver = new MutationObserver(() => {
|
||||
const quickbar = document.querySelector(".timetablepage .quickbar.visible");
|
||||
const quickbar = document.querySelector(
|
||||
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
|
||||
);
|
||||
if (quickbar?.getAttribute("data-type") === "class") {
|
||||
addEditButtonToQuickbar(quickbar as HTMLElement);
|
||||
}
|
||||
@@ -302,7 +306,13 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
};
|
||||
|
||||
const handleTimetable = async () => {
|
||||
await waitForElm(".timetablepage .entry", true, 10, 100);
|
||||
// Class entries (`div.entry.class`) load after the page shell; don't fail the whole
|
||||
// setup if they are slow or briefly absent (e.g. navigation). Observers still catch them.
|
||||
try {
|
||||
await waitForElm(".timetablepage .entry.class", true, 50, 300);
|
||||
} catch {
|
||||
/* entries may appear later */
|
||||
}
|
||||
processAllEntries();
|
||||
setupQuickbarObserver();
|
||||
syncQuickbarFromDOM();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
|
||||
import { attachNotificationsPanelAnimation } from "@/seqta/utils/attachNotificationsPanelAnimation";
|
||||
import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
|
||||
@@ -89,6 +90,7 @@ export async function AddBetterSEQTAElements() {
|
||||
addExtensionSettings();
|
||||
await createSettingsButton();
|
||||
setupSettingsButton();
|
||||
attachNotificationsPanelAnimation();
|
||||
}
|
||||
|
||||
function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
|
||||
@@ -423,10 +425,12 @@ async function setupEngageSettingsButton() {
|
||||
await addDarkLightToggle(parent);
|
||||
await createSettingsButton(parent);
|
||||
setupSettingsButton();
|
||||
attachNotificationsPanelAnimation();
|
||||
} catch {
|
||||
await addDarkLightToggle();
|
||||
await createSettingsButton();
|
||||
setupSettingsButton();
|
||||
attachNotificationsPanelAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,26 @@ class CloudAuthService {
|
||||
return clientId;
|
||||
}
|
||||
|
||||
public async startLogin(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const clientId = await this.getClientId();
|
||||
const result = (await browser.runtime.sendMessage({
|
||||
type: "cloudStartLogin",
|
||||
client_id: clientId,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
})) as { success?: boolean; error?: string };
|
||||
if (result?.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result?.error ?? "Failed to open login page" };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to open login page",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async login(
|
||||
login: string,
|
||||
password: string
|
||||
|
||||
@@ -1,61 +1,54 @@
|
||||
import stringToHTML from "../stringToHTML";
|
||||
import { settingsState } from "../listeners/SettingsState";
|
||||
import { openPopup } from "./PopupManager";
|
||||
import { attachPopupMediaFullscreenIfPresent } from "./attachPopupMediaFullscreen";
|
||||
|
||||
/** Same hosting pattern as the privacy statement branding images (avoids page-relative extension URLs on Engage). */
|
||||
const ENGAGE_PROMO_IMG_URL =
|
||||
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/bq%2Bengage.png";
|
||||
import { animate as motionAnimate } from "motion";
|
||||
|
||||
export function shouldShowEngageParentsAnnouncement(): boolean {
|
||||
return !settingsState.engageParentsAnnouncementShown;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time announcement that BetterSEQTA Plus works on SEQTA Engage (parents).
|
||||
* Non-blocking bottom-right toast announcing SEQTA Engage support. Shown once.
|
||||
*/
|
||||
export function showEngageParentsAnnouncement(onDismissed?: () => void) {
|
||||
if (document.getElementById("whatsnewbk")) {
|
||||
onDismissed?.();
|
||||
return;
|
||||
}
|
||||
if (!shouldShowEngageParentsAnnouncement()) {
|
||||
onDismissed?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const header = stringToHTML(
|
||||
/* html */
|
||||
`<div class="whatsnewHeader engageParentsAnnouncementHeader">
|
||||
<h1>BetterSEQTA Plus now supports <span class="seqtaEngageAccent">SEQTA Engage</span></h1>
|
||||
<p class="engageParentsSubheading">Buy your mom a BetterSEQTA Plus</p>
|
||||
</div>`,
|
||||
).firstChild as HTMLElement;
|
||||
|
||||
const text = stringToHTML(/* html */ `
|
||||
<div class="whatsnewTextContainer privacyStatement" style="overflow-y: auto; font-size: 1.2rem; line-height: 1.6;">
|
||||
<div class="engageParentsPromoWrap">
|
||||
<img class="engageParentsPromoImg" src="${ENGAGE_PROMO_IMG_URL}" width="1920" height="1080" alt="BetterSEQTA Plus now supports SEQTA Engage" />
|
||||
</div>
|
||||
<p>
|
||||
BetterSEQTA Plus now supports <strong class="seqtaEngageAccent">SEQTA Engage</strong>, so parents get the same kinds of improvements you are used to on SEQTA Learn—themes, a clearer home experience, and other Plus polish while browsing Engage.
|
||||
</p>
|
||||
<p>
|
||||
The title is a bit of fun; if the extension saves you time, you can always support development via Open Collective or Ko-fi from the What is New changelog or related links in settings.
|
||||
</p>
|
||||
<p>
|
||||
Close this dialog when you are done. We will not show this announcement again.
|
||||
</p>
|
||||
</div>
|
||||
`).firstChild as HTMLElement;
|
||||
|
||||
attachPopupMediaFullscreenIfPresent(text, ".engageParentsPromoImg");
|
||||
export function showEngageParentsToast() {
|
||||
if (!shouldShowEngageParentsAnnouncement()) return;
|
||||
|
||||
settingsState.engageParentsAnnouncementShown = true;
|
||||
|
||||
openPopup({
|
||||
header,
|
||||
content: [text],
|
||||
afterClose: onDismissed,
|
||||
});
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "bsplus-toast";
|
||||
toast.innerHTML = /* html */ `
|
||||
<div class="bsplus-toast-content">
|
||||
<strong>BetterSEQTA+ now supports <span class="seqtaEngageAccent">SEQTA Engage</span></strong>
|
||||
<p>Buy your mum a BetterSEQTA Plus! Parents now get themes, a cleaner home page, and all the Plus polish on SEQTA Engage.</p>
|
||||
</div>
|
||||
<button class="bsplus-toast-close" aria-label="Dismiss">×</button>
|
||||
`;
|
||||
|
||||
toast.style.opacity = "0";
|
||||
document.getElementById("container")?.append(toast);
|
||||
|
||||
if (settingsState.animations) {
|
||||
(motionAnimate as any)(
|
||||
toast,
|
||||
{ opacity: [0, 1], y: [40, 0] },
|
||||
{ duration: 0.35, easing: [0.22, 0.03, 0.26, 1] },
|
||||
);
|
||||
} else {
|
||||
toast.style.opacity = "1";
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
if (settingsState.animations) {
|
||||
(motionAnimate as any)(
|
||||
toast,
|
||||
{ opacity: [1, 0], y: [0, 40] },
|
||||
{ duration: 0.2, easing: [0.22, 0.03, 0.26, 1] },
|
||||
).then(() => toast.remove());
|
||||
} else {
|
||||
toast.remove();
|
||||
}
|
||||
};
|
||||
|
||||
toast.querySelector(".bsplus-toast-close")!.addEventListener("click", dismiss);
|
||||
|
||||
setTimeout(dismiss, 10000);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,10 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
||||
const text = stringToHTML(/* html */ `
|
||||
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
||||
|
||||
<h1>3.6.0 - Cloud backup, various fixes & SEQTA Engage support</h1>
|
||||
<h1>3.6.3 - Assessment overview fix</h1>
|
||||
<li>Fixed assessments overview failing to load.</li>
|
||||
|
||||
<h1>3.6.2 - Cloud backup, various fixes & SEQTA Engage support</h1>
|
||||
<li>BetterSEQTA Cloud: back up and restore extension settings from your account (General settings).</li>
|
||||
<li>Optional automatic cloud sync if signed in (on by default).</li>
|
||||
<li>Option to use cloud profile photo as the local SEQTA profile picture</li>
|
||||
@@ -45,7 +48,8 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
||||
<li>Fixed today's lessons on the homepage misbehaving in developer mode.</li>
|
||||
<li>Reduced overlap between BetterSEQTA subject averages and SEQTA's built-in averages UI.</li>
|
||||
<li>Updated outdated in-app links and update some under the hood code (Vite 8).</li>
|
||||
|
||||
<li>Added a notifications panel animation to work like settings.</li>
|
||||
<li>Fix timetable edit plugin not working correctly.</li>
|
||||
<h1>3.5.3 - Adaptive theme updates</h1>
|
||||
<li>Fixed adaptive theming on current-year course and assessment pages.</li>
|
||||
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import { settingsState } from "../listeners/SettingsState";
|
||||
import { OpenWhatsNewPopup } from "./OpenWhatsNewPopup";
|
||||
import {
|
||||
shouldShowPrivacyNotification,
|
||||
showPrivacyNotification,
|
||||
} from "./OpenPrivacyNotification";
|
||||
import {
|
||||
shouldShowEngageParentsAnnouncement,
|
||||
showEngageParentsAnnouncement,
|
||||
showEngageParentsToast,
|
||||
} from "./OpenEngageParentsAnnouncement";
|
||||
import {
|
||||
shouldShowBsCloudAutoSyncAnnouncement,
|
||||
showBsCloudAutoSyncAnnouncement,
|
||||
} from "./OpenBsCloudAutoSyncAnnouncement";
|
||||
|
||||
type QueueStep = (goNext: () => void) => void;
|
||||
|
||||
/**
|
||||
* Runs startup modals in order: What's New (if the extension just updated),
|
||||
* privacy statement (if required), SEQTA Engage announcement (once), then BS Cloud
|
||||
* auto-sync (once, last).
|
||||
* then shows the SEQTA Engage toast (once, non-blocking).
|
||||
*/
|
||||
export function runStartupPopupQueue() {
|
||||
const steps: QueueStep[] = [];
|
||||
@@ -27,21 +18,14 @@ export function runStartupPopupQueue() {
|
||||
steps.push((goNext) => OpenWhatsNewPopup(goNext));
|
||||
}
|
||||
|
||||
if (shouldShowPrivacyNotification()) {
|
||||
steps.push((goNext) => showPrivacyNotification(goNext));
|
||||
}
|
||||
|
||||
if (shouldShowEngageParentsAnnouncement()) {
|
||||
steps.push((goNext) => showEngageParentsAnnouncement(goNext));
|
||||
}
|
||||
|
||||
if (shouldShowBsCloudAutoSyncAnnouncement()) {
|
||||
steps.push((goNext) => showBsCloudAutoSyncAnnouncement(goNext));
|
||||
}
|
||||
|
||||
function runNext() {
|
||||
const step = steps.shift();
|
||||
if (step) step(runNext);
|
||||
else {
|
||||
if (shouldShowEngageParentsAnnouncement()) {
|
||||
showEngageParentsToast();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runNext();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { animate } from "motion";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
|
||||
/**
|
||||
* Finds the SEQTA notifications dropdown panel (the list container next to the bell).
|
||||
*/
|
||||
function findNotificationPanel(): HTMLElement | null {
|
||||
const wrapper = document.querySelector(".connectedNotificationsWrapper");
|
||||
if (!wrapper) return null;
|
||||
|
||||
const flat = wrapper.querySelector<HTMLElement>(":scope > div > button + div");
|
||||
if (flat) return flat;
|
||||
|
||||
const notifBlock = wrapper.querySelector("[class*='notifications__notifications___']");
|
||||
if (notifBlock?.nextElementSibling instanceof HTMLElement) {
|
||||
return notifBlock.nextElementSibling;
|
||||
}
|
||||
|
||||
const list = wrapper.querySelector<HTMLElement>("[class*='notifications__list___']");
|
||||
if (list) return list;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPanelVisible(el: HTMLElement): boolean {
|
||||
return (
|
||||
el.getClientRects().length > 0 && getComputedStyle(el).visibility !== "hidden"
|
||||
);
|
||||
}
|
||||
|
||||
let lastVisible = false;
|
||||
/** Invalidates in-flight open animations when the panel closes or reopens. */
|
||||
let motionGeneration = 0;
|
||||
|
||||
function runOpenAnimation(panel: HTMLElement) {
|
||||
const myGen = ++motionGeneration;
|
||||
panel.classList.add("bsplus-notifications-panel");
|
||||
|
||||
if (!settingsState.animations) {
|
||||
panel.style.opacity = "1";
|
||||
panel.style.transform = "scale(1)";
|
||||
return;
|
||||
}
|
||||
|
||||
panel.style.opacity = "0";
|
||||
panel.style.transform = "scale(0)";
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (myGen !== motionGeneration) return;
|
||||
animate(0, 1, {
|
||||
onUpdate: (progress) => {
|
||||
panel.style.opacity = String(progress);
|
||||
panel.style.transform = `scale(${progress})`;
|
||||
},
|
||||
type: "spring",
|
||||
stiffness: 280,
|
||||
damping: 20,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function clearPanelMotionStyles(panel: HTMLElement) {
|
||||
motionGeneration++;
|
||||
panel.style.opacity = "";
|
||||
panel.style.transform = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring open / fade close for the native SEQTA notifications dropdown, matching ExtensionPopup.
|
||||
*/
|
||||
export function attachNotificationsPanelAnimation() {
|
||||
void setupNotificationsPanelAnimation();
|
||||
}
|
||||
|
||||
async function setupNotificationsPanelAnimation() {
|
||||
try {
|
||||
await waitForElm(".connectedNotificationsWrapper", true, 100, 60);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.querySelector(".connectedNotificationsWrapper");
|
||||
if (!wrapper) return;
|
||||
|
||||
const sync = () => {
|
||||
const panel = findNotificationPanel();
|
||||
// When SEQTA removes the dropdown from the DOM on close, we must reset
|
||||
// lastVisible — otherwise the next open still looks "already visible" and skips animation.
|
||||
if (!panel) {
|
||||
if (lastVisible) {
|
||||
lastVisible = false;
|
||||
motionGeneration++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const visible = isPanelVisible(panel);
|
||||
if (visible === lastVisible) return;
|
||||
|
||||
if (visible) {
|
||||
runOpenAnimation(panel);
|
||||
} else {
|
||||
clearPanelMotionStyles(panel);
|
||||
}
|
||||
lastVisible = visible;
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
sync();
|
||||
});
|
||||
observer.observe(wrapper, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style", "class"],
|
||||
});
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(sync));
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
sync();
|
||||
}
|
||||
@@ -36,7 +36,10 @@ export const SENSITIVE_DEVICE_STORAGE_KEYS_EXACT = [
|
||||
/** e.g. any future `plugin.global-search.storage.*` keys in chrome.storage */
|
||||
export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = ["plugin.global-search.storage."] as const;
|
||||
|
||||
const CLIENT_ONLY_CLOUD_KEYS_EXACT = [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as const;
|
||||
const CLIENT_ONLY_CLOUD_KEYS_EXACT = [
|
||||
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
|
||||
"bsplus_lastCloudPoll",
|
||||
] as const;
|
||||
|
||||
/** After restoring from cloud, keep local session so the user stays signed in. */
|
||||
const AUTH_KEYS_TO_PRESERVE = [
|
||||
@@ -102,11 +105,12 @@ export function buildUploadPayload(all: Record<string, unknown>): {
|
||||
schemaVersion: number;
|
||||
data: Record<string, unknown>;
|
||||
} {
|
||||
const data: Record<string, unknown> = {};
|
||||
const filtered: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(all)) {
|
||||
if (shouldOmitKeyFromCloudPayload(k)) continue;
|
||||
data[k] = v;
|
||||
filtered[k] = v;
|
||||
}
|
||||
const data = migrateLegacyToPluginSettings(filtered);
|
||||
return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, data };
|
||||
}
|
||||
|
||||
@@ -124,8 +128,77 @@ export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise<
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace local extension storage with the downloaded snapshot, except auth keys
|
||||
* and device-only sensitive caches, which are preserved from the current device.
|
||||
* Migrate legacy storage keys to plugin settings format.
|
||||
* Only applies migrations for keys present in the data; does not overwrite
|
||||
* existing plugin settings if the legacy key is absent.
|
||||
*/
|
||||
function migrateLegacyToPluginSettings(data: Record<string, unknown>): Record<string, unknown> {
|
||||
const result = { ...data };
|
||||
|
||||
function ensurePluginSettings(pluginId: string): Record<string, unknown> {
|
||||
const key = `plugin.${pluginId}.settings`;
|
||||
if (!result[key] || typeof result[key] !== "object") {
|
||||
result[key] = {};
|
||||
}
|
||||
return result[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// animatedbk -> plugin.animated-background.settings.enabled
|
||||
if ("animatedbk" in result) {
|
||||
const settings = ensurePluginSettings("animated-background");
|
||||
if (settings.enabled === undefined) {
|
||||
settings.enabled = !!result.animatedbk;
|
||||
}
|
||||
delete result.animatedbk;
|
||||
}
|
||||
|
||||
// bksliderinput -> plugin.animated-background.settings.speed
|
||||
// Legacy: string "0"-"100", New: float 0.1-2.0
|
||||
if ("bksliderinput" in result) {
|
||||
const settings = ensurePluginSettings("animated-background");
|
||||
if (settings.speed === undefined) {
|
||||
const legacy = parseFloat(String(result.bksliderinput));
|
||||
if (!isNaN(legacy)) {
|
||||
settings.speed = Math.round((0.1 + (legacy / 100) * 1.9) * 100) / 100;
|
||||
}
|
||||
}
|
||||
delete result.bksliderinput;
|
||||
}
|
||||
|
||||
// assessmentsAverage -> plugin.assessments-average.settings.enabled
|
||||
if ("assessmentsAverage" in result) {
|
||||
const settings = ensurePluginSettings("assessments-average");
|
||||
if (settings.enabled === undefined) {
|
||||
settings.enabled = !!result.assessmentsAverage;
|
||||
}
|
||||
delete result.assessmentsAverage;
|
||||
}
|
||||
|
||||
// lettergrade -> plugin.assessments-average.settings.lettergrade
|
||||
if ("lettergrade" in result) {
|
||||
const settings = ensurePluginSettings("assessments-average");
|
||||
if (settings.lettergrade === undefined) {
|
||||
settings.lettergrade = !!result.lettergrade;
|
||||
}
|
||||
delete result.lettergrade;
|
||||
}
|
||||
|
||||
// notificationCollector -> plugin.notificationCollector.settings.enabled
|
||||
if ("notificationCollector" in result && typeof result.notificationCollector === "boolean") {
|
||||
const settings = ensurePluginSettings("notificationCollector");
|
||||
if (settings.enabled === undefined) {
|
||||
settings.enabled = result.notificationCollector;
|
||||
}
|
||||
delete result.notificationCollector;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the downloaded cloud snapshot by setting each key individually,
|
||||
* preserving auth keys and device-only sensitive caches.
|
||||
* Legacy keys are automatically migrated to plugin settings format.
|
||||
*/
|
||||
export async function applyDownloadedEnvelope(envelope: unknown): Promise<void> {
|
||||
let remoteFlat: Record<string, unknown>;
|
||||
@@ -145,10 +218,7 @@ export async function applyDownloadedEnvelope(envelope: unknown): Promise<void>
|
||||
throw new Error("Invalid cloud settings payload");
|
||||
}
|
||||
|
||||
const local = await browser.storage.local.get();
|
||||
const preserved = collectLocalKeysToPreserve(local);
|
||||
const remoteSanitized = stripExcludedKeysFromRemoteData(remoteFlat);
|
||||
|
||||
await browser.storage.local.clear();
|
||||
await browser.storage.local.set({ ...remoteSanitized, ...preserved });
|
||||
const migrated = migrateLegacyToPluginSettings(remoteFlat);
|
||||
const remoteSanitized = stripExcludedKeysFromRemoteData(migrated);
|
||||
await browser.storage.local.set(remoteSanitized);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user