mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2270602a3 | |||
| 8b1e5b2ee7 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "betterseqtaplus",
|
"name": "betterseqtaplus",
|
||||||
"version": "3.6.1",
|
"version": "3.6.0",
|
||||||
"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",
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ async function init() {
|
|||||||
IsSEQTAPage = true;
|
IsSEQTAPage = true;
|
||||||
console.info("[BetterSEQTA+] Verified SEQTA Page");
|
console.info("[BetterSEQTA+] Verified SEQTA Page");
|
||||||
|
|
||||||
|
if (typeof window !== "undefined" && window === window.top) {
|
||||||
|
void browser.runtime.sendMessage({ type: "cloudSettingsPoll" }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
registerFetchSeqtaAppLinkListener();
|
registerFetchSeqtaAppLinkListener();
|
||||||
|
|
||||||
const documentLoadStyle = document.createElement("style");
|
const documentLoadStyle = document.createElement("style");
|
||||||
|
|||||||
@@ -133,74 +133,6 @@ 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) {
|
||||||
@@ -337,7 +269,6 @@ 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,
|
||||||
|
|||||||
@@ -1653,13 +1653,6 @@ 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);
|
||||||
@@ -4371,41 +4364,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
<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,6 +21,13 @@
|
|||||||
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;
|
||||||
@@ -33,7 +40,8 @@
|
|||||||
token,
|
token,
|
||||||
})) as { success?: boolean; error?: string };
|
})) as { success?: boolean; error?: string };
|
||||||
if (res?.success) {
|
if (res?.success) {
|
||||||
statusMessage = "Settings uploaded.";
|
statusMessage = "Settings saved to the cloud.";
|
||||||
|
lastUploadAt = formatNow();
|
||||||
} else {
|
} else {
|
||||||
statusError = res?.error ?? "Upload failed";
|
statusError = res?.error ?? "Upload failed";
|
||||||
}
|
}
|
||||||
@@ -45,10 +53,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function promptDownload() {
|
function promptDownload() {
|
||||||
showDisclaimer(confirmDownload, () => {});
|
showRestoreConfirm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -60,7 +69,8 @@
|
|||||||
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.";
|
statusMessage = "Settings restored from the cloud. SEQTA tabs were reloaded.";
|
||||||
|
lastDownloadAt = formatNow();
|
||||||
} else {
|
} else {
|
||||||
statusError = res?.error ?? "Download failed";
|
statusError = res?.error ?? "Download failed";
|
||||||
}
|
}
|
||||||
@@ -72,13 +82,22 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if cloudState.isLoggedIn}
|
<div
|
||||||
<div class="flex flex-col gap-2.5">
|
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 items-center justify-between gap-3">
|
>
|
||||||
<div>
|
<h3 class="text-xs font-bold text-zinc-800 dark:text-zinc-100">Cloud settings backup</h3>
|
||||||
<p class="text-[11px] font-semibold text-zinc-800 dark:text-zinc-100">Automatic sync</p>
|
<p class="mt-0.5 text-[11px] leading-snug text-zinc-500 dark:text-zinc-400">
|
||||||
<p class="text-[10px] text-zinc-500 dark:text-zinc-400">Syncs settings when SEQTA loads and when you make changes</p>
|
Upload copies this browser’s BetterSEQTA+ settings to your account. Download replaces local settings with the
|
||||||
</div>
|
cloud copy (your sign-in stays on this device).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-2 flex flex-col gap-2 rounded-lg border border-zinc-200/50 bg-white/60 px-3 py-2.5 dark:border-zinc-600/40 dark:bg-zinc-800/40"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<p class="min-w-0 flex-1 pt-0.5 text-[11px] font-semibold leading-tight text-zinc-800 dark:text-zinc-100">
|
||||||
|
Automatic sync
|
||||||
|
</p>
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<Switch
|
<Switch
|
||||||
state={$settingsState.autoCloudSettingsSync !== false}
|
state={$settingsState.autoCloudSettingsSync !== false}
|
||||||
@@ -86,35 +105,62 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-[10px] leading-snug text-zinc-500 dark:text-zinc-400">
|
||||||
<div class="flex flex-wrap gap-2">
|
When signed in, each time SEQTA loads and also hourly, if the cloud backup is newer it will replace local
|
||||||
<Button
|
settings. Settings you change will upload shortly after you adjust them.
|
||||||
text={busy ? "Please wait\u2026" : "Upload"}
|
</p>
|
||||||
onClick={upload}
|
<p class="text-[10px] leading-snug text-zinc-500 dark:text-zinc-400">
|
||||||
disabled={busy}
|
Passwords, tokens, and other sensitive data are not included in the backup.
|
||||||
/>
|
|
||||||
<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="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"
|
class="ml-0.5 inline font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 transition-all duration-200 hover:text-emerald-700 hover:decoration-emerald-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-emerald-400 dark:decoration-emerald-400/50 dark:hover:text-emerald-300 dark:focus-visible:ring-offset-zinc-800 rounded-sm"
|
||||||
>Privacy policy</a>
|
>
|
||||||
|
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,6 +3,7 @@
|
|||||||
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;
|
||||||
@@ -22,10 +23,6 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleSignIn() {
|
|
||||||
await cloudAuth.startLogin();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -55,16 +52,7 @@
|
|||||||
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>
|
||||||
|
|
||||||
<button
|
<CloudLoginForm compact onSuccess={onClose} />
|
||||||
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,10 +1,11 @@
|
|||||||
<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, onClick = undefined } = $props<{
|
let { alwaysShowUserName = false } = $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);
|
||||||
@@ -41,19 +42,6 @@
|
|||||||
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 "?";
|
||||||
@@ -67,35 +55,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={handleButtonClick}
|
onclick={() => (open = !open)}
|
||||||
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"
|
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-100/80 dark:bg-zinc-700/80 hover:bg-zinc-200/80 dark:hover:bg-zinc-600/80 transition-colors duration-200 text-base font-medium text-zinc-900 dark:text-white"
|
||||||
>
|
>
|
||||||
{#if cloudState.isLoggedIn}
|
{#if cloudState.isLoggedIn}
|
||||||
{#if cloudState.user?.pfpUrl}
|
{#if cloudState.user?.pfpUrl}
|
||||||
<img
|
<img
|
||||||
src={cloudState.user.pfpUrl}
|
src={cloudState.user.pfpUrl}
|
||||||
alt=""
|
alt=""
|
||||||
class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
|
class="w-8 h-8 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<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]">
|
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-sm">
|
||||||
{getInitials()}
|
{getInitials()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<span
|
||||||
class={alwaysShowUserName
|
class={alwaysShowUserName
|
||||||
? "inline max-w-[10rem] truncate text-[0.75rem]"
|
? "inline max-w-[10rem] truncate text-sm"
|
||||||
: "hidden max-w-24 truncate sm:inline text-[0.75rem]"}
|
: "hidden max-w-24 truncate sm:inline text-base"}
|
||||||
>
|
>
|
||||||
{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-sm font-IconFamily" aria-hidden="true">{'\ued53'}</span>
|
<span class="text-xl font-IconFamily" aria-hidden="true">{'\ued53'}</span>
|
||||||
<span class="text-[0.75rem]">Sign in</span>
|
<span class="text-base font-medium">Sign in</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if !onClick && open}
|
{#if 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
|
||||||
@@ -139,21 +127,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col gap-3">
|
<CloudLoginForm
|
||||||
<p class="text-sm text-zinc-600 dark:text-zinc-400">
|
onSuccess={() => {
|
||||||
Sign in to sync favorites across devices.
|
open = false;
|
||||||
</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,16 +15,14 @@
|
|||||||
//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) => {
|
||||||
@@ -69,23 +67,15 @@
|
|||||||
|
|
||||||
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 openCloudPanel = () => {
|
const showDisclaimer = (onConfirm: () => void, onCancel: () => void) => {
|
||||||
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) {
|
||||||
@@ -287,13 +277,25 @@
|
|||||||
{/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, showCloudPanel: openCloudPanel },
|
props: { showColourPicker: openColourPicker, showDisclaimer },
|
||||||
},
|
},
|
||||||
{ title: "Shortcuts", Content: Shortcuts },
|
{ title: "Shortcuts", Content: Shortcuts },
|
||||||
{ title: "Themes", Content: Theme },
|
{ title: "Themes", Content: Theme },
|
||||||
@@ -308,20 +310,19 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showCloudPanel}
|
|
||||||
<CloudPanel
|
|
||||||
hidePanel={() => {
|
|
||||||
showCloudPanel = false;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showDisclaimerModal && disclaimerCallbacks}
|
{#if showDisclaimerModal && disclaimerCallbacks}
|
||||||
<DisclaimerModal
|
<DisclaimerModal
|
||||||
title={disclaimerTitle}
|
title="Assessment Averages Disclaimer"
|
||||||
message={disclaimerMessage}
|
message="This feature calculates a simple average of your assessment grades. It does not take into account:
|
||||||
|
• Assessment weightings
|
||||||
|
• Different grading scales
|
||||||
|
• Other factors used in official reports
|
||||||
|
|
||||||
|
The displayed average may be inaccurate compared to your actual marks found in reports.
|
||||||
|
|
||||||
|
Do you want to enable this feature?"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
disclaimerCallbacks?.onConfirm();
|
disclaimerCallbacks?.onConfirm();
|
||||||
showDisclaimerModal = false;
|
showDisclaimerModal = false;
|
||||||
|
|||||||
@@ -12,8 +12,6 @@
|
|||||||
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"
|
||||||
@@ -56,12 +54,6 @@
|
|||||||
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) {
|
||||||
if (Object.keys(plugin.settings).length === 0) continue;
|
if (Object.keys(plugin.settings).length === 0) continue;
|
||||||
@@ -103,10 +95,9 @@
|
|||||||
loadPluginSettings();
|
loadPluginSettings();
|
||||||
})
|
})
|
||||||
|
|
||||||
const { showColourPicker, showDisclaimer, showCloudPanel } = $props<{
|
const { showColourPicker, showDisclaimer } = $props<{
|
||||||
showColourPicker: () => void;
|
showColourPicker: () => void;
|
||||||
showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void;
|
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
|
||||||
showCloudPanel: () => void;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
async function exportCloudSettingsJsonToFile() {
|
async function exportCloudSettingsJsonToFile() {
|
||||||
@@ -205,11 +196,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Default Page",
|
title: "Default Page",
|
||||||
description: "Choose which page loads first when you open SEQTA",
|
description:
|
||||||
|
"The page to load when SEQTA Learn or SEQTA Engage opens (uses the same #?page=/… URL as SEQTA). BetterSEQTA home on Engage only applies when Home is selected.",
|
||||||
id: 10,
|
id: 10,
|
||||||
Component: Select,
|
Component: Select,
|
||||||
props: {
|
props: {
|
||||||
state: $settingsState.defaultPage ?? "home",
|
state: $settingsState.defaultPage,
|
||||||
onChange: (value: string) => (settingsState.defaultPage = value),
|
onChange: (value: string) => (settingsState.defaultPage = value),
|
||||||
options: [
|
options: [
|
||||||
{ value: "home", label: "Home" },
|
{ value: "home", label: "Home" },
|
||||||
@@ -318,9 +310,8 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -333,8 +324,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 and hide cloud-only settings when not signed in -->
|
<!-- Skip the 'enabled' setting if it's part of the settings object -->
|
||||||
{#if key !== 'enabled' && !(key === 'useCloudPfp' && !cloudState.isLoggedIn)}
|
{#if key !== 'enabled'}
|
||||||
<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>
|
||||||
@@ -397,25 +388,6 @@
|
|||||||
</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({
|
||||||
@@ -429,6 +401,10 @@
|
|||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
|||||||
@@ -0,0 +1,748 @@
|
|||||||
|
import type { Plugin } from "../../core/types";
|
||||||
|
import { booleanSetting } from "@/plugins/core/settingsHelpers";
|
||||||
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
import styles from "./styles.css?inline";
|
||||||
|
|
||||||
|
const messageFoldersSettings = {
|
||||||
|
showTagsInAllMessages: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Show folder tags in All Messages",
|
||||||
|
description:
|
||||||
|
"When off, folder tags are not shown on the message list until you select a folder.",
|
||||||
|
}),
|
||||||
|
hideFolderedMessagesInAll: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Hide foldered messages in All Messages",
|
||||||
|
description:
|
||||||
|
"When on, messages assigned to a custom folder are hidden from the inbox until you open that folder.",
|
||||||
|
}),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface Folder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageFoldersStorage {
|
||||||
|
folders: Folder[];
|
||||||
|
messageAssignments: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOLDER_COLORS = [
|
||||||
|
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b",
|
||||||
|
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FOLDER_ICON_SVG = `<svg style="width:24px;height:24px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||||||
|
const PLUS_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`;
|
||||||
|
const CHECK_SVG_WHITE = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#fff" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>`;
|
||||||
|
const CLOSE_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>`;
|
||||||
|
const EDIT_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>`;
|
||||||
|
const TRASH_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`;
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFoldersStorage> = {
|
||||||
|
id: "messageFolders",
|
||||||
|
name: "Message Folders",
|
||||||
|
description: "Organize direct messages into custom folders",
|
||||||
|
version: "1.0.0",
|
||||||
|
settings: messageFoldersSettings,
|
||||||
|
disableToggle: true,
|
||||||
|
defaultEnabled: true,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
const styleEl = document.createElement("style");
|
||||||
|
styleEl.textContent = styles;
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
|
||||||
|
await api.storage.loaded;
|
||||||
|
|
||||||
|
if (!api.storage.folders) api.storage.folders = [];
|
||||||
|
if (!api.storage.messageAssignments) api.storage.messageAssignments = {};
|
||||||
|
|
||||||
|
let activeFolderId: string | null = null;
|
||||||
|
let messageListObserver: MutationObserver | null = null;
|
||||||
|
let sidebarObserver: MutationObserver | null = null;
|
||||||
|
let actionsObserver: MutationObserver | null = null;
|
||||||
|
let openDropdown: HTMLElement | null = null;
|
||||||
|
let dropdownCloseHandler: ((e: MouseEvent) => void) | null = null;
|
||||||
|
const unregisters: Array<{ unregister: () => void }> = [];
|
||||||
|
|
||||||
|
// ── Storage accessors ──
|
||||||
|
|
||||||
|
const getFolders = (): Folder[] => api.storage.folders ?? [];
|
||||||
|
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
|
||||||
|
|
||||||
|
const saveFolders = (folders: Folder[]) => {
|
||||||
|
api.storage.folders = [...folders];
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAssignments = (assignments: Record<string, string[]>) => {
|
||||||
|
api.storage.messageAssignments = { ...assignments };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageFolderIds = (messageId: string): string[] => {
|
||||||
|
const assignments = getAssignments();
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const [folderId, msgIds] of Object.entries(assignments)) {
|
||||||
|
if (msgIds.includes(messageId)) ids.push(folderId);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMessageInFolder = (messageId: string, folderId: string) => {
|
||||||
|
const assignments = getAssignments();
|
||||||
|
if (!assignments[folderId]) assignments[folderId] = [];
|
||||||
|
const idx = assignments[folderId].indexOf(messageId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
assignments[folderId].splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
assignments[folderId].push(messageId);
|
||||||
|
}
|
||||||
|
saveAssignments(assignments);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFolderMessageCount = (folderId: string): number => {
|
||||||
|
return (getAssignments()[folderId] ?? []).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreSubjectPlain = (subject: Element) => {
|
||||||
|
subject.querySelector(".bsplus-msg-badges")?.remove();
|
||||||
|
const textWrap = subject.querySelector(".bsplus-subject-text");
|
||||||
|
if (textWrap) {
|
||||||
|
subject.textContent = textWrap.textContent ?? "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMessageInAnyCustomFolder = (messageId: string): boolean => {
|
||||||
|
for (const msgIds of Object.values(getAssignments())) {
|
||||||
|
if (msgIds.includes(messageId)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldShowBadgesInList = (): boolean => {
|
||||||
|
return api.settings.showTagsInAllMessages || activeFolderId !== null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Confirm modal ──
|
||||||
|
|
||||||
|
const showConfirmModal = (
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
onConfirm: () => void,
|
||||||
|
) => {
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "bsplus-modal-overlay";
|
||||||
|
|
||||||
|
const modal = document.createElement("div");
|
||||||
|
modal.className = "bsplus-modal";
|
||||||
|
modal.innerHTML = `
|
||||||
|
<h3>${title}</h3>
|
||||||
|
<p>${message}</p>
|
||||||
|
<div class="bsplus-modal-actions">
|
||||||
|
<button class="bsplus-modal-btn-cancel">Cancel</button>
|
||||||
|
<button class="bsplus-modal-btn-danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
overlay.remove();
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
overlay.addEventListener("click", (e) => {
|
||||||
|
if (e.target === overlay) remove();
|
||||||
|
});
|
||||||
|
modal.querySelector(".bsplus-modal-btn-cancel")!.addEventListener("click", remove);
|
||||||
|
modal.querySelector(".bsplus-modal-btn-danger")!.addEventListener("click", () => {
|
||||||
|
onConfirm();
|
||||||
|
remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Sidebar folder UI ──
|
||||||
|
|
||||||
|
const renderSidebarFolders = () => {
|
||||||
|
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
const ol = sidebar.querySelector("ol");
|
||||||
|
if (!ol) return;
|
||||||
|
|
||||||
|
let section = ol.querySelector(".bsplus-folders-section");
|
||||||
|
if (!section) {
|
||||||
|
section = document.createElement("div");
|
||||||
|
section.className = "bsplus-folders-section";
|
||||||
|
ol.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = getFolders();
|
||||||
|
const existingInput = section.querySelector(".bsplus-folder-input");
|
||||||
|
const existingColors = section.querySelector(".bsplus-folder-colors");
|
||||||
|
|
||||||
|
section.innerHTML = "";
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "bsplus-folders-header";
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = "Folders";
|
||||||
|
header.appendChild(label);
|
||||||
|
|
||||||
|
const addBtn = document.createElement("button");
|
||||||
|
addBtn.className = "bsplus-folders-add-btn";
|
||||||
|
addBtn.title = "New folder";
|
||||||
|
addBtn.innerHTML = PLUS_SVG;
|
||||||
|
addBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showNewFolderInput(section!);
|
||||||
|
});
|
||||||
|
header.appendChild(addBtn);
|
||||||
|
section.appendChild(header);
|
||||||
|
|
||||||
|
// "All Messages" item
|
||||||
|
const allItem = document.createElement("div");
|
||||||
|
allItem.className = `bsplus-folder-item${activeFolderId === null ? " bsplus-folder-active" : ""}`;
|
||||||
|
allItem.innerHTML = `
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" style="fill: currentcolor; opacity: 0.5; flex-shrink: 0;"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
||||||
|
<span class="bsplus-folder-name">All Messages</span>
|
||||||
|
`;
|
||||||
|
allItem.addEventListener("click", () => {
|
||||||
|
activeFolderId = null;
|
||||||
|
applyFolderFilter();
|
||||||
|
applyBadges();
|
||||||
|
renderSidebarFolders();
|
||||||
|
});
|
||||||
|
section.appendChild(allItem);
|
||||||
|
|
||||||
|
// Folder items
|
||||||
|
for (const folder of folders) {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = `bsplus-folder-item${activeFolderId === folder.id ? " bsplus-folder-active" : ""}`;
|
||||||
|
item.dataset.folderId = folder.id;
|
||||||
|
|
||||||
|
const dot = document.createElement("div");
|
||||||
|
dot.className = "bsplus-folder-dot";
|
||||||
|
dot.style.background = folder.color;
|
||||||
|
item.appendChild(dot);
|
||||||
|
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "bsplus-folder-name";
|
||||||
|
name.textContent = folder.name;
|
||||||
|
item.appendChild(name);
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "bsplus-folder-actions";
|
||||||
|
|
||||||
|
const editBtn = document.createElement("button");
|
||||||
|
editBtn.className = "bsplus-folder-action-btn";
|
||||||
|
editBtn.title = "Rename";
|
||||||
|
editBtn.innerHTML = EDIT_SVG;
|
||||||
|
editBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showEditFolderInput(section!, folder);
|
||||||
|
});
|
||||||
|
actions.appendChild(editBtn);
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement("button");
|
||||||
|
deleteBtn.className = "bsplus-folder-action-btn";
|
||||||
|
deleteBtn.title = "Delete";
|
||||||
|
deleteBtn.innerHTML = TRASH_SVG;
|
||||||
|
deleteBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showConfirmModal(
|
||||||
|
"Delete folder",
|
||||||
|
`Remove "${folder.name}"? Messages won't be deleted.`,
|
||||||
|
() => {
|
||||||
|
const folders = getFolders().filter((f) => f.id !== folder.id);
|
||||||
|
saveFolders(folders);
|
||||||
|
const assignments = getAssignments();
|
||||||
|
delete assignments[folder.id];
|
||||||
|
saveAssignments(assignments);
|
||||||
|
if (activeFolderId === folder.id) activeFolderId = null;
|
||||||
|
applyFolderFilter();
|
||||||
|
applyBadges();
|
||||||
|
renderSidebarFolders();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
actions.appendChild(deleteBtn);
|
||||||
|
|
||||||
|
item.appendChild(actions);
|
||||||
|
|
||||||
|
const count = document.createElement("span");
|
||||||
|
count.className = "bsplus-folder-count";
|
||||||
|
const c = getFolderMessageCount(folder.id);
|
||||||
|
count.textContent = c > 0 ? String(c) : "";
|
||||||
|
item.appendChild(count);
|
||||||
|
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
activeFolderId = folder.id;
|
||||||
|
applyFolderFilter();
|
||||||
|
applyBadges();
|
||||||
|
renderSidebarFolders();
|
||||||
|
});
|
||||||
|
|
||||||
|
section.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore input if it was open
|
||||||
|
if (existingInput || existingColors) {
|
||||||
|
// Don't restore – let user re-trigger
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showNewFolderInput = (container: Element, editFolder?: Folder) => {
|
||||||
|
const existing = container.querySelector(".bsplus-folder-input");
|
||||||
|
if (existing) existing.remove();
|
||||||
|
container.querySelector(".bsplus-folder-colors")?.remove();
|
||||||
|
|
||||||
|
let selectedColor = editFolder?.color ?? FOLDER_COLORS[Math.floor(Math.random() * FOLDER_COLORS.length)];
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "bsplus-folder-input";
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.placeholder = editFolder ? "Rename folder…" : "Folder name…";
|
||||||
|
input.value = editFolder?.name ?? "";
|
||||||
|
input.maxLength = 30;
|
||||||
|
|
||||||
|
const confirmBtn = document.createElement("button");
|
||||||
|
confirmBtn.className = "bsplus-folder-input-confirm";
|
||||||
|
confirmBtn.innerHTML = CHECK_SVG_WHITE;
|
||||||
|
|
||||||
|
const cancelBtn = document.createElement("button");
|
||||||
|
cancelBtn.className = "bsplus-folder-input-cancel";
|
||||||
|
cancelBtn.innerHTML = CLOSE_SVG;
|
||||||
|
|
||||||
|
row.appendChild(input);
|
||||||
|
row.appendChild(confirmBtn);
|
||||||
|
row.appendChild(cancelBtn);
|
||||||
|
|
||||||
|
// Color picker
|
||||||
|
const colorRow = document.createElement("div");
|
||||||
|
colorRow.className = "bsplus-folder-colors";
|
||||||
|
for (const color of FOLDER_COLORS) {
|
||||||
|
const swatch = document.createElement("button");
|
||||||
|
swatch.className = `bsplus-folder-color-opt${color === selectedColor ? " bsplus-color-selected" : ""}`;
|
||||||
|
swatch.style.background = color;
|
||||||
|
swatch.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
selectedColor = color;
|
||||||
|
colorRow.querySelectorAll(".bsplus-folder-color-opt").forEach((s) =>
|
||||||
|
s.classList.toggle("bsplus-color-selected", (s as HTMLElement).style.background === color),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
colorRow.appendChild(swatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
if (editFolder) {
|
||||||
|
const folders = getFolders().map((f) =>
|
||||||
|
f.id === editFolder.id ? { ...f, name, color: selectedColor } : f,
|
||||||
|
);
|
||||||
|
saveFolders(folders);
|
||||||
|
} else {
|
||||||
|
const folder: Folder = { id: generateId(), name, color: selectedColor };
|
||||||
|
saveFolders([...getFolders(), folder]);
|
||||||
|
}
|
||||||
|
applyBadges();
|
||||||
|
renderSidebarFolders();
|
||||||
|
};
|
||||||
|
|
||||||
|
confirmBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
confirm();
|
||||||
|
});
|
||||||
|
cancelBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
renderSidebarFolders();
|
||||||
|
});
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") confirm();
|
||||||
|
if (e.key === "Escape") renderSidebarFolders();
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(row);
|
||||||
|
container.appendChild(colorRow);
|
||||||
|
requestAnimationFrame(() => input.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
const showEditFolderInput = (container: Element, folder: Folder) => {
|
||||||
|
showNewFolderInput(container, folder);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Intercept native sidebar clicks to clear folder filter ──
|
||||||
|
|
||||||
|
const attachNativeSidebarListeners = () => {
|
||||||
|
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
const ol = sidebar.querySelector("ol");
|
||||||
|
if (!ol) return;
|
||||||
|
|
||||||
|
ol.addEventListener("click", (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest(".bsplus-folders-section")) return;
|
||||||
|
|
||||||
|
const li = target.closest("li");
|
||||||
|
if (li && ol.contains(li)) {
|
||||||
|
if (activeFolderId !== null) {
|
||||||
|
activeFolderId = null;
|
||||||
|
applyFolderFilter();
|
||||||
|
applyBadges();
|
||||||
|
renderSidebarFolders();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── "Add to folder" button in message action bar ──
|
||||||
|
|
||||||
|
const injectFolderButton = (actionsBar: Element) => {
|
||||||
|
if (actionsBar.querySelector(".bsplus-folder-btn")) return;
|
||||||
|
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "bsplus-folder-btn";
|
||||||
|
wrapper.style.position = "relative";
|
||||||
|
wrapper.style.display = "inline-block";
|
||||||
|
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
const btnClasses = actionsBar.querySelector("button")?.className ?? "";
|
||||||
|
btn.className = btnClasses;
|
||||||
|
btn.title = "Add to folder";
|
||||||
|
btn.innerHTML = FOLDER_ICON_SVG;
|
||||||
|
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
closeDropdown();
|
||||||
|
|
||||||
|
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
|
||||||
|
const messageId = selectedMsg?.getAttribute("data-message");
|
||||||
|
if (!messageId) return;
|
||||||
|
|
||||||
|
showFolderDropdown(wrapper, messageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.appendChild(btn);
|
||||||
|
|
||||||
|
const moreMenu = actionsBar.querySelector("[class*='MenuButton__Menu___']");
|
||||||
|
if (moreMenu) {
|
||||||
|
actionsBar.insertBefore(wrapper, moreMenu);
|
||||||
|
} else {
|
||||||
|
actionsBar.appendChild(wrapper);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showFolderDropdown = (anchor: HTMLElement, messageId: string) => {
|
||||||
|
const dropdown = document.createElement("div");
|
||||||
|
dropdown.className = "bsplus-folder-dropdown";
|
||||||
|
|
||||||
|
const folders = getFolders();
|
||||||
|
const currentFolderIds = getMessageFolderIds(messageId);
|
||||||
|
|
||||||
|
if (folders.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "bsplus-folder-dropdown-empty";
|
||||||
|
empty.textContent = "No folders yet";
|
||||||
|
dropdown.appendChild(empty);
|
||||||
|
} else {
|
||||||
|
for (const folder of folders) {
|
||||||
|
const isChecked = currentFolderIds.includes(folder.id);
|
||||||
|
const item = document.createElement("button");
|
||||||
|
item.className = `bsplus-folder-dropdown-item${isChecked ? " bsplus-checked" : ""}`;
|
||||||
|
|
||||||
|
const check = document.createElement("div");
|
||||||
|
check.className = "bsplus-folder-dropdown-check";
|
||||||
|
check.style.borderColor = isChecked ? folder.color : "";
|
||||||
|
check.style.background = isChecked ? folder.color : "";
|
||||||
|
check.innerHTML = CHECK_SVG_WHITE;
|
||||||
|
|
||||||
|
const dot = document.createElement("div");
|
||||||
|
dot.className = "bsplus-folder-dot";
|
||||||
|
dot.style.background = folder.color;
|
||||||
|
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.textContent = folder.name;
|
||||||
|
|
||||||
|
item.appendChild(check);
|
||||||
|
item.appendChild(dot);
|
||||||
|
item.appendChild(name);
|
||||||
|
|
||||||
|
item.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleMessageInFolder(messageId, folder.id);
|
||||||
|
|
||||||
|
const nowChecked = getMessageFolderIds(messageId).includes(folder.id);
|
||||||
|
item.classList.toggle("bsplus-checked", nowChecked);
|
||||||
|
check.style.borderColor = nowChecked ? folder.color : "";
|
||||||
|
check.style.background = nowChecked ? folder.color : "";
|
||||||
|
|
||||||
|
applyBadges();
|
||||||
|
applyFolderFilter();
|
||||||
|
renderSidebarFolders();
|
||||||
|
});
|
||||||
|
|
||||||
|
dropdown.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
anchor.appendChild(dropdown);
|
||||||
|
openDropdown = dropdown;
|
||||||
|
|
||||||
|
dropdownCloseHandler = (e: MouseEvent) => {
|
||||||
|
if (!dropdown.contains(e.target as Node) && !anchor.contains(e.target as Node)) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener("click", dropdownCloseHandler!, true);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDropdown = () => {
|
||||||
|
if (openDropdown) {
|
||||||
|
openDropdown.remove();
|
||||||
|
openDropdown = null;
|
||||||
|
}
|
||||||
|
if (dropdownCloseHandler) {
|
||||||
|
document.removeEventListener("click", dropdownCloseHandler, true);
|
||||||
|
dropdownCloseHandler = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Message badges ──
|
||||||
|
|
||||||
|
const applyBadges = () => {
|
||||||
|
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
|
||||||
|
|
||||||
|
if (!shouldShowBadgesInList()) {
|
||||||
|
for (const li of messageItems) {
|
||||||
|
const subject = li.querySelector("[class*='MessageList__subject___']");
|
||||||
|
if (subject && (subject.querySelector(".bsplus-msg-badges") || subject.querySelector(".bsplus-subject-text"))) {
|
||||||
|
restoreSubjectPlain(subject);
|
||||||
|
} else {
|
||||||
|
li.querySelector(".bsplus-msg-badges")?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = getFolders();
|
||||||
|
const assignments = getAssignments();
|
||||||
|
|
||||||
|
for (const li of messageItems) {
|
||||||
|
const msgId = li.getAttribute("data-message");
|
||||||
|
if (!msgId) continue;
|
||||||
|
|
||||||
|
let badgeContainer = li.querySelector(".bsplus-msg-badges") as HTMLElement | null;
|
||||||
|
|
||||||
|
const folderIds = [];
|
||||||
|
for (const [fId, mIds] of Object.entries(assignments)) {
|
||||||
|
if (mIds.includes(msgId)) folderIds.push(fId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderIds.length === 0) {
|
||||||
|
badgeContainer?.remove();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!badgeContainer) {
|
||||||
|
badgeContainer = document.createElement("div");
|
||||||
|
badgeContainer.className = "bsplus-msg-badges";
|
||||||
|
const subject = li.querySelector("[class*='MessageList__subject___']");
|
||||||
|
if (subject) {
|
||||||
|
if (!subject.querySelector(".bsplus-subject-text")) {
|
||||||
|
const textWrap = document.createElement("span");
|
||||||
|
textWrap.className = "bsplus-subject-text";
|
||||||
|
textWrap.textContent = subject.textContent;
|
||||||
|
subject.textContent = "";
|
||||||
|
subject.appendChild(textWrap);
|
||||||
|
}
|
||||||
|
subject.appendChild(badgeContainer);
|
||||||
|
} else {
|
||||||
|
li.appendChild(badgeContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeContainer.innerHTML = "";
|
||||||
|
for (const fId of folderIds) {
|
||||||
|
const folder = folders.find((f) => f.id === fId);
|
||||||
|
if (!folder) continue;
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "bsplus-msg-badge";
|
||||||
|
badge.style.background = folder.color;
|
||||||
|
badge.textContent = folder.name;
|
||||||
|
badge.title = `Filter by "${folder.name}"`;
|
||||||
|
badge.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
activeFolderId = folder.id;
|
||||||
|
applyFolderFilter();
|
||||||
|
applyBadges();
|
||||||
|
renderSidebarFolders();
|
||||||
|
});
|
||||||
|
badgeContainer.appendChild(badge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Folder filtering ──
|
||||||
|
|
||||||
|
const applyFolderFilter = () => {
|
||||||
|
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
|
||||||
|
const moreBtn = document.querySelector("[class*='MessageList__MessageList___'] ol > button");
|
||||||
|
|
||||||
|
if (activeFolderId === null) {
|
||||||
|
if (api.settings.hideFolderedMessagesInAll) {
|
||||||
|
for (const li of messageItems) {
|
||||||
|
const msgId = li.getAttribute("data-message");
|
||||||
|
if (msgId && isMessageInAnyCustomFolder(msgId)) {
|
||||||
|
li.classList.add("bsplus-folder-hidden");
|
||||||
|
} else {
|
||||||
|
li.classList.remove("bsplus-folder-hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const li of messageItems) {
|
||||||
|
li.classList.remove("bsplus-folder-hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moreBtn) (moreBtn as HTMLElement).classList.remove("bsplus-folder-hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderMsgIds = getAssignments()[activeFolderId] ?? [];
|
||||||
|
|
||||||
|
for (const li of messageItems) {
|
||||||
|
const msgId = li.getAttribute("data-message");
|
||||||
|
if (msgId && folderMsgIds.includes(msgId)) {
|
||||||
|
li.classList.remove("bsplus-folder-hidden");
|
||||||
|
} else {
|
||||||
|
li.classList.add("bsplus-folder-hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moreBtn) (moreBtn as HTMLElement).classList.add("bsplus-folder-hidden");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Observers ──
|
||||||
|
|
||||||
|
const setupMessageListObserver = () => {
|
||||||
|
const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol");
|
||||||
|
if (!messageList || messageListObserver) return;
|
||||||
|
|
||||||
|
messageListObserver = new MutationObserver(() => {
|
||||||
|
applyBadges();
|
||||||
|
applyFolderFilter();
|
||||||
|
});
|
||||||
|
messageListObserver.observe(messageList, { childList: true, subtree: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupActionsObserver = () => {
|
||||||
|
if (actionsObserver) return;
|
||||||
|
|
||||||
|
const target = document.querySelector("[class*='Viewer__Viewer___']") ?? document.querySelector("div.messages");
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
actionsObserver = new MutationObserver(() => {
|
||||||
|
const actionsBar = document.querySelector("[class*='Message__actions___']");
|
||||||
|
if (actionsBar && !actionsBar.querySelector(".bsplus-folder-btn")) {
|
||||||
|
injectFolderButton(actionsBar);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actionsObserver.observe(target, { childList: true, subtree: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Main page handler ──
|
||||||
|
|
||||||
|
const handleMessagesPage = async () => {
|
||||||
|
await waitForElm("[class*='Viewer__sidebar___'] ol", true, 50, 100);
|
||||||
|
|
||||||
|
renderSidebarFolders();
|
||||||
|
attachNativeSidebarListeners();
|
||||||
|
|
||||||
|
await waitForElm("[class*='MessageList__MessageList___'] ol", true, 50, 100);
|
||||||
|
applyBadges();
|
||||||
|
applyFolderFilter();
|
||||||
|
setupMessageListObserver();
|
||||||
|
|
||||||
|
// The actions bar only exists when a message is selected/open,
|
||||||
|
// so we observe the whole viewer for it to appear dynamically
|
||||||
|
setupActionsObserver();
|
||||||
|
|
||||||
|
// If a message is already selected, inject immediately
|
||||||
|
const actionsBar = document.querySelector("[class*='Message__actions___']");
|
||||||
|
if (actionsBar) injectFolderButton(actionsBar);
|
||||||
|
|
||||||
|
// Re-observe the sidebar for SEQTA re-renders
|
||||||
|
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
||||||
|
if (sidebar && !sidebarObserver) {
|
||||||
|
sidebarObserver = new MutationObserver(() => {
|
||||||
|
const ol = sidebar.querySelector("ol");
|
||||||
|
if (ol && !ol.querySelector(".bsplus-folders-section")) {
|
||||||
|
renderSidebarFolders();
|
||||||
|
attachNativeSidebarListeners();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sidebarObserver.observe(sidebar, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Lifecycle ──
|
||||||
|
|
||||||
|
const mountUnsub = api.seqta.onMount("div.messages", handleMessagesPage);
|
||||||
|
unregisters.push(mountUnsub);
|
||||||
|
|
||||||
|
unregisters.push(
|
||||||
|
api.settings.onChange("showTagsInAllMessages", () => {
|
||||||
|
applyBadges();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
unregisters.push(
|
||||||
|
api.settings.onChange("hideFolderedMessagesInAll", () => {
|
||||||
|
applyFolderFilter();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const u of unregisters) u.unregister();
|
||||||
|
messageListObserver?.disconnect();
|
||||||
|
sidebarObserver?.disconnect();
|
||||||
|
actionsObserver?.disconnect();
|
||||||
|
closeDropdown();
|
||||||
|
styleEl.remove();
|
||||||
|
document.querySelectorAll(".bsplus-folders-section").forEach((el) => el.remove());
|
||||||
|
document.querySelectorAll(".bsplus-folder-btn").forEach((el) => el.remove());
|
||||||
|
document.querySelectorAll(".bsplus-msg-badges").forEach((el) => el.remove());
|
||||||
|
document.querySelectorAll("[class*='MessageList__subject___']").forEach((subject) => {
|
||||||
|
if (subject.querySelector(".bsplus-subject-text")) {
|
||||||
|
restoreSubjectPlain(subject);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".bsplus-folder-hidden").forEach((el) =>
|
||||||
|
el.classList.remove("bsplus-folder-hidden"),
|
||||||
|
);
|
||||||
|
document.querySelectorAll(".bsplus-modal-overlay").forEach((el) => el.remove());
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default messageFoldersPlugin;
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
/* ── Sidebar folder section ── */
|
||||||
|
.bsplus-folders-section {
|
||||||
|
border-top: 1px solid var(--background-secondary, rgba(128, 128, 128, 0.2));
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folders-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 12px 2px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folders-header span {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-primary, #666);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folders-add-btn {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 20px !important;
|
||||||
|
height: 20px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folders-add-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Folder list items ── */
|
||||||
|
.bsplus-folder-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
gap: 8px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-item:hover {
|
||||||
|
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-item.bsplus-folder-active {
|
||||||
|
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-item.bsplus-folder-active::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--better-main, #007bff);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary, #999);
|
||||||
|
opacity: 0.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-item:hover .bsplus-folder-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-action-btn {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 20px !important;
|
||||||
|
height: 20px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-action-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--background-secondary, rgba(128, 128, 128, 0.15)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inline folder name input ── */
|
||||||
|
.bsplus-folder-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-input input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid var(--background-secondary, #ccc);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--background-secondary, #f5f5f5);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-input input:focus {
|
||||||
|
border-color: var(--better-main, #007bff);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-input-confirm,
|
||||||
|
.bsplus-folder-input-cancel {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 24px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-input-confirm {
|
||||||
|
background: var(--better-main, #007bff) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-input-confirm:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-input-cancel {
|
||||||
|
background: transparent !important;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-input-cancel:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Color picker row ── */
|
||||||
|
.bsplus-folder-colors {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px 6px;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-color-opt {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
border-color 0.2s ease,
|
||||||
|
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-color-opt:hover {
|
||||||
|
transform: scale(1.25);
|
||||||
|
box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-color-opt.bsplus-color-selected {
|
||||||
|
border-color: var(--text-primary, #333);
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-color-opt.bsplus-color-selected:hover {
|
||||||
|
transform: scale(1.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── "Add to folder" button in message actions bar ── */
|
||||||
|
.bsplus-folder-btn {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-btn svg {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Folder dropdown ── */
|
||||||
|
.bsplus-folder-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--background-primary, #fff);
|
||||||
|
border: 1px solid var(--background-secondary, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: bsplus-dropdown-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bsplus-dropdown-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px) scale(0.97);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dropdown-item:hover {
|
||||||
|
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dropdown-check {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--background-secondary, #ccc);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dropdown-item.bsplus-checked .bsplus-folder-dropdown-check {
|
||||||
|
background: var(--better-main, #007bff);
|
||||||
|
border-color: var(--better-main, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dropdown-check svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
color: white;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dropdown-item.bsplus-checked .bsplus-folder-dropdown-check svg {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dropdown-empty {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary, #999);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Let primary column use available space instead of being clipped ── */
|
||||||
|
[class*='MessageList__primary___'] {
|
||||||
|
flex: 1 1 0% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Make subject line a flex row so badges sit inline ── */
|
||||||
|
[class*='MessageList__subject___'] {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Subject text truncates to make room for badges ── */
|
||||||
|
.bsplus-subject-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shrink the secondary column to its content ── */
|
||||||
|
[class*='MessageList__secondary___'] {
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 200px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Constrain the flags/attachment icon column ── */
|
||||||
|
[class*='MessageList__flags___'] {
|
||||||
|
width: 24px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Message list folder badges ── */
|
||||||
|
.bsplus-msg-badges {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-msg-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: white;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-msg-badge:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Folder filtering (hide messages not in active folder) ── */
|
||||||
|
.bsplus-folder-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Delete confirmation modal ── */
|
||||||
|
@keyframes bsplus-modal-overlay-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bsplus-modal-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2147483647;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
animation: bsplus-modal-overlay-in 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin: 0 1rem;
|
||||||
|
min-width: 16rem;
|
||||||
|
max-width: 22rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--background-primary, #fff);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid var(--background-secondary, #e0e0e0);
|
||||||
|
animation: bsplus-modal-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary, #666);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal-actions button {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal-btn-cancel {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--background-secondary, #ccc);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal-btn-cancel:hover {
|
||||||
|
background: var(--background-secondary, rgba(128, 128, 128, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal-btn-danger {
|
||||||
|
background: #e53e3e;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal-btn-danger:hover {
|
||||||
|
background: #c53030;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35);
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@ const settings = defineSettings({
|
|||||||
useCloudPfp: booleanSetting({
|
useCloudPfp: booleanSetting({
|
||||||
default: false,
|
default: false,
|
||||||
title: "Use BetterSEQTA Cloud profile picture",
|
title: "Use BetterSEQTA Cloud profile picture",
|
||||||
description: "Use your cloud account avatar instead of the uploaded image below",
|
description:
|
||||||
|
"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,17 +680,9 @@ 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,12 +63,7 @@ function resetTimetableStyles(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleTimetable(): Promise<void> {
|
async function handleTimetable(): Promise<void> {
|
||||||
// SEQTA uses `.times` blocks on entries, not necessarily `.time`; avoid infinite polling on a missing selector.
|
await waitForElm(".time", true, 10);
|
||||||
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,9 +271,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const syncQuickbarFromDOM = () => {
|
const syncQuickbarFromDOM = () => {
|
||||||
const quickbar = document.querySelector(
|
const quickbar = document.querySelector(".timetablepage .quickbar.visible");
|
||||||
".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");
|
||||||
@@ -289,9 +287,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
if (!timetablePage || quickbarObserver) return;
|
if (!timetablePage || quickbarObserver) return;
|
||||||
|
|
||||||
quickbarObserver = new MutationObserver(() => {
|
quickbarObserver = new MutationObserver(() => {
|
||||||
const quickbar = document.querySelector(
|
const quickbar = document.querySelector(".timetablepage .quickbar.visible");
|
||||||
".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);
|
||||||
}
|
}
|
||||||
@@ -306,13 +302,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTimetable = async () => {
|
const handleTimetable = async () => {
|
||||||
// Class entries (`div.entry.class`) load after the page shell; don't fail the whole
|
await waitForElm(".timetablepage .entry", true, 10, 100);
|
||||||
// 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();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
|
|||||||
import profilePicturePlugin from "./built-in/profilePicture";
|
import profilePicturePlugin from "./built-in/profilePicture";
|
||||||
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
|
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
|
||||||
import backgroundMusicPlugin from "./built-in/backgroundMusic";
|
import backgroundMusicPlugin from "./built-in/backgroundMusic";
|
||||||
|
import messageFoldersPlugin from "./built-in/messageFolders";
|
||||||
//import testPlugin from './built-in/test';
|
//import testPlugin from './built-in/test';
|
||||||
|
|
||||||
// Heavy plugins (lazy-loaded only when enabled)
|
// Heavy plugins (lazy-loaded only when enabled)
|
||||||
@@ -28,6 +29,7 @@ pluginManager.registerPlugin(timetableEditPlugin);
|
|||||||
pluginManager.registerPlugin(profilePicturePlugin);
|
pluginManager.registerPlugin(profilePicturePlugin);
|
||||||
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
||||||
pluginManager.registerPlugin(backgroundMusicPlugin);
|
pluginManager.registerPlugin(backgroundMusicPlugin);
|
||||||
|
pluginManager.registerPlugin(messageFoldersPlugin);
|
||||||
//pluginManager.registerPlugin(testPlugin);
|
//pluginManager.registerPlugin(testPlugin);
|
||||||
|
|
||||||
// Register heavy plugins with lazy loading
|
// Register heavy plugins with lazy loading
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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";
|
||||||
|
|
||||||
@@ -90,7 +89,6 @@ export async function AddBetterSEQTAElements() {
|
|||||||
addExtensionSettings();
|
addExtensionSettings();
|
||||||
await createSettingsButton();
|
await createSettingsButton();
|
||||||
setupSettingsButton();
|
setupSettingsButton();
|
||||||
attachNotificationsPanelAnimation();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
|
function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
|
||||||
@@ -425,12 +423,10 @@ 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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,26 +127,6 @@ 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
|
||||||
|
|||||||
@@ -1,54 +1,61 @@
|
|||||||
|
import stringToHTML from "../stringToHTML";
|
||||||
import { settingsState } from "../listeners/SettingsState";
|
import { settingsState } from "../listeners/SettingsState";
|
||||||
import { animate as motionAnimate } from "motion";
|
import { openPopup } from "./PopupManager";
|
||||||
|
import { attachPopupMediaFullscreenIfPresent } from "./attachPopupMediaFullscreen";
|
||||||
|
|
||||||
|
/** Same hosting pattern as the privacy statement branding images (avoids page-relative extension URLs on Engage). */
|
||||||
|
const ENGAGE_PROMO_IMG_URL =
|
||||||
|
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/bq%2Bengage.png";
|
||||||
|
|
||||||
export function shouldShowEngageParentsAnnouncement(): boolean {
|
export function shouldShowEngageParentsAnnouncement(): boolean {
|
||||||
return !settingsState.engageParentsAnnouncementShown;
|
return !settingsState.engageParentsAnnouncementShown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Non-blocking bottom-right toast announcing SEQTA Engage support. Shown once.
|
* One-time announcement that BetterSEQTA Plus works on SEQTA Engage (parents).
|
||||||
*/
|
*/
|
||||||
export function showEngageParentsToast() {
|
export function showEngageParentsAnnouncement(onDismissed?: () => void) {
|
||||||
if (!shouldShowEngageParentsAnnouncement()) return;
|
if (document.getElementById("whatsnewbk")) {
|
||||||
|
onDismissed?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!shouldShowEngageParentsAnnouncement()) {
|
||||||
|
onDismissed?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = stringToHTML(
|
||||||
|
/* html */
|
||||||
|
`<div class="whatsnewHeader engageParentsAnnouncementHeader">
|
||||||
|
<h1>BetterSEQTA Plus now supports <span class="seqtaEngageAccent">SEQTA Engage</span></h1>
|
||||||
|
<p class="engageParentsSubheading">Buy your mom a BetterSEQTA Plus</p>
|
||||||
|
</div>`,
|
||||||
|
).firstChild as HTMLElement;
|
||||||
|
|
||||||
|
const text = stringToHTML(/* html */ `
|
||||||
|
<div class="whatsnewTextContainer privacyStatement" style="overflow-y: auto; font-size: 1.2rem; line-height: 1.6;">
|
||||||
|
<div class="engageParentsPromoWrap">
|
||||||
|
<img class="engageParentsPromoImg" src="${ENGAGE_PROMO_IMG_URL}" width="1920" height="1080" alt="BetterSEQTA Plus now supports SEQTA Engage" />
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
BetterSEQTA Plus now supports <strong class="seqtaEngageAccent">SEQTA Engage</strong>, so parents get the same kinds of improvements you are used to on SEQTA Learn—themes, a clearer home experience, and other Plus polish while browsing Engage.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The title is a bit of fun; if the extension saves you time, you can always support development via Open Collective or Ko-fi from the What is New changelog or related links in settings.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Close this dialog when you are done. We will not show this announcement again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
|
attachPopupMediaFullscreenIfPresent(text, ".engageParentsPromoImg");
|
||||||
|
|
||||||
settingsState.engageParentsAnnouncementShown = true;
|
settingsState.engageParentsAnnouncementShown = true;
|
||||||
|
|
||||||
const toast = document.createElement("div");
|
openPopup({
|
||||||
toast.className = "bsplus-toast";
|
header,
|
||||||
toast.innerHTML = /* html */ `
|
content: [text],
|
||||||
<div class="bsplus-toast-content">
|
afterClose: onDismissed,
|
||||||
<strong>BetterSEQTA+ now supports <span class="seqtaEngageAccent">SEQTA Engage</span></strong>
|
});
|
||||||
<p>Buy your mum a BetterSEQTA Plus! Parents now get themes, a cleaner home page, and all the Plus polish on SEQTA Engage.</p>
|
|
||||||
</div>
|
|
||||||
<button class="bsplus-toast-close" aria-label="Dismiss">×</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
toast.style.opacity = "0";
|
|
||||||
document.getElementById("container")?.append(toast);
|
|
||||||
|
|
||||||
if (settingsState.animations) {
|
|
||||||
(motionAnimate as any)(
|
|
||||||
toast,
|
|
||||||
{ opacity: [0, 1], y: [40, 0] },
|
|
||||||
{ duration: 0.35, easing: [0.22, 0.03, 0.26, 1] },
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.style.opacity = "1";
|
|
||||||
}
|
|
||||||
|
|
||||||
const dismiss = () => {
|
|
||||||
if (settingsState.animations) {
|
|
||||||
(motionAnimate as any)(
|
|
||||||
toast,
|
|
||||||
{ opacity: [1, 0], y: [0, 40] },
|
|
||||||
{ duration: 0.2, easing: [0.22, 0.03, 0.26, 1] },
|
|
||||||
).then(() => toast.remove());
|
|
||||||
} else {
|
|
||||||
toast.remove();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
toast.querySelector(".bsplus-toast-close")!.addEventListener("click", dismiss);
|
|
||||||
|
|
||||||
setTimeout(dismiss, 10000);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
|||||||
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.1 - Cloud backup, various fixes & SEQTA Engage support</h1>
|
<h1>3.6.0 - 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>
|
||||||
@@ -45,8 +45,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
|||||||
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import { settingsState } from "../listeners/SettingsState";
|
import { settingsState } from "../listeners/SettingsState";
|
||||||
import { OpenWhatsNewPopup } from "./OpenWhatsNewPopup";
|
import { OpenWhatsNewPopup } from "./OpenWhatsNewPopup";
|
||||||
|
import {
|
||||||
|
shouldShowPrivacyNotification,
|
||||||
|
showPrivacyNotification,
|
||||||
|
} from "./OpenPrivacyNotification";
|
||||||
import {
|
import {
|
||||||
shouldShowEngageParentsAnnouncement,
|
shouldShowEngageParentsAnnouncement,
|
||||||
showEngageParentsToast,
|
showEngageParentsAnnouncement,
|
||||||
} from "./OpenEngageParentsAnnouncement";
|
} from "./OpenEngageParentsAnnouncement";
|
||||||
|
import {
|
||||||
|
shouldShowBsCloudAutoSyncAnnouncement,
|
||||||
|
showBsCloudAutoSyncAnnouncement,
|
||||||
|
} from "./OpenBsCloudAutoSyncAnnouncement";
|
||||||
|
|
||||||
type QueueStep = (goNext: () => void) => void;
|
type QueueStep = (goNext: () => void) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs startup modals in order: What's New (if the extension just updated),
|
* Runs startup modals in order: What's New (if the extension just updated),
|
||||||
* then shows the SEQTA Engage toast (once, non-blocking).
|
* privacy statement (if required), SEQTA Engage announcement (once), then BS Cloud
|
||||||
|
* auto-sync (once, last).
|
||||||
*/
|
*/
|
||||||
export function runStartupPopupQueue() {
|
export function runStartupPopupQueue() {
|
||||||
const steps: QueueStep[] = [];
|
const steps: QueueStep[] = [];
|
||||||
@@ -18,14 +27,21 @@ export function runStartupPopupQueue() {
|
|||||||
steps.push((goNext) => OpenWhatsNewPopup(goNext));
|
steps.push((goNext) => OpenWhatsNewPopup(goNext));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldShowPrivacyNotification()) {
|
||||||
|
steps.push((goNext) => showPrivacyNotification(goNext));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldShowEngageParentsAnnouncement()) {
|
||||||
|
steps.push((goNext) => showEngageParentsAnnouncement(goNext));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldShowBsCloudAutoSyncAnnouncement()) {
|
||||||
|
steps.push((goNext) => showBsCloudAutoSyncAnnouncement(goNext));
|
||||||
|
}
|
||||||
|
|
||||||
function runNext() {
|
function runNext() {
|
||||||
const step = steps.shift();
|
const step = steps.shift();
|
||||||
if (step) step(runNext);
|
if (step) step(runNext);
|
||||||
else {
|
|
||||||
if (shouldShowEngageParentsAnnouncement()) {
|
|
||||||
showEngageParentsToast();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
runNext();
|
runNext();
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -102,12 +102,11 @@ export function buildUploadPayload(all: Record<string, unknown>): {
|
|||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
} {
|
} {
|
||||||
const filtered: Record<string, unknown> = {};
|
const data: 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;
|
||||||
filtered[k] = v;
|
data[k] = v;
|
||||||
}
|
}
|
||||||
const data = migrateLegacyToPluginSettings(filtered);
|
|
||||||
return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, data };
|
return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,77 +124,8 @@ export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrate legacy storage keys to plugin settings format.
|
* Replace local extension storage with the downloaded snapshot, except auth keys
|
||||||
* Only applies migrations for keys present in the data; does not overwrite
|
* and device-only sensitive caches, which are preserved from the current device.
|
||||||
* 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>;
|
||||||
@@ -215,7 +145,10 @@ export async function applyDownloadedEnvelope(envelope: unknown): Promise<void>
|
|||||||
throw new Error("Invalid cloud settings payload");
|
throw new Error("Invalid cloud settings payload");
|
||||||
}
|
}
|
||||||
|
|
||||||
const migrated = migrateLegacyToPluginSettings(remoteFlat);
|
const local = await browser.storage.local.get();
|
||||||
const remoteSanitized = stripExcludedKeysFromRemoteData(migrated);
|
const preserved = collectLocalKeysToPreserve(local);
|
||||||
await browser.storage.local.set(remoteSanitized);
|
const remoteSanitized = stripExcludedKeysFromRemoteData(remoteFlat);
|
||||||
|
|
||||||
|
await browser.storage.local.clear();
|
||||||
|
await browser.storage.local.set({ ...remoteSanitized, ...preserved });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user