mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "betterseqtaplus",
|
"name": "betterseqtaplus",
|
||||||
"version": "3.6.0",
|
"version": "3.6.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
|
"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",
|
"browserslist": "> 0.5%, last 2 versions, not dead",
|
||||||
|
|||||||
@@ -133,6 +133,74 @@ function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
|
|||||||
return true;
|
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 {
|
function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
|
||||||
const { refresh_token, client_id } = request;
|
const { refresh_token, client_id } = request;
|
||||||
if (!refresh_token || !client_id) {
|
if (!refresh_token || !client_id) {
|
||||||
@@ -269,6 +337,7 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
|||||||
fetchFromUrl: handleFetchFromUrl,
|
fetchFromUrl: handleFetchFromUrl,
|
||||||
cloudReserveClient: handleCloudReserveClient,
|
cloudReserveClient: handleCloudReserveClient,
|
||||||
cloudLogin: handleCloudLogin,
|
cloudLogin: handleCloudLogin,
|
||||||
|
cloudStartLogin: handleCloudStartLogin,
|
||||||
cloudRefresh: handleCloudRefresh,
|
cloudRefresh: handleCloudRefresh,
|
||||||
cloudFavorite: handleCloudFavorite,
|
cloudFavorite: handleCloudFavorite,
|
||||||
cloudSettingsUpload: handleCloudSettingsUpload,
|
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 CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`;
|
||||||
const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`;
|
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 UPLOAD_DEBOUNCE_MS = 2000;
|
||||||
|
const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll";
|
||||||
|
|
||||||
type CloudSummaryResponse = {
|
type CloudSummaryResponse = {
|
||||||
desqta?: unknown;
|
desqta?: unknown;
|
||||||
@@ -323,6 +323,9 @@ export function runCloudSettingsPoll(): Promise<void> {
|
|||||||
if (pollInFlight) return pollInFlight;
|
if (pollInFlight) return pollInFlight;
|
||||||
pollInFlight = (async () => {
|
pollInFlight = (async () => {
|
||||||
try {
|
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();
|
await runCloudSettingsPollInner();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[BS+ cloud sync] Poll error:", 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>;
|
const all = (await browser.storage.local.get()) as Record<string, unknown>;
|
||||||
if (!isAutoCloudSyncEnabled(all)) {
|
if (!isAutoCloudSyncEnabled(all)) {
|
||||||
await browser.alarms.clear(ALARM_NAME);
|
|
||||||
clearUploadDebounce();
|
clearUploadDebounce();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
await browser.alarms.create(ALARM_NAME, { periodInMinutes: PERIOD_MINUTES });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onStorageChanged(
|
function onStorageChanged(
|
||||||
@@ -377,7 +377,7 @@ function onStorageChanged(
|
|||||||
if (area !== "local") return;
|
if (area !== "local") return;
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) {
|
if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) {
|
||||||
void syncAlarmWithStorage();
|
void syncAutoUploadWithStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = Object.keys(changes);
|
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 {
|
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
|
||||||
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
|
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
|
||||||
browser.alarms.onAlarm.addListener(onAlarm);
|
|
||||||
browser.storage.onChanged.addListener(onStorageChanged);
|
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);
|
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 {
|
#menu li.active {
|
||||||
color: #ffffff !important;
|
color: #ffffff !important;
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: rgba(0, 0, 0, 0.35);
|
||||||
@@ -3524,6 +3531,26 @@ div.day-empty {
|
|||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
.whatsnewHeader.engageParentsAnnouncementHeader {
|
||||||
|
height: auto;
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
.whatsnewHeader.engageParentsAnnouncementHeader h1 {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.whatsnewHeader.engageParentsAnnouncementHeader .engageParentsSubheading {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
.seqtaEngageAccent {
|
||||||
|
color: #ea580c;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.dark .seqtaEngageAccent {
|
||||||
|
color: #fb923c;
|
||||||
|
}
|
||||||
.whatsnewBackground {
|
.whatsnewBackground {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -3652,6 +3679,138 @@ div.day-empty {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
.whatsnewTextContainer .engageParentsPromoWrap {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.28);
|
||||||
|
background: color-mix(in srgb, var(--background-secondary) 88%, var(--text-primary) 12%);
|
||||||
|
}
|
||||||
|
.whatsnewTextContainer .engageParentsPromoWrap .engageParentsPromoImg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
aspect-ratio: unset;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader {
|
||||||
|
height: auto;
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader h1 {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.bsCloudAccent {
|
||||||
|
color: #059669;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.dark .bsCloudAccent {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
.whatsnewTextContainer .bsCloudAutoSyncSignupCallout {
|
||||||
|
margin: 1.5rem 0 0;
|
||||||
|
padding: 1.25rem 1rem 0;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||||
|
font-size: clamp(1.35rem, 3.8vw, 1.85rem);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-media-fullscreenable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
.popup-media-fullscreenable:hover {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
.popup-media-fullscreenable:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.popup-media-fullscreenable:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--text-primary) 70%, transparent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2147483646;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: clamp(20px, 4vw, 48px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-inner {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
max-width: min(96vw, 1320px);
|
||||||
|
max-height: calc(100vh - clamp(40px, 10vw, 96px));
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--background-primary);
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.94) translateY(12px);
|
||||||
|
transition:
|
||||||
|
opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1),
|
||||||
|
transform 0.28s cubic-bezier(0.22, 0.03, 0.26, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible
|
||||||
|
.bsplus-popup-media-overlay-inner {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant
|
||||||
|
.bsplus-popup-media-overlay-inner {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-slot {
|
||||||
|
width: 100%;
|
||||||
|
max-height: inherit;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: clamp(16px, 3vw, 28px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-media {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: calc(100vh - clamp(120px, 22vh, 200px));
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% {
|
0% {
|
||||||
@@ -4212,3 +4371,41 @@ h2.home-subtitle {
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 400;
|
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 browser from "webextension-polyfill";
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import DisclaimerModal from "./DisclaimerModal.svelte";
|
|
||||||
import Button from "./Button.svelte";
|
import Button from "./Button.svelte";
|
||||||
import Switch from "./Switch.svelte";
|
import Switch from "./Switch.svelte";
|
||||||
|
|
||||||
|
let { showDisclaimer } = $props<{
|
||||||
|
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
let cloudState = $state(cloudAuth.state);
|
let cloudState = $state(cloudAuth.state);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let statusMessage = $state<string | null>(null);
|
let statusMessage = $state<string | null>(null);
|
||||||
let statusError = $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(() => {
|
$effect(() => {
|
||||||
const unsub = cloudAuth.subscribe((s) => {
|
const unsub = cloudAuth.subscribe((s) => {
|
||||||
@@ -21,13 +21,6 @@
|
|||||||
return unsub;
|
return unsub;
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatNow(): string {
|
|
||||||
return new Date().toLocaleString(undefined, {
|
|
||||||
dateStyle: "short",
|
|
||||||
timeStyle: "short",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upload() {
|
async function upload() {
|
||||||
const token = await cloudAuth.getStoredToken();
|
const token = await cloudAuth.getStoredToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -40,8 +33,7 @@
|
|||||||
token,
|
token,
|
||||||
})) as { success?: boolean; error?: string };
|
})) as { success?: boolean; error?: string };
|
||||||
if (res?.success) {
|
if (res?.success) {
|
||||||
statusMessage = "Settings saved to the cloud.";
|
statusMessage = "Settings uploaded.";
|
||||||
lastUploadAt = formatNow();
|
|
||||||
} else {
|
} else {
|
||||||
statusError = res?.error ?? "Upload failed";
|
statusError = res?.error ?? "Upload failed";
|
||||||
}
|
}
|
||||||
@@ -53,11 +45,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function promptDownload() {
|
function promptDownload() {
|
||||||
showRestoreConfirm = true;
|
showDisclaimer(confirmDownload, () => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDownload() {
|
async function confirmDownload() {
|
||||||
showRestoreConfirm = false;
|
|
||||||
const token = await cloudAuth.getStoredToken();
|
const token = await cloudAuth.getStoredToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
@@ -69,8 +60,7 @@
|
|||||||
token,
|
token,
|
||||||
})) as { success?: boolean; error?: string; notFound?: boolean };
|
})) as { success?: boolean; error?: string; notFound?: boolean };
|
||||||
if (res?.success) {
|
if (res?.success) {
|
||||||
statusMessage = "Settings restored from the cloud. SEQTA tabs were reloaded.";
|
statusMessage = "Settings restored.";
|
||||||
lastDownloadAt = formatNow();
|
|
||||||
} else {
|
} else {
|
||||||
statusError = res?.error ?? "Download failed";
|
statusError = res?.error ?? "Download failed";
|
||||||
}
|
}
|
||||||
@@ -82,22 +72,13 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
{#if cloudState.isLoggedIn}
|
||||||
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"
|
<div class="flex flex-col gap-2.5">
|
||||||
>
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h3 class="text-xs font-bold text-zinc-800 dark:text-zinc-100">Cloud settings backup</h3>
|
<div>
|
||||||
<p class="mt-0.5 text-[11px] leading-snug text-zinc-500 dark:text-zinc-400">
|
<p class="text-[11px] font-semibold text-zinc-800 dark:text-zinc-100">Automatic sync</p>
|
||||||
Upload copies this browser’s BetterSEQTA+ settings to your account. Download replaces local settings with the
|
<p class="text-[10px] text-zinc-500 dark:text-zinc-400">Syncs settings when SEQTA loads and when you make changes</p>
|
||||||
cloud copy (your sign-in stays on this device).
|
</div>
|
||||||
</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>
|
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<Switch
|
<Switch
|
||||||
state={$settingsState.autoCloudSettingsSync !== false}
|
state={$settingsState.autoCloudSettingsSync !== false}
|
||||||
@@ -105,62 +86,35 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div class="flex flex-wrap gap-2">
|
||||||
settings. Settings you change will upload shortly after you adjust them.
|
<Button
|
||||||
</p>
|
text={busy ? "Please wait\u2026" : "Upload"}
|
||||||
<p class="text-[10px] leading-snug text-zinc-500 dark:text-zinc-400">
|
onClick={upload}
|
||||||
Passwords, tokens, and other sensitive data are not included in the backup.
|
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
|
<a
|
||||||
href="https://betterseqta.org/privacy"
|
href="https://betterseqta.org/privacy"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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>
|
||||||
Privacy policy
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { animate } from "motion";
|
import { animate } from "motion";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
import CloudLoginForm from "@/interface/components/store/CloudLoginForm.svelte";
|
|
||||||
|
|
||||||
let { onClose } = $props<{ onClose: () => void }>();
|
let { onClose } = $props<{ onClose: () => void }>();
|
||||||
let modalElement: HTMLElement;
|
let modalElement: HTMLElement;
|
||||||
@@ -23,6 +22,10 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function handleSignIn() {
|
||||||
|
await cloudAuth.startLogin();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -52,7 +55,16 @@
|
|||||||
Sign in to the Theme Store to save favorites across devices, or create an account to get started.
|
Sign in to the Theme Store to save favorites across devices, or create an account to get started.
|
||||||
</p>
|
</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">
|
<div class="flex justify-end mt-4">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
import CloudLoginForm from "./CloudLoginForm.svelte";
|
|
||||||
|
|
||||||
let { alwaysShowUserName = false } = $props<{
|
let { alwaysShowUserName = false, onClick = undefined } = $props<{
|
||||||
/** When true (e.g. narrow extension popup), show display name below sm breakpoint */
|
|
||||||
alwaysShowUserName?: boolean;
|
alwaysShowUserName?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let cloudState = $state(cloudAuth.state);
|
let cloudState = $state(cloudAuth.state);
|
||||||
@@ -42,6 +41,19 @@
|
|||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSignIn() {
|
||||||
|
await cloudAuth.startLogin();
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleButtonClick() {
|
||||||
|
if (onClick) {
|
||||||
|
onClick();
|
||||||
|
} else {
|
||||||
|
open = !open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getInitials(): string {
|
function getInitials(): string {
|
||||||
const u = cloudState.user;
|
const u = cloudState.user;
|
||||||
if (!u) return "?";
|
if (!u) return "?";
|
||||||
@@ -55,35 +67,35 @@
|
|||||||
<div class="relative flex items-center" bind:this={dropdownEl}>
|
<div class="relative flex items-center" bind:this={dropdownEl}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (open = !open)}
|
onclick={handleButtonClick}
|
||||||
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"
|
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.isLoggedIn}
|
||||||
{#if cloudState.user?.pfpUrl}
|
{#if cloudState.user?.pfpUrl}
|
||||||
<img
|
<img
|
||||||
src={cloudState.user.pfpUrl}
|
src={cloudState.user.pfpUrl}
|
||||||
alt=""
|
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}
|
{: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()}
|
{getInitials()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<span
|
||||||
class={alwaysShowUserName
|
class={alwaysShowUserName
|
||||||
? "inline max-w-[10rem] truncate text-sm"
|
? "inline max-w-[10rem] truncate text-[0.75rem]"
|
||||||
: "hidden max-w-24 truncate sm:inline text-base"}
|
: "hidden max-w-24 truncate sm:inline text-[0.75rem]"}
|
||||||
>
|
>
|
||||||
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
|
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-xl font-IconFamily" aria-hidden="true">{'\ued53'}</span>
|
<span class="text-sm font-IconFamily" aria-hidden="true">{'\ued53'}</span>
|
||||||
<span class="text-base font-medium">Sign in</span>
|
<span class="text-[0.75rem]">Sign in</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if open}
|
{#if !onClick && open}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
@@ -127,11 +139,21 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<CloudLoginForm
|
<div class="flex flex-col gap-3">
|
||||||
onSuccess={() => {
|
<p class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
open = false;
|
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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,14 +15,16 @@
|
|||||||
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
|
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
|
||||||
|
|
||||||
import ColourPicker from "../components/ColourPicker.svelte";
|
import ColourPicker from "../components/ColourPicker.svelte";
|
||||||
|
import CloudPanel from "../components/CloudPanel.svelte";
|
||||||
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
||||||
import CloudHeader from "@/interface/components/store/CloudHeader.svelte";
|
|
||||||
import { settingsPopup } from "../hooks/SettingsPopup";
|
import { settingsPopup } from "../hooks/SettingsPopup";
|
||||||
|
|
||||||
let devModeSequence = "";
|
let devModeSequence = "";
|
||||||
let settingsActiveTab = $state(0);
|
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);
|
||||||
|
let disclaimerTitle = $state("Confirm");
|
||||||
|
let disclaimerMessage = $state("");
|
||||||
|
|
||||||
const handleDevModeToggle = () => {
|
const handleDevModeToggle = () => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -67,15 +69,23 @@
|
|||||||
|
|
||||||
let { standalone } = $props<{ standalone?: boolean }>();
|
let { standalone } = $props<{ standalone?: boolean }>();
|
||||||
let showColourPicker = $state<boolean>(false);
|
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 };
|
disclaimerCallbacks = { onConfirm, onCancel };
|
||||||
|
disclaimerTitle = title ?? "Confirm";
|
||||||
|
disclaimerMessage = message ?? "";
|
||||||
showDisclaimerModal = true;
|
showDisclaimerModal = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
settingsPopup.addListener(() => {
|
settingsPopup.addListener(() => {
|
||||||
showColourPicker = false;
|
showColourPicker = false;
|
||||||
|
showCloudPanel = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (standalone) {
|
if (standalone) {
|
||||||
@@ -277,25 +287,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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
|
<TabbedContainer
|
||||||
bind:activeTab={settingsActiveTab}
|
bind:activeTab={settingsActiveTab}
|
||||||
tabs={[
|
tabs={[
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
Content: Settings,
|
Content: Settings,
|
||||||
props: { showColourPicker: openColourPicker, showDisclaimer },
|
props: { showColourPicker: openColourPicker, showDisclaimer, showCloudPanel: openCloudPanel },
|
||||||
},
|
},
|
||||||
{ title: "Shortcuts", Content: Shortcuts },
|
{ title: "Shortcuts", Content: Shortcuts },
|
||||||
{ title: "Themes", Content: Theme },
|
{ title: "Themes", Content: Theme },
|
||||||
@@ -310,19 +308,20 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showCloudPanel}
|
||||||
|
<CloudPanel
|
||||||
|
hidePanel={() => {
|
||||||
|
showCloudPanel = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showDisclaimerModal && disclaimerCallbacks}
|
{#if showDisclaimerModal && disclaimerCallbacks}
|
||||||
<DisclaimerModal
|
<DisclaimerModal
|
||||||
title="Assessment Averages Disclaimer"
|
title={disclaimerTitle}
|
||||||
message="This feature calculates a simple average of your assessment grades. It does not take into account:
|
message={disclaimerMessage}
|
||||||
• 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?"
|
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
disclaimerCallbacks?.onConfirm();
|
disclaimerCallbacks?.onConfirm();
|
||||||
showDisclaimerModal = false;
|
showDisclaimerModal = false;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
|
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
|
||||||
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
|
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
|
||||||
import CloudSettingsSync from "@/interface/components/CloudSettingsSync.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 { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
|
||||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
||||||
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
|
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
|
||||||
@@ -53,6 +55,12 @@
|
|||||||
|
|
||||||
const pluginSettings = getAllPluginSettings() as Plugin[];
|
const pluginSettings = getAllPluginSettings() as Plugin[];
|
||||||
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({});
|
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() {
|
async function loadPluginSettings() {
|
||||||
for (const plugin of pluginSettings) {
|
for (const plugin of pluginSettings) {
|
||||||
@@ -95,9 +103,10 @@
|
|||||||
loadPluginSettings();
|
loadPluginSettings();
|
||||||
})
|
})
|
||||||
|
|
||||||
const { showColourPicker, showDisclaimer } = $props<{
|
const { showColourPicker, showDisclaimer, showCloudPanel } = $props<{
|
||||||
showColourPicker: () => void;
|
showColourPicker: () => void;
|
||||||
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
|
showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void;
|
||||||
|
showCloudPanel: () => void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
async function exportCloudSettingsJsonToFile() {
|
async function exportCloudSettingsJsonToFile() {
|
||||||
@@ -196,12 +205,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Default Page",
|
title: "Default Page",
|
||||||
description:
|
description: "Choose which page loads first when you open SEQTA",
|
||||||
"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.",
|
|
||||||
id: 10,
|
id: 10,
|
||||||
Component: Select,
|
Component: Select,
|
||||||
props: {
|
props: {
|
||||||
state: $settingsState.defaultPage,
|
state: $settingsState.defaultPage ?? "home",
|
||||||
onChange: (value: string) => (settingsState.defaultPage = value),
|
onChange: (value: string) => (settingsState.defaultPage = value),
|
||||||
options: [
|
options: [
|
||||||
{ value: "home", label: "Home" },
|
{ value: "home", label: "Home" },
|
||||||
@@ -310,8 +318,9 @@
|
|||||||
async () => {
|
async () => {
|
||||||
await updatePluginSetting(plugin.pluginId, 'enabled', true);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -324,8 +333,8 @@
|
|||||||
|
|
||||||
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
|
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
|
||||||
{#each Object.entries(plugin.settings) as [key, setting]}
|
{#each Object.entries(plugin.settings) as [key, setting]}
|
||||||
<!-- Skip the 'enabled' setting if it's part of the settings object -->
|
<!-- Skip the 'enabled' setting and hide cloud-only settings when not signed in -->
|
||||||
{#if key !== 'enabled'}
|
{#if key !== 'enabled' && !(key === 'useCloudPfp' && !cloudState.isLoggedIn)}
|
||||||
<div class="flex justify-between items-center px-4 py-3">
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
<div class="pr-4">
|
<div class="pr-4">
|
||||||
<h2 class="text-sm font-bold">{setting.title || key}</h2>
|
<h2 class="text-sm font-bold">{setting.title || key}</h2>
|
||||||
@@ -388,6 +397,25 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/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>
|
<div class="p-1 border-none"></div>
|
||||||
|
|
||||||
{@render Setting({
|
{@render Setting({
|
||||||
@@ -401,10 +429,6 @@
|
|||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div class="border-none py-3">
|
|
||||||
<CloudSettingsSync />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if $settingsState.devMode}
|
{#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-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">
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"64": "resources/icons/icon-64.png"
|
"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/", "*://*/*"],
|
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
|
||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background.ts"
|
"service_worker": "background.ts"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Plugin } from "../../core/types";
|
import type { Plugin } from "../../core/types";
|
||||||
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
|
|
||||||
interface NotificationCollectorStorage {
|
interface NotificationCollectorStorage {
|
||||||
lastNotificationCount: number;
|
lastNotificationCount: number;
|
||||||
@@ -15,6 +16,10 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
|
|||||||
disableToggle: true,
|
disableToggle: true,
|
||||||
|
|
||||||
run: async (api) => {
|
run: async (api) => {
|
||||||
|
if (isSeqtaEngageExperience()) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
let pollInterval: number | null = null;
|
let pollInterval: number | null = null;
|
||||||
let isVisible = !document.hidden;
|
let isVisible = !document.hidden;
|
||||||
let baseInterval = 30000; // 30 seconds
|
let baseInterval = 30000; // 30 seconds
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ const settings = defineSettings({
|
|||||||
useCloudPfp: booleanSetting({
|
useCloudPfp: booleanSetting({
|
||||||
default: false,
|
default: false,
|
||||||
title: "Use BetterSEQTA Cloud profile picture",
|
title: "Use BetterSEQTA Cloud profile picture",
|
||||||
description:
|
description: "Use your cloud account avatar instead of the uploaded image below",
|
||||||
"When enabled, uses the avatar from your BetterSEQTA Cloud account (sign in from the extension store). Otherwise uses the uploaded image below.",
|
|
||||||
}),
|
}),
|
||||||
picture: componentSetting({
|
picture: componentSetting({
|
||||||
title: "Profile Picture",
|
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.
|
* 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).
|
* 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> {
|
public async checkStoreThemeUpdates(): Promise<void> {
|
||||||
if (this.storeUpdateCheckRunning) return;
|
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;
|
this.storeUpdateCheckRunning = true;
|
||||||
|
localStorage.setItem(ThemeManager.STORE_CHECK_KEY, String(Date.now()));
|
||||||
try {
|
try {
|
||||||
const token = await cloudAuth.getStoredToken();
|
const token = await cloudAuth.getStoredToken();
|
||||||
const res = (await browser.runtime.sendMessage({
|
const res = (await browser.runtime.sendMessage({
|
||||||
|
|||||||
@@ -63,7 +63,12 @@ function resetTimetableStyles(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleTimetable(): Promise<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
|
// Convert time format if needed
|
||||||
if (settingsState.timeFormat == "12") {
|
if (settingsState.timeFormat == "12") {
|
||||||
|
|||||||
@@ -271,7 +271,9 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const syncQuickbarFromDOM = () => {
|
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") {
|
if (quickbar && quickbar.getAttribute("data-type") === "class") {
|
||||||
const titleEl = quickbar.querySelector(".title");
|
const titleEl = quickbar.querySelector(".title");
|
||||||
const roomEl = quickbar.querySelector(".meta .room");
|
const roomEl = quickbar.querySelector(".meta .room");
|
||||||
@@ -287,7 +289,9 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
if (!timetablePage || quickbarObserver) return;
|
if (!timetablePage || quickbarObserver) return;
|
||||||
|
|
||||||
quickbarObserver = new MutationObserver(() => {
|
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") {
|
if (quickbar?.getAttribute("data-type") === "class") {
|
||||||
addEditButtonToQuickbar(quickbar as HTMLElement);
|
addEditButtonToQuickbar(quickbar as HTMLElement);
|
||||||
}
|
}
|
||||||
@@ -302,7 +306,13 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTimetable = async () => {
|
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();
|
processAllEntries();
|
||||||
setupQuickbarObserver();
|
setupQuickbarObserver();
|
||||||
syncQuickbarFromDOM();
|
syncQuickbarFromDOM();
|
||||||
|
|||||||
+55
-15
@@ -29,8 +29,7 @@ import {
|
|||||||
updateEngageHomeMenuActive,
|
updateEngageHomeMenuActive,
|
||||||
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
||||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||||
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
|
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
|
||||||
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification";
|
|
||||||
|
|
||||||
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
||||||
|
|
||||||
@@ -106,14 +105,7 @@ export async function finishLoad() {
|
|||||||
console.error("Error during loading cleanup:", err);
|
console.error("Error during loading cleanup:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check and show privacy statement notification (before what's new)
|
runStartupPopupQueue();
|
||||||
if (!document.getElementById("privacy-notification")) {
|
|
||||||
await showPrivacyNotification();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settingsState.justupdated && !document.getElementById("whatsnewbk") && !document.getElementById("privacy-notification")) {
|
|
||||||
OpenWhatsNewPopup();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetCSSElement(file: string) {
|
export function GetCSSElement(file: string) {
|
||||||
@@ -517,6 +509,9 @@ function CheckNoticeTextColour(notice: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function watchForEngageLogin() {
|
function watchForEngageLogin() {
|
||||||
|
if (!document.querySelector(".login")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
if (!document.querySelector(".login")) {
|
if (!document.querySelector(".login")) {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
@@ -526,24 +521,69 @@ function watchForEngageLogin() {
|
|||||||
observer.observe(document.body, { childList: true, subtree: true });
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wait until Engage shows either the login shell or the main app (`#content`), so we never call `LoadPageElements` while still on login (which would hang on `waitForElm("#content")`). */
|
||||||
|
function waitForEngageLoginOrContent(): Promise<"login" | "app" | "timeout"> {
|
||||||
|
if (document.querySelector(".login")) {
|
||||||
|
return Promise.resolve("login");
|
||||||
|
}
|
||||||
|
if (document.getElementById("content")) {
|
||||||
|
return Promise.resolve("app");
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (mode: "login" | "app") => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
mo.disconnect();
|
||||||
|
window.clearTimeout(tid);
|
||||||
|
resolve(mode);
|
||||||
|
};
|
||||||
|
const check = () => {
|
||||||
|
if (document.querySelector(".login")) finish("login");
|
||||||
|
else if (document.getElementById("content")) finish("app");
|
||||||
|
};
|
||||||
|
const mo = new MutationObserver(check);
|
||||||
|
mo.observe(document.documentElement, { subtree: true, childList: true });
|
||||||
|
const tid = window.setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
mo.disconnect();
|
||||||
|
settled = true;
|
||||||
|
if (document.querySelector(".login")) resolve("login");
|
||||||
|
else if (document.getElementById("content")) resolve("app");
|
||||||
|
else {
|
||||||
|
console.warn(
|
||||||
|
"[BetterSEQTA+] Engage: timed out waiting for .login or #content; unblocking load UI.",
|
||||||
|
);
|
||||||
|
resolve("timeout");
|
||||||
|
}
|
||||||
|
}, 120_000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function tryLoad() {
|
export function tryLoad() {
|
||||||
if (isSeqtaEngageExperience()) {
|
if (isSeqtaEngageExperience()) {
|
||||||
updateIframesWithDarkMode();
|
updateIframesWithDarkMode();
|
||||||
window.addEventListener("load", () => removeThemeTagsFromNotices(), { once: true });
|
window.addEventListener("load", () => removeThemeTagsFromNotices(), { once: true });
|
||||||
|
|
||||||
const runEngageLoad = () => {
|
const runEngageLoad = async () => {
|
||||||
if (document.querySelector(".login")) {
|
const mode = await waitForEngageLoginOrContent();
|
||||||
|
if (mode === "login") {
|
||||||
finishLoad();
|
finishLoad();
|
||||||
watchForEngageLogin();
|
watchForEngageLogin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void LoadPageElements();
|
if (mode === "timeout") {
|
||||||
|
finishLoad();
|
||||||
|
void waitForElm("#content").then(() => void LoadPageElements());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await LoadPageElements();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.readyState === "complete") {
|
if (document.readyState === "complete") {
|
||||||
runEngageLoad();
|
void runEngageLoad();
|
||||||
} else {
|
} else {
|
||||||
window.addEventListener("load", () => runEngageLoad(), { once: true });
|
window.addEventListener("load", () => void runEngageLoad(), { once: true });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 653 KiB |
Binary file not shown.
@@ -3,6 +3,7 @@ import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
|||||||
import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
||||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||||
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
|
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
|
||||||
|
import { attachNotificationsPanelAnimation } from "@/seqta/utils/attachNotificationsPanelAnimation";
|
||||||
import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton";
|
import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ export async function AddBetterSEQTAElements() {
|
|||||||
addExtensionSettings();
|
addExtensionSettings();
|
||||||
await createSettingsButton();
|
await createSettingsButton();
|
||||||
setupSettingsButton();
|
setupSettingsButton();
|
||||||
|
attachNotificationsPanelAnimation();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
|
function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
|
||||||
@@ -423,10 +425,12 @@ async function setupEngageSettingsButton() {
|
|||||||
await addDarkLightToggle(parent);
|
await addDarkLightToggle(parent);
|
||||||
await createSettingsButton(parent);
|
await createSettingsButton(parent);
|
||||||
setupSettingsButton();
|
setupSettingsButton();
|
||||||
|
attachNotificationsPanelAnimation();
|
||||||
} catch {
|
} catch {
|
||||||
await addDarkLightToggle();
|
await addDarkLightToggle();
|
||||||
await createSettingsButton();
|
await createSettingsButton();
|
||||||
setupSettingsButton();
|
setupSettingsButton();
|
||||||
|
attachNotificationsPanelAnimation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,25 @@ class CloudAuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pull cloud settings backup after a fresh sign-in (matches manual “Download from cloud”). */
|
||||||
|
private triggerCloudSettingsDownloadAfterLogin(accessToken: string): void {
|
||||||
|
void browser.runtime
|
||||||
|
.sendMessage({
|
||||||
|
type: "cloudSettingsDownload",
|
||||||
|
token: accessToken,
|
||||||
|
})
|
||||||
|
.then((res: unknown) => {
|
||||||
|
const r = res as { success?: boolean; notFound?: boolean; error?: string } | undefined;
|
||||||
|
if (r?.success || r?.notFound) return;
|
||||||
|
if (r?.error) {
|
||||||
|
console.warn("[BetterSEQTA+] Cloud settings download after login:", r.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn("[BetterSEQTA+] Cloud settings download after login failed:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async getStoredToken(): Promise<string | null> {
|
public async getStoredToken(): Promise<string | null> {
|
||||||
const result = await browser.storage.local.get(STORAGE_KEYS.accessToken);
|
const result = await browser.storage.local.get(STORAGE_KEYS.accessToken);
|
||||||
return (result[STORAGE_KEYS.accessToken] as string) ?? null;
|
return (result[STORAGE_KEYS.accessToken] as string) ?? null;
|
||||||
@@ -108,6 +127,26 @@ class CloudAuthService {
|
|||||||
return clientId;
|
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(
|
public async login(
|
||||||
login: string,
|
login: string,
|
||||||
password: string
|
password: string
|
||||||
@@ -135,6 +174,7 @@ class CloudAuthService {
|
|||||||
user: result.user ?? null,
|
user: result.user ?? null,
|
||||||
};
|
};
|
||||||
this.notify();
|
this.notify();
|
||||||
|
this.triggerCloudSettingsDownloadAfterLogin(result.access_token);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import stringToHTML from "../stringToHTML";
|
||||||
|
import { settingsState } from "../listeners/SettingsState";
|
||||||
|
import { openPopup } from "./PopupManager";
|
||||||
|
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
|
||||||
|
|
||||||
|
/** Same hosting pattern as the What's New update video (GitHub raw). */
|
||||||
|
const BS_CLOUD_DEMO_VIDEO_URL =
|
||||||
|
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/bsclouddemo.webm";
|
||||||
|
|
||||||
|
export function shouldShowBsCloudAutoSyncAnnouncement(): boolean {
|
||||||
|
return !settingsState.bsCloudAutoSyncAnnouncementShown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time announcement for BetterSEQTA Cloud automatic settings sync (after other startup popups).
|
||||||
|
* Video layout matches {@link OpenWhatsNewPopup} (`whatsnewImgContainer` / `whatsnewImg`).
|
||||||
|
*/
|
||||||
|
export function showBsCloudAutoSyncAnnouncement(onDismissed?: () => void) {
|
||||||
|
if (document.getElementById("whatsnewbk")) {
|
||||||
|
onDismissed?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!shouldShowBsCloudAutoSyncAnnouncement()) {
|
||||||
|
onDismissed?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = stringToHTML(
|
||||||
|
/* html */
|
||||||
|
`<div class="whatsnewHeader bsCloudAutoSyncAnnouncementHeader">
|
||||||
|
<h1>BetterSEQTA Cloud</h1>
|
||||||
|
</div>`,
|
||||||
|
).firstChild as HTMLElement;
|
||||||
|
|
||||||
|
const imageContainer = document.createElement("div");
|
||||||
|
imageContainer.classList.add("whatsnewImgContainer");
|
||||||
|
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const source = document.createElement("source");
|
||||||
|
source.setAttribute("src", BS_CLOUD_DEMO_VIDEO_URL);
|
||||||
|
source.setAttribute("type", "video/webm");
|
||||||
|
video.autoplay = true;
|
||||||
|
video.muted = true;
|
||||||
|
video.loop = true;
|
||||||
|
video.appendChild(source);
|
||||||
|
video.classList.add("whatsnewImg");
|
||||||
|
imageContainer.appendChild(video);
|
||||||
|
attachPopupMediaFullscreen(video);
|
||||||
|
|
||||||
|
const text = stringToHTML(/* html */ `
|
||||||
|
<div class="whatsnewTextContainer privacyStatement" style="height: 50%; overflow-y: auto; font-size: 1.2rem; line-height: 1.6;">
|
||||||
|
<p>
|
||||||
|
<strong class="bsCloudAccent">BetterSEQTA Cloud</strong> can keep your BetterSEQTA+ settings backed up and in
|
||||||
|
sync across browsers. Optional <strong>automatic settings sync</strong> runs when you are signed in (passwords
|
||||||
|
and tokens are never included).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Close this dialog when you are done. We will not show this announcement again.
|
||||||
|
</p>
|
||||||
|
<p class="bsCloudAutoSyncSignupCallout">Sign up in BetterSEQTA settings</p>
|
||||||
|
</div>
|
||||||
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
|
settingsState.bsCloudAutoSyncAnnouncementShown = true;
|
||||||
|
|
||||||
|
openPopup({
|
||||||
|
header,
|
||||||
|
content: [imageContainer, text],
|
||||||
|
afterClose: onDismissed,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { settingsState } from "../listeners/SettingsState";
|
||||||
|
import { animate as motionAnimate } from "motion";
|
||||||
|
|
||||||
|
export function shouldShowEngageParentsAnnouncement(): boolean {
|
||||||
|
return !settingsState.engageParentsAnnouncementShown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-blocking bottom-right toast announcing SEQTA Engage support. Shown once.
|
||||||
|
*/
|
||||||
|
export function showEngageParentsToast() {
|
||||||
|
if (!shouldShowEngageParentsAnnouncement()) return;
|
||||||
|
|
||||||
|
settingsState.engageParentsAnnouncementShown = true;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,13 +1,31 @@
|
|||||||
import stringToHTML from "../stringToHTML";
|
import stringToHTML from "../stringToHTML";
|
||||||
import { settingsState } from "../listeners/SettingsState";
|
import { settingsState } from "../listeners/SettingsState";
|
||||||
import { openPopup } from "./PopupManager";
|
import { openPopup } from "./PopupManager";
|
||||||
|
import { attachPopupMediaFullscreenIfPresent } from "./attachPopupMediaFullscreen";
|
||||||
|
|
||||||
export function showPrivacyNotification() {
|
const PRIVACY_STATEMENT_VERSION = "2025-12-19";
|
||||||
const lastUpdated = "2025-12-19";
|
|
||||||
|
|
||||||
if (document.getElementById("whatsnewbk")) return;
|
export function shouldShowPrivacyNotification(): boolean {
|
||||||
if (settingsState.privacyStatementShown) return;
|
if (settingsState.privacyStatementShown) return false;
|
||||||
if (settingsState.privacyStatementLastUpdated && new Date(settingsState.privacyStatementLastUpdated) > new Date(lastUpdated)) return;
|
if (
|
||||||
|
settingsState.privacyStatementLastUpdated &&
|
||||||
|
new Date(settingsState.privacyStatementLastUpdated) >
|
||||||
|
new Date(PRIVACY_STATEMENT_VERSION)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showPrivacyNotification(onDismissed?: () => void) {
|
||||||
|
if (document.getElementById("whatsnewbk")) {
|
||||||
|
onDismissed?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!shouldShowPrivacyNotification()) {
|
||||||
|
onDismissed?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const header = stringToHTML(
|
const header = stringToHTML(
|
||||||
/* html */
|
/* html */
|
||||||
@@ -42,11 +60,14 @@ export function showPrivacyNotification() {
|
|||||||
</div>
|
</div>
|
||||||
`).firstChild as HTMLElement;
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
|
attachPopupMediaFullscreenIfPresent(text, "img.aboutImg");
|
||||||
|
|
||||||
settingsState.privacyStatementLastUpdated = "2025-12-20";
|
settingsState.privacyStatementLastUpdated = "2025-12-20";
|
||||||
settingsState.privacyStatementShown = true;
|
settingsState.privacyStatementShown = true;
|
||||||
|
|
||||||
openPopup({
|
openPopup({
|
||||||
header,
|
header,
|
||||||
content: [text],
|
content: [text],
|
||||||
|
afterClose: onDismissed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import stringToHTML from "../stringToHTML";
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import kofi from "@/resources/kofi.png?base64";
|
import kofi from "@/resources/kofi.png?base64";
|
||||||
import { openPopup } from "./PopupManager";
|
import { openPopup } from "./PopupManager";
|
||||||
|
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
|
||||||
|
|
||||||
export function OpenWhatsNewPopup() {
|
export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
||||||
const header = stringToHTML(
|
const header = stringToHTML(
|
||||||
/* html */
|
/* html */
|
||||||
`<div class="whatsnewHeader">
|
`<div class="whatsnewHeader">
|
||||||
@@ -28,11 +29,12 @@ export function OpenWhatsNewPopup() {
|
|||||||
video.appendChild(source);
|
video.appendChild(source);
|
||||||
video.classList.add("whatsnewImg");
|
video.classList.add("whatsnewImg");
|
||||||
imageContainer.appendChild(video);
|
imageContainer.appendChild(video);
|
||||||
|
attachPopupMediaFullscreen(video);
|
||||||
|
|
||||||
const text = stringToHTML(/* html */ `
|
const text = stringToHTML(/* html */ `
|
||||||
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
||||||
|
|
||||||
<h1>3.6.0 - Cloud backup, various fixes & SEQTA Engage support</h1>
|
<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>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>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>
|
<li>Option to use cloud profile photo as the local SEQTA profile picture</li>
|
||||||
@@ -43,7 +45,8 @@ export function OpenWhatsNewPopup() {
|
|||||||
<li>Fixed today's lessons on the homepage misbehaving in developer mode.</li>
|
<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>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>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>
|
<h1>3.5.3 - Adaptive theme updates</h1>
|
||||||
<li>Fixed adaptive theming on current-year course and assessment pages.</li>
|
<li>Fixed adaptive theming on current-year course and assessment pages.</li>
|
||||||
|
|
||||||
@@ -364,5 +367,7 @@ export function OpenWhatsNewPopup() {
|
|||||||
openPopup({
|
openPopup({
|
||||||
header,
|
header,
|
||||||
content: [imageContainer, text, footer],
|
content: [imageContainer, text, footer],
|
||||||
|
afterClose: onDismissed,
|
||||||
|
clearJustUpdated: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { animate as motionAnimate, stagger } from "motion";
|
|||||||
type AnimationTarget = string | Element | Element[] | NodeList | null;
|
type AnimationTarget = string | Element | Element[] | NodeList | null;
|
||||||
|
|
||||||
let isClosing = false;
|
let isClosing = false;
|
||||||
|
let pendingAfterClose: (() => void) | undefined;
|
||||||
|
|
||||||
|
function invokeAfterClose() {
|
||||||
|
const fn = pendingAfterClose;
|
||||||
|
pendingAfterClose = undefined;
|
||||||
|
fn?.();
|
||||||
|
}
|
||||||
|
|
||||||
export async function closePopup() {
|
export async function closePopup() {
|
||||||
if (isClosing) return;
|
if (isClosing) return;
|
||||||
@@ -16,12 +23,14 @@ export async function closePopup() {
|
|||||||
|
|
||||||
if (!background || !popup) {
|
if (!background || !popup) {
|
||||||
isClosing = false;
|
isClosing = false;
|
||||||
|
invokeAfterClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settingsState.animations) {
|
if (!settingsState.animations) {
|
||||||
background.remove();
|
background.remove();
|
||||||
isClosing = false;
|
isClosing = false;
|
||||||
|
invokeAfterClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,19 +42,28 @@ export async function closePopup() {
|
|||||||
|
|
||||||
background.remove();
|
background.remove();
|
||||||
isClosing = false;
|
isClosing = false;
|
||||||
|
invokeAfterClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenPopupOptions {
|
interface OpenPopupOptions {
|
||||||
header?: Node | null;
|
header?: Node | null;
|
||||||
content?: (Node | null | undefined)[];
|
content?: (Node | null | undefined)[];
|
||||||
animateSelector?: AnimationTarget;
|
animateSelector?: AnimationTarget;
|
||||||
|
/** Called once after this popup is fully closed (including skip-animation path). */
|
||||||
|
afterClose?: () => void;
|
||||||
|
/** When true, clears the post-update flag when this popup opens (What's New only). */
|
||||||
|
clearJustUpdated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openPopup({
|
export function openPopup({
|
||||||
header,
|
header,
|
||||||
content = [],
|
content = [],
|
||||||
animateSelector = ".whatsnewTextContainer *",
|
animateSelector = ".whatsnewTextContainer *",
|
||||||
|
afterClose,
|
||||||
|
clearJustUpdated = false,
|
||||||
}: OpenPopupOptions = {}) {
|
}: OpenPopupOptions = {}) {
|
||||||
|
pendingAfterClose = afterClose;
|
||||||
|
|
||||||
const background = document.createElement("div");
|
const background = document.createElement("div");
|
||||||
background.id = "whatsnewbk";
|
background.id = "whatsnewbk";
|
||||||
background.classList.add("whatsnewBackground");
|
background.classList.add("whatsnewBackground");
|
||||||
@@ -88,7 +106,9 @@ export function openPopup({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
delete settingsState.justupdated;
|
if (clearJustUpdated) {
|
||||||
|
delete settingsState.justupdated;
|
||||||
|
}
|
||||||
|
|
||||||
background.addEventListener("click", (event) => {
|
background.addEventListener("click", (event) => {
|
||||||
if (event.target === background) void closePopup();
|
if (event.target === background) void closePopup();
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { settingsState } from "../listeners/SettingsState";
|
||||||
|
import { OpenWhatsNewPopup } from "./OpenWhatsNewPopup";
|
||||||
|
import {
|
||||||
|
shouldShowEngageParentsAnnouncement,
|
||||||
|
showEngageParentsToast,
|
||||||
|
} from "./OpenEngageParentsAnnouncement";
|
||||||
|
|
||||||
|
type QueueStep = (goNext: () => void) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs startup modals in order: What's New (if the extension just updated),
|
||||||
|
* then shows the SEQTA Engage toast (once, non-blocking).
|
||||||
|
*/
|
||||||
|
export function runStartupPopupQueue() {
|
||||||
|
const steps: QueueStep[] = [];
|
||||||
|
|
||||||
|
if (settingsState.justupdated) {
|
||||||
|
steps.push((goNext) => OpenWhatsNewPopup(goNext));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runNext() {
|
||||||
|
const step = steps.shift();
|
||||||
|
if (step) step(runNext);
|
||||||
|
else {
|
||||||
|
if (shouldShowEngageParentsAnnouncement()) {
|
||||||
|
showEngageParentsToast();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runNext();
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Makes popup hero images/videos open a padded overlay (not browser fullscreen) on click.
|
||||||
|
* Escape or backdrop click dismisses it. Clicks use stopPropagation so the
|
||||||
|
* parent SEQTA popup does not close.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { settingsState } from "../listeners/SettingsState";
|
||||||
|
|
||||||
|
const FULLSCREENABLE_CLASS = "popup-media-fullscreenable";
|
||||||
|
const OVERLAY_VISIBLE_CLASS = "bsplus-popup-media-overlay-backdrop--visible";
|
||||||
|
const OVERLAY_ANIM_MS = 280;
|
||||||
|
|
||||||
|
function isImageOrVideo(el: Element): el is HTMLImageElement | HTMLVideoElement {
|
||||||
|
return el instanceof HTMLImageElement || el instanceof HTMLVideoElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachPopupMediaFullscreen(el: HTMLImageElement | HTMLVideoElement) {
|
||||||
|
el.classList.add(FULLSCREENABLE_CLASS);
|
||||||
|
el.setAttribute("tabindex", "0");
|
||||||
|
el.setAttribute("role", "button");
|
||||||
|
el.setAttribute("aria-label", "View larger");
|
||||||
|
el.title = "Click to view larger";
|
||||||
|
|
||||||
|
const open = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openMediaOverlayViewer(el);
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener("click", open);
|
||||||
|
el.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
open(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMediaOverlayViewer(source: HTMLImageElement | HTMLVideoElement) {
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
backdrop.id = "bsplus-popup-media-overlay";
|
||||||
|
backdrop.className = "bsplus-popup-media-overlay-backdrop";
|
||||||
|
|
||||||
|
const inner = document.createElement("div");
|
||||||
|
inner.className = "bsplus-popup-media-overlay-inner";
|
||||||
|
|
||||||
|
const slot = document.createElement("div");
|
||||||
|
slot.className = "bsplus-popup-media-overlay-slot";
|
||||||
|
|
||||||
|
let media: HTMLImageElement | HTMLVideoElement;
|
||||||
|
if (source instanceof HTMLVideoElement) {
|
||||||
|
const v = source;
|
||||||
|
const nv = document.createElement("video");
|
||||||
|
nv.classList.add("bsplus-popup-media-overlay-media");
|
||||||
|
nv.controls = true;
|
||||||
|
nv.playsInline = true;
|
||||||
|
nv.loop = v.loop;
|
||||||
|
nv.muted = v.muted;
|
||||||
|
nv.volume = v.volume;
|
||||||
|
for (const s of v.querySelectorAll("source")) {
|
||||||
|
const ns = document.createElement("source");
|
||||||
|
ns.src = (s as HTMLSourceElement).src;
|
||||||
|
const t = (s as HTMLSourceElement).type;
|
||||||
|
if (t) ns.type = t;
|
||||||
|
nv.appendChild(ns);
|
||||||
|
}
|
||||||
|
nv.addEventListener(
|
||||||
|
"loadeddata",
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
nv.currentTime = v.currentTime;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
void nv.play().catch(() => {});
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
v.pause();
|
||||||
|
nv.load();
|
||||||
|
media = nv;
|
||||||
|
} else {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.classList.add("bsplus-popup-media-overlay-media");
|
||||||
|
img.src = source.currentSrc || source.src;
|
||||||
|
img.alt = source.alt || "";
|
||||||
|
media = img;
|
||||||
|
}
|
||||||
|
|
||||||
|
media.addEventListener("click", (e) => e.stopPropagation());
|
||||||
|
|
||||||
|
slot.appendChild(media);
|
||||||
|
inner.append(slot);
|
||||||
|
backdrop.appendChild(inner);
|
||||||
|
document.body.append(backdrop);
|
||||||
|
|
||||||
|
if (!settingsState.animations) {
|
||||||
|
backdrop.classList.add("bsplus-popup-media-overlay--instant");
|
||||||
|
backdrop.classList.add(OVERLAY_VISIBLE_CLASS);
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
backdrop.classList.add(OVERLAY_VISIBLE_CLASS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
inner.addEventListener("click", (e) => e.stopPropagation());
|
||||||
|
|
||||||
|
let done = false;
|
||||||
|
const removeOverlay = () => {
|
||||||
|
if (source instanceof HTMLVideoElement && media instanceof HTMLVideoElement) {
|
||||||
|
try {
|
||||||
|
source.currentTime = media.currentTime;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
void source.play().catch(() => {});
|
||||||
|
}
|
||||||
|
backdrop.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
document.removeEventListener("keydown", onDocKey, true);
|
||||||
|
|
||||||
|
if (!settingsState.animations) {
|
||||||
|
removeOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
backdrop.classList.remove(OVERLAY_VISIBLE_CLASS);
|
||||||
|
window.setTimeout(removeOverlay, OVERLAY_ANIM_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocKey = (ev: KeyboardEvent) => {
|
||||||
|
if (ev.key === "Escape") {
|
||||||
|
ev.stopPropagation();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onDocKey, true);
|
||||||
|
|
||||||
|
backdrop.addEventListener("click", () => {
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachPopupMediaFullscreenIfPresent(
|
||||||
|
root: ParentNode,
|
||||||
|
selector: string,
|
||||||
|
) {
|
||||||
|
const el = root.querySelector(selector);
|
||||||
|
if (el && isImageOrVideo(el)) {
|
||||||
|
attachPopupMediaFullscreen(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */
|
/** 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;
|
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. */
|
/** After restoring from cloud, keep local session so the user stays signed in. */
|
||||||
const AUTH_KEYS_TO_PRESERVE = [
|
const AUTH_KEYS_TO_PRESERVE = [
|
||||||
@@ -102,11 +105,12 @@ export function buildUploadPayload(all: Record<string, unknown>): {
|
|||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
} {
|
} {
|
||||||
const data: Record<string, unknown> = {};
|
const filtered: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(all)) {
|
for (const [k, v] of Object.entries(all)) {
|
||||||
if (shouldOmitKeyFromCloudPayload(k)) continue;
|
if (shouldOmitKeyFromCloudPayload(k)) continue;
|
||||||
data[k] = v;
|
filtered[k] = v;
|
||||||
}
|
}
|
||||||
|
const data = migrateLegacyToPluginSettings(filtered);
|
||||||
return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, data };
|
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
|
* Migrate legacy storage keys to plugin settings format.
|
||||||
* and device-only sensitive caches, which are preserved from the current device.
|
* 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> {
|
export async function applyDownloadedEnvelope(envelope: unknown): Promise<void> {
|
||||||
let remoteFlat: Record<string, unknown>;
|
let remoteFlat: Record<string, unknown>;
|
||||||
@@ -145,10 +218,7 @@ export async function applyDownloadedEnvelope(envelope: unknown): Promise<void>
|
|||||||
throw new Error("Invalid cloud settings payload");
|
throw new Error("Invalid cloud settings payload");
|
||||||
}
|
}
|
||||||
|
|
||||||
const local = await browser.storage.local.get();
|
const migrated = migrateLegacyToPluginSettings(remoteFlat);
|
||||||
const preserved = collectLocalKeysToPreserve(local);
|
const remoteSanitized = stripExcludedKeysFromRemoteData(migrated);
|
||||||
const remoteSanitized = stripExcludedKeysFromRemoteData(remoteFlat);
|
await browser.storage.local.set(remoteSanitized);
|
||||||
|
|
||||||
await browser.storage.local.clear();
|
|
||||||
await browser.storage.local.set({ ...remoteSanitized, ...preserved });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export interface SettingsState {
|
|||||||
justupdated?: boolean;
|
justupdated?: boolean;
|
||||||
privacyStatementShown?: boolean;
|
privacyStatementShown?: boolean;
|
||||||
privacyStatementLastUpdated?: string;
|
privacyStatementLastUpdated?: string;
|
||||||
|
/** One-time announcement: SEQTA Engage support for parents (dismissed popup queue). */
|
||||||
|
engageParentsAnnouncementShown?: boolean;
|
||||||
|
/** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */
|
||||||
|
bsCloudAutoSyncAnnouncementShown?: boolean;
|
||||||
timeFormat?: string;
|
timeFormat?: string;
|
||||||
animations: boolean;
|
animations: boolean;
|
||||||
defaultPage: string;
|
defaultPage: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user