mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-17 17:07:07 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3bdb0fe09 | |||
| 5d8cb55b94 | |||
| d10fca6c0f | |||
| 62ed702e64 | |||
| 8a5424c5a4 | |||
| 0e696e0175 | |||
| b8709f6391 | |||
| 20a4078f60 | |||
| ce3468e2ab | |||
| b3a4182a52 | |||
| 7d18b2483f | |||
| 9bfd1bbf0e | |||
| feaf4dced5 | |||
| 5c195f1148 |
@@ -18,6 +18,7 @@ betterseqtaplus-safari/
|
||||
|
||||
.million/
|
||||
.vscode/
|
||||
.cursor/
|
||||
**/.DS_Store
|
||||
.parcel-cache
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
/**
|
||||
* crxjs 2.6.x only replaces the first `__LIVE_RELOAD__` in `@crx/client-worker`,
|
||||
* which crashes the service worker when the dev server reconnects.
|
||||
*/
|
||||
export default function fixCrxWorkerLiveReload(): Plugin {
|
||||
return {
|
||||
name: "fix-crx-worker-live-reload",
|
||||
apply: "serve",
|
||||
enforce: "post",
|
||||
transform(code, id) {
|
||||
if (!id.includes("@crx/client-worker") || !code.includes("__LIVE_RELOAD__")) {
|
||||
return;
|
||||
}
|
||||
return code.replaceAll("__LIVE_RELOAD__", "true");
|
||||
},
|
||||
};
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "betterseqtaplus",
|
||||
"version": "3.7.0",
|
||||
"version": "3.7.1",
|
||||
"type": "module",
|
||||
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
|
||||
"browserslist": "> 0.5%, last 2 versions, not dead",
|
||||
@@ -42,7 +42,7 @@
|
||||
"@babel/plugin-transform-runtime": "^7.26.9",
|
||||
"@babel/runtime": "^7.26.9",
|
||||
"@bedframe/cli": "^0.1.2",
|
||||
"@crxjs/vite-plugin": "^2.4.0",
|
||||
"@crxjs/vite-plugin": "^2.6.1",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-shape": "^3.1.8",
|
||||
"@types/jest": "^30.0.0",
|
||||
@@ -77,7 +77,7 @@
|
||||
"@codemirror/search": "^6.5.10",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.36.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"@types/chrome": "^0.1.4",
|
||||
@@ -122,7 +122,7 @@
|
||||
"svelte": "^5.46.4",
|
||||
"typescript": "^5.8.2",
|
||||
"uuid": "^11.1.0",
|
||||
"vite": "^8.0.5",
|
||||
"vite": "^6.2.1",
|
||||
"webextension-polyfill": "^0.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -6,7 +6,7 @@ import documentLoadCSS from "@/css/documentload.scss?inline";
|
||||
import icon48 from "@/resources/icons/icon-48.png?base64";
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
import * as plugins from "@/plugins";
|
||||
import { init as Monofile } from "@/plugins/monofile";
|
||||
import { main } from "@/seqta/main";
|
||||
import { delay } from "./seqta/utils/delay";
|
||||
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
||||
@@ -104,10 +104,11 @@ async function init() {
|
||||
}
|
||||
|
||||
await main();
|
||||
plugins.Monofile();
|
||||
Monofile();
|
||||
|
||||
if (settingsState.onoff) {
|
||||
await plugins.initializePlugins();
|
||||
const { initializePlugins } = await import("@/plugins/runtime");
|
||||
await initializePlugins();
|
||||
}
|
||||
|
||||
if (settingsState.devMode) {
|
||||
|
||||
+151
-69
@@ -10,7 +10,9 @@ import {
|
||||
performCloudSettingsUploadWithRetry,
|
||||
requestCloudSettingsDebouncedUpload,
|
||||
runCloudSettingsPoll,
|
||||
withSuppressedCloudAutoUpload,
|
||||
} from "./background/cloudSettingsAutoSync";
|
||||
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
|
||||
|
||||
/**
|
||||
* Session-only dev-mode override of the content API base.
|
||||
@@ -45,6 +47,11 @@ function reloadSeqtaPages() {
|
||||
/** Callback for sending a response back to the message sender */
|
||||
type MessageSender = { (response?: unknown): void };
|
||||
|
||||
async function getAccessTokenFromStorage(): Promise<string | null> {
|
||||
const { bsplus_token } = await browser.storage.local.get("bsplus_token");
|
||||
return typeof bsplus_token === "string" && bsplus_token.length > 0 ? bsplus_token : null;
|
||||
}
|
||||
|
||||
/** Accept API + GitHub fallback shapes; always return `{ success, data?: { themes } }`. */
|
||||
function normalizeFetchThemesResponse(json: unknown): {
|
||||
success: boolean;
|
||||
@@ -79,66 +86,101 @@ function normalizeFetchThemesResponse(json: unknown): {
|
||||
}
|
||||
|
||||
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
|
||||
const { token } = request;
|
||||
const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
|
||||
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
fetch(apiUrl, { cache: "no-store", headers })
|
||||
.then(async (r) => {
|
||||
const json = await r.json();
|
||||
if (!r.ok) {
|
||||
throw new Error(
|
||||
(json && typeof json === "object" && "error" in json && typeof (json as { error?: string }).error === "string"
|
||||
? (json as { error: string }).error
|
||||
: null) ?? `Themes API HTTP ${r.status}`,
|
||||
);
|
||||
}
|
||||
return normalizeFetchThemesResponse(json);
|
||||
})
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
|
||||
fetch(githubUrl, { cache: "no-store" })
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error(`GitHub fallback HTTP ${r.status}`);
|
||||
const data = await r.json();
|
||||
const themes = Array.isArray(data) ? data : (data?.themes ?? []);
|
||||
return normalizeFetchThemesResponse({ success: true, data: { themes } });
|
||||
})
|
||||
.then(sendResponse)
|
||||
.catch((fallbackErr) => {
|
||||
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
|
||||
sendResponse({ success: false, error: fallbackErr?.message });
|
||||
});
|
||||
});
|
||||
void (async () => {
|
||||
const token = await getAccessTokenFromStorage();
|
||||
const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
|
||||
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
fetch(apiUrl, { cache: "no-store", headers })
|
||||
.then(async (r) => {
|
||||
const json = await r.json();
|
||||
if (!r.ok) {
|
||||
throw new Error(
|
||||
(json && typeof json === "object" && "error" in json && typeof (json as { error?: string }).error === "string"
|
||||
? (json as { error: string }).error
|
||||
: null) ?? `Themes API HTTP ${r.status}`,
|
||||
);
|
||||
}
|
||||
return normalizeFetchThemesResponse(json);
|
||||
})
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
|
||||
fetch(githubUrl, { cache: "no-store" })
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error(`GitHub fallback HTTP ${r.status}`);
|
||||
const data = await r.json();
|
||||
const themes = Array.isArray(data) ? data : (data?.themes ?? []);
|
||||
return normalizeFetchThemesResponse({ success: true, data: { themes } });
|
||||
})
|
||||
.then(sendResponse)
|
||||
.catch((fallbackErr) => {
|
||||
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
|
||||
sendResponse({ success: false, error: fallbackErr?.message });
|
||||
});
|
||||
});
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boolean {
|
||||
const { themeId, token } = request;
|
||||
const { themeId } = request;
|
||||
if (!themeId || typeof themeId !== "string") {
|
||||
sendResponse({ success: false, error: "Missing themeId" });
|
||||
return false;
|
||||
}
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers })
|
||||
.then((r) => r.json())
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
console.error("[Background] fetchThemeDetails error:", err);
|
||||
sendResponse({ success: false, error: err?.message });
|
||||
});
|
||||
void (async () => {
|
||||
const token = await getAccessTokenFromStorage();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers })
|
||||
.then((r) => r.json())
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
console.error("[Background] fetchThemeDetails error:", err);
|
||||
sendResponse({ success: false, error: err?.message });
|
||||
});
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleFetchFromUrl(request: any, sendResponse: MessageSender): boolean {
|
||||
function isTrustedSender(sender?: browser.Runtime.MessageSender): boolean {
|
||||
if (!sender) return false;
|
||||
if (sender.id && sender.id !== browser.runtime.id) return false;
|
||||
|
||||
const urls = [sender.url, sender.tab?.url].filter(Boolean) as string[];
|
||||
for (const pageUrl of urls) {
|
||||
if (/^chrome-extension:\/\//.test(pageUrl) || /^moz-extension:\/\//.test(pageUrl)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (isSeqtaOrigin(new URL(pageUrl).origin)) return true;
|
||||
} catch {
|
||||
// try next URL
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleFetchFromUrl(
|
||||
request: any,
|
||||
sendResponse: MessageSender,
|
||||
sender?: browser.Runtime.MessageSender,
|
||||
): boolean {
|
||||
if (!isTrustedSender(sender)) {
|
||||
sendResponse({ error: "Unauthorized sender" });
|
||||
return false;
|
||||
}
|
||||
const { url } = request;
|
||||
if (!url || typeof url !== "string") {
|
||||
sendResponse({ error: "Missing url" });
|
||||
return false;
|
||||
}
|
||||
if (!isAllowedFetchUrl(url)) {
|
||||
sendResponse({ error: "URL not allowed" });
|
||||
return false;
|
||||
}
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((data) => sendResponse({ data }))
|
||||
@@ -177,7 +219,15 @@ function handleCloudReserveClient(request: any, sendResponse: MessageSender): bo
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
|
||||
function handleCloudLogin(
|
||||
request: any,
|
||||
sendResponse: MessageSender,
|
||||
sender?: browser.Runtime.MessageSender,
|
||||
): boolean {
|
||||
if (!isTrustedSender(sender)) {
|
||||
sendResponse({ error: "Unauthorized sender" });
|
||||
return false;
|
||||
}
|
||||
const { client_id, redirect_uri, login, password } = request;
|
||||
if (!client_id || !redirect_uri || !login || !password) {
|
||||
sendResponse({ error: "Missing client_id, redirect_uri, login, or password" });
|
||||
@@ -291,10 +341,18 @@ function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): boolean {
|
||||
function handleCloudSettingsUpload(
|
||||
request: any,
|
||||
sendResponse: MessageSender,
|
||||
sender?: browser.Runtime.MessageSender,
|
||||
): boolean {
|
||||
if (!isTrustedSender(sender)) {
|
||||
sendResponse({ success: false, error: "Unauthorized sender" });
|
||||
return false;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const token = request.token as string | undefined;
|
||||
const token = await getAccessTokenFromStorage();
|
||||
if (!token) {
|
||||
sendResponse({ success: false, error: "Not authenticated" });
|
||||
return;
|
||||
@@ -316,10 +374,18 @@ function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): b
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): boolean {
|
||||
function handleCloudSettingsDownload(
|
||||
request: any,
|
||||
sendResponse: MessageSender,
|
||||
sender?: browser.Runtime.MessageSender,
|
||||
): boolean {
|
||||
if (!isTrustedSender(sender)) {
|
||||
sendResponse({ success: false, error: "Unauthorized sender" });
|
||||
return false;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const token = request.token as string | undefined;
|
||||
const token = await getAccessTokenFromStorage();
|
||||
if (!token) {
|
||||
sendResponse({ success: false, error: "Not authenticated" });
|
||||
return;
|
||||
@@ -343,22 +409,29 @@ function handleCloudSettingsDownload(request: any, sendResponse: MessageSender):
|
||||
}
|
||||
|
||||
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
|
||||
const { themeId, token, action } = request;
|
||||
if (!themeId || !token) {
|
||||
sendResponse({ success: false, error: "Theme ID and token required" });
|
||||
const { themeId, action } = request;
|
||||
if (!themeId) {
|
||||
sendResponse({ success: false, error: "Theme ID required" });
|
||||
return false;
|
||||
}
|
||||
const isFavorite = action === "favorite";
|
||||
fetch(`${apiBase()}/api/themes/${themeId}/favorite`, {
|
||||
method: isFavorite ? "POST" : "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
console.error("[Background] cloudFavorite error:", err);
|
||||
sendResponse({ success: false, error: err?.message });
|
||||
});
|
||||
void (async () => {
|
||||
const token = await getAccessTokenFromStorage();
|
||||
if (!token) {
|
||||
sendResponse({ success: false, error: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
const isFavorite = action === "favorite";
|
||||
fetch(`${apiBase()}/api/themes/${themeId}/favorite`, {
|
||||
method: isFavorite ? "POST" : "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
console.error("[Background] cloudFavorite error:", err);
|
||||
sendResponse({ success: false, error: err?.message });
|
||||
});
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -376,7 +449,12 @@ function isSeqtaOrigin(origin: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSetDevApiBase(request: any): boolean {
|
||||
function handleSetDevApiBase(
|
||||
request: any,
|
||||
_sendResponse: MessageSender,
|
||||
sender?: browser.Runtime.MessageSender,
|
||||
): boolean {
|
||||
if (!isTrustedSender(sender)) return false;
|
||||
const url = typeof request?.url === "string" ? request.url.trim() : null;
|
||||
if (url && /^https?:\/\//.test(url)) {
|
||||
DEV_API_BASE = url.replace(/\/$/, "");
|
||||
@@ -415,7 +493,11 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
||||
});
|
||||
return true;
|
||||
},
|
||||
sendNews: (req, sendResponse) => {
|
||||
sendNews: (req, sendResponse, sender) => {
|
||||
if (!isTrustedSender(sender)) {
|
||||
sendResponse({ error: "Unauthorized sender" });
|
||||
return false;
|
||||
}
|
||||
fetchNews(req.source ?? "australia", sendResponse);
|
||||
return true;
|
||||
},
|
||||
@@ -492,10 +574,10 @@ function getDefaultValues(): SettingsState {
|
||||
return getDefaultSettingsState();
|
||||
}
|
||||
|
||||
function SetStorageValue(object: any) {
|
||||
for (var i in object) {
|
||||
browser.storage.local.set({ [i]: object[i] });
|
||||
}
|
||||
function SetStorageValue(object: SettingsState) {
|
||||
void withSuppressedCloudAutoUpload(() =>
|
||||
browser.storage.local.set(object as Record<string, unknown>),
|
||||
);
|
||||
}
|
||||
|
||||
/** One-time migration for 3.6.5: opt upgraders into Global Search + indexing + transparency defaults. */
|
||||
|
||||
@@ -25,6 +25,11 @@ const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`;
|
||||
const UPLOAD_DEBOUNCE_MS = 2000;
|
||||
const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000;
|
||||
const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll";
|
||||
const FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
function fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> {
|
||||
return fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
||||
}
|
||||
|
||||
type CloudSummaryResponse = {
|
||||
desqta?: unknown;
|
||||
@@ -35,6 +40,7 @@ let reloadSeqtaPagesFn: (() => void) | null = null;
|
||||
let suppressAutoUploadDuringRestore = false;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pollInFlight: Promise<void> | null = null;
|
||||
let autoSyncInitialized = false;
|
||||
|
||||
function isAutoCloudSyncEnabled(all: Record<string, unknown>): boolean {
|
||||
return all.autoCloudSettingsSync !== false;
|
||||
@@ -65,7 +71,7 @@ async function tryRefreshTokens(): Promise<boolean> {
|
||||
if (!refresh_token || !client_id) return false;
|
||||
|
||||
try {
|
||||
const r = await fetch(REFRESH_URL, {
|
||||
const r = await fetchWithTimeout(REFRESH_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token, client_id }),
|
||||
@@ -100,7 +106,7 @@ async function fetchCloudSummaryOnce(
|
||||
| { ok: false; unauthorized: boolean; error?: string }
|
||||
> {
|
||||
try {
|
||||
const r = await fetch(CLOUD_SUMMARY_URL, {
|
||||
const r = await fetchWithTimeout(CLOUD_SUMMARY_URL, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
@@ -177,7 +183,7 @@ async function putSettingsOnce(token: string): Promise<PutResult> {
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
|
||||
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
|
||||
const r = await fetchWithTimeout(CLOUD_SETTINGS_SYNC_URL, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -235,7 +241,7 @@ type GetResult =
|
||||
|
||||
async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
|
||||
try {
|
||||
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
|
||||
const r = await fetchWithTimeout(CLOUD_SETTINGS_SYNC_URL, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: "no-store",
|
||||
@@ -373,8 +379,8 @@ export function runCloudSettingsPoll(): Promise<void> {
|
||||
try {
|
||||
const { [POLL_THROTTLE_KEY]: last } = await browser.storage.local.get(POLL_THROTTLE_KEY);
|
||||
if (Date.now() - (Number(last) || 0) < POLL_THROTTLE_MS) return;
|
||||
await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() });
|
||||
await runCloudSettingsPollInner();
|
||||
await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() });
|
||||
} catch (e) {
|
||||
console.error("[BS+ cloud sync] Poll error:", e);
|
||||
} finally {
|
||||
@@ -451,8 +457,21 @@ function onStorageChanged(
|
||||
})();
|
||||
}
|
||||
|
||||
export async function withSuppressedCloudAutoUpload<T>(
|
||||
operation: () => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
suppressAutoUploadDuringRestore = true;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
suppressAutoUploadDuringRestore = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
|
||||
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
|
||||
if (autoSyncInitialized) return;
|
||||
autoSyncInitialized = true;
|
||||
browser.storage.onChanged.addListener(onStorageChanged);
|
||||
}
|
||||
|
||||
|
||||
+20
-6
@@ -1,5 +1,7 @@
|
||||
import Parser from "rss-parser";
|
||||
|
||||
const MAX_RATE_LIMIT_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* Fetches news articles specifically for Australia from the NewsAPI.
|
||||
*
|
||||
@@ -13,15 +15,23 @@ import Parser from "rss-parser";
|
||||
* to send the fetched news data back to the caller.
|
||||
* It's called with an object like `{ news: responseData }`.
|
||||
*/
|
||||
const fetchAustraliaNews = async (url: string, sendResponse: any) => {
|
||||
const fetchAustraliaNews = async (
|
||||
url: string,
|
||||
sendResponse: any,
|
||||
rateLimitRetryCount = 0,
|
||||
) => {
|
||||
fetch(url)
|
||||
.then((result) => result.json())
|
||||
.then((response) => {
|
||||
if (response.code == "rateLimited") {
|
||||
fetchAustraliaNews((url += "%00"), sendResponse);
|
||||
if (response.code == "rateLimited" && rateLimitRetryCount < MAX_RATE_LIMIT_RETRIES) {
|
||||
fetchAustraliaNews(`${url}%00`, sendResponse, rateLimitRetryCount + 1);
|
||||
} else {
|
||||
sendResponse({ news: response });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[BetterSEQTA+] Failed to fetch Australia news", error);
|
||||
sendResponse({ news: { articles: [] } });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -99,13 +109,14 @@ export async function fetchNews(source: string | undefined, sendResponse: any) {
|
||||
|
||||
if (normalizedSource === "australia") {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 5);
|
||||
|
||||
const from =
|
||||
date.getFullYear() +
|
||||
"-" +
|
||||
(date.getMonth() + 1) +
|
||||
String(date.getMonth() + 1).padStart(2, "0") +
|
||||
"-" +
|
||||
(date.getDate() - 5);
|
||||
String(date.getDate()).padStart(2, "0");
|
||||
|
||||
const url = `https://newsapi.org/v2/everything?domains=abc.net.au&from=${from}&apiKey=17c0da766ba347c89d094449504e3080`;
|
||||
fetchAustraliaNews(url, sendResponse);
|
||||
@@ -115,7 +126,6 @@ export async function fetchNews(source: string | undefined, sendResponse: any) {
|
||||
|
||||
const parser = new Parser();
|
||||
let feeds: string[];
|
||||
console.log("fetchNews", normalizedSource);
|
||||
|
||||
if (rssFeedsByCountry[normalizedSource.toLowerCase()]) {
|
||||
feeds = rssFeedsByCountry[normalizedSource.toLowerCase()];
|
||||
@@ -129,6 +139,10 @@ export async function fetchNews(source: string | undefined, sendResponse: any) {
|
||||
const articlesPromises = feeds.map(async (feedUrl) => {
|
||||
try {
|
||||
const response = await fetch(feedUrl);
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch RSS feed: ${feedUrl} (${response.status})`);
|
||||
return [];
|
||||
}
|
||||
const feedString = await response.text();
|
||||
const feed = await parser.parseString(feedString);
|
||||
|
||||
|
||||
@@ -3615,26 +3615,6 @@ div.day-empty {
|
||||
font-size: 1em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.whatsnewHeader.engageParentsAnnouncementHeader {
|
||||
height: auto;
|
||||
min-height: unset;
|
||||
}
|
||||
.whatsnewHeader.engageParentsAnnouncementHeader h1 {
|
||||
line-height: 1.2;
|
||||
}
|
||||
.whatsnewHeader.engageParentsAnnouncementHeader .engageParentsSubheading {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.seqtaEngageAccent {
|
||||
color: #ea580c;
|
||||
font-weight: 700;
|
||||
}
|
||||
.dark .seqtaEngageAccent {
|
||||
color: #fb923c;
|
||||
}
|
||||
.whatsnewBackground {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -3763,25 +3743,6 @@ div.day-empty {
|
||||
object-fit: cover;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.whatsnewTextContainer .engageParentsPromoWrap {
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 9;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.28);
|
||||
background: color-mix(in srgb, var(--background-secondary) 88%, var(--text-primary) 12%);
|
||||
}
|
||||
.whatsnewTextContainer .engageParentsPromoWrap .engageParentsPromoImg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
aspect-ratio: unset;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader {
|
||||
height: auto;
|
||||
@@ -5014,66 +4975,3 @@ h2.home-subtitle {
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.bsplus-toast {
|
||||
position: fixed;
|
||||
right: max(18px, env(safe-area-inset-right));
|
||||
bottom: max(18px, env(safe-area-inset-bottom));
|
||||
z-index: 10000;
|
||||
width: min(360px, calc(100vw - 36px));
|
||||
padding: 14px 16px 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||
border-radius: 20px;
|
||||
background: var(--background-primary, #fff);
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.bsplus-toast-eyebrow {
|
||||
margin: 0 0 6px !important;
|
||||
font-size: 0.72rem !important;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, #ea580c 82%, var(--text-primary) 18%);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.dark .bsplus-toast-eyebrow {
|
||||
color: color-mix(in srgb, #fb923c 82%, var(--text-primary) 18%);
|
||||
}
|
||||
.bsplus-toast-content strong {
|
||||
display: block;
|
||||
padding-right: 34px;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.bsplus-toast-content p:not(.bsplus-toast-eyebrow) {
|
||||
display: -webkit-box;
|
||||
margin: 8px 0 0;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.bsplus-toast-close {
|
||||
position: absolute !important;
|
||||
top: 4px !important;
|
||||
right: 4px !important;
|
||||
z-index: 2;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 16px !important;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
.bsplus-toast-close:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
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; }}
|
||||
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick(e as unknown as MouseEvent) }}
|
||||
>
|
||||
<div
|
||||
bind:this={content}
|
||||
|
||||
@@ -22,15 +22,13 @@
|
||||
});
|
||||
|
||||
async function upload() {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
if (!token) return;
|
||||
if (!cloudState.isLoggedIn) return;
|
||||
busy = true;
|
||||
statusError = null;
|
||||
statusMessage = null;
|
||||
try {
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: "cloudSettingsUpload",
|
||||
token,
|
||||
})) as { success?: boolean; error?: string };
|
||||
if (res?.success) {
|
||||
statusMessage = "Settings uploaded.";
|
||||
@@ -49,15 +47,13 @@
|
||||
}
|
||||
|
||||
async function confirmDownload() {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
if (!token) return;
|
||||
if (!cloudState.isLoggedIn) return;
|
||||
busy = true;
|
||||
statusError = null;
|
||||
statusMessage = null;
|
||||
try {
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: "cloudSettingsDownload",
|
||||
token,
|
||||
})) as { success?: boolean; error?: string; notFound?: boolean };
|
||||
if (res?.success) {
|
||||
statusMessage = "Settings restored.";
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
let editor = $state<HTMLDivElement | null>(null)
|
||||
let view: EditorView | null = null;
|
||||
let unsubSettings: (() => void) | undefined;
|
||||
let editorTheme = new Compartment();
|
||||
let { value, onChange, className } = $props<{value: string, onChange: (value: string) => void, className?: string}>()
|
||||
|
||||
@@ -73,7 +74,7 @@
|
||||
view = createEditorView(state, editor as HTMLElement);
|
||||
}
|
||||
|
||||
settingsState.subscribe((settings) => {
|
||||
unsubSettings = settingsState.subscribe((settings) => {
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
effects: editorTheme.reconfigure(
|
||||
@@ -85,6 +86,7 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubSettings?.();
|
||||
if (view) {
|
||||
view.destroy();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import ColourPicker from './ColourPicker.tsx';
|
||||
import ReactAdapter from './utils/ReactAdapter.svelte';
|
||||
import type { Component } from 'svelte'
|
||||
import { animate } from 'motion';
|
||||
import { delay } from '@/seqta/utils/delay.ts'
|
||||
|
||||
@@ -15,6 +14,19 @@
|
||||
|
||||
let background = $state<HTMLDivElement | null>(null);
|
||||
let content = $state<HTMLDivElement | null>(null);
|
||||
let ReactAdapter = $state<Component | null>(null);
|
||||
let ColourPickerEl = $state<unknown>(null);
|
||||
let pickerReady = $state(false);
|
||||
|
||||
const loadPicker = async () => {
|
||||
const [adapterMod, pickerMod] = await Promise.all([
|
||||
import('./utils/ReactAdapter.svelte'),
|
||||
import('./ColourPicker.tsx'),
|
||||
]);
|
||||
ReactAdapter = adapterMod.default;
|
||||
ColourPickerEl = pickerMod.default;
|
||||
pickerReady = true;
|
||||
};
|
||||
|
||||
const closePicker = async () => {
|
||||
if (standalone) return;
|
||||
@@ -37,28 +49,30 @@
|
||||
);
|
||||
|
||||
await delay(400);
|
||||
hidePicker();
|
||||
hidePicker?.();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (standalone) return;
|
||||
if (!background || !content) return;
|
||||
void loadPicker().then(() => {
|
||||
if (standalone) return;
|
||||
if (!background || !content) return;
|
||||
|
||||
animate(
|
||||
background,
|
||||
{ opacity: [0, 1] },
|
||||
{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }
|
||||
);
|
||||
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
|
||||
}
|
||||
);
|
||||
animate(
|
||||
content,
|
||||
{ scale: [0.4, 1], opacity: [0, 1] },
|
||||
{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
@@ -82,7 +96,9 @@
|
||||
|
||||
{#if standalone}
|
||||
<div class="h-auto overflow-clip rounded-xl">
|
||||
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
|
||||
{#if pickerReady && ReactAdapter && ColourPickerEl}
|
||||
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPickerEl} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -90,13 +106,15 @@
|
||||
bind:this={background}
|
||||
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full shadow-2xl cursor-pointer bg-black/20 border border-[#DDDDDD]/30 dark:border-[#38373D]/30"
|
||||
onclick={handleBackgroundClick}
|
||||
onkeydown={(e) => { e.key === 'Enter' && handleBackgroundClick }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') handleBackgroundClick(e as unknown as MouseEvent) }}
|
||||
>
|
||||
<div
|
||||
bind:this={content}
|
||||
class="p-4 h-auto bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
|
||||
>
|
||||
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
|
||||
{#if pickerReady && ReactAdapter && ColourPickerEl}
|
||||
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPickerEl} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
const startRecording = () => {
|
||||
isRecording = true;
|
||||
recordedKeys.clear();
|
||||
recordedKeys = new Set();
|
||||
inputElement?.focus();
|
||||
};
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
if (recordedKeys.has('esc')) {
|
||||
onChange('');
|
||||
isRecording = false;
|
||||
recordedKeys.clear();
|
||||
recordedKeys = new Set();
|
||||
inputElement?.blur();
|
||||
return;
|
||||
}
|
||||
@@ -113,10 +113,16 @@
|
||||
}
|
||||
|
||||
isRecording = false;
|
||||
recordedKeys.clear();
|
||||
recordedKeys = new Set();
|
||||
inputElement?.blur();
|
||||
};
|
||||
|
||||
const addRecordedKey = (key: string) => {
|
||||
const next = new Set(recordedKeys);
|
||||
next.add(key);
|
||||
recordedKeys = next;
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isRecording) return;
|
||||
|
||||
@@ -126,14 +132,14 @@
|
||||
const key = formatKeyForHotkey(e.key);
|
||||
|
||||
// Add modifiers
|
||||
if (e.ctrlKey) recordedKeys.add('ctrl');
|
||||
if (e.metaKey) recordedKeys.add('cmd');
|
||||
if (e.altKey) recordedKeys.add('alt');
|
||||
if (e.shiftKey) recordedKeys.add('shift');
|
||||
if (e.ctrlKey) addRecordedKey('ctrl');
|
||||
if (e.metaKey) addRecordedKey('cmd');
|
||||
if (e.altKey) addRecordedKey('alt');
|
||||
if (e.shiftKey) addRecordedKey('shift');
|
||||
|
||||
// Add the main key (ignore modifier keys themselves)
|
||||
if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
|
||||
recordedKeys.add(key);
|
||||
addRecordedKey(key);
|
||||
}
|
||||
|
||||
// Auto-stop recording if we have a main key
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
let { state, onChange, min = 0, max = 100, step = 1 } = $props<{
|
||||
let { state = $bindable(), onChange, min = 0, max = 100, step = 1 } = $props<{
|
||||
state: number,
|
||||
onChange: (value: number) => void,
|
||||
min?: number,
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container">
|
||||
<div class="flex flex-col h-full min-h-0">
|
||||
<div class="top-0 z-10 shrink-0 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container" role="tablist">
|
||||
<div bind:this={containerRef} class="flex relative">
|
||||
<MotionDiv
|
||||
class="absolute top-0 left-0 z-0 h-full bg-gradient-to-tr dark:from-[#38373D]/80 dark:to-[#38373D] from-[#DDDDDD]/80 to-[#DDDDDD] rounded-full opacity-40 tab-width"
|
||||
@@ -48,6 +48,8 @@
|
||||
/>
|
||||
{#each tabs as { title }, index}
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={activeTab === index}
|
||||
class="relative z-10 flex-1 px-4 py-2 focus-visible:outline-none"
|
||||
onclick={() => activeTab = index}
|
||||
>
|
||||
@@ -56,21 +58,17 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-hidden px-4 h-full">
|
||||
<MotionDiv
|
||||
class="h-full"
|
||||
animate={{ x: `${-activeTab * 100}%` }}
|
||||
transition={springTransition}
|
||||
>
|
||||
<div class="flex">
|
||||
{#each tabs as { Content, props }, index}
|
||||
<div class="absolute focus:outline-none w-full pt-2 transition-opacity duration-300 overflow-y-scroll no-scrollbar pb-2 h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}"
|
||||
style="left: {index * 100}%;">
|
||||
<div style="left: {index * 100}%;" class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
|
||||
<Content {...props} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</MotionDiv>
|
||||
<div class="overflow-hidden px-4 flex-1 min-h-0">
|
||||
{#each tabs as { Content, props }, index (index)}
|
||||
{#if activeTab === index}
|
||||
<div
|
||||
role="tabpanel"
|
||||
class="focus:outline-none w-full h-full min-h-0 pt-2 overflow-y-auto no-scrollbar pb-6 tab active"
|
||||
>
|
||||
<div class="sticky top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
|
||||
<Content {...props} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import logo from '@/resources/icons/betterseqta-dark-full.png';
|
||||
import logoDark from '@/resources/icons/betterseqta-light-full.png';
|
||||
import { closeStore } from '@/seqta/ui/renderStore'
|
||||
import browser from 'webextension-polyfill';
|
||||
import CloudHeader from './CloudHeader.svelte';
|
||||
|
||||
const handleCloseStore = () => {
|
||||
void import('@/seqta/ui/renderStore').then((module) => module.closeStore());
|
||||
};
|
||||
|
||||
// Props
|
||||
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
|
||||
searchTerm: string,
|
||||
@@ -64,7 +67,7 @@
|
||||
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
onclick={closeStore}
|
||||
onclick={handleCloseStore}
|
||||
class="p-1 px-3"
|
||||
>
|
||||
<span class="text-2xl font-IconFamily"></span>
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
|
||||
<div
|
||||
onclick={onClick}
|
||||
onkeydown={onClick}
|
||||
tabindex="-1"
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClick() }}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="relative w-16 h-16 cursor-pointer rounded-xl transition ring-3 dark:ring-zinc-500/50 ring-zinc-300 {isEditMode ? 'animate-shake' : ''} {isSelected ? 'dark:ring-4 ring-4' : 'ring-0'}"
|
||||
>
|
||||
{#if isEditMode}
|
||||
<div
|
||||
tabindex="-1"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="absolute top-0 right-0 z-10 flex w-6 h-6 p-2 text-white translate-x-1/2 -translate-y-1/2 bg-red-600 rounded-full place-items-center"
|
||||
onclick={onDelete}
|
||||
onkeydown={onDelete}
|
||||
onclick={(e) => { e.stopPropagation(); onDelete() }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); onDelete() } }}
|
||||
>
|
||||
<div class="w-4 h-0.5 bg-white"></div>
|
||||
</div>
|
||||
|
||||
@@ -174,18 +174,19 @@
|
||||
if (parentElement) {
|
||||
observer = new MutationObserver(checkActiveClass);
|
||||
observer.observe(parentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
backgroundUpdates.removeListener(syncBackgrounds);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
backgroundUpdates.removeListener(syncBackgrounds);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
observer?.disconnect();
|
||||
backgrounds.forEach((bg) => {
|
||||
if (bg.url) URL.revokeObjectURL(bg.url);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import browser from 'webextension-polyfill'
|
||||
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
||||
import { OpenStorePage } from '@/seqta/ui/renderStore'
|
||||
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
|
||||
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
|
||||
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
||||
@@ -21,11 +19,14 @@
|
||||
let prevLoggedIn = $state(false);
|
||||
let showSignInModal = $state(false);
|
||||
|
||||
cloudAuth.subscribe((s) => {
|
||||
const now = s.isLoggedIn;
|
||||
if (now && !prevLoggedIn && themes) void fetchThemes();
|
||||
prevLoggedIn = now;
|
||||
cloudLoggedIn = now;
|
||||
$effect(() => {
|
||||
const unsub = cloudAuth.subscribe((s) => {
|
||||
const now = s.isLoggedIn;
|
||||
if (now && !prevLoggedIn && themes) void fetchThemes();
|
||||
prevLoggedIn = now;
|
||||
cloudLoggedIn = now;
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
|
||||
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
|
||||
@@ -102,17 +103,14 @@
|
||||
selectedTheme: themeManager.getSelectedThemeId() || '',
|
||||
}
|
||||
if (themes && cloudLoggedIn) {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
if (token) {
|
||||
const status: Record<string, boolean> = {};
|
||||
await Promise.all(
|
||||
themes.themes.map(async (t) => {
|
||||
try {
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: 'fetchThemeDetails',
|
||||
themeId: t.id,
|
||||
token,
|
||||
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
|
||||
const status: Record<string, boolean> = {};
|
||||
await Promise.all(
|
||||
themes.themes.map(async (t) => {
|
||||
try {
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: 'fetchThemeDetails',
|
||||
themeId: t.id,
|
||||
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
|
||||
if (res?.success && res?.data?.theme) {
|
||||
status[t.id] = !!res.data.theme.is_favorited;
|
||||
}
|
||||
@@ -122,25 +120,32 @@
|
||||
})
|
||||
);
|
||||
favoriteStatus = status;
|
||||
}
|
||||
} else {
|
||||
favoriteStatus = {};
|
||||
}
|
||||
}
|
||||
|
||||
const openStorePage = async () => {
|
||||
const { OpenStorePage } = await import('@/seqta/ui/renderStore')
|
||||
OpenStorePage()
|
||||
}
|
||||
|
||||
const openThemeCreator = async (themeId?: string) => {
|
||||
const { OpenThemeCreator } = await import('@/plugins/built-in/themes/ThemeCreator')
|
||||
OpenThemeCreator(themeId)
|
||||
closeExtensionPopup()
|
||||
}
|
||||
|
||||
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!cloudLoggedIn) {
|
||||
showSignInModal = true;
|
||||
return;
|
||||
}
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
if (!token) return;
|
||||
const isFavorite = !favoriteStatus[theme.id];
|
||||
const result = (await browser.runtime.sendMessage({
|
||||
type: 'cloudFavorite',
|
||||
themeId: theme.id,
|
||||
token,
|
||||
action: isFavorite ? 'favorite' : 'unfavorite',
|
||||
})) as { success?: boolean };
|
||||
if (result?.success) {
|
||||
@@ -216,8 +221,8 @@
|
||||
</div>
|
||||
<div
|
||||
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
|
||||
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
|
||||
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') OpenThemeCreator(theme.id); closeExtensionPopup() }}
|
||||
onclick={(event) => { event.stopPropagation(); void openThemeCreator(theme.id) }}
|
||||
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') void openThemeCreator(theme.id) }}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
@@ -265,7 +270,7 @@
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={() => OpenStorePage()}
|
||||
onclick={() => void openStorePage()}
|
||||
class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white"
|
||||
>
|
||||
<span class="text-xl font-IconFamily"></span>
|
||||
@@ -273,7 +278,7 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => { OpenThemeCreator(); closeExtensionPopup() }}
|
||||
onclick={() => void openThemeCreator()}
|
||||
class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white"
|
||||
>
|
||||
<span class="text-xl font-IconFamily"></span>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
<script lang="ts">
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
const e = React.createElement;
|
||||
let container: HTMLDivElement;
|
||||
let adapterProps = $props();
|
||||
let container = $state<HTMLDivElement | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
const { el, children, class: _, ...props } = $$props;
|
||||
$effect(() => {
|
||||
if (!container) return;
|
||||
|
||||
const { el, children, class: className, ...rest } = adapterProps;
|
||||
try {
|
||||
ReactDOM.render(e(el, props, children), container);
|
||||
ReactDOM.render(e(el, rest, children), container);
|
||||
} catch (err) {
|
||||
console.warn(`react-adapter failed to mount.`, { err });
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (!container) return;
|
||||
try {
|
||||
ReactDOM.unmountComponentAtNode(container);
|
||||
} catch (err) {
|
||||
@@ -24,4 +28,4 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class={$$props.class}></div>
|
||||
<div bind:this={container} class={adapterProps.class}></div>
|
||||
|
||||
@@ -1,53 +1 @@
|
||||
type SettingsPopupCallback = () => void;
|
||||
|
||||
/**
|
||||
* This is a singleton that triggers an update when the settings popup is closed.
|
||||
* This is used to close the colour picker.
|
||||
* Usage:
|
||||
* settingsPopup.addListener(() => {
|
||||
* console.log('Settings popup closed');
|
||||
* });
|
||||
*/
|
||||
class SettingsPopup {
|
||||
private static instance: SettingsPopup;
|
||||
private listeners: Set<SettingsPopupCallback> = new Set();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): SettingsPopup {
|
||||
if (!SettingsPopup.instance) {
|
||||
SettingsPopup.instance = new SettingsPopup();
|
||||
}
|
||||
return SettingsPopup.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a callback function to be invoked when the settings popup is closed.
|
||||
*
|
||||
* @param {SettingsPopupCallback} callback The function to call when the settings popup closes.
|
||||
* This callback takes no arguments and returns void.
|
||||
*/
|
||||
public addListener(callback: SettingsPopupCallback): void {
|
||||
this.listeners.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a previously added callback function.
|
||||
* After calling this method, the provided callback will no longer be invoked when the settings popup closes.
|
||||
*
|
||||
* @param {SettingsPopupCallback} callback The callback function to remove from the listeners.
|
||||
*/
|
||||
public removeListener(callback: SettingsPopupCallback): void {
|
||||
this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes all registered listener callbacks.
|
||||
* This method should be called when the settings popup is closed to notify all subscribed components or services.
|
||||
*/
|
||||
public triggerClose(): void {
|
||||
this.listeners.forEach((callback) => callback());
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsPopup = SettingsPopup.getInstance();
|
||||
export { settingsPopup } from "@/seqta/utils/settingsPopup";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
import { standalone as StandaloneStore } from "../utils/standalone.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
|
||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
|
||||
@@ -14,11 +14,11 @@
|
||||
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
|
||||
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
|
||||
|
||||
import ColourPicker from "../components/ColourPicker.svelte";
|
||||
import type { Component } from "svelte";
|
||||
import FontPickerModal from "../components/FontPickerModal.svelte";
|
||||
import CloudPanel from "../components/CloudPanel.svelte";
|
||||
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
||||
import { settingsPopup } from "../hooks/SettingsPopup";
|
||||
import { settingsPopup } from "@/seqta/utils/settingsPopup";
|
||||
import {
|
||||
checkGithubReleaseUpdate,
|
||||
dismissNightlyUpdate,
|
||||
@@ -64,7 +64,12 @@
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
const openColourPicker = () => {
|
||||
let ColourPickerComponent = $state<Component | null>(null);
|
||||
|
||||
const openColourPicker = async () => {
|
||||
if (!ColourPickerComponent) {
|
||||
ColourPickerComponent = (await import("../components/ColourPicker.svelte")).default;
|
||||
}
|
||||
showColourPicker = true;
|
||||
};
|
||||
|
||||
@@ -108,12 +113,14 @@
|
||||
showDisclaimerModal = true;
|
||||
};
|
||||
|
||||
const closePopupsOnSettingsClose = () => {
|
||||
showColourPicker = false;
|
||||
showFontPicker = false;
|
||||
showCloudPanel = false;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
settingsPopup.addListener(() => {
|
||||
showColourPicker = false;
|
||||
showFontPicker = false;
|
||||
showCloudPanel = false;
|
||||
});
|
||||
settingsPopup.addListener(closePopupsOnSettingsClose);
|
||||
|
||||
if (standalone) {
|
||||
StandaloneStore.setStandalone(true);
|
||||
@@ -125,6 +132,10 @@
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
settingsPopup.removeListener(closePopupsOnSettingsClose);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -133,10 +144,10 @@
|
||||
: ''} {standalone ? 'h-[600px]' : 'h-full rounded-xl'} overflow-clip"
|
||||
>
|
||||
<div
|
||||
class="flex relative flex-col gap-2 h-full overflow-clip bg-white dark:bg-zinc-800 dark:text-white"
|
||||
class="flex relative flex-col gap-2 h-full min-h-0 overflow-hidden bg-white dark:bg-zinc-800 dark:text-white"
|
||||
>
|
||||
<div
|
||||
class="grid place-items-center border-b border-b-zinc-200/40 dark:border-b-zinc-700/40"
|
||||
class="grid shrink-0 place-items-center border-b border-b-zinc-200/40 dark:border-b-zinc-700/40"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
@@ -343,22 +354,24 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<TabbedContainer
|
||||
bind:activeTab={settingsActiveTab}
|
||||
tabs={[
|
||||
{
|
||||
title: "Settings",
|
||||
Content: Settings,
|
||||
props: { showColourPicker: openColourPicker, showFontPicker: openFontPicker, showDisclaimer, showCloudPanel: openCloudPanel },
|
||||
},
|
||||
{ title: "Shortcuts", Content: Shortcuts },
|
||||
{ title: "Themes", Content: Theme },
|
||||
]}
|
||||
/>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<TabbedContainer
|
||||
bind:activeTab={settingsActiveTab}
|
||||
tabs={[
|
||||
{
|
||||
title: "Settings",
|
||||
Content: Settings,
|
||||
props: { showColourPicker: openColourPicker, showFontPicker: openFontPicker, showDisclaimer, showCloudPanel: openCloudPanel },
|
||||
},
|
||||
{ title: "Shortcuts", Content: Shortcuts },
|
||||
{ title: "Themes", Content: Theme },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showColourPicker}
|
||||
<ColourPicker
|
||||
{#if showColourPicker && ColourPickerComponent}
|
||||
<ColourPickerComponent
|
||||
hidePicker={() => {
|
||||
showColourPicker = false;
|
||||
}}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
||||
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
|
||||
import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
let devApiBaseInput = $state<string>(getStoredOverride() ?? "")
|
||||
let devApiBaseActive = $state<string | null>(getStoredOverride())
|
||||
@@ -128,9 +129,9 @@
|
||||
await browser.storage.local.set({ [storageKey]: currentSettings });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadPluginSettings();
|
||||
})
|
||||
onMount(() => {
|
||||
void loadPluginSettings();
|
||||
});
|
||||
|
||||
const { showColourPicker, showFontPicker, showDisclaimer, showCloudPanel } = $props<{
|
||||
showColourPicker: () => void;
|
||||
|
||||
@@ -23,7 +23,10 @@
|
||||
const themeManager = ThemeManager.getInstance();
|
||||
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
|
||||
|
||||
cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
|
||||
$effect(() => {
|
||||
const unsub = cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
|
||||
return unsub;
|
||||
});
|
||||
|
||||
// State variables
|
||||
let searchTerm = $state('');
|
||||
@@ -86,13 +89,11 @@
|
||||
}
|
||||
|
||||
const toggleFavorite = async (theme: Theme) => {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
if (!token) return;
|
||||
if (!cloudLoggedIn) return;
|
||||
const isFavorite = !theme.is_favorited;
|
||||
const result = (await browser.runtime.sendMessage({
|
||||
type: 'cloudFavorite',
|
||||
themeId: theme.id,
|
||||
token,
|
||||
action: isFavorite ? 'favorite' : 'unfavorite',
|
||||
})) as { success?: boolean };
|
||||
if (result?.success) {
|
||||
@@ -119,14 +120,12 @@
|
||||
error = null;
|
||||
}
|
||||
try {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
const data = await sendMessageWithTimeout<{
|
||||
success?: boolean;
|
||||
data?: { themes: unknown[] };
|
||||
error?: string;
|
||||
}>({
|
||||
type: 'fetchThemes',
|
||||
token: token ?? undefined,
|
||||
});
|
||||
if (!data?.success || !Array.isArray(data?.data?.themes)) {
|
||||
throw new Error(data?.error || 'Failed to fetch themes');
|
||||
|
||||
+14
-6
@@ -509,7 +509,13 @@ function deepFunctionCheck(obj, path = "") {
|
||||
}
|
||||
}
|
||||
|
||||
function isTrustedMessage(event) {
|
||||
return event.source === window && event.origin === window.location.origin;
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
if (!isTrustedMessage(event)) return;
|
||||
|
||||
if (event.data.type === "reactFiberRequest") {
|
||||
const { selector, action, payload, debug, messageId } = event.data;
|
||||
const fiberInstance = ReactFiber.find(selector, {
|
||||
@@ -522,12 +528,14 @@ window.addEventListener("message", (event) => {
|
||||
response = fiberInstance.getState(payload.key);
|
||||
break;
|
||||
case "setState":
|
||||
// Handle both function and object updates
|
||||
if (payload.updateFn) {
|
||||
const updateFn = new Function('return ' + payload.updateFn)();
|
||||
fiberInstance.setState(updateFn);
|
||||
} else {
|
||||
if (
|
||||
payload.updateObject &&
|
||||
typeof payload.updateObject === "object" &&
|
||||
!Array.isArray(payload.updateObject)
|
||||
) {
|
||||
fiberInstance.setState(payload.updateObject);
|
||||
} else {
|
||||
console.warn("[pageState] setState rejected: only plain objects are allowed");
|
||||
}
|
||||
response = {};
|
||||
break;
|
||||
@@ -580,7 +588,7 @@ window.addEventListener("message", (event) => {
|
||||
response,
|
||||
messageId,
|
||||
},
|
||||
"*",
|
||||
window.location.origin,
|
||||
);
|
||||
} else if (event.data.type === "triggerKeyboardEvent") {
|
||||
// Handle keyboard event triggering from content script
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
processAssessments,
|
||||
type WeightingEntry,
|
||||
} from "./utils.ts";
|
||||
import { injectRubricCopyButtons } from "./rubricCopy.ts";
|
||||
import { injectRubricCopyButtons, teardownRubricCopyButtons } from "./rubricCopy.ts";
|
||||
|
||||
interface weightingsStorage {
|
||||
weightings: Record<string, WeightingEntry>;
|
||||
@@ -41,6 +41,8 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
|
||||
const instance = new AssessmentsAveragePluginClass();
|
||||
|
||||
let overrideListenerController: AbortController | null = null;
|
||||
let wrapperColourObserver: MutationObserver | null = null;
|
||||
let wrapperColourObserverTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||
id: "assessments-average",
|
||||
@@ -54,7 +56,9 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||
await initStorage(api);
|
||||
clearStuck(api);
|
||||
|
||||
api.seqta.onMount(".assessmentsWrapper", async () => {
|
||||
const { unregister: unregisterWrapperMount } = api.seqta.onMount(
|
||||
".assessmentsWrapper",
|
||||
async () => {
|
||||
await waitForElm(
|
||||
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
|
||||
true,
|
||||
@@ -88,17 +92,43 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||
void parseAssessments(api);
|
||||
const wrapper = document.querySelector(".assessmentsWrapper");
|
||||
if (wrapper) {
|
||||
const observer = new MutationObserver(() => {
|
||||
wrapperColourObserver?.disconnect();
|
||||
if (wrapperColourObserverTimeout) {
|
||||
clearTimeout(wrapperColourObserverTimeout);
|
||||
}
|
||||
wrapperColourObserver = new MutationObserver(() => {
|
||||
applySubjectColourToOverallResult();
|
||||
});
|
||||
observer.observe(wrapper, { childList: true, subtree: true });
|
||||
setTimeout(() => observer.disconnect(), 10000);
|
||||
wrapperColourObserver.observe(wrapper, { childList: true, subtree: true });
|
||||
wrapperColourObserverTimeout = setTimeout(() => {
|
||||
wrapperColourObserver?.disconnect();
|
||||
wrapperColourObserver = null;
|
||||
wrapperColourObserverTimeout = null;
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
api.seqta.onMount("[class*='SelectedAssessment__']", () => {
|
||||
},
|
||||
);
|
||||
const { unregister: unregisterSelectedMount } = api.seqta.onMount(
|
||||
"[class*='SelectedAssessment__']",
|
||||
() => {
|
||||
injectWeightingsTab(api);
|
||||
injectRubricCopyButtons();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
overrideListenerController?.abort();
|
||||
overrideListenerController = null;
|
||||
wrapperColourObserver?.disconnect();
|
||||
wrapperColourObserver = null;
|
||||
if (wrapperColourObserverTimeout) {
|
||||
clearTimeout(wrapperColourObserverTimeout);
|
||||
wrapperColourObserverTimeout = null;
|
||||
}
|
||||
teardownRubricCopyButtons();
|
||||
unregisterWrapperMount();
|
||||
unregisterSelectedMount();
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,91 @@ export interface WeightingEntry {
|
||||
|
||||
export type WeightingsMap = Record<string, WeightingEntry>;
|
||||
|
||||
/** Primary storage key for weightings / overrides. */
|
||||
export function assessmentIdKey(mark: { id: string | number }): string {
|
||||
return String(mark.id);
|
||||
}
|
||||
|
||||
/** Composite lookup key when the same title appears in multiple metaclasses. */
|
||||
export function assessmentTitleLookupKey(mark: {
|
||||
metaclassID?: string | number;
|
||||
title?: string;
|
||||
}): string | null {
|
||||
const title = mark.title?.trim();
|
||||
if (!title) return null;
|
||||
const metaclassID = mark.metaclassID;
|
||||
if (metaclassID != null && metaclassID !== "") {
|
||||
return `${metaclassID}:${title}`;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
function registerAssessmentLookup(api: any, mark: any) {
|
||||
const assessmentID = assessmentIdKey(mark);
|
||||
const next: Record<string, string> = {
|
||||
...api.storage.assessments,
|
||||
[assessmentID]: assessmentID,
|
||||
};
|
||||
const compositeKey = assessmentTitleLookupKey(mark);
|
||||
if (compositeKey) next[compositeKey] = assessmentID;
|
||||
api.storage.assessments = next;
|
||||
}
|
||||
|
||||
type MarkLike = {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
metaclassID?: string | number;
|
||||
};
|
||||
|
||||
function collectMarksFromFiberState(state: Record<string, unknown>): MarkLike[] {
|
||||
return [
|
||||
...(Array.isArray(state.marks) ? state.marks : []),
|
||||
...(Array.isArray(state.upcoming) ? state.upcoming : []),
|
||||
...(Array.isArray(state.pending) ? state.pending : []),
|
||||
] as MarkLike[];
|
||||
}
|
||||
|
||||
async function resolveAssessmentId(
|
||||
api: any,
|
||||
title: string,
|
||||
marks?: MarkLike[],
|
||||
): Promise<string | undefined> {
|
||||
const assessments = (api.storage.assessments ?? {}) as Record<string, string>;
|
||||
let resolvedMarks = marks;
|
||||
|
||||
if (!resolvedMarks) {
|
||||
try {
|
||||
const state = await ReactFiber.find(
|
||||
"[class*='AssessmentList__items___']",
|
||||
).getState();
|
||||
resolvedMarks = collectMarksFromFiberState(state);
|
||||
} catch {
|
||||
resolvedMarks = [];
|
||||
}
|
||||
}
|
||||
|
||||
const matching = resolvedMarks.filter((mark) => mark.title?.trim() === title);
|
||||
if (matching.length === 1) {
|
||||
return assessmentIdKey(matching[0]);
|
||||
}
|
||||
|
||||
for (const mark of matching) {
|
||||
const compositeKey = assessmentTitleLookupKey(mark);
|
||||
if (compositeKey && assessments[compositeKey]) {
|
||||
return assessments[compositeKey];
|
||||
}
|
||||
}
|
||||
|
||||
if (assessments[title]) return assessments[title];
|
||||
|
||||
const suffix = `:${title}`;
|
||||
for (const [key, id] of Object.entries(assessments)) {
|
||||
if (key.endsWith(suffix)) return id;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function computeFingerprint(mark: any): string {
|
||||
const score =
|
||||
mark?.results?.percentage ?? mark?.results?.score ?? null;
|
||||
@@ -264,6 +349,7 @@ function createWeightLabel(
|
||||
weighting: string | undefined,
|
||||
api: any,
|
||||
refreshing = false,
|
||||
assessmentID?: string,
|
||||
) {
|
||||
let statsContainer = assessmentItem.querySelector(
|
||||
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
|
||||
@@ -289,10 +375,8 @@ function createWeightLabel(
|
||||
? "space-between"
|
||||
: "flex-end";
|
||||
|
||||
const title = assessmentItem
|
||||
.querySelector(`[class*='AssessmentItem__title___']`)
|
||||
?.textContent?.trim();
|
||||
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
|
||||
const resolvedAssessmentId =
|
||||
assessmentID ?? assessmentItem.dataset.betterseqtaAssessmentId;
|
||||
|
||||
const existingLabel = statsContainer.querySelector(
|
||||
".betterseqta-weight-label",
|
||||
@@ -302,7 +386,7 @@ function createWeightLabel(
|
||||
updateWeightLabelContent(
|
||||
existingLabel,
|
||||
weighting,
|
||||
assessmentID,
|
||||
resolvedAssessmentId,
|
||||
api,
|
||||
refreshing,
|
||||
);
|
||||
@@ -340,7 +424,7 @@ function createWeightLabel(
|
||||
updateWeightLabelContent(
|
||||
weightLabel,
|
||||
weighting,
|
||||
assessmentID,
|
||||
resolvedAssessmentId,
|
||||
api,
|
||||
refreshing,
|
||||
);
|
||||
@@ -352,14 +436,24 @@ export const isFirefox =
|
||||
!navigator.userAgent.toLowerCase().includes("seamonkey") &&
|
||||
!navigator.userAgent.toLowerCase().includes("waterfox");
|
||||
|
||||
function trustedPageOrigin(): string {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
function escJsSingleQuoted(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
||||
const isBlobUrl = url.startsWith("blob:");
|
||||
const pageOrigin = trustedPageOrigin();
|
||||
|
||||
if (isBlobUrl || isFirefox) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`;
|
||||
const escapedUrl = url.replace(/'/g, "\\'");
|
||||
const escapedUrl = escJsSingleQuoted(url);
|
||||
const escapedOrigin = escJsSingleQuoted(pageOrigin);
|
||||
|
||||
script.textContent = `
|
||||
(function() {
|
||||
@@ -375,19 +469,20 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
||||
type: '${requestId}',
|
||||
success: true,
|
||||
data: Array.from(new Uint8Array(arrayBuffer))
|
||||
}, '*');
|
||||
}, '${escapedOrigin}');
|
||||
})
|
||||
.catch(error => {
|
||||
window.postMessage({
|
||||
type: '${requestId}',
|
||||
success: false,
|
||||
error: error.message || String(error)
|
||||
}, '*');
|
||||
}, '${escapedOrigin}');
|
||||
});
|
||||
})();
|
||||
`;
|
||||
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (event.origin !== pageOrigin || event.source !== window) return;
|
||||
if (event.data?.type === requestId) {
|
||||
window.removeEventListener("message", messageHandler);
|
||||
if (script.parentNode) {
|
||||
@@ -449,23 +544,22 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
if (isFirefox) {
|
||||
const { lib: pdfLibUrl, worker: pdfWorkerUrl } =
|
||||
getPdfjsPageContextUrls();
|
||||
const escJsSingleQuoted = (s: string) =>
|
||||
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
|
||||
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
|
||||
|
||||
const pageOrigin = trustedPageOrigin();
|
||||
const escapedOrigin = escJsSingleQuoted(pageOrigin);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
|
||||
|
||||
const escapedUrl = url
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/"/g, '\\"');
|
||||
const escapedUrl = escJsSingleQuoted(url);
|
||||
|
||||
script.textContent = `
|
||||
(function() {
|
||||
const requestId = '${requestId}';
|
||||
const pageOrigin = '${escapedOrigin}';
|
||||
const url = '${escapedUrl}';
|
||||
const pdfLibSrc = '${pdfLibInj}';
|
||||
const pdfWorkerSrc = '${pdfWorkerInj}';
|
||||
@@ -485,7 +579,7 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'Failed to load pdfjs library'
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
};
|
||||
|
||||
document.head.appendChild(pdfjsScript);
|
||||
@@ -506,7 +600,7 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'HTTP ' + xhr.status + ': ' + xhr.statusText
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -542,21 +636,21 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
type: requestId,
|
||||
success: true,
|
||||
text: text
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
})
|
||||
.catch(error => {
|
||||
window.postMessage({
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'PDF parsing error: ' + (error.message || String(error))
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
});
|
||||
} catch (error) {
|
||||
window.postMessage({
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'ArrayBuffer error: ' + (error.message || String(error))
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -565,7 +659,7 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'Network error fetching PDF'
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
};
|
||||
|
||||
xhr.ontimeout = function() {
|
||||
@@ -573,7 +667,7 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'Timeout fetching PDF'
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
};
|
||||
|
||||
xhr.timeout = 30000;
|
||||
@@ -583,13 +677,14 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'Setup error: ' + (error.message || String(error))
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
}
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (event.origin !== pageOrigin || event.source !== window) return;
|
||||
if (event.data?.type === requestId) {
|
||||
window.removeEventListener("message", messageHandler);
|
||||
if (script.parentNode) {
|
||||
@@ -646,9 +741,8 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function handleWeightings(mark: any, api: any) {
|
||||
const assessmentID = mark.id;
|
||||
const assessmentID = assessmentIdKey(mark);
|
||||
const metaclassID = mark.metaclassID;
|
||||
const title = mark.title;
|
||||
|
||||
const fingerprint = computeFingerprint(mark);
|
||||
const existing = api.storage.weightings[assessmentID] as
|
||||
@@ -687,10 +781,7 @@ async function handleWeightings(mark: any, api: any) {
|
||||
[assessmentID]: placeholder,
|
||||
};
|
||||
|
||||
api.storage.assessments = {
|
||||
...api.storage.assessments,
|
||||
[title.trim()]: assessmentID,
|
||||
};
|
||||
registerAssessmentLookup(api, mark);
|
||||
|
||||
// Surface the refreshing indicator on the affected row immediately,
|
||||
// without waiting for the PDF fetch to finish.
|
||||
@@ -813,6 +904,16 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
let hasRefreshingWeighting = false;
|
||||
let count = 0;
|
||||
|
||||
let fiberMarks: MarkLike[] = [];
|
||||
try {
|
||||
const state = await ReactFiber.find(
|
||||
"[class*='AssessmentList__items___']",
|
||||
).getState();
|
||||
fiberMarks = collectMarksFromFiberState(state);
|
||||
} catch {
|
||||
fiberMarks = [];
|
||||
}
|
||||
|
||||
for (const assessmentItem of assessmentItems) {
|
||||
const titleEl = assessmentItem.querySelector(
|
||||
`[class*='AssessmentItem__title___']`,
|
||||
@@ -822,7 +923,11 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
const title = titleEl.textContent?.trim();
|
||||
if (!title) continue;
|
||||
|
||||
const assessmentID = api.storage.assessments?.[title];
|
||||
const assessmentID = await resolveAssessmentId(api, title, fiberMarks);
|
||||
if (assessmentID) {
|
||||
(assessmentItem as HTMLElement).dataset.betterseqtaAssessmentId =
|
||||
assessmentID;
|
||||
}
|
||||
const entry = assessmentID
|
||||
? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
|
||||
: undefined;
|
||||
@@ -833,7 +938,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
const weighting = override ?? autoWeighting;
|
||||
const refreshing = !override && Boolean(entry?.refreshing);
|
||||
|
||||
createWeightLabel(assessmentItem, weighting, api, refreshing);
|
||||
createWeightLabel(assessmentItem, weighting, api, refreshing, assessmentID);
|
||||
|
||||
const gradeElement = assessmentItem.querySelector(
|
||||
`[class*='Thermoscore__text___']`,
|
||||
@@ -935,12 +1040,17 @@ function resolveTabSetClasses(): Record<string, string> {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
|
||||
const titleEl = document.querySelector(
|
||||
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']",
|
||||
async function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
|
||||
const selectedItem = document.querySelector(
|
||||
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
|
||||
) as HTMLElement | null;
|
||||
const titleEl = selectedItem?.querySelector(
|
||||
"[class*='AssessmentItem__title___']",
|
||||
);
|
||||
const title = titleEl?.textContent?.trim();
|
||||
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
|
||||
const assessmentID =
|
||||
selectedItem?.dataset.betterseqtaAssessmentId ??
|
||||
(title ? await resolveAssessmentId(api, title) : undefined);
|
||||
|
||||
const entry = assessmentID
|
||||
? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
|
||||
@@ -1093,7 +1203,7 @@ export function injectWeightingsTab(api: any) {
|
||||
container.appendChild(newSheet);
|
||||
|
||||
newTab.addEventListener("click", () => {
|
||||
buildWeightingsTabContent(api, newSheet);
|
||||
void buildWeightingsTabContent(api, newSheet);
|
||||
});
|
||||
|
||||
const allTabs = Array.from(tabList.querySelectorAll("li"));
|
||||
@@ -1107,20 +1217,22 @@ export function injectWeightingsTab(api: any) {
|
||||
t.className.includes("TabSet__selected___"),
|
||||
);
|
||||
if (i === currentIndex) return;
|
||||
const goingRight = i > currentIndex;
|
||||
const goingRight = currentIndex < 0 ? true : i > currentIndex;
|
||||
|
||||
allTabs.forEach((t) => {
|
||||
t.className = "";
|
||||
t.setAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
allSheets[currentIndex].className = [
|
||||
cls["TabSet__tabsheet___"],
|
||||
cls["TabSet__hidden___"],
|
||||
goingRight
|
||||
? cls["TabSet__disappearToLeft___"]
|
||||
: cls["TabSet__disappearToRight___"],
|
||||
].join(" ");
|
||||
if (currentIndex >= 0) {
|
||||
allSheets[currentIndex].className = [
|
||||
cls["TabSet__tabsheet___"],
|
||||
cls["TabSet__hidden___"],
|
||||
goingRight
|
||||
? cls["TabSet__disappearToLeft___"]
|
||||
: cls["TabSet__disappearToRight___"],
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
allSheets[i].className = [
|
||||
cls["TabSet__tabsheet___"],
|
||||
|
||||
@@ -29,6 +29,9 @@ async function fetchJSON(url: string, body: any) {
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -164,7 +167,7 @@ async function getLearnAssessmentsData(studentId: number) {
|
||||
}
|
||||
|
||||
export async function getAssessmentsData() {
|
||||
if (settingsState.mockNotices) {
|
||||
if (settingsState.hideSensitiveContent) {
|
||||
return getMockAssessmentsData();
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ async function fetchJSON(url: string, body: unknown) {
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Plugin } from "../../core/types";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { getAssessmentsData } from "./api";
|
||||
import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
|
||||
import { renderErrorState, renderGrid, renderSkeletonLoader, teardownOverviewUi } from "./ui";
|
||||
import styles from "./styles.css?inline";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
@@ -61,11 +61,14 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
const gridItem = document.createElement("li");
|
||||
gridItem.className = "item";
|
||||
gridItem.classList.add(OVERVIEW_MENU_CLASS);
|
||||
gridItem.dataset.betterseqta = "true";
|
||||
const label = document.createElement("label");
|
||||
label.textContent = "Overview";
|
||||
gridItem.appendChild(label);
|
||||
menu.insertBefore(gridItem, menu.firstChild);
|
||||
|
||||
let loadRequestId = 0;
|
||||
|
||||
const menuObserver = new MutationObserver(() => {
|
||||
ensureOverviewMenuPosition(menu, gridItem);
|
||||
});
|
||||
@@ -77,11 +80,24 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
|
||||
const clickHandler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
void loadGridView();
|
||||
};
|
||||
gridItem.addEventListener("click", clickHandler);
|
||||
gridItem.addEventListener("click", clickHandler, true);
|
||||
|
||||
const popstateHandler = () => {
|
||||
if (isOverviewRoute()) {
|
||||
void loadGridView();
|
||||
} else {
|
||||
loadRequestId += 1;
|
||||
teardownOverviewUi();
|
||||
}
|
||||
};
|
||||
window.addEventListener("popstate", popstateHandler);
|
||||
|
||||
async function loadGridView() {
|
||||
const requestId = ++loadRequestId;
|
||||
await delay(1);
|
||||
|
||||
if (isSeqtaEngageExperience()) {
|
||||
@@ -98,7 +114,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
}
|
||||
|
||||
const main = document.getElementById("main");
|
||||
if (!main) return;
|
||||
if (!main || requestId !== loadRequestId) return;
|
||||
|
||||
document
|
||||
.querySelectorAll('[data-key="assessments"] .item')
|
||||
@@ -110,17 +126,22 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
.querySelector('[data-key="assessments"]')
|
||||
?.classList.add("active");
|
||||
|
||||
main.innerHTML = '<div id="grid-view-container" class="bsplus-overview-host"></div>';
|
||||
main.innerHTML =
|
||||
'<div id="grid-view-container" class="bsplus-overview-host"></div>';
|
||||
const container = document.getElementById(
|
||||
"grid-view-container",
|
||||
) as HTMLElement;
|
||||
|
||||
if (requestId !== loadRequestId) return;
|
||||
|
||||
renderSkeletonLoader(container);
|
||||
|
||||
try {
|
||||
const data = await getAssessmentsData();
|
||||
if (requestId !== loadRequestId) return;
|
||||
renderGrid(container, data);
|
||||
} catch (err) {
|
||||
if (requestId !== loadRequestId) return;
|
||||
console.error("Failed to load assessments:", err);
|
||||
renderErrorState(
|
||||
container,
|
||||
@@ -130,8 +151,11 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
}
|
||||
|
||||
return () => {
|
||||
loadRequestId += 1;
|
||||
window.removeEventListener("popstate", popstateHandler);
|
||||
menuObserver.disconnect();
|
||||
gridItem.removeEventListener("click", clickHandler);
|
||||
gridItem.removeEventListener("click", clickHandler, true);
|
||||
teardownOverviewUi();
|
||||
gridItem.remove();
|
||||
};
|
||||
},
|
||||
|
||||
@@ -62,7 +62,7 @@ export function activeSubjectsFromEngageChild(child: {
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const term of child.terms ?? []) {
|
||||
if (term.active !== 1) continue;
|
||||
if (!isActiveTermFlag(term.active)) continue;
|
||||
for (const raw of term.subjects ?? []) {
|
||||
const subject = normalizeOverviewSubject(raw);
|
||||
if (!subject) continue;
|
||||
@@ -151,7 +151,14 @@ export function determineStatus(item: any): string {
|
||||
}
|
||||
|
||||
const completedKey = "betterseqta-completed-assessments";
|
||||
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
|
||||
let completed: unknown[] = [];
|
||||
try {
|
||||
const raw = localStorage.getItem(completedKey);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
completed = Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
completed = [];
|
||||
}
|
||||
if (completed.includes(item.id)) {
|
||||
return "MARKS_RELEASED";
|
||||
}
|
||||
|
||||
@@ -74,7 +74,10 @@ function ensureGestureStart(handler: () => void): () => void {
|
||||
|
||||
async function startPlayback(volume: number): Promise<void> {
|
||||
const blob = await loadAudioBlob();
|
||||
if (!blob) return;
|
||||
if (!blob) {
|
||||
stopAndCleanupAudio();
|
||||
return;
|
||||
}
|
||||
|
||||
stopAndCleanupAudio();
|
||||
|
||||
@@ -123,7 +126,7 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
|
||||
}
|
||||
});
|
||||
|
||||
// Note: Stop button/event removed by user; no stop handling needed
|
||||
// Note: Stop button dispatches betterseqta-background-music-stop on remove
|
||||
|
||||
// Start if we have audio and autoplay is enabled
|
||||
const tryStart = async () => {
|
||||
@@ -160,16 +163,21 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
|
||||
};
|
||||
document.addEventListener("visibilitychange", visHandler);
|
||||
|
||||
// Allow uploads to trigger refresh
|
||||
// Allow uploads to trigger refresh; stop event clears playback on remove
|
||||
const uploadedHandler = () => {
|
||||
const vol = (api.settings as any).volume ?? 0.5;
|
||||
startPlayback(vol);
|
||||
};
|
||||
const stopHandler = () => {
|
||||
stopAndCleanupAudio();
|
||||
};
|
||||
window.addEventListener("betterseqta-background-music-updated", uploadedHandler);
|
||||
window.addEventListener("betterseqta-background-music-stop", stopHandler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", visHandler);
|
||||
window.removeEventListener("betterseqta-background-music-updated", uploadedHandler);
|
||||
window.removeEventListener("betterseqta-background-music-stop", stopHandler);
|
||||
if (cleanupRegistered && (window as any).__betterseqta_bg_music_cancel__) {
|
||||
(window as any).__betterseqta_bg_music_cancel__();
|
||||
(window as any).__betterseqta_bg_music_cancel__ = undefined;
|
||||
|
||||
@@ -255,9 +255,9 @@ const watchNavigator = (navigator: Element, onChange: () => void) => {
|
||||
return observer;
|
||||
};
|
||||
|
||||
const handleSlidePane = (pane: Element) => {
|
||||
const handleSlidePane = (pane: Element): (() => void) => {
|
||||
const navigator = pane.querySelector(".navigator");
|
||||
if (!navigator) return;
|
||||
if (!navigator) return () => {};
|
||||
|
||||
requestAnimationFrame(() => scrollSelectedIntoView(navigator));
|
||||
setTimeout(() => scrollSelectedIntoView(navigator), 50);
|
||||
@@ -272,17 +272,22 @@ const handleSlidePane = (pane: Element) => {
|
||||
childList: true,
|
||||
});
|
||||
|
||||
const cleanup = new MutationObserver((muts) => {
|
||||
const paneCleanup = new MutationObserver((muts) => {
|
||||
muts.forEach((m) => {
|
||||
m.removedNodes.forEach((n) => {
|
||||
if (n === pane) {
|
||||
observer.disconnect();
|
||||
cleanup.disconnect();
|
||||
paneCleanup.disconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
cleanup.observe(document.body, { childList: true });
|
||||
paneCleanup.observe(document.body, { childList: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
paneCleanup.disconnect();
|
||||
};
|
||||
};
|
||||
|
||||
const enhancedNavigationPlugin: Plugin<typeof settings> = {
|
||||
@@ -301,7 +306,11 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
|
||||
window.addEventListener("resize", positionArrows);
|
||||
window.addEventListener("scroll", positionArrows, true);
|
||||
|
||||
api.seqta.onMount(".course", async (element) => {
|
||||
const navObservers: MutationObserver[] = [];
|
||||
const courseObservers: MutationObserver[] = [];
|
||||
const slidePaneCleanups: Array<() => void> = [];
|
||||
|
||||
const courseMount = api.seqta.onMount(".course", async (element) => {
|
||||
const course = element as HTMLElement;
|
||||
let navObserver: MutationObserver | null = null;
|
||||
|
||||
@@ -318,6 +327,7 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
|
||||
}
|
||||
ensureArrows(course);
|
||||
});
|
||||
navObservers.push(navObserver);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -325,6 +335,7 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
|
||||
const courseObserver = new MutationObserver(() => {
|
||||
if (setup()) courseObserver.disconnect();
|
||||
});
|
||||
courseObservers.push(courseObserver);
|
||||
courseObserver.observe(course, { childList: true, subtree: true });
|
||||
}
|
||||
});
|
||||
@@ -334,13 +345,21 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
|
||||
m.addedNodes.forEach((n) => {
|
||||
if (n.nodeType !== 1) return;
|
||||
const el = n as Element;
|
||||
if (el.classList?.contains("uiSlidePane")) handleSlidePane(el);
|
||||
if (el.classList?.contains("uiSlidePane")) {
|
||||
slidePaneCleanups.push(handleSlidePane(el));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
bodyObserver.observe(document.body, { childList: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", positionArrows);
|
||||
window.removeEventListener("scroll", positionArrows, true);
|
||||
courseMount.unregister();
|
||||
navObservers.forEach((observer) => observer.disconnect());
|
||||
courseObservers.forEach((observer) => observer.disconnect());
|
||||
slidePaneCleanups.forEach((cleanup) => cleanup());
|
||||
bodyObserver.disconnect();
|
||||
document.getElementById(ARROW_CONTAINER_ID)?.remove();
|
||||
document.getElementById(STYLE_ID)?.remove();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { circOut, quintOut } from 'svelte/easing';
|
||||
import { type StaticCommandItem } from '../core/commands';
|
||||
import type { CombinedResult } from '../core/types';
|
||||
import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils';
|
||||
import { createSearchIndexes, applyDynamicIndexDelta, performSearch as doSearch, type DynamicItemsUpdatedDetail } from '../search/searchUtils';
|
||||
import Fuse from 'fuse.js';
|
||||
import Calculator from './Calculator.svelte';
|
||||
import { actionMap } from '../indexing/actions';
|
||||
@@ -129,7 +129,31 @@
|
||||
|
||||
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
||||
|
||||
const itemsUpdatedHandler = () => {
|
||||
const itemsUpdatedHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail;
|
||||
|
||||
if (
|
||||
detail?.vectorUpdate &&
|
||||
!detail.changedItems?.length &&
|
||||
!detail.removedIds?.length
|
||||
) {
|
||||
performSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (detail?.incremental && !detail.fullRebuild) {
|
||||
const updatedFuse = applyDynamicIndexDelta(
|
||||
dynamicContentFuse,
|
||||
dynamicIdToItemMap,
|
||||
detail,
|
||||
);
|
||||
if (updatedFuse) {
|
||||
dynamicContentFuse = updatedFuse;
|
||||
performSearch();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setupSearchIndexes();
|
||||
performSearch();
|
||||
};
|
||||
@@ -175,29 +199,35 @@
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
const requestId = ++searchRequestId;
|
||||
|
||||
if (commandsFuse && dynamicContentFuse) {
|
||||
const results = await doSearch(
|
||||
term,
|
||||
commandsFuse,
|
||||
commandIdToItemMap,
|
||||
dynamicContentFuse,
|
||||
dynamicIdToItemMap,
|
||||
true, // sortByRecent
|
||||
);
|
||||
try {
|
||||
if (commandsFuse && dynamicContentFuse) {
|
||||
const results = await doSearch(
|
||||
term,
|
||||
commandsFuse,
|
||||
commandIdToItemMap,
|
||||
dynamicContentFuse,
|
||||
dynamicIdToItemMap,
|
||||
true, // sortByRecent
|
||||
);
|
||||
|
||||
// Drop the result if the user has typed since this search started, or
|
||||
// if the current term no longer matches what we searched for. This
|
||||
// keeps the visible list anchored to the latest query.
|
||||
if (requestId !== searchRequestId) return;
|
||||
if (searchTerm.trim().toLowerCase() !== term) return;
|
||||
// Drop the result if the user has typed since this search started, or
|
||||
// if the current term no longer matches what we searched for. This
|
||||
// keeps the visible list anchored to the latest query.
|
||||
if (requestId !== searchRequestId) return;
|
||||
if (searchTerm.trim().toLowerCase() !== term) return;
|
||||
|
||||
combinedResults = results;
|
||||
} else {
|
||||
if (requestId !== searchRequestId) return;
|
||||
combinedResults = [];
|
||||
combinedResults = results;
|
||||
} else {
|
||||
if (requestId !== searchRequestId) return;
|
||||
combinedResults = [];
|
||||
}
|
||||
} finally {
|
||||
// Only clear loading for the latest in-flight search — stale async
|
||||
// passes must not leave the spinner stuck after fast typing.
|
||||
if (requestId === searchRequestId) {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
// Optimized debounce: shorter delay for better responsiveness
|
||||
|
||||
@@ -214,7 +214,7 @@ const staticCommands: StaticCommandItem[] = [
|
||||
code: 'KeyM',
|
||||
keyCode: 77,
|
||||
altKey: true
|
||||
}, "*");
|
||||
}, location.origin);
|
||||
},
|
||||
keywords: ["compose", "message", "dm", "direct message", "new message"],
|
||||
priority: 3,
|
||||
|
||||
@@ -286,10 +286,10 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
||||
const title = document.querySelector("#title");
|
||||
|
||||
if (title) {
|
||||
mountSearchBar(title, api, appRef);
|
||||
void mountSearchBar(title, api, appRef);
|
||||
} else {
|
||||
const titleElement = await waitForElm("#title", true, 100, 60);
|
||||
mountSearchBar(titleElement, api, appRef);
|
||||
void mountSearchBar(titleElement, api, appRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import renderSvelte from "@/interface/main";
|
||||
import SearchBar from "../components/SearchBar.svelte";
|
||||
import { unmount } from "svelte";
|
||||
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
|
||||
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
export function mountSearchBar(
|
||||
export async function mountSearchBar(
|
||||
titleElement: Element,
|
||||
api: any,
|
||||
appRef: {
|
||||
@@ -37,6 +36,41 @@ export function mountSearchBar(
|
||||
const searchButton = document.createElement("div");
|
||||
searchButton.className = "search-trigger";
|
||||
|
||||
const searchIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
searchIcon.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
searchIcon.setAttribute("width", "16");
|
||||
searchIcon.setAttribute("height", "16");
|
||||
searchIcon.setAttribute("viewBox", "0 0 24 24");
|
||||
searchIcon.setAttribute("fill", "none");
|
||||
searchIcon.setAttribute("stroke", "currentColor");
|
||||
searchIcon.setAttribute("stroke-width", "2");
|
||||
searchIcon.setAttribute("stroke-linecap", "round");
|
||||
searchIcon.setAttribute("stroke-linejoin", "round");
|
||||
|
||||
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
circle.setAttribute("cx", "11");
|
||||
circle.setAttribute("cy", "11");
|
||||
circle.setAttribute("r", "8");
|
||||
searchIcon.appendChild(circle);
|
||||
|
||||
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
||||
line.setAttribute("x1", "21");
|
||||
line.setAttribute("y1", "21");
|
||||
line.setAttribute("x2", "16.65");
|
||||
line.setAttribute("y2", "16.65");
|
||||
searchIcon.appendChild(line);
|
||||
|
||||
const searchLabel = document.createElement("p");
|
||||
searchLabel.textContent = "Quick search...";
|
||||
|
||||
const hotkeySpan = document.createElement("span");
|
||||
hotkeySpan.className = "search-trigger-hotkey";
|
||||
hotkeySpan.style.marginLeft = "auto";
|
||||
hotkeySpan.style.display = "flex";
|
||||
hotkeySpan.style.alignItems = "center";
|
||||
hotkeySpan.style.color = "#777";
|
||||
hotkeySpan.style.fontSize = "12px";
|
||||
|
||||
const progressBarWrapper = document.createElement("div");
|
||||
progressBarWrapper.className = "search-progress-bar-wrapper";
|
||||
|
||||
@@ -234,14 +268,10 @@ export function mountSearchBar(
|
||||
appRef.clearDoneFlashTimer = clearDoneFlashTimer;
|
||||
|
||||
const updateSearchButtonDisplay = () => {
|
||||
searchButton.innerHTML = /* html */ `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<p>Quick search...</p>
|
||||
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">${hotkeyDisplay}</span>
|
||||
`;
|
||||
hotkeySpan.textContent = hotkeyDisplay;
|
||||
if (!searchButton.contains(searchIcon)) {
|
||||
searchButton.replaceChildren(searchIcon, searchLabel, hotkeySpan);
|
||||
}
|
||||
};
|
||||
|
||||
updateSearchButtonDisplay();
|
||||
@@ -274,6 +304,7 @@ export function mountSearchBar(
|
||||
});
|
||||
|
||||
try {
|
||||
const { default: renderSvelte } = await import("@/interface/main");
|
||||
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
|
||||
transparencyEffects: api.settings.transparencyEffects ? true : false,
|
||||
showRecentFirst: api.settings.showRecentFirst,
|
||||
|
||||
@@ -184,6 +184,56 @@ export async function put(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply puts and deletes in a single readwrite transaction.
|
||||
*/
|
||||
export async function applyStoreDiff(
|
||||
store: string,
|
||||
puts: Array<{ key: string; value: any }>,
|
||||
removeKeys: string[],
|
||||
): Promise<void> {
|
||||
if (puts.length === 0 && removeKeys.length === 0) return;
|
||||
|
||||
try {
|
||||
const db = await openDB();
|
||||
|
||||
if (!db.objectStoreNames.contains(store)) {
|
||||
await upgradeDB(store);
|
||||
const upgradedDb = await openDB();
|
||||
await runStoreDiffTransaction(upgradedDb, store, puts, removeKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
await runStoreDiffTransaction(db, store, puts, removeKeys);
|
||||
} catch (error) {
|
||||
console.error(`Error in applyStoreDiff for store ${store}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function runStoreDiffTransaction(
|
||||
db: IDBDatabase,
|
||||
store: string,
|
||||
puts: Array<{ key: string; value: any }>,
|
||||
removeKeys: string[],
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(store, "readwrite");
|
||||
const objectStore = tx.objectStore(store);
|
||||
|
||||
for (const key of removeKeys) {
|
||||
objectStore.delete(key);
|
||||
}
|
||||
for (const { key, value } of puts) {
|
||||
objectStore.put(value, key);
|
||||
}
|
||||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function remove(store: string, key: string): Promise<void> {
|
||||
try {
|
||||
const s = await getStore(store, "readwrite");
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { clear, get, getAll, put, remove, resetDatabase } from "./db";
|
||||
import { applyStoreDiff, get, getAll, put, remove, resetDatabase } from "./db";
|
||||
import { jobs } from "./jobs";
|
||||
import { renderComponentMap } from "./renderComponents";
|
||||
import { decorateIndexItems } from "./renderComponents";
|
||||
import type { IndexItem, Job, JobContext } from "./types";
|
||||
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
||||
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||
import { getVectorizedItemIds } from "./utils";
|
||||
import { getVectorizedItemIds, pruneOrphanVectorEmbeddings } from "./utils";
|
||||
import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion";
|
||||
|
||||
const META_STORE = "meta";
|
||||
@@ -89,12 +89,64 @@ function shouldRun(job: Job, lastRun?: number): boolean {
|
||||
}
|
||||
|
||||
function getLastRunMeta(jobId: string): Promise<number | undefined> {
|
||||
return getAll(META_STORE).then((metaItems) => {
|
||||
const match = metaItems.find((m: any) => m.jobId === jobId);
|
||||
return match?.lastRun;
|
||||
return get(META_STORE, jobId).then((rec) => rec?.lastRun);
|
||||
}
|
||||
|
||||
function indexItemStorageKey(item: IndexItem): string {
|
||||
return JSON.stringify({
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
category: item.category,
|
||||
content: item.content,
|
||||
dateAdded: item.dateAdded,
|
||||
metadata: item.metadata,
|
||||
actionId: item.actionId,
|
||||
renderComponentId: item.renderComponentId,
|
||||
});
|
||||
}
|
||||
|
||||
function indexItemsEqual(a: IndexItem, b: IndexItem): boolean {
|
||||
return indexItemStorageKey(a) === indexItemStorageKey(b);
|
||||
}
|
||||
|
||||
async function diffAndStoreItems(
|
||||
targetStore: string,
|
||||
items: IndexItem[],
|
||||
): Promise<void> {
|
||||
const validItems = items.filter((i) => i && i.id);
|
||||
if (validItems.length !== items.length) {
|
||||
console.warn(
|
||||
`[Indexer] Filtered out ${items.length - validItems.length} invalid items before storing in '${targetStore}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = (await getAll(targetStore)) as IndexItem[];
|
||||
const existingMap = new Map(
|
||||
existing.filter((i) => i?.id).map((i) => [i.id, i]),
|
||||
);
|
||||
const newMap = new Map(validItems.map((i) => [i.id, i]));
|
||||
|
||||
const puts: Array<{ key: string; value: IndexItem }> = [];
|
||||
const removeKeys: string[] = [];
|
||||
|
||||
for (const [id, item] of newMap) {
|
||||
const prev = existingMap.get(id);
|
||||
if (!prev || !indexItemsEqual(prev, item)) {
|
||||
puts.push({ key: id, value: item });
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of existingMap.keys()) {
|
||||
if (!newMap.has(id)) {
|
||||
removeKeys.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (puts.length > 0 || removeKeys.length > 0) {
|
||||
await applyStoreDiff(targetStore, puts, removeKeys);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLastRunMeta(jobId: string): Promise<void> {
|
||||
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
|
||||
}
|
||||
@@ -220,6 +272,7 @@ export async function runIndexing(): Promise<void> {
|
||||
startHeartbeat();
|
||||
console.debug("%c[Indexer] Starting indexing...", "color: green");
|
||||
|
||||
try {
|
||||
const jobIds = Object.keys(jobs);
|
||||
let completedJobs = 0;
|
||||
const totalSteps = jobIds.length + 1;
|
||||
@@ -254,14 +307,7 @@ export async function runIndexing(): Promise<void> {
|
||||
await getAll(storeId ?? jobId);
|
||||
const setStoredItems = async (items: IndexItem[], storeId?: string) => {
|
||||
const targetStore = storeId ?? jobId;
|
||||
await clear(targetStore);
|
||||
const validItems = items.filter((i) => i && i.id);
|
||||
if (validItems.length !== items.length) {
|
||||
console.warn(
|
||||
`[Indexer Job ${jobId} -> Store ${targetStore}] Filtered out ${items.length - validItems.length} invalid items before storing.`,
|
||||
);
|
||||
}
|
||||
await Promise.all(validItems.map((i) => put(targetStore, i, i.id)));
|
||||
await diffAndStoreItems(targetStore, items);
|
||||
};
|
||||
const addItem = async (item: IndexItem, storeId?: string) => {
|
||||
const targetStore = storeId ?? jobId;
|
||||
@@ -320,6 +366,17 @@ export async function runIndexing(): Promise<void> {
|
||||
|
||||
let allItemsInPrimaryStores = await loadAllStoredItems();
|
||||
|
||||
const liveItemIds = new Set(allItemsInPrimaryStores.map((item) => item.id));
|
||||
const prunedCount = await pruneOrphanVectorEmbeddings(liveItemIds);
|
||||
if (prunedCount > 0) {
|
||||
try {
|
||||
const { refreshVectorCache } = await import("../search/vector/vectorSearch");
|
||||
await refreshVectorCache();
|
||||
} catch (e) {
|
||||
console.warn("[Indexer] Failed to refresh vector cache after prune:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (allItemsInPrimaryStores.length > 0) {
|
||||
console.debug(
|
||||
`%c[Indexer] Checking ${allItemsInPrimaryStores.length} items for vectorization...`,
|
||||
@@ -434,38 +491,17 @@ export async function runIndexing(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
stopHeartbeat();
|
||||
|
||||
allItemsInPrimaryStores = await loadAllStoredItems();
|
||||
// Create new objects to avoid XrayWrapper issues in Firefox
|
||||
const itemsWithComponents = allItemsInPrimaryStores.map(item => {
|
||||
try {
|
||||
const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId];
|
||||
let renderComponent = item.renderComponent;
|
||||
if (jobDef) {
|
||||
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
|
||||
} else if (renderComponentMap[item.renderComponentId]) {
|
||||
renderComponent = renderComponentMap[item.renderComponentId];
|
||||
}
|
||||
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
|
||||
// Use JSON serialization to ensure all nested properties are accessible
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(item));
|
||||
cloned.renderComponent = renderComponent;
|
||||
return cloned;
|
||||
} catch (e) {
|
||||
// Fallback to shallow copy if deep clone fails
|
||||
console.warn("[Indexer] Failed to deep clone item, using shallow copy:", e);
|
||||
return { ...item, renderComponent };
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
|
||||
console.warn("[Indexer] Failed to add render component to item (Firefox XrayWrapper):", error);
|
||||
return item;
|
||||
}
|
||||
});
|
||||
const itemsWithComponents = decorateIndexItems(allItemsInPrimaryStores);
|
||||
loadDynamicItems(itemsWithComponents);
|
||||
window.dispatchEvent(new Event("dynamic-items-updated"));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dynamic-items-updated", {
|
||||
detail: { fullRebuild: true },
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
stopHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IndexItem } from "./types";
|
||||
import { put, getAll } from "./db";
|
||||
import { getAll, put } from "./db";
|
||||
import {
|
||||
buildIndexItem,
|
||||
extractTextFromValue,
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
pickTitle,
|
||||
} from "./extract";
|
||||
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
|
||||
import { loadAllStoredItems } from "./indexer";
|
||||
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||
import { renderComponentMap } from "./renderComponents";
|
||||
import { jobs } from "./jobs";
|
||||
import { mergeDynamicItems } from "../utils/dynamicItems";
|
||||
import { decorateIndexItems } from "./renderComponents";
|
||||
|
||||
/**
|
||||
* Passive network observer.
|
||||
@@ -41,6 +39,8 @@ const MAX_PER_RESPONSE_TEXT_CHARS = 1500;
|
||||
let installed = false;
|
||||
let pendingFlush: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingDirty = false;
|
||||
/** Items persisted since the last flush — only these are pushed to the search layer. */
|
||||
const pendingChangedItems = new Map<string, IndexItem>();
|
||||
|
||||
export function isPassiveObserverInstalled(): boolean {
|
||||
return installed;
|
||||
@@ -386,6 +386,7 @@ async function persistItems(items: IndexItem[]): Promise<void> {
|
||||
for (const item of items) {
|
||||
try {
|
||||
await put(STORE_ID, item, item.id);
|
||||
pendingChangedItems.set(item.id, item);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`[Passive Observer] Failed to persist item ${item.id}:`,
|
||||
@@ -409,38 +410,20 @@ function scheduleFlush() {
|
||||
}
|
||||
|
||||
async function flushDynamicItems(): Promise<void> {
|
||||
if (pendingChangedItems.size === 0) return;
|
||||
|
||||
const rawChanged = Array.from(pendingChangedItems.values());
|
||||
pendingChangedItems.clear();
|
||||
|
||||
try {
|
||||
const all = await loadAllStoredItems();
|
||||
const decorated = all.map((item) => {
|
||||
try {
|
||||
const jobDef =
|
||||
jobs[item.category] ||
|
||||
Object.values(jobs).find((j) => j.id === item.category) ||
|
||||
jobs[item.renderComponentId];
|
||||
let renderComponent = item.renderComponent;
|
||||
if (jobDef) {
|
||||
renderComponent =
|
||||
renderComponentMap[jobDef.renderComponentId] || renderComponent;
|
||||
} else if (renderComponentMap[item.renderComponentId]) {
|
||||
renderComponent = renderComponentMap[item.renderComponentId];
|
||||
}
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(item));
|
||||
cloned.renderComponent = renderComponent;
|
||||
return cloned;
|
||||
} catch {
|
||||
return { ...item, renderComponent };
|
||||
}
|
||||
} catch {
|
||||
return item;
|
||||
}
|
||||
});
|
||||
loadDynamicItems(decorated);
|
||||
const decorated = decorateIndexItems(rawChanged);
|
||||
mergeDynamicItems(decorated);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("dynamic-items-updated", {
|
||||
detail: {
|
||||
incremental: true,
|
||||
jobId: STORE_ID,
|
||||
changedItems: decorated,
|
||||
streaming: false,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,8 @@ import AssessmentItem from "../components/items/AssessmentItem.svelte";
|
||||
import ForumItem from "../components/items/ForumItem.svelte";
|
||||
import SubjectItem from "../components/items/SubjectItem.svelte";
|
||||
import GenericItem from "../components/items/GenericItem.svelte";
|
||||
import type { IndexItem } from "./types";
|
||||
import { jobs } from "./jobs";
|
||||
|
||||
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
||||
assessment: AssessmentItem as unknown as typeof SvelteComponent,
|
||||
@@ -22,3 +24,37 @@ export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
||||
goal: GenericItem as unknown as typeof SvelteComponent,
|
||||
passive: GenericItem as unknown as typeof SvelteComponent,
|
||||
};
|
||||
|
||||
function resolveRenderComponent(item: IndexItem): typeof SvelteComponent | undefined {
|
||||
const jobDef =
|
||||
jobs[item.category] ||
|
||||
Object.values(jobs).find((j) => j.id === item.category) ||
|
||||
jobs[item.renderComponentId];
|
||||
if (jobDef) {
|
||||
return renderComponentMap[jobDef.renderComponentId] || item.renderComponent;
|
||||
}
|
||||
if (renderComponentMap[item.renderComponentId]) {
|
||||
return renderComponentMap[item.renderComponentId];
|
||||
}
|
||||
return item.renderComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach render components and deep-clone items for search UI (Firefox XrayWrapper).
|
||||
*/
|
||||
export function decorateIndexItems(items: IndexItem[]): IndexItem[] {
|
||||
return items.map((item) => {
|
||||
try {
|
||||
const renderComponent = resolveRenderComponent(item);
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(item)) as IndexItem;
|
||||
cloned.renderComponent = renderComponent;
|
||||
return cloned;
|
||||
} catch {
|
||||
return { ...item, renderComponent };
|
||||
}
|
||||
} catch {
|
||||
return item;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,6 +53,75 @@ export async function getVectorizedItemIds(): Promise<Set<string>> {
|
||||
});
|
||||
}
|
||||
|
||||
const EMBEDDIA_DB = "embeddiaDB";
|
||||
const EMBEDDIA_STORE = "embeddiaObjectStore";
|
||||
|
||||
/**
|
||||
* Remove vector embeddings for the given item ids from embeddiaDB.
|
||||
*/
|
||||
export async function removeVectorEmbeddings(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const request = indexedDB.open(EMBEDDIA_DB);
|
||||
|
||||
request.onerror = () => resolve();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
|
||||
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
|
||||
db.close();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const transaction = db.transaction([EMBEDDIA_STORE], "readwrite");
|
||||
const store = transaction.objectStore(EMBEDDIA_STORE);
|
||||
|
||||
for (const id of ids) {
|
||||
store.delete(id);
|
||||
}
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
|
||||
transaction.onerror = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[Indexer] Failed to remove vector embeddings:", error);
|
||||
db.close();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete vector embeddings that no longer exist in the structured index.
|
||||
* Returns the number of orphaned embeddings removed.
|
||||
*/
|
||||
export async function pruneOrphanVectorEmbeddings(
|
||||
liveItemIds: Set<string>,
|
||||
): Promise<number> {
|
||||
const vectorizedIds = await getVectorizedItemIds();
|
||||
const orphanIds = [...vectorizedIds].filter((id) => !liveItemIds.has(id));
|
||||
|
||||
if (orphanIds.length > 0) {
|
||||
console.debug(
|
||||
`[Indexer] Pruning ${orphanIds.length} orphaned vector embedding(s)`,
|
||||
);
|
||||
await removeVectorEmbeddings(orphanIds);
|
||||
}
|
||||
|
||||
return orphanIds.length;
|
||||
}
|
||||
|
||||
export function htmlToPlainText(rawHtml: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawHtml, "text/html");
|
||||
|
||||
@@ -132,6 +132,7 @@ export async function hybridSearch(
|
||||
bm25Results: CombinedResult[],
|
||||
query: string,
|
||||
options: HybridSearchOptions = {},
|
||||
precomputedVectorResults?: VectorSearchResult[],
|
||||
): Promise<CombinedResult[]> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
@@ -146,9 +147,10 @@ export async function hybridSearch(
|
||||
|
||||
if (trimmedQuery.length > 2) {
|
||||
try {
|
||||
// Get more vector results than BM25 results to ensure coverage
|
||||
// This allows us to find semantic matches that BM25 might have missed
|
||||
const vectorSearchResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
|
||||
const vectorTopK = opts.bm25TopK * 2;
|
||||
const vectorSearchResults =
|
||||
precomputedVectorResults ??
|
||||
(await searchVectors(trimmedQuery, vectorTopK));
|
||||
|
||||
// Create a map of item ID to vector similarity
|
||||
const vectorMap = new Map<string, number>();
|
||||
@@ -242,20 +244,33 @@ export async function hybridSearch(
|
||||
export async function hybridSearchWithExpansion(
|
||||
bm25Results: CombinedResult[],
|
||||
query: string,
|
||||
_allItems: IndexItem[],
|
||||
allItems: IndexItem[],
|
||||
options: HybridSearchOptions = {},
|
||||
): Promise<CombinedResult[]> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const liveIndexIds = new Set(allItems.map((item) => item.id));
|
||||
|
||||
// First, rerank BM25 results
|
||||
const rerankedBm25 = await hybridSearch(bm25Results, query, options);
|
||||
|
||||
// If query is too short, skip vector expansion
|
||||
if (trimmedQuery.length <= 2) {
|
||||
return rerankedBm25;
|
||||
return hybridSearch(bm25Results, query, options);
|
||||
}
|
||||
|
||||
let vectorResults: VectorSearchResult[] = [];
|
||||
try {
|
||||
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
|
||||
} catch (e) {
|
||||
console.warn("[Hybrid Search] Vector search failed:", e);
|
||||
return hybridSearch(bm25Results, query, options);
|
||||
}
|
||||
|
||||
// Rerank BM25 results using the single vector pass above
|
||||
const rerankedBm25 = await hybridSearch(
|
||||
bm25Results,
|
||||
query,
|
||||
options,
|
||||
vectorResults,
|
||||
);
|
||||
|
||||
// For short / single-token queries vector expansion brings in too much
|
||||
// noise (and is the main reason results "flicker" between adjacent
|
||||
// keystrokes). Keep semantic recall for longer queries.
|
||||
@@ -263,15 +278,6 @@ export async function hybridSearchWithExpansion(
|
||||
return rerankedBm25.slice(0, opts.finalLimit);
|
||||
}
|
||||
|
||||
// Get vector search results
|
||||
let vectorResults: VectorSearchResult[] = [];
|
||||
try {
|
||||
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK);
|
||||
} catch (e) {
|
||||
console.warn("[Hybrid Search] Vector search failed:", e);
|
||||
return rerankedBm25;
|
||||
}
|
||||
|
||||
// Find vector results that weren't in BM25 results
|
||||
const bm25Ids = new Set(bm25Results.map(r => r.item.id));
|
||||
const vectorOnlyResults: CombinedResult[] = [];
|
||||
@@ -298,6 +304,9 @@ export async function hybridSearchWithExpansion(
|
||||
vectorResults.forEach(v => {
|
||||
if (bm25Ids.has(v.object.id)) return;
|
||||
|
||||
// Drop stale vector hits for items no longer in the live structured index.
|
||||
if (!liveIndexIds.has(v.object.id)) return;
|
||||
|
||||
// This is a semantic match that BM25 missed
|
||||
const item = v.object;
|
||||
|
||||
|
||||
@@ -101,6 +101,94 @@ if (typeof window !== 'undefined') {
|
||||
});
|
||||
}
|
||||
|
||||
/** Rebuild Fuse when incremental delta exceeds this count. */
|
||||
export const INCREMENTAL_FUSE_REBUILD_THRESHOLD = 75;
|
||||
|
||||
export const DYNAMIC_FUSE_OPTIONS = {
|
||||
keys: [
|
||||
{ name: "text", weight: 3 },
|
||||
{ name: "content", weight: 1 },
|
||||
{ name: "category", weight: 0.4 },
|
||||
{ name: "metadata.subjectName", weight: 1.6 },
|
||||
{ name: "metadata.subjectCode", weight: 1.6 },
|
||||
{ name: "metadata.subject", weight: 1.4 },
|
||||
{ name: "metadata.courseCode", weight: 1.2 },
|
||||
{ name: "metadata.filename", weight: 1.2 },
|
||||
{ name: "metadata.author", weight: 0.8 },
|
||||
{ name: "metadata.authorName", weight: 0.8 },
|
||||
{ name: "metadata.label", weight: 0.6 },
|
||||
{ name: "metadata.categoryName", weight: 0.6 },
|
||||
{ name: "metadata.entityType", weight: 0.4 },
|
||||
],
|
||||
includeScore: true,
|
||||
includeMatches: true,
|
||||
threshold: 0.5,
|
||||
minMatchCharLength: 2,
|
||||
distance: 100,
|
||||
useExtendedSearch: true,
|
||||
ignoreLocation: true,
|
||||
findAllMatches: true,
|
||||
shouldSort: true,
|
||||
} as const;
|
||||
|
||||
export interface DynamicItemsUpdatedDetail {
|
||||
incremental?: boolean;
|
||||
fullRebuild?: boolean;
|
||||
jobId?: string;
|
||||
changedItems?: IndexItem[];
|
||||
removedIds?: string[];
|
||||
vectorUpdate?: boolean;
|
||||
streaming?: boolean;
|
||||
newItemCount?: number;
|
||||
}
|
||||
|
||||
export function createDynamicContentFuse(
|
||||
items: IndexItem[],
|
||||
): Fuse<IndexItem> {
|
||||
return new Fuse(
|
||||
dedupeIndexItemsForSearch(items),
|
||||
DYNAMIC_FUSE_OPTIONS,
|
||||
) as Fuse<IndexItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an incremental dynamic-item delta to an existing Fuse index.
|
||||
* Returns null when a full rebuild is recommended.
|
||||
*/
|
||||
export function applyDynamicIndexDelta(
|
||||
fuse: Fuse<IndexItem> | undefined,
|
||||
idToItemMap: Map<string, IndexItem>,
|
||||
detail: DynamicItemsUpdatedDetail,
|
||||
): Fuse<IndexItem> | null {
|
||||
const changedItems = detail.changedItems ?? [];
|
||||
const removedIds = detail.removedIds ?? [];
|
||||
const deltaSize = changedItems.length + removedIds.length;
|
||||
|
||||
if (
|
||||
detail.fullRebuild ||
|
||||
!fuse ||
|
||||
idToItemMap.size === 0 ||
|
||||
deltaSize === 0 ||
|
||||
deltaSize > INCREMENTAL_FUSE_REBUILD_THRESHOLD
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const id of removedIds) {
|
||||
fuse.remove((item) => item.id === id);
|
||||
idToItemMap.delete(id);
|
||||
}
|
||||
|
||||
for (const item of changedItems) {
|
||||
fuse.remove((existing) => existing.id === item.id);
|
||||
fuse.add(item);
|
||||
idToItemMap.set(item.id, item);
|
||||
}
|
||||
|
||||
clearSearchCache();
|
||||
return fuse;
|
||||
}
|
||||
|
||||
export function createSearchIndexes() {
|
||||
clearSearchCache();
|
||||
const commands = getStaticCommands();
|
||||
@@ -118,49 +206,9 @@ export function createSearchIndexes() {
|
||||
findAllMatches: false, // Performance optimization
|
||||
};
|
||||
|
||||
// Optimized dynamic content search options.
|
||||
// The expanded corpus mixes structured entities (assessments, subjects)
|
||||
// with free-form text (course content, notices, folio bodies, passive
|
||||
// captures) so we list a broad set of metadata keys while keeping titles
|
||||
// dominant in the ranking.
|
||||
// NOTE: metadata.route is intentionally excluded. Raw API paths like
|
||||
// `/seqta/student/load/message/people` should never influence ranking — they
|
||||
// historically caused passive-capture support records to bubble up above
|
||||
// real assessments when the user typed substrings that happened to appear in
|
||||
// the path.
|
||||
const dynamicOptions = {
|
||||
keys: [
|
||||
{ name: "text", weight: 3 }, // Title is king
|
||||
{ name: "content", weight: 1 },
|
||||
{ name: "category", weight: 0.4 },
|
||||
{ name: "metadata.subjectName", weight: 1.6 },
|
||||
{ name: "metadata.subjectCode", weight: 1.6 },
|
||||
{ name: "metadata.subject", weight: 1.4 },
|
||||
{ name: "metadata.courseCode", weight: 1.2 },
|
||||
{ name: "metadata.filename", weight: 1.2 },
|
||||
{ name: "metadata.author", weight: 0.8 },
|
||||
{ name: "metadata.authorName", weight: 0.8 },
|
||||
{ name: "metadata.label", weight: 0.6 },
|
||||
{ name: "metadata.categoryName", weight: 0.6 },
|
||||
{ name: "metadata.entityType", weight: 0.4 },
|
||||
],
|
||||
includeScore: true,
|
||||
includeMatches: true,
|
||||
threshold: 0.5,
|
||||
minMatchCharLength: 2,
|
||||
distance: 100,
|
||||
useExtendedSearch: true,
|
||||
ignoreLocation: true,
|
||||
findAllMatches: true,
|
||||
shouldSort: true,
|
||||
};
|
||||
|
||||
return {
|
||||
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
|
||||
dynamicContentFuse: new Fuse(
|
||||
dynamicItems,
|
||||
dynamicOptions,
|
||||
) as Fuse<IndexItem>,
|
||||
dynamicContentFuse: createDynamicContentFuse(dynamicItems),
|
||||
commands,
|
||||
dynamicItems,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as math from 'mathjs';
|
||||
import { create, all, typeOf as mathTypeOf, format as mathFormat } from 'mathjs';
|
||||
import { unitFullNames } from './unitMap';
|
||||
|
||||
export interface CalculatorResult {
|
||||
@@ -10,66 +10,42 @@ export interface CalculatorResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const expandedMath = math.create(math.all);
|
||||
/** Hard cap on calculator input length to limit parse/eval cost. */
|
||||
export const CALCULATOR_MAX_INPUT_LENGTH = 128;
|
||||
|
||||
expandedMath.import({
|
||||
five: 5,
|
||||
ten: 10,
|
||||
three: 3,
|
||||
four: 4,
|
||||
eight: 8,
|
||||
sixteen: 16,
|
||||
twenty: 20,
|
||||
twentyfive: 25,
|
||||
fifty: 50,
|
||||
hundred: 100,
|
||||
plus: (a: number, b: number) => a + b,
|
||||
minus: (a: number, b: number) => a - b,
|
||||
times: (a: number, b: number) => a * b,
|
||||
divided: (a: number, b: number) => a / b,
|
||||
power: (a: number, b: number) => Math.pow(a, b),
|
||||
half: (a: number) => a / 2,
|
||||
double: (a: number) => a * 2,
|
||||
quarter: (a: number) => a / 4,
|
||||
/**
|
||||
* Functions safe to replace with stubs. Do not block type constructors
|
||||
* (`complex`, `typed`, `fraction`, `bignumber`, `sparse`) or parse pipeline
|
||||
* (`parse`, `compile`, `parser`) — mathjs needs those internally and
|
||||
* `evaluate()` depends on them.
|
||||
*/
|
||||
const BLOCKED_MATH_FUNCTIONS = [
|
||||
'import',
|
||||
'createUnit',
|
||||
'random',
|
||||
'pickRandom',
|
||||
'chain',
|
||||
'help',
|
||||
] as const;
|
||||
|
||||
// String functions
|
||||
length: (str: string) => str.length,
|
||||
concat: (...args: string[]) => args.join(''),
|
||||
uppercase: (str: string) => str.toUpperCase(),
|
||||
lowercase: (str: string) => str.toLowerCase(),
|
||||
substr: (str: string, start: number, length: number) => str.substr(start, length),
|
||||
function createSandboxedMath() {
|
||||
const sandbox = create(all);
|
||||
const blockFn = () => {
|
||||
throw new Error('Function not allowed');
|
||||
};
|
||||
const blocked: Record<string, () => never> = {};
|
||||
for (const name of BLOCKED_MATH_FUNCTIONS) {
|
||||
blocked[name] = blockFn;
|
||||
}
|
||||
sandbox.import(blocked, { override: true });
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
// Random functions
|
||||
randomInt: (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min,
|
||||
|
||||
// Comparison and Boolean operations
|
||||
and: (a: boolean, b: boolean) => a && b,
|
||||
or: (a: boolean, b: boolean) => a || b,
|
||||
not: (a: boolean) => !a,
|
||||
|
||||
// Combinatorics
|
||||
permutations: (n: number, r: number) => expandedMath.combinations(n, r) * expandedMath.factorial(r),
|
||||
nPr: (n: number, r: number) => expandedMath.combinations(n, r) * expandedMath.factorial(r),
|
||||
nCr: (n: number, r: number) => expandedMath.combinations(n, r),
|
||||
|
||||
// Number theory
|
||||
gcd: (a: number, b: number) => expandedMath.gcd(a, b),
|
||||
lcm: (a: number, b: number) => expandedMath.lcm(a, b),
|
||||
|
||||
// Precision functions
|
||||
precision: (num: number, digits: number) => parseFloat(num.toPrecision(digits)),
|
||||
fix: (num: number, digits: number) => parseFloat(num.toFixed(digits)),
|
||||
|
||||
// Percentage operations
|
||||
percent: (value: number) => value / 100,
|
||||
|
||||
// Financial operations
|
||||
compound: (principal: number, rate: number, time: number) => principal * Math.pow(1 + rate, time),
|
||||
}, { override: true });
|
||||
const calculatorMath = createSandboxedMath();
|
||||
|
||||
function detectUnit(expression: string): string {
|
||||
try {
|
||||
const unit = expandedMath.unit(expression);
|
||||
const unit = calculatorMath.unit(expression);
|
||||
if (unit) {
|
||||
const unitStr = unit.formatUnits();
|
||||
return unitFullNames[unitStr] || unitStr;
|
||||
@@ -120,9 +96,9 @@ function tryCompleteExpression(expression: string): string | null {
|
||||
// Handle cases like "4 + 3 *" -> evaluate "4 + 3"
|
||||
if (partial && !partial.match(/[\+\-\*\/\^]\s*$/)) {
|
||||
try {
|
||||
const result = expandedMath.evaluate(partial);
|
||||
const result = calculatorMath.evaluate(partial);
|
||||
if (typeof result === 'number' && !isNaN(result)) {
|
||||
return expandedMath.format(result, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
return calculatorMath.format(result, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to other attempts
|
||||
@@ -147,6 +123,17 @@ export function calculateExpression(input: string): CalculatorResult {
|
||||
outputUnit: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmed.length > CALCULATOR_MAX_INPUT_LENGTH) {
|
||||
return {
|
||||
result: null,
|
||||
isValid: false,
|
||||
isPartial: false,
|
||||
inputUnit: '',
|
||||
outputUnit: '',
|
||||
error: `Expression too long (max ${CALCULATOR_MAX_INPUT_LENGTH} characters)`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if this looks like a math expression at all
|
||||
if (!isLikelyMathExpression(trimmed)) {
|
||||
@@ -161,23 +148,23 @@ export function calculateExpression(input: string): CalculatorResult {
|
||||
|
||||
try {
|
||||
// First try to evaluate the expression as-is
|
||||
const evaluated = expandedMath.evaluate(trimmed.replace('**', '^'));
|
||||
const evaluated = calculatorMath.evaluate(trimmed.replace('**', '^'));
|
||||
|
||||
if (evaluated !== undefined) {
|
||||
let result: string;
|
||||
let inputUnit = '';
|
||||
let outputUnit = '';
|
||||
|
||||
if (math.typeOf(evaluated) === 'Unit') {
|
||||
if (mathTypeOf(evaluated) === 'Unit') {
|
||||
// Handle unit conversion results
|
||||
result = expandedMath.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
result = calculatorMath.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
inputUnit = detectUnit(trimmed);
|
||||
outputUnit = detectUnit(result);
|
||||
} else if (typeof evaluated === 'number') {
|
||||
// Handle regular numbers
|
||||
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
result = mathFormat(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
} else {
|
||||
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
result = mathFormat(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -220,4 +207,4 @@ export function calculateExpression(input: string): CalculatorResult {
|
||||
inputUnit: '',
|
||||
outputUnit: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,29 @@ export interface DynamicContentItem {
|
||||
let dynamicItems: IndexItem[] = [];
|
||||
|
||||
/**
|
||||
* Loads a new set of dynamic items.
|
||||
* Loads a new set of dynamic items (full replace).
|
||||
*/
|
||||
export function loadDynamicItems(items: IndexItem[]) {
|
||||
dynamicItems = items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge changed items and remove deleted ids without reloading the full corpus.
|
||||
*/
|
||||
export function mergeDynamicItems(
|
||||
changedItems: IndexItem[],
|
||||
removedIds: string[] = [],
|
||||
): void {
|
||||
if (changedItems.length === 0 && removedIds.length === 0) return;
|
||||
|
||||
const removeSet = new Set(removedIds);
|
||||
const changeMap = new Map(changedItems.map((item) => [item.id, item]));
|
||||
const kept = dynamicItems.filter(
|
||||
(item) => !removeSet.has(item.id) && !changeMap.has(item.id),
|
||||
);
|
||||
dynamicItems = [...kept, ...changedItems];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all currently loaded dynamic items.
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,14 @@ export interface ParsedHotkey {
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** Single-key allowlist: a-z, 0-9, and F1–F12 only. */
|
||||
const ALLOWED_HOTKEY_KEY = /^([a-z0-9]|f(1[0-2]|[1-9]))$/;
|
||||
|
||||
export function isAllowedHotkeyKey(key: string): boolean {
|
||||
if (!key) return false;
|
||||
return ALLOWED_HOTKEY_KEY.test(key.toLowerCase());
|
||||
}
|
||||
|
||||
export function parseHotkey(hotkeyString: string): ParsedHotkey {
|
||||
const parts = hotkeyString.toLowerCase().split('+').map(part => part.trim()).filter(part => part.length > 0);
|
||||
|
||||
@@ -68,14 +76,14 @@ export function formatHotkeyForDisplay(hotkeyString: string): string {
|
||||
parts.push(isMac ? '⇧' : 'Shift');
|
||||
}
|
||||
|
||||
if (parsed.key) {
|
||||
if (parsed.key && isAllowedHotkeyKey(parsed.key)) {
|
||||
parts.push(parsed.key.toUpperCase());
|
||||
}
|
||||
|
||||
return parts.join(isMac ? ' ' : '+');
|
||||
} catch (error) {
|
||||
console.warn('Invalid hotkey string:', hotkeyString);
|
||||
return hotkeyString; // Fallback to original string
|
||||
return 'Ctrl+K';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +92,7 @@ export function matchesHotkey(event: KeyboardEvent, hotkeyString: string): boole
|
||||
const parsed = parseHotkey(hotkeyString);
|
||||
|
||||
// If no key is specified, don't match anything
|
||||
if (!parsed.key) {
|
||||
if (!parsed.key || !isAllowedHotkeyKey(parsed.key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -111,8 +119,8 @@ export function matchesHotkey(event: KeyboardEvent, hotkeyString: string): boole
|
||||
export function isValidHotkey(hotkeyString: string): boolean {
|
||||
try {
|
||||
const parsed = parseHotkey(hotkeyString);
|
||||
return parsed.key.length > 0;
|
||||
return parsed.key.length > 0 && isAllowedHotkeyKey(parsed.key);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import * as Chart from "./chart/index";
|
||||
import { scaleLinear } from "d3-scale";
|
||||
import { Area, AreaChart, ChartClipPath, Spline } from "layerchart";
|
||||
import { curveNatural } from "d3-shape";
|
||||
import { curveMonotoneX } from "d3-shape";
|
||||
import { cubicInOut } from "svelte/easing";
|
||||
import type { Assessment } from "./types";
|
||||
import {
|
||||
buildGradeTrendChart,
|
||||
getTimeRangeLabel,
|
||||
type TimeRange,
|
||||
type TrendPoint,
|
||||
} from "./timeRange";
|
||||
import { computeGradeForecast, aggregateToMonthlyPoints } from "./utils/gradePrediction";
|
||||
import PredictionMonthsSlider from "./PredictionMonthsSlider.svelte";
|
||||
@@ -46,7 +47,7 @@
|
||||
return computeGradeForecast(points, predictionMonths);
|
||||
});
|
||||
|
||||
/** Bridge point + future months — separate from historical so the main line stays intact. */
|
||||
/** Bridge point + future months — separate series rendered via Spline. */
|
||||
const forecastLineData = $derived.by(() => {
|
||||
if (!showPrediction || !forecast) return [];
|
||||
|
||||
@@ -60,19 +61,21 @@
|
||||
];
|
||||
});
|
||||
|
||||
/** Ghost future dates (null grades) extend the x domain without touching the historical line. */
|
||||
const chartData = $derived.by(() => {
|
||||
if (!showPrediction || forecastLineData.length <= 1) {
|
||||
return historicalData;
|
||||
const xDomain = $derived.by((): [Date, Date] | undefined => {
|
||||
const times = historicalData.map((p) => p.date.getTime());
|
||||
|
||||
if (showPrediction && forecastLineData.length > 1) {
|
||||
for (const point of forecastLineData.slice(1)) {
|
||||
times.push(point.date.getTime());
|
||||
}
|
||||
}
|
||||
|
||||
const futurePadding = forecastLineData.slice(1).map((p) => ({
|
||||
date: p.date,
|
||||
average: null,
|
||||
count: 0,
|
||||
}));
|
||||
if (!times.length) return undefined;
|
||||
|
||||
return [...historicalData, ...futurePadding];
|
||||
return [
|
||||
new Date(Math.min(...times)),
|
||||
new Date(Math.max(...times)),
|
||||
];
|
||||
});
|
||||
|
||||
const chartConfig = $derived.by(() => {
|
||||
@@ -157,12 +160,49 @@
|
||||
);
|
||||
return monthly.length >= 3;
|
||||
});
|
||||
|
||||
/** Historical + future forecast points so tooltips work across the dashed line. */
|
||||
const chartData = $derived.by((): TrendPoint[] => {
|
||||
if (!showPrediction || !forecast?.points.length) {
|
||||
return historicalData;
|
||||
}
|
||||
|
||||
const points = historicalData.map((p) => ({ ...p }));
|
||||
const validHist = points.filter((p) => !Number.isNaN(p.average));
|
||||
const last = validHist[validHist.length - 1];
|
||||
|
||||
if (last) {
|
||||
const lastIdx = points.findIndex((p) => p.date.getTime() === last.date.getTime());
|
||||
if (lastIdx >= 0) {
|
||||
points[lastIdx] = { ...points[lastIdx], forecast: last.average };
|
||||
}
|
||||
}
|
||||
|
||||
const futurePoints: TrendPoint[] = forecast.points.map((p) => ({
|
||||
date: p.date,
|
||||
count: 0,
|
||||
forecast: p.value,
|
||||
})) as TrendPoint[];
|
||||
|
||||
return [...points, ...futurePoints];
|
||||
});
|
||||
</script>
|
||||
|
||||
<article class="bsplus-analytics-card">
|
||||
<header class="bsplus-analytics-card-header bsplus-analytics-card-header-split">
|
||||
<div>
|
||||
<h3 class="bsplus-analytics-card-title">Grade trends</h3>
|
||||
<div class="bsplus-analytics-card-header-text">
|
||||
<div class="bsplus-analytics-card-title-row">
|
||||
<h3 class="bsplus-analytics-card-title">Grade trends</h3>
|
||||
<label class="bsplus-analytics-checkbox bsplus-analytics-forecast-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={showPrediction}
|
||||
disabled={!canForecast}
|
||||
/>
|
||||
<span class="bsplus-analytics-checkmark" aria-hidden="true"></span>
|
||||
<span>Forecast</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="bsplus-analytics-card-desc">
|
||||
{#if showSubjectTrends}
|
||||
Overall and per-subject averages · {getTimeRangeLabel(timeRange)}
|
||||
@@ -172,21 +212,14 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bsplus-analytics-card-controls bsplus-analytics-forecast-controls">
|
||||
<label class="bsplus-analytics-checkbox bsplus-analytics-forecast-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={showPrediction}
|
||||
disabled={!canForecast}
|
||||
/>
|
||||
<span>Grade forecast</span>
|
||||
</label>
|
||||
|
||||
<div class="bsplus-analytics-card-control bsplus-analytics-forecast-horizon">
|
||||
<span class="bsplus-analytics-field-label">Months ahead</span>
|
||||
<PredictionMonthsSlider bind:value={predictionMonths} disabled={!showPrediction} />
|
||||
{#if showPrediction}
|
||||
<div class="bsplus-analytics-card-controls">
|
||||
<label class="bsplus-analytics-card-control bsplus-analytics-forecast-horizon">
|
||||
<span class="bsplus-analytics-field-label">Months</span>
|
||||
<PredictionMonthsSlider bind:value={predictionMonths} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="bsplus-analytics-card-body">
|
||||
@@ -197,11 +230,12 @@
|
||||
legend
|
||||
data={chartData}
|
||||
x="date"
|
||||
{xDomain}
|
||||
yScale={yScale}
|
||||
series={areaSeries}
|
||||
props={{
|
||||
area: {
|
||||
curve: curveNatural,
|
||||
curve: curveMonotoneX,
|
||||
"fill-opacity": showSubjectTrends ? 0.12 : 0.35,
|
||||
line: { class: "stroke-2" },
|
||||
motion: "tween",
|
||||
@@ -263,7 +297,7 @@
|
||||
data={forecastLineData}
|
||||
x="date"
|
||||
y="forecast"
|
||||
curve={curveNatural}
|
||||
curve={curveMonotoneX}
|
||||
class="bsplus-analytics-forecast-line"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -268,7 +268,7 @@
|
||||
|
||||
xScale={scaleBand().padding(distribution().modeUsed === "letter" ? 0.22 : 0.28)}
|
||||
|
||||
yScale={yScale()}
|
||||
yScale={yScale}
|
||||
|
||||
x="grade"
|
||||
|
||||
@@ -310,8 +310,6 @@
|
||||
|
||||
y: { type: "tween", duration: 600, easing: cubicInOut },
|
||||
|
||||
height: { type: "tween", duration: 600, easing: cubicInOut },
|
||||
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
@@ -35,6 +35,15 @@
|
||||
),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
sortedData.length;
|
||||
itemsPerPage;
|
||||
const maxPage = Math.max(0, pageCount - 1);
|
||||
if (currentPage > maxPage) {
|
||||
currentPage = maxPage;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleSort(column: keyof Assessment) {
|
||||
if (sortColumn === column) {
|
||||
sortDirection = sortDirection === "asc" ? "desc" : "asc";
|
||||
|
||||
@@ -53,8 +53,9 @@
|
||||
const [minG, maxG] = gradeRange;
|
||||
return analyticsData.filter((a) => {
|
||||
if (filterSubjects.length && !filterSubjects.includes(a.subject)) return false;
|
||||
const grade = a.finalGrade ?? -1;
|
||||
if (grade < minG || grade > maxG) return false;
|
||||
if (a.finalGrade !== undefined) {
|
||||
if (a.finalGrade < minG || a.finalGrade > maxG) return false;
|
||||
}
|
||||
if (
|
||||
filterSearch &&
|
||||
!a.title.toLowerCase().includes(filterSearch.toLowerCase()) &&
|
||||
@@ -209,7 +210,6 @@
|
||||
</span>
|
||||
{/if}
|
||||
</h1>
|
||||
<p>Track your academic performance and progress over time</p>
|
||||
{#if lastUpdated && analyticsData && analyticsData.length > 0}
|
||||
<p class="bsplus-analytics-meta">Last updated: {formattedTimestamp()}</p>
|
||||
{/if}
|
||||
@@ -244,32 +244,22 @@
|
||||
<div class="bsplus-analytics-spinner" aria-label="Loading analytics"></div>
|
||||
</div>
|
||||
{:else if analyticsData && analyticsData.length > 0}
|
||||
<section
|
||||
class="bsplus-analytics-stats bsplus-analytics-animate bsplus-analytics-delay-1"
|
||||
aria-label="Summary statistics"
|
||||
>
|
||||
<div class="bsplus-analytics-stat">
|
||||
<div class="bsplus-analytics-stat-label">Average grade</div>
|
||||
<div class="bsplus-analytics-stat-value bsplus-analytics-stat-value-accent">
|
||||
{statsAverage !== null ? `${statsAverage}%` : "—"}
|
||||
<div class="bsplus-analytics-layout bsplus-analytics-animate bsplus-analytics-delay-1">
|
||||
<aside class="bsplus-analytics-filters" aria-label="Filters">
|
||||
<div class="bsplus-analytics-filters-head">
|
||||
<h2 class="bsplus-analytics-filters-title">Filters</h2>
|
||||
{#if hasActiveFilters()}
|
||||
<button
|
||||
type="button"
|
||||
class="bsplus-analytics-filters-clear"
|
||||
onclick={clearFilters}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bsplus-analytics-stat">
|
||||
<div class="bsplus-analytics-stat-label">Graded shown</div>
|
||||
<div class="bsplus-analytics-stat-value">{gradedFiltered().length}</div>
|
||||
</div>
|
||||
<div class="bsplus-analytics-stat">
|
||||
<div class="bsplus-analytics-stat-label">Subjects</div>
|
||||
<div class="bsplus-analytics-stat-value">{statsSubjectCount}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="bsplus-analytics-toolbar bsplus-analytics-animate bsplus-analytics-delay-2">
|
||||
<div class="bsplus-analytics-toolbar-grid">
|
||||
<div
|
||||
class="bsplus-analytics-field bsplus-analytics-toolbar-dropdown-field"
|
||||
data-analytics-dropdown
|
||||
>
|
||||
<div class="bsplus-analytics-filter-group" data-analytics-dropdown>
|
||||
<span class="bsplus-analytics-field-label">Time period</span>
|
||||
<div class="bsplus-analytics-dropdown" data-analytics-dropdown>
|
||||
<button
|
||||
@@ -309,10 +299,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bsplus-analytics-field bsplus-analytics-toolbar-dropdown-field"
|
||||
data-analytics-dropdown
|
||||
>
|
||||
<div class="bsplus-analytics-filter-group" data-analytics-dropdown>
|
||||
<span class="bsplus-analytics-field-label">Subjects</span>
|
||||
<div class="bsplus-analytics-dropdown" data-analytics-dropdown>
|
||||
<button
|
||||
@@ -361,7 +348,7 @@
|
||||
<span class="bsplus-analytics-dropdown-check"
|
||||
>{selected ? "✓" : ""}</span
|
||||
>
|
||||
<span style="overflow:hidden;text-overflow:ellipsis">{subject}</span>
|
||||
<span class="bsplus-analytics-filter-subject-name">{subject}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -369,7 +356,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bsplus-analytics-field bsplus-analytics-toolbar-search">
|
||||
<div class="bsplus-analytics-filter-group">
|
||||
<span class="bsplus-analytics-field-label">Search</span>
|
||||
<input
|
||||
type="search"
|
||||
@@ -379,62 +366,65 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if hasActiveFilters()}
|
||||
<button
|
||||
type="button"
|
||||
class="bsplus-analytics-btn bsplus-analytics-btn-ghost bsplus-analytics-toolbar-clear"
|
||||
onclick={clearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="bsplus-analytics-field bsplus-analytics-grade-range">
|
||||
<div class="bsplus-analytics-filter-group">
|
||||
<span class="bsplus-analytics-field-label">Grade range</span>
|
||||
<GradeRangeSlider bind:value={gradeRange} />
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="bsplus-analytics-checkbox bsplus-analytics-toolbar-trends"
|
||||
class:bsplus-analytics-toolbar-trends-top={!hasActiveFilters()}
|
||||
>
|
||||
<input type="checkbox" bind:checked={showSubjectTrends} />
|
||||
<span>Show per-subject trends on chart</span>
|
||||
</label>
|
||||
<div class="bsplus-analytics-filter-group">
|
||||
<label class="bsplus-analytics-checkbox">
|
||||
<input type="checkbox" bind:checked={showSubjectTrends} />
|
||||
<span class="bsplus-analytics-checkmark" aria-hidden="true"></span>
|
||||
<span>Per-subject trends</span>
|
||||
</label>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="bsplus-analytics-main">
|
||||
<div class="bsplus-analytics-stats" aria-label="Summary statistics">
|
||||
<div class="bsplus-analytics-stat">
|
||||
<div class="bsplus-analytics-stat-label">Average grade</div>
|
||||
<div class="bsplus-analytics-stat-value bsplus-analytics-stat-value-accent">
|
||||
{statsAverage !== null ? `${statsAverage}%` : "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bsplus-analytics-stat">
|
||||
<div class="bsplus-analytics-stat-label">Graded shown</div>
|
||||
<div class="bsplus-analytics-stat-value">{gradedFiltered().length}</div>
|
||||
</div>
|
||||
<div class="bsplus-analytics-stat">
|
||||
<div class="bsplus-analytics-stat-label">Subjects</div>
|
||||
<div class="bsplus-analytics-stat-value">{statsSubjectCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bsplus-analytics-results">
|
||||
<div class="bsplus-analytics-charts">
|
||||
<div class="bsplus-analytics-chart-cell">
|
||||
<AnalyticsAreaChart
|
||||
data={gradedFiltered()}
|
||||
{timeRange}
|
||||
showSubjectTrends={showSubjectTrends}
|
||||
/>
|
||||
</div>
|
||||
<div class="bsplus-analytics-chart-cell">
|
||||
<AnalyticsBarChart data={gradedFiltered()} {timeRange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssessmentTable data={timeScopedData()} />
|
||||
</div>
|
||||
|
||||
<footer class="bsplus-analytics-footer">
|
||||
<span>
|
||||
{timeScopedData().length} of {analyticsData.length} assessments shown
|
||||
{#if gradedFiltered().length !== timeScopedData().length}
|
||||
({gradedFiltered().length} with grades)
|
||||
{/if}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bsplus-analytics-charts">
|
||||
{#key filteredData().length + "-" + gradeRange.join(",") + filterSearch + filterSubjects.join("|") + timeRange + String(showSubjectTrends)}
|
||||
<div class="bsplus-analytics-chart-cell">
|
||||
<div class="bsplus-analytics-animate bsplus-analytics-delay-3">
|
||||
<AnalyticsAreaChart
|
||||
data={gradedFiltered()}
|
||||
{timeRange}
|
||||
showSubjectTrends={showSubjectTrends}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bsplus-analytics-chart-cell">
|
||||
<div class="bsplus-analytics-animate bsplus-analytics-delay-4">
|
||||
<AnalyticsBarChart data={gradedFiltered()} {timeRange} />
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<div class="bsplus-analytics-animate bsplus-analytics-delay-5">
|
||||
<AssessmentTable data={timeScopedData()} />
|
||||
</div>
|
||||
|
||||
<footer class="bsplus-analytics-footer">
|
||||
<span>
|
||||
{timeScopedData().length} of {analyticsData.length} assessments shown
|
||||
{#if gradedFiltered().length !== timeScopedData().length}
|
||||
({gradedFiltered().length} with grades)
|
||||
{/if}
|
||||
</span>
|
||||
</footer>
|
||||
{:else}
|
||||
<div class="bsplus-analytics-empty bsplus-analytics-animate" transition:fade={{ duration: 300 }}>
|
||||
<h2>No analytics data yet</h2>
|
||||
|
||||
@@ -24,6 +24,9 @@ async function fetchJSON(url: string, body: Record<string, unknown>) {
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -254,10 +257,19 @@ async function loadAllPast(
|
||||
const results: Record<string, unknown>[][] = [];
|
||||
for (let i = 0; i < subjects.length; i += PAST_FETCH_CONCURRENCY) {
|
||||
const batch = subjects.slice(i, i + PAST_FETCH_CONCURRENCY);
|
||||
const batchResults = await Promise.all(
|
||||
const batchResults = await Promise.allSettled(
|
||||
batch.map((s) => loadPastForSubject(studentId, s)),
|
||||
);
|
||||
results.push(...batchResults);
|
||||
for (const result of batchResults) {
|
||||
if (result.status === "fulfilled") {
|
||||
results.push(result.value);
|
||||
} else {
|
||||
console.error(
|
||||
"[BetterSEQTA+] Past assessments fetch failed:",
|
||||
result.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.flat();
|
||||
}
|
||||
@@ -295,7 +307,7 @@ function mergeRawAssessments(
|
||||
}
|
||||
|
||||
export async function getStudentId(): Promise<number> {
|
||||
const info = await getUserInfo();
|
||||
const info = await getUserInfo({ validateSession: true });
|
||||
const id = Number(info?.id);
|
||||
if (!id || isNaN(id)) throw new Error("Could not resolve student ID");
|
||||
return id;
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{#if item.value !== undefined}
|
||||
{#if item.value != null && !Number.isNaN(Number(item.value))}
|
||||
<span class="font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { renderAnalyticsPage } from "./ui";
|
||||
|
||||
let loadInFlight: Promise<void> | null = null;
|
||||
|
||||
@@ -27,7 +28,15 @@ async function loadAnalyticsPageInner(): Promise<void> {
|
||||
});
|
||||
document.querySelector('[data-key="analytics"]')?.classList.add("active");
|
||||
|
||||
const main = (await waitForElm("#main", true, 100, 60)) as HTMLElement;
|
||||
let main: HTMLElement;
|
||||
try {
|
||||
main = (await waitForElm("#main", true, 100, 60)) as HTMLElement;
|
||||
} catch {
|
||||
console.warn(
|
||||
"[BetterSEQTA+] Analytics: timed out waiting for #main (shell not ready).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
main.innerHTML = "";
|
||||
main.style.overflow = "auto";
|
||||
@@ -41,6 +50,5 @@ async function loadAnalyticsPageInner(): Promise<void> {
|
||||
const titlediv = document.getElementById("title")?.firstChild;
|
||||
if (titlediv) (titlediv as HTMLElement).innerText = "Analytics";
|
||||
|
||||
const { renderAnalyticsPage } = await import("./ui");
|
||||
renderAnalyticsPage(container);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
min-height: min(100%, calc(100vh - 6rem));
|
||||
}
|
||||
|
||||
/* Theme tokens (mount + page); layout padding only on the inner page root */
|
||||
.bsplus-analytics-mount,
|
||||
.bsplus-analytics-root {
|
||||
--bsplus-analytics-radius: 16px;
|
||||
--bsplus-analytics-radius-sm: 12px;
|
||||
@@ -34,21 +36,46 @@
|
||||
/* Set on host via ui.ts from --better-main / user selectedColor */
|
||||
--bsplus-analytics-accent: var(--better-main, #007bff);
|
||||
--bsplus-analytics-chart-height: 300px;
|
||||
--bsplus-analytics-stack-gap: 0.625rem;
|
||||
/* Form controls — aligned with global SEQTA select styling */
|
||||
--bsplus-analytics-control-bg: color-mix(
|
||||
in srgb,
|
||||
var(--bsplus-analytics-surface) 94%,
|
||||
var(--bsplus-analytics-surface-2) 6%
|
||||
);
|
||||
--bsplus-analytics-control-bg-elevated: var(--bsplus-analytics-surface);
|
||||
--bsplus-analytics-control-border: color-mix(
|
||||
in srgb,
|
||||
var(--theme-offset-bg, var(--bsplus-analytics-surface-2)) 88%,
|
||||
transparent
|
||||
);
|
||||
--bsplus-analytics-control-border-strong: color-mix(
|
||||
in srgb,
|
||||
var(--bsplus-analytics-text) 18%,
|
||||
var(--theme-offset-bg, var(--bsplus-analytics-surface-2)) 82%
|
||||
);
|
||||
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
min-height: min(100%, calc(100vh - 6rem));
|
||||
box-sizing: border-box;
|
||||
padding: 1.5rem 1.25rem 2rem;
|
||||
}
|
||||
|
||||
.bsplus-analytics-mount {
|
||||
min-height: min(100%, calc(100vh - 6rem));
|
||||
}
|
||||
|
||||
.bsplus-analytics-root {
|
||||
font-family: Rubik, system-ui, sans-serif;
|
||||
font-size: 0.875rem;
|
||||
color: var(--bsplus-analytics-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.bsplus-analytics-mount.dark,
|
||||
.bsplus-analytics-root.dark {
|
||||
--bsplus-analytics-shadow: 0 5px 20px 6px rgba(0, 0, 0, 0.45);
|
||||
--bsplus-analytics-shadow-hover: 0 10px 28px 10px rgba(0, 0, 0, 0.55);
|
||||
@@ -56,8 +83,8 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bsplus-analytics-root {
|
||||
padding: 1.25rem 1rem 1.5rem;
|
||||
gap: 1.5rem;
|
||||
padding: 0.875rem 0.75rem 1rem;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +136,7 @@
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1.25rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bsplus-analytics-header-actions {
|
||||
@@ -138,7 +165,7 @@
|
||||
}
|
||||
|
||||
.bsplus-analytics-header-text h1 {
|
||||
margin: 0 0 0.35rem;
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
@@ -154,7 +181,7 @@
|
||||
}
|
||||
|
||||
.bsplus-analytics-meta {
|
||||
margin-top: 0.5rem;
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--bsplus-analytics-muted);
|
||||
}
|
||||
@@ -266,58 +293,200 @@
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* ─── Stat cards ─── */
|
||||
.bsplus-analytics-stats {
|
||||
/* ─── Sidebar layout (shop-style filters) ─── */
|
||||
.bsplus-analytics-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--bsplus-analytics-stack-gap);
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--bsplus-analytics-stack-gap);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
padding-right: 0.375rem;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters-title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--bsplus-analytics-text);
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters-clear {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--bsplus-analytics-accent);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15em;
|
||||
text-decoration-color: color-mix(
|
||||
in srgb,
|
||||
var(--bsplus-analytics-accent) 45%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters-clear:hover {
|
||||
color: var(--bsplus-analytics-text);
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters-clear:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bsplus-analytics-filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bsplus-analytics-filter-group .bsplus-analytics-field-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bsplus-analytics-muted);
|
||||
}
|
||||
|
||||
.bsplus-analytics-filter-subject-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters .bsplus-analytics-dropdown {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters .bsplus-analytics-dropdown-trigger {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 2.35rem;
|
||||
padding-top: 0.4rem;
|
||||
padding-bottom: 0.4rem;
|
||||
background-color: var(--bsplus-analytics-control-bg-elevated);
|
||||
border-color: var(--bsplus-analytics-control-border-strong);
|
||||
box-shadow: 0 1px 4px
|
||||
color-mix(in srgb, var(--bsplus-analytics-text) 10%, transparent);
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters .bsplus-analytics-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 2.35rem;
|
||||
padding-top: 0.4rem;
|
||||
padding-bottom: 0.4rem;
|
||||
padding-left: 2.25rem;
|
||||
padding-right: 0;
|
||||
background-color: var(--bsplus-analytics-control-bg-elevated);
|
||||
border-color: var(--bsplus-analytics-control-border-strong);
|
||||
box-shadow: 0 1px 4px
|
||||
color-mix(in srgb, var(--bsplus-analytics-text) 10%, transparent);
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters .bsplus-grade-range-slider {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters .bsplus-analytics-range-value {
|
||||
min-width: 3.75rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.bsplus-analytics-layout {
|
||||
grid-template-columns: minmax(13rem, 15rem) minmax(0, 1fr);
|
||||
gap: var(--bsplus-analytics-stack-gap);
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters {
|
||||
position: sticky;
|
||||
top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.bsplus-analytics-filters-head {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Stat strip ─── */
|
||||
.bsplus-analytics-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.bsplus-analytics-stats {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bsplus-analytics-stat {
|
||||
padding: 1.1rem 1.25rem;
|
||||
border-radius: var(
|
||||
--bsplus-theme-card-radius,
|
||||
var(--bsplus-analytics-radius)
|
||||
);
|
||||
background: var(--bsplus-theme-card-bg, var(--bsplus-analytics-surface));
|
||||
border: var(
|
||||
--bsplus-theme-card-border,
|
||||
1px solid var(--bsplus-analytics-border)
|
||||
);
|
||||
box-shadow: var(--bsplus-theme-card-shadow, var(--bsplus-analytics-shadow));
|
||||
backdrop-filter: var(--bsplus-theme-card-blur, none);
|
||||
transition:
|
||||
transform 0.25s var(--bsplus-analytics-ease),
|
||||
box-shadow 0.25s var(--bsplus-analytics-ease);
|
||||
}
|
||||
|
||||
.bsplus-analytics-stat:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(
|
||||
--bsplus-theme-card-shadow-hover,
|
||||
var(--bsplus-analytics-shadow-hover)
|
||||
);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.bsplus-analytics-stat-label {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
color: var(--bsplus-analytics-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-stat-label::after {
|
||||
content: ":";
|
||||
}
|
||||
|
||||
.bsplus-analytics-stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
line-height: 1.2;
|
||||
color: var(--bsplus-analytics-text);
|
||||
}
|
||||
|
||||
@@ -325,95 +494,11 @@
|
||||
color: var(--bsplus-analytics-accent);
|
||||
}
|
||||
|
||||
/* ─── Filter toolbar ─── */
|
||||
.bsplus-analytics-toolbar {
|
||||
padding: 1rem 1.15rem;
|
||||
border-radius: var(
|
||||
--bsplus-theme-card-radius,
|
||||
var(--bsplus-analytics-radius)
|
||||
);
|
||||
background: var(--bsplus-theme-card-bg, var(--bsplus-analytics-surface));
|
||||
border: var(
|
||||
--bsplus-theme-card-border,
|
||||
1px solid var(--bsplus-analytics-border)
|
||||
);
|
||||
box-shadow: var(--bsplus-theme-card-shadow, var(--bsplus-analytics-shadow));
|
||||
backdrop-filter: var(--bsplus-theme-card-blur, none);
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.bsplus-analytics-toolbar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(9rem, 1fr) minmax(9rem, 1fr) minmax(12rem, 1.4fr) auto;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 0.9rem 1rem;
|
||||
align-items: end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bsplus-analytics-toolbar-clear {
|
||||
grid-column: 4;
|
||||
grid-row: 1;
|
||||
justify-self: end;
|
||||
align-self: end;
|
||||
padding: 0.65rem 0.9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bsplus-analytics-toolbar-trends {
|
||||
grid-column: 4;
|
||||
grid-row: 2;
|
||||
justify-self: end;
|
||||
align-self: center;
|
||||
margin-left: 0;
|
||||
max-width: 14rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.bsplus-analytics-toolbar-trends-top {
|
||||
grid-row: 1;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.bsplus-analytics-toolbar-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.bsplus-analytics-toolbar-search {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.bsplus-analytics-toolbar-clear {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: auto;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.bsplus-analytics-grade-range {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.bsplus-analytics-toolbar-trends {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: auto;
|
||||
justify-self: start;
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.bsplus-analytics-toolbar-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.bsplus-analytics-toolbar-dropdown-field {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
.bsplus-analytics-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--bsplus-analytics-stack-gap);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-field {
|
||||
@@ -432,11 +517,13 @@
|
||||
}
|
||||
|
||||
.bsplus-analytics-checkbox {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.35rem 0;
|
||||
font-size: 0.8125rem;
|
||||
height: 20px;
|
||||
padding-left: 25px;
|
||||
padding-right: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--bsplus-analytics-text);
|
||||
cursor: pointer;
|
||||
@@ -445,12 +532,67 @@
|
||||
}
|
||||
|
||||
.bsplus-analytics-checkbox input[type="checkbox"] {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
accent-color: var(--bsplus-analytics-accent);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-checkmark {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
box-sizing: content-box;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border: 3px solid
|
||||
var(--item-colour, var(--better-main, var(--bsplus-analytics-accent)));
|
||||
border-radius: 5px;
|
||||
color: var(--bsplus-analytics-text);
|
||||
}
|
||||
|
||||
.bsplus-analytics-checkbox:hover
|
||||
input:not(:disabled)
|
||||
~ .bsplus-analytics-checkmark {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
.bsplus-analytics-checkbox input:checked ~ .bsplus-analytics-checkmark {
|
||||
background: var(--item-colour, var(--better-main, var(--bsplus-analytics-accent)));
|
||||
}
|
||||
|
||||
.lc-legend-swatch-group {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bsplus-analytics-checkmark::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
border: solid white;
|
||||
border-width: 0 3px 3px 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-checkbox input:checked ~ .bsplus-analytics-checkmark::after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bsplus-analytics-checkbox input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bsplus-analytics-checkbox input:disabled ~ .bsplus-analytics-checkmark,
|
||||
.bsplus-analytics-checkbox input:disabled ~ span:not(.bsplus-analytics-checkmark) {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bsplus-analytics-select,
|
||||
@@ -460,16 +602,24 @@
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--bsplus-analytics-text);
|
||||
background: var(--bsplus-analytics-surface-2);
|
||||
border: 2px solid var(--bsplus-analytics-border);
|
||||
background-color: var(--bsplus-analytics-control-bg);
|
||||
border: 2px solid var(--bsplus-analytics-control-border);
|
||||
border-radius: var(--bsplus-analytics-radius-sm);
|
||||
padding: 0.65rem 2.25rem 0.65rem 0.9rem;
|
||||
min-height: 2.75rem;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s var(--bsplus-analytics-ease);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 3px
|
||||
color-mix(in srgb, var(--bsplus-analytics-text) 8%, transparent);
|
||||
}
|
||||
|
||||
.bsplus-analytics-card .bsplus-analytics-select,
|
||||
.bsplus-analytics-card .bsplus-analytics-input,
|
||||
.bsplus-analytics-card .bsplus-analytics-dropdown-trigger {
|
||||
background-color: var(--bsplus-analytics-surface-2);
|
||||
}
|
||||
|
||||
.bsplus-analytics-select {
|
||||
@@ -497,26 +647,58 @@
|
||||
|
||||
.bsplus-analytics-select:hover,
|
||||
.bsplus-analytics-input:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--bsplus-analytics-surface) 96%,
|
||||
var(--bsplus-analytics-surface-2) 4%
|
||||
);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--bsplus-analytics-accent) 35%,
|
||||
var(--bsplus-analytics-border)
|
||||
var(--bsplus-analytics-control-border)
|
||||
);
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters .bsplus-analytics-dropdown-trigger:hover {
|
||||
background-color: var(--bsplus-analytics-control-bg-elevated);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--bsplus-analytics-accent) 40%,
|
||||
var(--bsplus-analytics-control-border-strong)
|
||||
);
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters .bsplus-analytics-input:hover {
|
||||
background-color: var(--bsplus-analytics-control-bg-elevated);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--bsplus-analytics-accent) 40%,
|
||||
var(--bsplus-analytics-control-border-strong)
|
||||
);
|
||||
}
|
||||
|
||||
.bsplus-analytics-select:focus,
|
||||
.bsplus-analytics-input:focus {
|
||||
outline: none;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--bsplus-analytics-surface) 96%,
|
||||
var(--bsplus-analytics-surface-2) 4%
|
||||
);
|
||||
border-color: var(--bsplus-analytics-accent);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--bsplus-analytics-accent) 22%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--bsplus-analytics-text) 12%, transparent),
|
||||
0 0 0 3px color-mix(in srgb, var(--bsplus-analytics-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.bsplus-analytics-grade-range {
|
||||
grid-column: 1 / 4;
|
||||
grid-row: 2;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
.bsplus-analytics-filters .bsplus-analytics-dropdown-trigger:focus-visible {
|
||||
background-color: var(--bsplus-analytics-control-bg-elevated);
|
||||
border-color: var(--bsplus-analytics-accent);
|
||||
}
|
||||
|
||||
.bsplus-analytics-filters .bsplus-analytics-input:focus {
|
||||
background-color: var(--bsplus-analytics-control-bg-elevated);
|
||||
border-color: var(--bsplus-analytics-accent);
|
||||
}
|
||||
|
||||
.bsplus-analytics-range-value {
|
||||
@@ -528,24 +710,16 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.bsplus-analytics-toolbar-search {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Toolbar custom dropdowns (time period, subjects) */
|
||||
/* Custom dropdowns (time period, subjects) */
|
||||
.bsplus-analytics-dropdown {
|
||||
position: relative;
|
||||
min-width: 11rem;
|
||||
}
|
||||
|
||||
.dark .bsplus-analytics-dropdown-trigger {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='rgba(255,255,255,0.72)'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bsplus-analytics-dropdown-trigger {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem 2.25rem 0.65rem 0.9rem;
|
||||
@@ -554,35 +728,86 @@
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
color: var(--bsplus-analytics-text);
|
||||
background: var(--bsplus-analytics-surface-2);
|
||||
border: 2px solid var(--bsplus-analytics-border);
|
||||
background-color: var(--bsplus-analytics-control-bg);
|
||||
border: 2px solid var(--bsplus-analytics-control-border);
|
||||
border-radius: var(--bsplus-analytics-radius-sm);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2364748b'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E");
|
||||
background-position: right 0.75rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1rem;
|
||||
box-shadow: 0 1px 3px
|
||||
color-mix(in srgb, var(--bsplus-analytics-text) 8%, transparent);
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s var(--bsplus-analytics-ease);
|
||||
}
|
||||
|
||||
.bsplus-analytics-dropdown-trigger::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.9rem;
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-right: 2px solid var(--bsplus-analytics-muted);
|
||||
border-bottom: 2px solid var(--bsplus-analytics-muted);
|
||||
transform: translateY(-65%) rotate(45deg);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s var(--bsplus-analytics-ease);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bsplus-analytics-dropdown-trigger::after {
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.bsplus-analytics-dropdown-trigger:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--bsplus-analytics-surface) 96%,
|
||||
var(--bsplus-analytics-surface-2) 4%
|
||||
);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--bsplus-analytics-accent) 35%,
|
||||
var(--bsplus-analytics-border)
|
||||
var(--bsplus-analytics-control-border)
|
||||
);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
.bsplus-analytics-dropdown-trigger:hover::after {
|
||||
border-color: var(--bsplus-analytics-text);
|
||||
}
|
||||
|
||||
.bsplus-analytics-card .bsplus-analytics-dropdown-trigger:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--bsplus-analytics-surface-2) 92%,
|
||||
var(--bsplus-analytics-surface) 8%
|
||||
);
|
||||
}
|
||||
|
||||
.bsplus-analytics-dropdown-trigger:focus-visible {
|
||||
outline: none;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--bsplus-analytics-surface) 96%,
|
||||
var(--bsplus-analytics-surface-2) 4%
|
||||
);
|
||||
border-color: var(--bsplus-analytics-accent);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--bsplus-analytics-accent) 22%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--bsplus-analytics-text) 12%, transparent),
|
||||
0 0 0 3px color-mix(in srgb, var(--bsplus-analytics-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.bsplus-analytics-dropdown-trigger:focus-visible::after {
|
||||
border-color: var(--bsplus-analytics-accent);
|
||||
}
|
||||
|
||||
.bsplus-analytics-dropdown-trigger[aria-expanded="true"]::after {
|
||||
transform: translateY(-35%) rotate(225deg);
|
||||
}
|
||||
|
||||
.bsplus-analytics-dropdown-menu {
|
||||
@@ -595,9 +820,9 @@
|
||||
overflow-y: auto;
|
||||
padding: 0.35rem;
|
||||
border-radius: var(--bsplus-analytics-radius-sm);
|
||||
background: var(--bsplus-analytics-surface);
|
||||
border: 1px solid var(--bsplus-analytics-border);
|
||||
box-shadow: var(--bsplus-analytics-shadow-hover);
|
||||
background: var(--bsplus-analytics-control-bg-elevated);
|
||||
border: 1px solid var(--bsplus-analytics-control-border-strong);
|
||||
box-shadow: var(--bsplus-analytics-shadow);
|
||||
}
|
||||
|
||||
.bsplus-analytics-dropdown-item {
|
||||
@@ -645,7 +870,7 @@
|
||||
.bsplus-analytics-charts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
gap: var(--bsplus-analytics-stack-gap);
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -660,42 +885,17 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-chart-cell > :global(.bsplus-analytics-animate) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.bsplus-analytics-chart-cell > :global(.bsplus-analytics-card) {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-chart-cell :global(.bsplus-analytics-card) {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Fade-in animation must not paint above the filter toolbar / dropdown */
|
||||
.bsplus-analytics-charts > .bsplus-analytics-animate {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-charts > .bsplus-analytics-animate > .bsplus-analytics-card {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.bsplus-analytics-charts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.bsplus-analytics-charts .bsplus-analytics-card-header {
|
||||
min-height: 5.75rem;
|
||||
box-sizing: border-box;
|
||||
align-items: flex-end;
|
||||
gap: var(--bsplus-analytics-stack-gap);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,10 +930,9 @@
|
||||
.bsplus-analytics-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
min-height: 4.75rem;
|
||||
padding: 1.15rem 1.25rem;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem 0.75rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-bottom: 1px solid var(--bsplus-analytics-border);
|
||||
}
|
||||
|
||||
@@ -741,27 +940,40 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bsplus-analytics-card-header-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bsplus-analytics-card-title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.bsplus-analytics-card-title-row .bsplus-analytics-card-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-card-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem 1rem;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-forecast-controls {
|
||||
min-width: min(100%, 18rem);
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.bsplus-analytics-forecast-toggle {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
height: 20px;
|
||||
padding: 0 0 0 25px;
|
||||
font-size: 0.8125rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bsplus-analytics-forecast-horizon {
|
||||
flex: 1;
|
||||
min-width: 11rem;
|
||||
min-width: 8.5rem;
|
||||
max-width: 11rem;
|
||||
}
|
||||
|
||||
.bsplus-analytics-forecast-line {
|
||||
@@ -816,18 +1028,20 @@
|
||||
}
|
||||
|
||||
.bsplus-analytics-card-desc {
|
||||
margin: 0.25rem 0 0;
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bsplus-analytics-muted);
|
||||
}
|
||||
|
||||
.bsplus-analytics-card-body {
|
||||
padding: 1rem 1.15rem;
|
||||
padding: 0.5rem 0.85rem 0.65rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--bsplus-analytics-surface);
|
||||
}
|
||||
|
||||
@@ -836,19 +1050,14 @@
|
||||
}
|
||||
|
||||
.bsplus-analytics-card-footer {
|
||||
padding: 0.85rem 1.25rem 1.1rem;
|
||||
padding: 0.5rem 0.85rem 0.65rem;
|
||||
border-top: 1px solid var(--bsplus-analytics-border);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bsplus-analytics-muted);
|
||||
line-height: 1.5;
|
||||
line-height: 1.4;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.bsplus-analytics-charts .bsplus-analytics-card-footer {
|
||||
min-height: 4.25rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bsplus-analytics-card-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -884,21 +1093,13 @@
|
||||
color: var(--bsplus-analytics-muted);
|
||||
}
|
||||
|
||||
.bsplus-analytics-root .bsplus-chart-surface {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: var(--bsplus-analytics-chart-height, 280px);
|
||||
min-height: var(--bsplus-analytics-chart-height, 280px);
|
||||
max-height: var(--bsplus-analytics-chart-height, 280px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bsplus-analytics-root .bsplus-chart-surface,
|
||||
.bsplus-analytics-root .bsplus-chart-surface-bar {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 320px;
|
||||
min-height: 320px;
|
||||
max-height: 320px;
|
||||
height: var(--bsplus-analytics-chart-height, 300px);
|
||||
min-height: var(--bsplus-analytics-chart-height, 300px);
|
||||
max-height: var(--bsplus-analytics-chart-height, 300px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1033,7 +1234,7 @@
|
||||
}
|
||||
|
||||
.bsplus-analytics-table-header {
|
||||
padding: 1rem 1.25rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-bottom: 1px solid var(--bsplus-analytics-border);
|
||||
}
|
||||
|
||||
@@ -1062,7 +1263,7 @@
|
||||
}
|
||||
|
||||
.bsplus-analytics-table th {
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
color: var(--bsplus-analytics-muted);
|
||||
@@ -1085,7 +1286,7 @@
|
||||
}
|
||||
|
||||
.bsplus-analytics-table td {
|
||||
padding: 0.7rem 1rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-top: 1px solid var(--bsplus-analytics-border);
|
||||
color: var(--bsplus-analytics-text);
|
||||
}
|
||||
@@ -1128,8 +1329,8 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
gap: 0.65rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-top: 1px solid var(--bsplus-analytics-border);
|
||||
color: var(--bsplus-analytics-muted);
|
||||
font-size: 0.8125rem;
|
||||
@@ -1150,8 +1351,8 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0;
|
||||
color: var(--bsplus-analytics-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -1,4 +1,4 @@
|
||||
import tailwindStyles from "@/interface/index.css?inline";
|
||||
import tailwindStyles from "./tailwind.css?inline";
|
||||
import pluginStyles from "./styles.css?inline";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { mount, unmount } from "svelte";
|
||||
@@ -124,6 +124,7 @@ function syncThemeFromPage(target: HTMLElement) {
|
||||
|
||||
target.style.setProperty("--bsplus-analytics-accent", palette.accent);
|
||||
target.style.setProperty("--bsplus-analytics-accent-subtle", palette.accentSubtle);
|
||||
target.style.setProperty("--item-colour", palette.accent);
|
||||
target.style.setProperty(
|
||||
"--bsplus-analytics-forecast",
|
||||
`color-mix(in srgb, ${palette.accent} 72%, ${target.classList.contains("dark") ? "#f8fafc" : "#64748b"})`,
|
||||
@@ -210,7 +211,7 @@ export function renderAnalyticsPage(container: HTMLElement) {
|
||||
shadow.appendChild(styleElement);
|
||||
|
||||
analyticsRoot = document.createElement("div");
|
||||
analyticsRoot.className = "bsplus-analytics-root";
|
||||
analyticsRoot.className = "bsplus-analytics-mount";
|
||||
syncThemeToAnalyticsUi();
|
||||
shadow.appendChild(analyticsRoot);
|
||||
|
||||
|
||||
@@ -67,6 +67,44 @@ function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||
}
|
||||
|
||||
function isAllowedFolderColor(color: unknown): color is string {
|
||||
return typeof color === "string" && FOLDER_COLORS.includes(color);
|
||||
}
|
||||
|
||||
function isAllowedFolderIcon(icon: unknown): icon is string {
|
||||
return typeof icon === "string" && FOLDER_HEROICONS.includes(icon);
|
||||
}
|
||||
|
||||
function normalizeFolder(folder: Folder): Folder {
|
||||
return {
|
||||
id: typeof folder.id === "string" && folder.id ? folder.id : generateId(),
|
||||
name: typeof folder.name === "string" ? folder.name.trim().slice(0, 30) : "Folder",
|
||||
color: isAllowedFolderColor(folder.color) ? folder.color : FOLDER_COLORS[0],
|
||||
emoji: isAllowedFolderIcon(folder.emoji) ? folder.emoji : FOLDER_HEROICONS[0],
|
||||
};
|
||||
}
|
||||
|
||||
function setSvgIconContent(parent: HTMLElement, svgMarkup: string): void {
|
||||
parent.replaceChildren();
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = svgMarkup.trim();
|
||||
const node = template.content.firstElementChild;
|
||||
if (node) parent.appendChild(node);
|
||||
}
|
||||
|
||||
function appendFolderBadgeContent(badge: HTMLElement, folder: Folder): void {
|
||||
badge.replaceChildren();
|
||||
if (folder.emoji) {
|
||||
const iconWrap = document.createElement("span");
|
||||
iconWrap.style.display = "inline-flex";
|
||||
iconWrap.style.verticalAlign = "middle";
|
||||
iconWrap.style.marginRight = "2px";
|
||||
setSvgIconContent(iconWrap, folder.emoji);
|
||||
badge.appendChild(iconWrap);
|
||||
}
|
||||
badge.appendChild(document.createTextNode(folder.name));
|
||||
}
|
||||
|
||||
const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFoldersStorage> = {
|
||||
id: "messageFolders",
|
||||
name: "Message Folders",
|
||||
@@ -95,7 +133,8 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
let foldedSection: HTMLElement | null = null;
|
||||
const unregisters: Array<{ unregister: () => void }> = [];
|
||||
|
||||
const getFolders = (): Folder[] => api.storage.folders ?? [];
|
||||
const getFolders = (): Folder[] =>
|
||||
(api.storage.folders ?? []).map((folder) => normalizeFolder(folder));
|
||||
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
|
||||
|
||||
const saveFolders = (folders: Folder[]) => {
|
||||
@@ -298,7 +337,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.className = "bsplus-folder-icon";
|
||||
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
|
||||
setSvgIconContent(iconSpan, folder.emoji || FOLDER_HEROICONS[0]);
|
||||
item.appendChild(iconSpan);
|
||||
|
||||
const name = document.createElement("span");
|
||||
@@ -622,7 +661,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.className = "bsplus-folder-icon";
|
||||
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
|
||||
setSvgIconContent(iconSpan, folder.emoji || FOLDER_HEROICONS[0]);
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.textContent = folder.name;
|
||||
@@ -725,7 +764,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
dot.style.background = folder.color;
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.className = "bsplus-folder-icon";
|
||||
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
|
||||
setSvgIconContent(iconSpan, folder.emoji || FOLDER_HEROICONS[0]);
|
||||
const name = document.createElement("span");
|
||||
name.textContent = folder.name;
|
||||
item.appendChild(dot);
|
||||
@@ -810,7 +849,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "bsplus-msg-badge";
|
||||
badge.style.background = folder.color;
|
||||
badge.innerHTML = `${folder.emoji ? `<span style="display:inline-flex;vertical-align:middle;margin-right:2px">${folder.emoji}</span>` : ""}${folder.name}`;
|
||||
appendFolderBadgeContent(badge, folder);
|
||||
badge.title = `Filter by "${folder.name}"`;
|
||||
badge.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -62,6 +62,10 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Heartbeat HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Store notification count for history
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import renderSvelte from "@/interface/main";
|
||||
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
|
||||
import { unmount } from "svelte";
|
||||
import themeCreator from "@/interface/pages/themeCreator.svelte";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
|
||||
let themeCreatorSvelteApp: any = null;
|
||||
@@ -11,10 +9,15 @@ let themeCreatorSvelteApp: any = null;
|
||||
* @param themeID - The ID of the theme to load in the Theme Creator
|
||||
* @returns void
|
||||
*/
|
||||
export function OpenThemeCreator(themeID: string = "") {
|
||||
export async function OpenThemeCreator(themeID: string = "") {
|
||||
CloseThemeCreator();
|
||||
|
||||
// Only store original color if we're not editing an existing theme
|
||||
const [{ default: renderSvelte }, { default: themeCreator }] =
|
||||
await Promise.all([
|
||||
import("@/interface/main"),
|
||||
import("@/interface/pages/themeCreator.svelte"),
|
||||
]);
|
||||
|
||||
localStorage.setItem("themeCreatorOpen", "true");
|
||||
if (!themeID) {
|
||||
localStorage.setItem("originalPreviewColor", settingsState.selectedColor);
|
||||
@@ -34,7 +37,6 @@ export function OpenThemeCreator(themeID: string = "") {
|
||||
const mainContent = document.querySelector("#container") as HTMLDivElement;
|
||||
if (mainContent) mainContent.style.width = `calc(100% - ${width})`;
|
||||
|
||||
// close button
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.classList.add("themeCloseButton");
|
||||
closeButton.textContent = "×";
|
||||
@@ -92,7 +94,6 @@ export function OpenThemeCreator(themeID: string = "") {
|
||||
* @returns void
|
||||
*/
|
||||
export function CloseThemeCreator() {
|
||||
// Remove the stored flag
|
||||
localStorage.removeItem("themeCreatorOpen");
|
||||
|
||||
const themeCreator = document.getElementById("themeCreator");
|
||||
|
||||
@@ -10,8 +10,8 @@ import { BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY } from "@/seqta/utils/cloud
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import debounce from "@/seqta/utils/debounce";
|
||||
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import { getApiBase } from "@/seqta/utils/DevApiBase";
|
||||
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
|
||||
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
||||
import {
|
||||
clearCustomThemeAdaptiveCssVariables,
|
||||
@@ -667,8 +667,12 @@ export class ThemeManager {
|
||||
if (!downloadData?.success || !downloadData?.data?.theme_json_url) {
|
||||
throw new Error("Failed to get theme download URL");
|
||||
}
|
||||
const themeJsonUrl = downloadData.data.theme_json_url;
|
||||
if (!isAllowedFetchUrl(themeJsonUrl)) {
|
||||
throw new Error("Theme download URL not allowed");
|
||||
}
|
||||
themeData = (await this.fetchFromUrl(
|
||||
downloadData.data.theme_json_url,
|
||||
themeJsonUrl,
|
||||
)) as ThemeContent;
|
||||
} catch (apiError) {
|
||||
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
|
||||
@@ -796,10 +800,8 @@ export class ThemeManager {
|
||||
this.storeUpdateCheckRunning = true;
|
||||
localStorage.setItem(ThemeManager.STORE_CHECK_KEY, String(Date.now()));
|
||||
try {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: "fetchThemes",
|
||||
token: token ?? undefined,
|
||||
})) as {
|
||||
success?: boolean;
|
||||
data?: { themes?: Array<{ id: string; updated_at?: number }> };
|
||||
|
||||
@@ -84,6 +84,8 @@ async function handleTimetable(): Promise<void> {
|
||||
}
|
||||
|
||||
function handleTimetableZoom(): void {
|
||||
if (document.querySelector(".timetable-zoom-controls")) return;
|
||||
|
||||
console.log("Initializing timetable zoom controls");
|
||||
|
||||
// Create zoom controls
|
||||
@@ -130,6 +132,8 @@ function handleTimetableZoom(): void {
|
||||
}
|
||||
|
||||
function handleTimetableAssessmentHide(): void {
|
||||
if (document.querySelector(".timetable-hide-controls")) return;
|
||||
|
||||
const hideControls = document.createElement("div");
|
||||
hideControls.className = "timetable-hide-controls";
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { eventManager } from "@/seqta/utils/listeners/EventManager";
|
||||
import ReactFiber from "@/seqta/utils/ReactFiber";
|
||||
import browser from "webextension-polyfill";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import type { SettingsState } from "@/types/storage";
|
||||
|
||||
function createSEQTAAPI(): SEQTAAPI {
|
||||
return {
|
||||
@@ -85,7 +86,10 @@ function createSEQTAAPI(): SEQTAAPI {
|
||||
*/
|
||||
function createSettingsAPI<T extends PluginSettings>(
|
||||
plugin: Plugin<T>,
|
||||
): SettingsAPI<T> & { loaded: Promise<void> } {
|
||||
): {
|
||||
settings: SettingsAPI<T> & { loaded: Promise<void> };
|
||||
dispose: () => void;
|
||||
} {
|
||||
const storageKey = `plugin.${plugin.id}.settings`;
|
||||
const listeners = new Map<keyof T, Set<(value: any) => void>>();
|
||||
|
||||
@@ -146,26 +150,28 @@ function createSettingsAPI<T extends PluginSettings>(
|
||||
|
||||
settingsWithMeta.loaded = loaded;
|
||||
|
||||
// Listen for storage changes and update settingsWithMeta
|
||||
const handleStorageChange = (
|
||||
changes: { [key: string]: browser.Storage.StorageChange },
|
||||
area: string,
|
||||
) => {
|
||||
if (area !== "local" || !(storageKey in changes)) return;
|
||||
const handleSettingsChange = (newValue: unknown) => {
|
||||
if (!newValue || typeof newValue !== "object") return;
|
||||
|
||||
const newValue = changes[storageKey].newValue as
|
||||
| Partial<Record<keyof T, any>>
|
||||
| undefined;
|
||||
if (!newValue) return;
|
||||
|
||||
for (const key in newValue) {
|
||||
const newSettings = newValue as Partial<Record<keyof T, any>>;
|
||||
for (const key in newSettings) {
|
||||
const typedKey = key as keyof T;
|
||||
settingsWithMeta[typedKey] = newValue[typedKey];
|
||||
listeners.get(typedKey)?.forEach((cb) => cb(newValue[typedKey]));
|
||||
settingsWithMeta[typedKey] = newSettings[typedKey];
|
||||
listeners.get(typedKey)?.forEach((cb) => cb(newSettings[typedKey]));
|
||||
}
|
||||
};
|
||||
|
||||
browser.storage.onChanged.addListener(handleStorageChange);
|
||||
settingsState.register(
|
||||
storageKey as keyof SettingsState,
|
||||
handleSettingsChange,
|
||||
);
|
||||
|
||||
const dispose = () => {
|
||||
settingsState.unregister(
|
||||
storageKey as keyof SettingsState,
|
||||
handleSettingsChange,
|
||||
);
|
||||
};
|
||||
|
||||
const proxy = new Proxy(settingsWithMeta, {
|
||||
get(target, prop) {
|
||||
@@ -183,6 +189,17 @@ function createSettingsAPI<T extends PluginSettings>(
|
||||
dataToStore[key] = target[key];
|
||||
}
|
||||
|
||||
// Preserve enabled flag managed separately for disableToggle plugins
|
||||
if (plugin.disableToggle) {
|
||||
const allSettings = settingsState.getAll() as Record<string, unknown>;
|
||||
const existing = allSettings[storageKey] as
|
||||
| { enabled?: boolean }
|
||||
| undefined;
|
||||
if (existing?.enabled !== undefined) {
|
||||
dataToStore.enabled = existing.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
browser.storage.local.set({ [storageKey]: dataToStore });
|
||||
|
||||
listeners.get(prop as keyof T)?.forEach((cb) => cb(value));
|
||||
@@ -190,18 +207,18 @@ function createSettingsAPI<T extends PluginSettings>(
|
||||
},
|
||||
}) as SettingsAPI<T> & { loaded: Promise<void> };
|
||||
|
||||
return proxy;
|
||||
return { settings: proxy, dispose };
|
||||
}
|
||||
|
||||
function createStorageAPI<T = any>(
|
||||
pluginId: string,
|
||||
): StorageAPI<T> & { [K in keyof T]: T[K] } {
|
||||
): {
|
||||
storage: StorageAPI<T> & { [K in keyof T]: T[K] };
|
||||
dispose: () => void;
|
||||
} {
|
||||
const prefix = `plugin.${pluginId}.storage.`;
|
||||
const cache: Record<string, any> = {};
|
||||
const listeners = new Map<string, Set<(value: any) => void>>();
|
||||
const storageListeners = new Set<
|
||||
(changes: { [key: string]: any }, area: string) => void
|
||||
>();
|
||||
|
||||
// Load all existing storage values for this plugin
|
||||
const loadStoragePromise = (async () => {
|
||||
@@ -223,30 +240,26 @@ function createStorageAPI<T = any>(
|
||||
}
|
||||
})();
|
||||
|
||||
// Listen for storage changes
|
||||
const handleStorageChange = (
|
||||
changes: { [key: string]: any },
|
||||
area: string,
|
||||
newValue: unknown,
|
||||
_oldValue: unknown,
|
||||
key: string,
|
||||
) => {
|
||||
if (area === "local") {
|
||||
Object.entries(changes).forEach(([key, change]) => {
|
||||
if (key.startsWith(prefix)) {
|
||||
const shortKey = key.slice(prefix.length);
|
||||
cache[shortKey] = change.newValue;
|
||||
if (!key.startsWith(prefix)) return;
|
||||
|
||||
// Notify listeners
|
||||
listeners
|
||||
.get(shortKey)
|
||||
?.forEach((callback) => callback(change.newValue));
|
||||
}
|
||||
});
|
||||
}
|
||||
const shortKey = key.slice(prefix.length);
|
||||
cache[shortKey] = newValue;
|
||||
listeners.get(shortKey)?.forEach((callback) => callback(newValue));
|
||||
};
|
||||
|
||||
settingsState.registerGlobal(handleStorageChange);
|
||||
|
||||
const dispose = () => {
|
||||
settingsState.unregisterGlobal(handleStorageChange);
|
||||
};
|
||||
browser.storage.onChanged.addListener(handleStorageChange);
|
||||
storageListeners.add(handleStorageChange);
|
||||
|
||||
// Create the proxy for direct property access
|
||||
return new Proxy(cache, {
|
||||
const storage = new Proxy(cache, {
|
||||
get(target, prop: string) {
|
||||
if (prop === "onChange") {
|
||||
return (key: keyof T, callback: (value: T[keyof T]) => void) => {
|
||||
@@ -288,6 +301,8 @@ function createStorageAPI<T = any>(
|
||||
return true;
|
||||
},
|
||||
}) as StorageAPI<T> & { [K in keyof T]: T[K] };
|
||||
|
||||
return { storage, dispose };
|
||||
}
|
||||
|
||||
function createEventsAPI(pluginId: string): EventsAPI {
|
||||
@@ -357,10 +372,17 @@ function createEventsAPI(pluginId: string): EventsAPI {
|
||||
export function createPluginAPI<T extends PluginSettings, S = any>(
|
||||
plugin: Plugin<T, S>,
|
||||
): PluginAPI<T, S> {
|
||||
const { settings, dispose: disposeSettings } = createSettingsAPI(plugin);
|
||||
const { storage, dispose: disposeStorage } = createStorageAPI<S>(plugin.id);
|
||||
|
||||
return {
|
||||
seqta: createSEQTAAPI(),
|
||||
settings: createSettingsAPI(plugin),
|
||||
storage: createStorageAPI<S>(plugin.id),
|
||||
settings,
|
||||
storage,
|
||||
events: createEventsAPI(plugin.id),
|
||||
dispose: () => {
|
||||
disposeSettings();
|
||||
disposeStorage();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+67
-17
@@ -23,6 +23,23 @@ interface StorageChange<T = any> {
|
||||
newValue?: T;
|
||||
}
|
||||
|
||||
/** Phased plugin startup: critical UI first, light DOM next, heavy plugins last. */
|
||||
const PLUGIN_START_PHASES: readonly string[][] = [
|
||||
["themes", "animated-background"],
|
||||
[
|
||||
"timetable",
|
||||
"timetableEdit",
|
||||
"notificationCollector",
|
||||
"enhanced-navigation",
|
||||
"assessments-overview",
|
||||
"assessments-average",
|
||||
"messageFolders",
|
||||
"profile-picture",
|
||||
"background-music",
|
||||
],
|
||||
["global-search", "grade-analytics"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Singleton class responsible for the entire lifecycle of plugins.
|
||||
* This includes registration, starting, stopping, event dispatching,
|
||||
@@ -35,6 +52,7 @@ export class PluginManager {
|
||||
private runningPlugins: Map<string, boolean> = new Map();
|
||||
private eventBacklog: Map<string, any[]> = new Map();
|
||||
private cleanupFunctions: Map<string, () => void> = new Map();
|
||||
private apiDisposers: Map<string, () => void> = new Map();
|
||||
private listeners: Map<string, Set<(...args: any[]) => void>> = new Map();
|
||||
private styleElements: Map<string, HTMLStyleElement> = new Map();
|
||||
|
||||
@@ -148,6 +166,7 @@ export class PluginManager {
|
||||
|
||||
try {
|
||||
const api = createPluginAPI(plugin);
|
||||
this.apiDisposers.set(pluginId, api.dispose);
|
||||
|
||||
// Check if plugin is enabled before starting
|
||||
if (plugin.disableToggle) {
|
||||
@@ -158,6 +177,7 @@ export class PluginManager {
|
||||
const enabled =
|
||||
pluginSettings?.enabled ?? plugin.defaultEnabled ?? true;
|
||||
if (!enabled) {
|
||||
this.disposePluginAPI(pluginId);
|
||||
console.info(
|
||||
`Plugin "${pluginId}" is disabled, skipping initialization`,
|
||||
);
|
||||
@@ -186,6 +206,8 @@ export class PluginManager {
|
||||
// Process any backlogged events
|
||||
await this.processBackloggedEvents(pluginId);
|
||||
} catch (error) {
|
||||
this.removePluginStyles(pluginId);
|
||||
this.disposePluginAPI(pluginId);
|
||||
console.error(
|
||||
`[BetterSEQTA+] Failed to start plugin ${pluginId}:`,
|
||||
error,
|
||||
@@ -194,25 +216,57 @@ export class PluginManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to start all registered plugins.
|
||||
* Errors during the start of individual plugins are caught and logged,
|
||||
* allowing other plugins to attempt to start.
|
||||
*
|
||||
* @returns {Promise<void>} A promise that resolves when all plugins have attempted to start.
|
||||
* It uses `Promise.allSettled` to wait for all start operations.
|
||||
*/
|
||||
public async startAllPlugins(): Promise<void> {
|
||||
const startPromises = Array.from(this.plugins.keys()).map((id) =>
|
||||
private removePluginStyles(pluginId: string): void {
|
||||
const styleElement = this.styleElements.get(pluginId);
|
||||
if (styleElement) {
|
||||
styleElement.remove();
|
||||
this.styleElements.delete(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
private disposePluginAPI(pluginId: string): void {
|
||||
const dispose = this.apiDisposers.get(pluginId);
|
||||
if (dispose) {
|
||||
dispose();
|
||||
this.apiDisposers.delete(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
private async startPluginPhase(pluginIds: string[]): Promise<void> {
|
||||
const registeredIds = new Set(this.plugins.keys());
|
||||
const idsToStart = pluginIds.filter((id) => registeredIds.has(id));
|
||||
|
||||
const startPromises = idsToStart.map((id) =>
|
||||
this.startPlugin(id).catch((error) => {
|
||||
console.error(`Failed to start plugin "${id}":`, error);
|
||||
return Promise.reject(error); // Still reject to indicate failure for this specific plugin if needed by caller
|
||||
return Promise.reject(error);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.allSettled(startPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to start all registered plugins in phased order.
|
||||
* Errors during the start of individual plugins are caught and logged,
|
||||
* allowing other plugins to attempt to start.
|
||||
*
|
||||
* @returns {Promise<void>} A promise that resolves when all plugins have attempted to start.
|
||||
*/
|
||||
public async startAllPlugins(): Promise<void> {
|
||||
for (const phase of PLUGIN_START_PHASES) {
|
||||
await this.startPluginPhase(phase);
|
||||
}
|
||||
|
||||
const phasedIds = new Set(PLUGIN_START_PHASES.flat());
|
||||
const remainingIds = Array.from(this.plugins.keys()).filter(
|
||||
(id) => !phasedIds.has(id),
|
||||
);
|
||||
if (remainingIds.length > 0) {
|
||||
await this.startPluginPhase(remainingIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops a specific plugin by its ID.
|
||||
* This involves:
|
||||
@@ -225,12 +279,8 @@ export class PluginManager {
|
||||
* @returns {Promise<void>} A promise that resolves when the plugin has been stopped.
|
||||
*/
|
||||
public async stopPlugin(pluginId: string): Promise<void> {
|
||||
// Remove plugin styles
|
||||
const styleElement = this.styleElements.get(pluginId);
|
||||
if (styleElement) {
|
||||
styleElement.remove();
|
||||
this.styleElements.delete(pluginId);
|
||||
}
|
||||
this.removePluginStyles(pluginId);
|
||||
this.disposePluginAPI(pluginId);
|
||||
|
||||
const cleanup = this.cleanupFunctions.get(pluginId);
|
||||
if (cleanup) {
|
||||
|
||||
@@ -141,6 +141,7 @@ export interface PluginAPI<T extends PluginSettings, S = any> {
|
||||
settings: SettingsAPI<T>;
|
||||
storage: TypedStorageAPI<S>;
|
||||
events: EventsAPI;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export interface Plugin<T extends PluginSettings = PluginSettings, S = any> {
|
||||
|
||||
@@ -39,8 +39,6 @@ pluginManager.registerPlugin(enhancedNavigationPlugin);
|
||||
pluginManager.registerPlugin(globalSearchPluginLazy);
|
||||
pluginManager.registerPlugin(gradeAnalyticsPluginLazy);
|
||||
|
||||
export { init as Monofile } from "./monofile";
|
||||
|
||||
export async function initializePlugins(): Promise<void> {
|
||||
await pluginManager.startAllPlugins();
|
||||
}
|
||||
|
||||
+34
-19
@@ -26,7 +26,6 @@ import {
|
||||
updateEngageHomeMenuActive,
|
||||
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||
import { loadAnalyticsPage } from "@/plugins/built-in/gradeAnalytics/loadAnalyticsPage";
|
||||
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
|
||||
|
||||
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
||||
@@ -337,7 +336,11 @@ async function handleSublink(sublink: string | undefined): Promise<void> {
|
||||
break;
|
||||
case "analytics":
|
||||
console.info("[BetterSEQTA+] Started Init (Analytics)");
|
||||
if (settingsState.onoff) void loadAnalyticsPage();
|
||||
if (settingsState.onoff) {
|
||||
void import("@/plugins/built-in/gradeAnalytics/loadAnalyticsPage").then(
|
||||
(m) => m.loadAnalyticsPage(),
|
||||
);
|
||||
}
|
||||
finishLoad();
|
||||
break;
|
||||
case undefined:
|
||||
@@ -488,25 +491,37 @@ async function handleReports(node: Element): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function CheckNoticeTextColour(notice: any) {
|
||||
eventManager.register(
|
||||
"noticeAdded",
|
||||
{
|
||||
elementType: "div",
|
||||
className: "notice",
|
||||
parentElement: notice,
|
||||
},
|
||||
(node) => {
|
||||
var hex = (node as HTMLElement).style.cssText.split(" ")[1];
|
||||
if (hex) {
|
||||
const hex1 = hex.slice(0, -1);
|
||||
var threshold = GetThresholdOfColor(hex1);
|
||||
if (settingsState.DarkMode && threshold < 100) {
|
||||
(node as HTMLElement).style.cssText = "--color: undefined;";
|
||||
function CheckNoticeTextColour(notice: Element) {
|
||||
const adjustNoticeColor = (node: Element) => {
|
||||
const hex = (node as HTMLElement).style.cssText.split(" ")[1];
|
||||
if (hex) {
|
||||
const hex1 = hex.slice(0, -1);
|
||||
const threshold = GetThresholdOfColor(hex1);
|
||||
if (settingsState.DarkMode && threshold < 100) {
|
||||
(node as HTMLElement).style.cssText = "--color: undefined;";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const node of notice.querySelectorAll("div.notice")) {
|
||||
adjustNoticeColor(node);
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const added of mutation.addedNodes) {
|
||||
if (!(added instanceof Element)) continue;
|
||||
if (added.matches("div.notice")) {
|
||||
adjustNoticeColor(added);
|
||||
}
|
||||
for (const node of added.querySelectorAll("div.notice")) {
|
||||
adjustNoticeColor(node);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(notice, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
function watchForEngageLogin() {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export { init as Monofile } from "./monofile";
|
||||
|
||||
export async function initializePlugins(): Promise<void> {
|
||||
const { pluginManager } = await import("./index");
|
||||
await pluginManager.startAllPlugins();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Serializable plugin setting defaults for cloud sync backfill.
|
||||
*
|
||||
* Kept separate from `@/plugins` so the service worker never imports Svelte UI or
|
||||
* Vite HMR clients. Values must match each plugin's non-component settings.
|
||||
*/
|
||||
function defaultSearchHotkey(): string {
|
||||
if (typeof navigator !== "undefined") {
|
||||
return navigator.platform.toUpperCase().includes("MAC") ? "cmd+k" : "ctrl+k";
|
||||
}
|
||||
return "ctrl+k";
|
||||
}
|
||||
|
||||
/** `plugin.<id>.settings` defaults (component/button keys omitted). */
|
||||
export const SYNCABLE_PLUGIN_SETTING_DEFAULTS: Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
> = {
|
||||
themes: {},
|
||||
"animated-background": { speed: 1 },
|
||||
"assessments-average": { lettergrade: false },
|
||||
notificationCollector: {},
|
||||
timetable: {},
|
||||
timetableEdit: {},
|
||||
"profile-picture": { useCloudPfp: false },
|
||||
"assessments-overview": {},
|
||||
"background-music": { volume: 0.5, pauseOnHidden: true },
|
||||
messageFolders: {
|
||||
showTagsInAllMessages: true,
|
||||
hideFolderedMessagesInAll: true,
|
||||
},
|
||||
"enhanced-navigation": { autoScrollOnClick: false },
|
||||
"global-search": {
|
||||
searchHotkey: defaultSearchHotkey(),
|
||||
showRecentFirst: true,
|
||||
transparencyEffects: true,
|
||||
runIndexingOnLoad: true,
|
||||
passiveIndexing: true,
|
||||
},
|
||||
"grade-analytics": { cacheTtlHours: 24 },
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 54 KiB |
@@ -16,6 +16,26 @@ import { updateAllColors } from "./colors/Manager";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
|
||||
let cachedUserInfo: any = null;
|
||||
let userInfoFetchPromise: Promise<any> | null = null;
|
||||
let userInfoCacheListenersAttached = false;
|
||||
|
||||
export function invalidateCachedUserInfo(): void {
|
||||
cachedUserInfo = null;
|
||||
userInfoFetchPromise = null;
|
||||
}
|
||||
|
||||
function attachUserInfoCacheInvalidation(): void {
|
||||
if (userInfoCacheListenersAttached || typeof window === "undefined") return;
|
||||
userInfoCacheListenersAttached = true;
|
||||
|
||||
window.addEventListener("pageshow", (event) => {
|
||||
if (event.persisted) {
|
||||
invalidateCachedUserInfo();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
attachUserInfoCacheInvalidation();
|
||||
|
||||
let LightDarkModeSnakeEggButton = 0;
|
||||
let sidebarAccessibilityObserver: MutationObserver | null = null;
|
||||
@@ -25,28 +45,66 @@ let sidebarAccessibilityListenersAttached = false;
|
||||
/** Marks menu rows that are off-screen in the drill stack (CSS blocks clicks). */
|
||||
const BSPLUS_SIDEBAR_OFFSCREEN = "bsplus-sidebar-offscreen";
|
||||
|
||||
export async function getUserInfo() {
|
||||
if (cachedUserInfo) return cachedUserInfo;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${location.origin}/seqta/student/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mode: "normal",
|
||||
query: null,
|
||||
redirect_url: location.origin,
|
||||
}),
|
||||
});
|
||||
|
||||
cachedUserInfo = (await response.json()).payload;
|
||||
export async function getUserInfo(options?: { validateSession?: boolean }) {
|
||||
if (cachedUserInfo && !options?.validateSession) {
|
||||
return cachedUserInfo;
|
||||
} catch (error) {
|
||||
console.error("[BetterSEQTA+] Failed to get user info:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (userInfoFetchPromise && !options?.validateSession) {
|
||||
return userInfoFetchPromise;
|
||||
}
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const response = await fetch(`${location.origin}/seqta/student/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mode: "normal",
|
||||
query: null,
|
||||
redirect_url: location.origin,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get user info: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()).payload;
|
||||
|
||||
if (
|
||||
cachedUserInfo &&
|
||||
options?.validateSession &&
|
||||
payload?.id != null &&
|
||||
cachedUserInfo.id != null &&
|
||||
payload.id !== cachedUserInfo.id
|
||||
) {
|
||||
console.warn(
|
||||
"[BetterSEQTA+] Session user changed; invalidating cached user info",
|
||||
);
|
||||
invalidateCachedUserInfo();
|
||||
}
|
||||
|
||||
cachedUserInfo = payload;
|
||||
return cachedUserInfo;
|
||||
} catch (error) {
|
||||
console.error("[BetterSEQTA+] Failed to get user info:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (!options?.validateSession) {
|
||||
userInfoFetchPromise = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (options?.validateSession) {
|
||||
return fetchUserInfo();
|
||||
}
|
||||
|
||||
userInfoFetchPromise = fetchUserInfo();
|
||||
return userInfoFetchPromise;
|
||||
}
|
||||
|
||||
export async function AddBetterSEQTAElements() {
|
||||
@@ -76,11 +134,7 @@ export async function AddBetterSEQTAElements() {
|
||||
menuList.insertBefore(fragment, menuList.firstChild);
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
appendBackgroundToUI(),
|
||||
handleUserInfo(),
|
||||
handleStudentData(),
|
||||
]);
|
||||
await Promise.all([appendBackgroundToUI(), handleUserInfoAndStudentData()]);
|
||||
} catch (error) {
|
||||
console.error("[BetterSEQTA+] Failed to initialize UI elements:", error);
|
||||
}
|
||||
@@ -110,11 +164,26 @@ function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
|
||||
);
|
||||
}
|
||||
|
||||
async function handleUserInfo() {
|
||||
async function handleUserInfoAndStudentData() {
|
||||
try {
|
||||
updateUserInfo(await getUserInfo());
|
||||
const [userInfo, studentResponse] = await Promise.all([
|
||||
getUserInfo(),
|
||||
fetch(`${location.origin}/seqta/student/load/message/people`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({ mode: "student" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
updateUserInfo(userInfo);
|
||||
await updateStudentInfo((await studentResponse.json()).payload, userInfo);
|
||||
} catch (error) {
|
||||
console.error("[BetterSEQTA+] Failed to handle user info:", error);
|
||||
console.error(
|
||||
"[BetterSEQTA+] Failed to handle user info and student data:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,27 +239,7 @@ function updateUserInfo(info: {
|
||||
.appendChild(document.getElementsByClassName("logout")[0]);
|
||||
}
|
||||
|
||||
async function handleStudentData() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${location.origin}/seqta/student/load/message/people`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({ mode: "student" }),
|
||||
},
|
||||
);
|
||||
|
||||
await updateStudentInfo((await response.json()).payload);
|
||||
} catch (error) {
|
||||
console.error("[BetterSEQTA+] Failed to handle student data:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStudentInfo(students: any) {
|
||||
const info = await getUserInfo();
|
||||
async function updateStudentInfo(students: any, info: Awaited<ReturnType<typeof getUserInfo>>) {
|
||||
const index = students.findIndex(
|
||||
(person: any) =>
|
||||
person.firstname == info.userDesc.split(" ")[0] &&
|
||||
|
||||
@@ -21,6 +21,7 @@ export async function appendBackgroundToUI() {
|
||||
}
|
||||
|
||||
let lastLoadedId: string | null = null;
|
||||
let lastBlobUrl: string | null = null;
|
||||
|
||||
export async function loadBackground() {
|
||||
if (!isIndexedDBSupported()) {
|
||||
@@ -36,6 +37,10 @@ export async function loadBackground() {
|
||||
backgroundContainer.remove();
|
||||
}
|
||||
lastLoadedId = null;
|
||||
if (lastBlobUrl) {
|
||||
URL.revokeObjectURL(lastBlobUrl);
|
||||
lastBlobUrl = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,12 +78,19 @@ export async function loadBackground() {
|
||||
|
||||
mediaContainer.innerHTML = "";
|
||||
|
||||
if (lastBlobUrl) {
|
||||
URL.revokeObjectURL(lastBlobUrl);
|
||||
lastBlobUrl = null;
|
||||
}
|
||||
|
||||
const mediaElement =
|
||||
background.type === "video"
|
||||
? document.createElement("video")
|
||||
: document.createElement("img");
|
||||
|
||||
mediaElement.src = URL.createObjectURL(background.blob);
|
||||
const blobUrl = URL.createObjectURL(background.blob);
|
||||
lastBlobUrl = blobUrl;
|
||||
mediaElement.src = blobUrl;
|
||||
mediaElement.classList.add("background");
|
||||
|
||||
if (mediaElement instanceof HTMLVideoElement) {
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import renderSvelte from "@/interface/main";
|
||||
import Store from "@/interface/pages/store.svelte";
|
||||
|
||||
import { unmount } from "svelte";
|
||||
|
||||
let remove: () => void;
|
||||
|
||||
export function OpenStorePage() {
|
||||
remove = renderStore();
|
||||
export async function OpenStorePage(): Promise<void> {
|
||||
remove = await renderStore();
|
||||
}
|
||||
|
||||
export function renderStore() {
|
||||
export async function renderStore() {
|
||||
const [{ default: renderSvelte }, { default: Store }] = await Promise.all([
|
||||
import("@/interface/main"),
|
||||
import("@/interface/pages/store.svelte"),
|
||||
]);
|
||||
|
||||
const container = document.querySelector("#container");
|
||||
if (!container) {
|
||||
throw new Error("Container not found");
|
||||
}
|
||||
|
||||
// Avoid stacking multiple store roots if opened repeatedly without close.
|
||||
document.getElementById("store")?.remove();
|
||||
|
||||
const child = document.createElement("div");
|
||||
child.id = "store";
|
||||
container!.appendChild(child);
|
||||
container.appendChild(child);
|
||||
|
||||
const shadow = child.attachShadow({ mode: "open" });
|
||||
const app = renderSvelte(Store, shadow);
|
||||
|
||||
@@ -3,11 +3,10 @@ import {
|
||||
closeExtensionPopup,
|
||||
SettingsClicked,
|
||||
} from "../Closers/closeExtensionPopup";
|
||||
import renderSvelte from "@/interface/main";
|
||||
import { SettingsResizer } from "@/seqta/ui/SettingsResizer";
|
||||
import Settings from "@/interface/pages/settings.svelte";
|
||||
|
||||
let isSettingsRendered = false;
|
||||
let settingsLoadPromise: Promise<void> | null = null;
|
||||
|
||||
function extensionOutsideClickHandler(extensionPopup: HTMLElement) {
|
||||
return (event: MouseEvent) => {
|
||||
@@ -38,21 +37,39 @@ export function addExtensionSettings() {
|
||||
(extensionContainer ?? document.body).addEventListener("click", handler, false);
|
||||
}
|
||||
|
||||
export function renderSettingsIfNeeded() {
|
||||
async function loadSettingsUi(extensionPopup: HTMLElement): Promise<void> {
|
||||
if (isSettingsRendered) return;
|
||||
|
||||
|
||||
const [{ default: renderSvelte }, { default: Settings }] = await Promise.all([
|
||||
import("@/interface/main"),
|
||||
import("@/interface/pages/settings.svelte"),
|
||||
]);
|
||||
|
||||
const shadow = extensionPopup.attachShadow({ mode: "open" });
|
||||
const mount = () => renderSvelte(Settings, shadow);
|
||||
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(mount);
|
||||
} else {
|
||||
mount();
|
||||
}
|
||||
|
||||
isSettingsRendered = true;
|
||||
}
|
||||
|
||||
export async function renderSettingsIfNeeded(): Promise<void> {
|
||||
if (isSettingsRendered) return;
|
||||
|
||||
const extensionPopup = document.getElementById("ExtensionPopup");
|
||||
if (!extensionPopup) return;
|
||||
|
||||
try {
|
||||
const shadow = extensionPopup.attachShadow({ mode: "open" });
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => renderSvelte(Settings, shadow));
|
||||
} else {
|
||||
renderSvelte(Settings, shadow);
|
||||
}
|
||||
isSettingsRendered = true;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (!settingsLoadPromise) {
|
||||
settingsLoadPromise = loadSettingsUi(extensionPopup).catch((err) => {
|
||||
settingsLoadPromise = null;
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
await settingsLoadPromise;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { animate } from "motion";
|
||||
|
||||
import { settingsPopup } from "@/interface/hooks/SettingsPopup";
|
||||
import { settingsPopup } from "@/seqta/utils/settingsPopup";
|
||||
|
||||
export let SettingsClicked = false;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
|
||||
import { clearLastUploadedSnapshot } from "@/seqta/utils/cloudSettingsSync";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
|
||||
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
|
||||
|
||||
@@ -87,11 +86,10 @@ class CloudAuthService {
|
||||
}
|
||||
|
||||
/** Pull cloud settings backup after a fresh sign-in (matches manual “Download from cloud”). */
|
||||
private triggerCloudSettingsDownloadAfterLogin(accessToken: string): void {
|
||||
private triggerCloudSettingsDownloadAfterLogin(): void {
|
||||
void browser.runtime
|
||||
.sendMessage({
|
||||
type: "cloudSettingsDownload",
|
||||
token: accessToken,
|
||||
})
|
||||
.then((res: unknown) => {
|
||||
const r = res as { success?: boolean; notFound?: boolean; error?: string } | undefined;
|
||||
@@ -112,7 +110,6 @@ class CloudAuthService {
|
||||
|
||||
/** Persist an updated user object (e.g. after cloud profile picture sync). */
|
||||
public async setUser(user: CloudUser | null): Promise<void> {
|
||||
(settingsState as any).setKey(STORAGE_KEYS.user, user);
|
||||
await browser.storage.local.set({ [STORAGE_KEYS.user]: user });
|
||||
this._state = {
|
||||
isLoggedIn: this._state.isLoggedIn,
|
||||
@@ -122,11 +119,8 @@ class CloudAuthService {
|
||||
}
|
||||
|
||||
private async getClientId(): Promise<string> {
|
||||
let clientId = (settingsState as any)[STORAGE_KEYS.clientId] as string | undefined;
|
||||
if (!clientId) {
|
||||
const stored = await browser.storage.local.get(STORAGE_KEYS.clientId);
|
||||
clientId = stored[STORAGE_KEYS.clientId] as string | undefined;
|
||||
}
|
||||
const stored = await browser.storage.local.get(STORAGE_KEYS.clientId);
|
||||
let clientId = stored[STORAGE_KEYS.clientId] as string | undefined;
|
||||
if (!clientId) {
|
||||
const reserveResult = (await browser.runtime.sendMessage({
|
||||
type: "cloudReserveClient",
|
||||
@@ -136,7 +130,7 @@ class CloudAuthService {
|
||||
throw new Error(reserveResult?.error ?? "Failed to reserve client");
|
||||
}
|
||||
clientId = reserveResult.client_id;
|
||||
(settingsState as any).setKey(STORAGE_KEYS.clientId, clientId);
|
||||
await browser.storage.local.set({ [STORAGE_KEYS.clientId]: clientId });
|
||||
}
|
||||
return clientId;
|
||||
}
|
||||
@@ -180,15 +174,17 @@ class CloudAuthService {
|
||||
error?: string;
|
||||
};
|
||||
if (result?.access_token && result?.refresh_token) {
|
||||
(settingsState as any).setKey(STORAGE_KEYS.accessToken, result.access_token);
|
||||
(settingsState as any).setKey(STORAGE_KEYS.refreshToken, result.refresh_token);
|
||||
(settingsState as any).setKey(STORAGE_KEYS.user, result.user ?? null);
|
||||
await browser.storage.local.set({
|
||||
[STORAGE_KEYS.accessToken]: result.access_token,
|
||||
[STORAGE_KEYS.refreshToken]: result.refresh_token,
|
||||
[STORAGE_KEYS.user]: result.user ?? null,
|
||||
});
|
||||
this._state = {
|
||||
isLoggedIn: true,
|
||||
user: result.user ?? null,
|
||||
};
|
||||
this.notify();
|
||||
this.triggerCloudSettingsDownloadAfterLogin(result.access_token);
|
||||
this.triggerCloudSettingsDownloadAfterLogin();
|
||||
return { success: true };
|
||||
}
|
||||
return {
|
||||
@@ -239,9 +235,11 @@ class CloudAuthService {
|
||||
};
|
||||
|
||||
if (refreshResult?.access_token && refreshResult?.refresh_token) {
|
||||
(settingsState as any).setKey(STORAGE_KEYS.accessToken, refreshResult.access_token);
|
||||
(settingsState as any).setKey(STORAGE_KEYS.refreshToken, refreshResult.refresh_token);
|
||||
(settingsState as any).setKey(STORAGE_KEYS.user, refreshResult.user ?? null);
|
||||
await browser.storage.local.set({
|
||||
[STORAGE_KEYS.accessToken]: refreshResult.access_token,
|
||||
[STORAGE_KEYS.refreshToken]: refreshResult.refresh_token,
|
||||
[STORAGE_KEYS.user]: refreshResult.user ?? null,
|
||||
});
|
||||
this._state = {
|
||||
isLoggedIn: true,
|
||||
user: refreshResult.user ?? null,
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import stringToHTML from "../stringToHTML";
|
||||
|
||||
function isSafeShortcutHref(url: string): boolean {
|
||||
if (typeof url !== "string" || !url.trim()) return false;
|
||||
try {
|
||||
const parsed = new URL(url, window.location.href);
|
||||
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateCustomShortcutDiv(element: any) {
|
||||
// Creates the stucture and element information for each seperate shortcut
|
||||
const container = document.getElementById("shortcuts");
|
||||
if (!container) return;
|
||||
|
||||
var shortcut = document.createElement("a");
|
||||
shortcut.setAttribute("href", element.url);
|
||||
shortcut.setAttribute("target", "_blank");
|
||||
if (isSafeShortcutHref(element.url)) {
|
||||
shortcut.setAttribute("href", element.url);
|
||||
shortcut.setAttribute("target", "_blank");
|
||||
} else {
|
||||
shortcut.setAttribute("href", "#");
|
||||
shortcut.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
var shortcutdiv = document.createElement("div");
|
||||
shortcutdiv.classList.add("shortcut");
|
||||
shortcutdiv.classList.add("customshortcut");
|
||||
|
||||
@@ -52,6 +52,7 @@ export function OpenAboutPage() {
|
||||
</a>
|
||||
<a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
|
||||
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg>
|
||||
</a>
|
||||
<a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
|
||||
<svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
|
||||
|
||||
@@ -61,11 +61,12 @@ export function showBsCloudAutoSyncAnnouncement(onDismissed?: () => void) {
|
||||
</div>
|
||||
`).firstChild as HTMLElement;
|
||||
|
||||
settingsState.bsCloudAutoSyncAnnouncementShown = true;
|
||||
|
||||
openPopup({
|
||||
header,
|
||||
content: [imageContainer, text],
|
||||
afterClose: onDismissed,
|
||||
afterClose: () => {
|
||||
settingsState.bsCloudAutoSyncAnnouncementShown = true;
|
||||
onDismissed?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { settingsState } from "../listeners/SettingsState";
|
||||
import { animate as motionAnimate } from "motion";
|
||||
|
||||
export function shouldShowEngageParentsAnnouncement(): boolean {
|
||||
return !settingsState.engageParentsAnnouncementShown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-blocking bottom-right toast announcing SEQTA Engage support. Shown once.
|
||||
*/
|
||||
export function showEngageParentsToast() {
|
||||
if (!shouldShowEngageParentsAnnouncement()) return;
|
||||
|
||||
settingsState.engageParentsAnnouncementShown = true;
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "bsplus-toast engageParentsToast";
|
||||
toast.innerHTML = /* html */ `
|
||||
<button class="bsplus-toast-close" aria-label="Dismiss">×</button>
|
||||
<div class="bsplus-toast-content">
|
||||
<p class="bsplus-toast-eyebrow">SEQTA Engage support</p>
|
||||
<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>
|
||||
`;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -5,7 +5,18 @@ import Sortable from "sortablejs";
|
||||
|
||||
export let MenuOptionsOpen = false;
|
||||
|
||||
function escapeHtmlAttr(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export function OpenMenuOptions() {
|
||||
if (MenuOptionsOpen) return;
|
||||
|
||||
var container = document.getElementById("container");
|
||||
var menu = document.getElementById("menu");
|
||||
|
||||
@@ -23,9 +34,9 @@ export function OpenMenuOptions() {
|
||||
for (let i = 0; i < childnodes.length; i++) {
|
||||
const element = childnodes[i];
|
||||
if (
|
||||
!settingsState.defaultmenuorder.indexOf(
|
||||
settingsState.defaultmenuorder.indexOf(
|
||||
(element as HTMLElement).dataset.key,
|
||||
)
|
||||
) === -1
|
||||
) {
|
||||
let newdefaultmenuorder = settingsState.defaultmenuorder;
|
||||
newdefaultmenuorder.push((element as HTMLElement).dataset.key);
|
||||
@@ -53,7 +64,7 @@ export function OpenMenuOptions() {
|
||||
var savebutton = document.createElement("div");
|
||||
savebutton.classList.add("editmenuoption");
|
||||
savebutton.innerText = "Save";
|
||||
savebutton.id = "restoredefaultoption";
|
||||
savebutton.id = "savemenuoption";
|
||||
|
||||
menusettings.appendChild(defaultbutton);
|
||||
menusettings.appendChild(savebutton);
|
||||
@@ -71,15 +82,18 @@ export function OpenMenuOptions() {
|
||||
(element.firstChild as HTMLElement).classList.remove("noscroll");
|
||||
}
|
||||
|
||||
const menuKey = escapeHtmlAttr((element as HTMLElement).dataset.key ?? "");
|
||||
let MenuItemToggle = stringToHTML(
|
||||
`<div class="onoffswitch" style="margin: auto 0;"><input class="onoffswitch-checkbox notification menuitem" type="checkbox" id="${(element as HTMLElement).dataset.key}"><label for="${(element as HTMLElement).dataset.key}" class="onoffswitch-label"></label>`,
|
||||
`<div class="onoffswitch" style="margin: auto 0;"><input class="onoffswitch-checkbox notification menuitem" type="checkbox" id="${menuKey}"><label for="${menuKey}" class="onoffswitch-label"></label>`,
|
||||
).firstChild;
|
||||
(element as HTMLElement).append(MenuItemToggle!);
|
||||
|
||||
if (!element.dataset.betterseqta) {
|
||||
const a = document.createElement("section");
|
||||
a.innerHTML = element.innerHTML;
|
||||
cloneAttributes(a, element);
|
||||
while (element.firstChild) {
|
||||
a.appendChild(element.firstChild);
|
||||
}
|
||||
menu!.firstChild!.insertBefore(a, element);
|
||||
element.remove();
|
||||
}
|
||||
@@ -109,12 +123,12 @@ export function OpenMenuOptions() {
|
||||
} else {
|
||||
(buttons[i] as HTMLInputElement).checked = true;
|
||||
}
|
||||
(buttons[i] as HTMLInputElement).checked = true;
|
||||
}
|
||||
|
||||
let sortable: Sortable | undefined;
|
||||
try {
|
||||
var el = document.querySelector("#menu > ul");
|
||||
var sortable = Sortable.create(el as HTMLElement, {
|
||||
sortable = Sortable.create(el as HTMLElement, {
|
||||
draggable: ".draggable",
|
||||
dataIdAttr: "data-key",
|
||||
animation: 150,
|
||||
@@ -178,8 +192,10 @@ export function OpenMenuOptions() {
|
||||
|
||||
if (!element.dataset.betterseqta) {
|
||||
const a = document.createElement("li");
|
||||
a.innerHTML = element.innerHTML;
|
||||
cloneAttributes(a, element);
|
||||
while (element.firstChild) {
|
||||
a.appendChild(element.firstChild);
|
||||
}
|
||||
menu!.firstChild!.insertBefore(a, element);
|
||||
element.remove();
|
||||
}
|
||||
@@ -209,7 +225,7 @@ export function OpenMenuOptions() {
|
||||
"important",
|
||||
);
|
||||
}
|
||||
saveNewOrder(sortable);
|
||||
if (sortable) saveNewOrder(sortable);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ export function OpenMinecraftServerPopup() {
|
||||
</a>
|
||||
<a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
|
||||
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg>
|
||||
</a>
|
||||
<a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
|
||||
<svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
|
||||
|
||||
@@ -62,12 +62,13 @@ export function showPrivacyNotification(onDismissed?: () => void) {
|
||||
|
||||
attachPopupMediaFullscreenIfPresent(text, "img.aboutImg");
|
||||
|
||||
settingsState.privacyStatementLastUpdated = "2025-12-20";
|
||||
settingsState.privacyStatementShown = true;
|
||||
|
||||
openPopup({
|
||||
header,
|
||||
content: [text],
|
||||
afterClose: onDismissed,
|
||||
afterClose: () => {
|
||||
settingsState.privacyStatementLastUpdated = "2025-12-20";
|
||||
settingsState.privacyStatementShown = true;
|
||||
onDismissed?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,12 +31,12 @@ export function OpenPrivacyStatement() {
|
||||
<p>BetterSEQTA+ uses your browser's local storage to save your preferences and settings. This data remains on your device and is never transmitted anywhere. You can clear this data at any time through your browser's settings.</p>
|
||||
|
||||
<h3>Open Source</h3>
|
||||
<p>BetterSEQTA+ is an open-source project. You can review our code on <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" style="color: inherit; text-decoration: underline;">GitHub</a> to verify our privacy practices. We believe in transparency and encourage you to inspect the code yourself.</p>
|
||||
<p>BetterSEQTA+ is an open-source project. You can review our code on <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">GitHub</a> to verify our privacy practices. We believe in transparency and encourage you to inspect the code yourself.</p>
|
||||
|
||||
<h3>Our Commitment</h3>
|
||||
<p>We are committed to providing the best features possible while respecting your privacy. We understand that schools and students have concerns about data privacy, and we want to assure you that BetterSEQTA+ is designed with privacy as a core principle.</p>
|
||||
|
||||
<p style="margin-top: 20px; font-weight: bold;">If you have any questions or concerns about our privacy practices, please reach out to us through our <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" style="color: inherit; text-decoration: underline;">GitHub repository</a>.</p>
|
||||
<p style="margin-top: 20px; font-weight: bold;">If you have any questions or concerns about our privacy practices, please reach out to us through our <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">GitHub repository</a>.</p>
|
||||
</div>
|
||||
`).firstChild as HTMLElement;
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import { settingsState } from "../listeners/SettingsState";
|
||||
import { closePopup } from "./PopupManager";
|
||||
import { getApiBase } from "../DevApiBase";
|
||||
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
|
||||
import { cloudAuth } from "../CloudAuth";
|
||||
import type { Theme } from "@/interface/types/Theme";
|
||||
import {
|
||||
buildModalHeroSlides,
|
||||
normalizeStoreTheme,
|
||||
} from "@/interface/utils/themeStoreFlavours";
|
||||
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
|
||||
import { allowedPopupImageUrl } from "./allowedPopupImageUrl";
|
||||
|
||||
export interface ThemeOfTheMonthEntry {
|
||||
id: string;
|
||||
@@ -67,17 +67,15 @@ function heroUrlFromStoreTheme(theme: {
|
||||
coverImage?: string | null;
|
||||
}): string | null {
|
||||
const url = (theme.marqueeImage || theme.coverImage || "").trim();
|
||||
return url || null;
|
||||
return allowedPopupImageUrl(url);
|
||||
}
|
||||
|
||||
export async function fetchThemeStoreTheme(themeId: string): Promise<Theme | null> {
|
||||
try {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: "fetchThemeDetails",
|
||||
themeId,
|
||||
token: token ?? undefined,
|
||||
})) as { success?: boolean; data?: { theme?: Record<string, unknown> } };
|
||||
try {
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: "fetchThemeDetails",
|
||||
themeId,
|
||||
})) as { success?: boolean; data?: { theme?: Record<string, unknown> } };
|
||||
|
||||
if (!res?.success || !res?.data?.theme) return null;
|
||||
return normalizeStoreTheme(res.data.theme);
|
||||
@@ -100,7 +98,12 @@ function buildPopupGallerySlides(
|
||||
heroUrl: string | null,
|
||||
): PopupGallerySlide[] {
|
||||
if (storeTheme) {
|
||||
return buildModalHeroSlides(storeTheme).filter((s) => s.imageUrl.trim());
|
||||
return buildModalHeroSlides(storeTheme)
|
||||
.map((s) => {
|
||||
const imageUrl = allowedPopupImageUrl(s.imageUrl);
|
||||
return imageUrl ? { imageUrl, caption: s.caption } : null;
|
||||
})
|
||||
.filter((s): s is PopupGallerySlide => s !== null);
|
||||
}
|
||||
if (heroUrl) {
|
||||
return [{ imageUrl: heroUrl, caption: entry.title }];
|
||||
@@ -642,7 +645,7 @@ export async function OpenThemeOfTheMonthPopup(
|
||||
const storeTheme = linkedThemeId ? await fetchThemeStoreTheme(linkedThemeId) : null;
|
||||
const heroUrl =
|
||||
(storeTheme ? heroUrlFromStoreTheme(storeTheme) : null) ??
|
||||
entry.cover_image?.trim() ??
|
||||
allowedPopupImageUrl(entry.cover_image) ??
|
||||
null;
|
||||
const gallerySlides = buildPopupGallerySlides(entry, storeTheme, heroUrl);
|
||||
const hasExpandableContent = gallerySlides.length > 0 || entry.description.trim().length > 0;
|
||||
@@ -782,7 +785,7 @@ export async function OpenThemeOfTheMonthPopup(
|
||||
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
|
||||
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
||||
dismissWithCleanup();
|
||||
openThemeStoreWithHighlight(linkedThemeId!);
|
||||
void openThemeStoreWithHighlight(linkedThemeId!);
|
||||
});
|
||||
|
||||
const openDontShowConfirm = () => {
|
||||
|
||||
@@ -396,6 +396,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
||||
</a>
|
||||
<a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
|
||||
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg>
|
||||
</a>
|
||||
<a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
|
||||
<svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
|
||||
@@ -403,7 +404,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://ko-fi.com/sethburkart" target="_blank" style="background: none !important; margin:0;margin-left:6px;padding:0; display: flex; align-items: center;">
|
||||
<a href="https://ko-fi.com/sethburkart" target="_blank" rel="noopener noreferrer" style="background: none !important; margin:0;margin-left:6px;padding:0; display: flex; align-items: center;">
|
||||
<img height="25" style="border:0px; height:25px; margin-right: -6px;" src="${kofi}" border="0" alt="Buy Me a Coffee at ko-fi.com" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,15 @@ interface OpenPopupOptions {
|
||||
containerClass?: string;
|
||||
}
|
||||
|
||||
function chainAfterClose(next?: () => void) {
|
||||
if (!next) return;
|
||||
const previous = pendingAfterClose;
|
||||
pendingAfterClose = () => {
|
||||
next();
|
||||
previous?.();
|
||||
};
|
||||
}
|
||||
|
||||
export function openPopup({
|
||||
header,
|
||||
content = [],
|
||||
@@ -65,7 +74,12 @@ export function openPopup({
|
||||
clearJustUpdated = false,
|
||||
containerClass,
|
||||
}: OpenPopupOptions = {}) {
|
||||
pendingAfterClose = afterClose;
|
||||
if (document.getElementById("whatsnewbk")) {
|
||||
chainAfterClose(afterClose);
|
||||
return;
|
||||
}
|
||||
|
||||
chainAfterClose(afterClose);
|
||||
|
||||
const background = document.createElement("div");
|
||||
background.id = "whatsnewbk";
|
||||
@@ -87,7 +101,9 @@ export function openPopup({
|
||||
container.append(closeButton);
|
||||
|
||||
background.append(container);
|
||||
document.getElementById("container")!.append(background);
|
||||
const appContainer = document.getElementById("container");
|
||||
if (!appContainer) return;
|
||||
appContainer.append(background);
|
||||
|
||||
if (settingsState.animations) {
|
||||
(motionAnimate as any)(
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { settingsState } from "../listeners/SettingsState";
|
||||
import { OpenWhatsNewPopup } from "./OpenWhatsNewPopup";
|
||||
import {
|
||||
shouldShowEngageParentsAnnouncement,
|
||||
showEngageParentsToast,
|
||||
} from "./OpenEngageParentsAnnouncement";
|
||||
import {
|
||||
fetchThemeOfTheMonth,
|
||||
OpenThemeOfTheMonthPopup,
|
||||
@@ -15,8 +11,7 @@ type QueueStep = (goNext: () => void) => void;
|
||||
|
||||
/**
|
||||
* Runs startup modals in order: What's New (if the extension just updated),
|
||||
* Theme of the Month (when the user hasn't dismissed this calendar month), then shows
|
||||
* the SEQTA Engage toast (once, non-blocking).
|
||||
* Theme of the Month (when the user hasn't dismissed this calendar month).
|
||||
*/
|
||||
export async function runStartupPopupQueue() {
|
||||
// Make sure the background script knows about any dev-mode API override
|
||||
@@ -41,11 +36,6 @@ export async function runStartupPopupQueue() {
|
||||
function runNext() {
|
||||
const step = steps.shift();
|
||||
if (step) step(runNext);
|
||||
else {
|
||||
if (shouldShowEngageParentsAnnouncement()) {
|
||||
showEngageParentsToast();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runNext();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Minimal allowlist for remote popup/API image (and media) URLs.
|
||||
* Only https: URLs are accepted; everything else is rejected.
|
||||
*/
|
||||
export function allowedPopupImageUrl(url: string | null | undefined): string | null {
|
||||
if (!url) return null;
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== "https:") return null;
|
||||
return parsed.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { settingsState } from "../listeners/SettingsState";
|
||||
import { allowedPopupImageUrl } from "./allowedPopupImageUrl";
|
||||
|
||||
const FULLSCREENABLE_CLASS = "popup-media-fullscreenable";
|
||||
const OVERLAY_VISIBLE_CLASS = "bsplus-popup-media-overlay-backdrop--visible";
|
||||
@@ -56,13 +57,22 @@ function openMediaOverlayViewer(source: HTMLImageElement | HTMLVideoElement) {
|
||||
nv.loop = v.loop;
|
||||
nv.muted = v.muted;
|
||||
nv.volume = v.volume;
|
||||
let hasValidSource = false;
|
||||
for (const s of v.querySelectorAll("source")) {
|
||||
const src = allowedPopupImageUrl((s as HTMLSourceElement).src);
|
||||
if (!src) continue;
|
||||
hasValidSource = true;
|
||||
const ns = document.createElement("source");
|
||||
ns.src = (s as HTMLSourceElement).src;
|
||||
ns.src = src;
|
||||
const t = (s as HTMLSourceElement).type;
|
||||
if (t) ns.type = t;
|
||||
nv.appendChild(ns);
|
||||
}
|
||||
if (!hasValidSource) {
|
||||
const directSrc = allowedPopupImageUrl(v.currentSrc || v.src);
|
||||
if (!directSrc) return;
|
||||
nv.src = directSrc;
|
||||
}
|
||||
nv.addEventListener(
|
||||
"loadeddata",
|
||||
() => {
|
||||
@@ -79,9 +89,12 @@ function openMediaOverlayViewer(source: HTMLImageElement | HTMLVideoElement) {
|
||||
nv.load();
|
||||
media = nv;
|
||||
} else {
|
||||
const rawSrc = source.currentSrc || source.src;
|
||||
const safeSrc = allowedPopupImageUrl(rawSrc);
|
||||
if (!safeSrc) return;
|
||||
const img = document.createElement("img");
|
||||
img.classList.add("bsplus-popup-media-overlay-media");
|
||||
img.src = source.currentSrc || source.src;
|
||||
img.src = safeSrc;
|
||||
img.alt = source.alt || "";
|
||||
media = img;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ class ReactFiber {
|
||||
return new ReactFiber(selector, options);
|
||||
}
|
||||
|
||||
private getTargetOrigin(): string {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
private isTrustedMessage(event: MessageEvent): boolean {
|
||||
return (
|
||||
event.source === window && event.origin === this.getTargetOrigin()
|
||||
);
|
||||
}
|
||||
|
||||
private async sendMessage(action: string, payload: any = {}): Promise<any> {
|
||||
return new Promise((resolve, _) => {
|
||||
const messageId = this.messageIdCounter++;
|
||||
@@ -34,7 +44,8 @@ class ReactFiber {
|
||||
messageId,
|
||||
};
|
||||
|
||||
const listener = (response: any) => {
|
||||
const listener = (response: MessageEvent) => {
|
||||
if (!this.isTrustedMessage(response)) return;
|
||||
if (
|
||||
response.data?.type === "reactFiberResponse" &&
|
||||
response.data?.messageId === messageId
|
||||
@@ -47,7 +58,7 @@ class ReactFiber {
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", listener);
|
||||
window.postMessage(message, "*");
|
||||
window.postMessage(message, this.getTargetOrigin());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,15 +68,14 @@ class ReactFiber {
|
||||
});
|
||||
}
|
||||
|
||||
async setState(update: any | ((prevState: any) => any)): Promise<ReactFiber> {
|
||||
const updateFnString =
|
||||
typeof update === "function" ? update.toString() : null;
|
||||
const updateObject = typeof update !== "function" ? update : null;
|
||||
async setState(update: Record<string, unknown>): Promise<ReactFiber> {
|
||||
if (typeof update !== "object" || update === null || Array.isArray(update)) {
|
||||
throw new TypeError(
|
||||
"ReactFiber.setState only accepts plain JSON-serializable objects",
|
||||
);
|
||||
}
|
||||
|
||||
await this.sendMessage("setState", {
|
||||
updateFn: updateFnString,
|
||||
updateObject,
|
||||
});
|
||||
await this.sendMessage("setState", { updateObject: update });
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ export async function SendNewsPage() {
|
||||
? article.description
|
||||
: "No description available.";
|
||||
|
||||
description.innerHTML =
|
||||
description.textContent =
|
||||
articleDescription.length > 400
|
||||
? articleDescription.substring(0, 400) + "..."
|
||||
: articleDescription;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
const ALLOWED_HOST_SUFFIXES = [
|
||||
"betterseqta.org",
|
||||
"accounts.betterseqta.org",
|
||||
"raw.githubusercontent.com",
|
||||
"github.com",
|
||||
] as const;
|
||||
|
||||
function isPrivateOrLocalHost(hostname: string): boolean {
|
||||
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
||||
if (host === "127.0.0.1" || host.startsWith("127.")) return true;
|
||||
if (host === "::1" || host === "0.0.0.0") return true;
|
||||
|
||||
const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (ipv4) {
|
||||
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
|
||||
if (a === 10) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a === 127 || a === 0) return true;
|
||||
}
|
||||
|
||||
if (host.includes(":")) {
|
||||
const h = host.split("%")[0];
|
||||
if (h.startsWith("fe80") || h.startsWith("fc") || h.startsWith("fd")) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAllowedHost(hostname: string): boolean {
|
||||
const host = hostname.toLowerCase();
|
||||
return ALLOWED_HOST_SUFFIXES.some(
|
||||
(suffix) => host === suffix || host.endsWith(`.${suffix}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** HTTPS-only fetch allowlist for background `fetchFromUrl`. */
|
||||
export function isAllowedFetchUrl(urlString: string): boolean {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(urlString);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
if (isPrivateOrLocalHost(parsed.hostname)) return false;
|
||||
return isAllowedHost(parsed.hostname);
|
||||
}
|
||||
@@ -73,14 +73,23 @@ const OMIT_FROM_UPLOAD_EXACT = new Set<string>([
|
||||
...KEYS_OMITTED_FROM_CLOUD_UPLOAD,
|
||||
...SENSITIVE_DEVICE_STORAGE_KEYS_EXACT,
|
||||
...CLIENT_ONLY_CLOUD_KEYS_EXACT,
|
||||
"devMode",
|
||||
"devGhReleaseVersionOverride",
|
||||
]);
|
||||
|
||||
const UNSAFE_STORAGE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
|
||||
function isUnsafeStorageKey(key: string): boolean {
|
||||
return UNSAFE_STORAGE_KEYS.has(key);
|
||||
}
|
||||
|
||||
/** True if a storage key is part of the upload payload (and should trigger auto-upload when changed). */
|
||||
export function isKeyIncludedInCloudUploadPayload(key: string): boolean {
|
||||
return !shouldOmitKeyFromCloudPayload(key);
|
||||
}
|
||||
|
||||
function shouldOmitKeyFromCloudPayload(key: string): boolean {
|
||||
if (isUnsafeStorageKey(key)) return true;
|
||||
if (OMIT_FROM_UPLOAD_EXACT.has(key)) return true;
|
||||
for (const prefix of SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES) {
|
||||
if (key.startsWith(prefix)) return true;
|
||||
@@ -115,6 +124,7 @@ function collectLocalKeysToPreserve(local: Record<string, unknown>): Record<stri
|
||||
function stripExcludedKeysFromRemoteData(remote: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(remote)) {
|
||||
if (isUnsafeStorageKey(k)) continue;
|
||||
if (shouldOmitKeyFromCloudPayload(k)) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
@@ -336,5 +346,7 @@ export async function applyDownloadedEnvelope(envelope: unknown): Promise<void>
|
||||
|
||||
const migrated = migrateLegacyToPluginSettings(remoteFlat);
|
||||
const remoteSanitized = stripExcludedKeysFromRemoteData(migrated);
|
||||
await browser.storage.local.set(remoteSanitized);
|
||||
const local = (await browser.storage.local.get()) as Record<string, unknown>;
|
||||
const preserve = collectLocalKeysToPreserve(local);
|
||||
await browser.storage.local.set({ ...remoteSanitized, ...preserve });
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ export function getDefaultSettingsState(): SettingsState {
|
||||
selectedFont: "rubik",
|
||||
timeFormat: "24",
|
||||
privacyStatementShown: false,
|
||||
engageParentsAnnouncementShown: false,
|
||||
bsCloudAutoSyncAnnouncementShown: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import { getAllPluginSettings } from "@/plugins";
|
||||
import { SYNCABLE_PLUGIN_SETTING_DEFAULTS } from "@/plugins/syncablePluginDefaults";
|
||||
import { getDefaultSettingsState } from "@/seqta/utils/defaultSettings";
|
||||
import {
|
||||
isKeyIncludedInCloudUploadPayload,
|
||||
@@ -24,7 +24,6 @@ const OPTIONAL_UNSET_MEANS_DEFAULT_KEYS = [
|
||||
"selectedFont",
|
||||
"privacyStatementShown",
|
||||
"privacyStatementLastUpdated",
|
||||
"engageParentsAnnouncementShown",
|
||||
"bsCloudAutoSyncAnnouncementShown",
|
||||
"themeOfTheMonthDismissedMonth",
|
||||
"themeOfTheMonthLastSeenId",
|
||||
@@ -38,17 +37,7 @@ const OPTIONAL_UNSET_MEANS_DEFAULT_KEYS = [
|
||||
"profile_picture_revision",
|
||||
] as const;
|
||||
|
||||
function buildDefaultPluginSettings(
|
||||
plugin: ReturnType<typeof getAllPluginSettings>[number],
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, setting] of Object.entries(plugin.settings)) {
|
||||
const meta = setting as { type?: string; default?: unknown };
|
||||
if (meta.type === "component" || meta.type === "button") continue;
|
||||
out[key] = meta.default;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let defaultsEnsured = false;
|
||||
|
||||
/**
|
||||
* Flat default map in upload shape (plugin-format only; no legacy keys).
|
||||
@@ -65,9 +54,10 @@ export function getSyncableStorageDefaults(): Record<string, unknown> {
|
||||
delete flat[key];
|
||||
}
|
||||
|
||||
for (const plugin of getAllPluginSettings()) {
|
||||
flat[`plugin.${plugin.pluginId}.settings`] =
|
||||
buildDefaultPluginSettings(plugin);
|
||||
for (const [pluginId, settings] of Object.entries(
|
||||
SYNCABLE_PLUGIN_SETTING_DEFAULTS,
|
||||
)) {
|
||||
flat[`plugin.${pluginId}.settings`] = settings;
|
||||
}
|
||||
|
||||
return flat;
|
||||
@@ -88,6 +78,8 @@ function mergePluginSettingsDefaults(
|
||||
* Never overwrites existing values. Missing plugin settings respect legacy keys.
|
||||
*/
|
||||
export async function ensureSyncableStorageDefaults(): Promise<void> {
|
||||
if (defaultsEnsured) return;
|
||||
|
||||
const existing = await browser.storage.local.get();
|
||||
const migratedFromExisting = migrateLegacyToPluginSettings({
|
||||
...existing,
|
||||
@@ -113,4 +105,6 @@ export async function ensureSyncableStorageDefaults(): Promise<void> {
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await browser.storage.local.set(patch);
|
||||
}
|
||||
|
||||
defaultsEnsured = true;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ const handleNotificationClick = async (target: HTMLElement) => {
|
||||
(item: any) => item.notificationID === parseInt(buttonId),
|
||||
);
|
||||
|
||||
if (!matchingNotification?.message?.messageID) return;
|
||||
|
||||
await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20);
|
||||
|
||||
// Select the specific direct message
|
||||
|
||||
@@ -3,6 +3,7 @@ interface EventListenerOptions {
|
||||
textContent?: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
selector?: string;
|
||||
customCheck?: (element: Element) => boolean;
|
||||
once?: boolean;
|
||||
parentElement?: Element;
|
||||
@@ -20,6 +21,7 @@ class EventManager {
|
||||
private listeners: Map<string, EventListener[]> = new Map();
|
||||
private mutationObservers: Map<Element, MutationObserver> = new Map();
|
||||
private pendingElements: Set<Element> = new Set();
|
||||
private firedOnceIds: Set<string> = new Set();
|
||||
private throttleTimeout: number = 5; // 5ms throttle
|
||||
private throttleTimer: number | undefined;
|
||||
private chunkSize: number = 50; // Process 50 elements per chunk
|
||||
@@ -58,6 +60,7 @@ class EventManager {
|
||||
}
|
||||
|
||||
private buildSelector(options: EventListenerOptions): string | null {
|
||||
if (options.selector) return options.selector;
|
||||
if (options.textContent || options.customCheck) return null;
|
||||
|
||||
let selector = options.elementType || "";
|
||||
@@ -71,6 +74,23 @@ class EventManager {
|
||||
return selector.trim() || null;
|
||||
}
|
||||
|
||||
private getElementsToCheck(
|
||||
element: Element,
|
||||
options: EventListenerOptions,
|
||||
): Element[] {
|
||||
const selector = this.buildSelector(options);
|
||||
if (!selector) return [element];
|
||||
|
||||
const targets = new Set<Element>();
|
||||
if (element.matches(selector)) {
|
||||
targets.add(element);
|
||||
}
|
||||
for (const match of element.querySelectorAll(selector)) {
|
||||
targets.add(match);
|
||||
}
|
||||
return Array.from(targets);
|
||||
}
|
||||
|
||||
private async scanExistingElements(
|
||||
options: EventListenerOptions,
|
||||
callback: (element: Element) => void,
|
||||
@@ -174,10 +194,17 @@ class EventManager {
|
||||
private async checkElement(element: Element): Promise<void> {
|
||||
for (const [event, listeners] of this.listeners.entries()) {
|
||||
for (const { id, options, callback } of listeners) {
|
||||
if (this.matchesOptions(element, options)) {
|
||||
callback(element);
|
||||
if (options.once && this.firedOnceIds.has(id)) continue;
|
||||
|
||||
const targets = this.getElementsToCheck(element, options);
|
||||
for (const target of targets) {
|
||||
if (!this.matchesOptions(target, options)) continue;
|
||||
|
||||
callback(target);
|
||||
if (options.once) {
|
||||
this.firedOnceIds.add(id);
|
||||
this.unregisterById(event, id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,20 @@ import {
|
||||
OpenMenuOptions,
|
||||
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
||||
|
||||
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
|
||||
import {
|
||||
CloseThemeCreator,
|
||||
OpenThemeCreator,
|
||||
} from "@/plugins/built-in/themes/ThemeCreator";
|
||||
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
|
||||
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
|
||||
import type { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
|
||||
|
||||
const themeManager = ThemeManager.getInstance();
|
||||
let themeManagerPromise: Promise<ThemeManager> | null = null;
|
||||
|
||||
function getThemeManager(): Promise<ThemeManager> {
|
||||
if (!themeManagerPromise) {
|
||||
themeManagerPromise = import("@/plugins/built-in/themes/theme-manager").then(
|
||||
({ ThemeManager }) => ThemeManager.getInstance(),
|
||||
);
|
||||
}
|
||||
return themeManagerPromise;
|
||||
}
|
||||
|
||||
export class MessageHandler {
|
||||
constructor() {
|
||||
@@ -34,6 +39,7 @@ export class MessageHandler {
|
||||
case "UpdateThemePreview":
|
||||
if (request?.save == true) {
|
||||
const save = async () => {
|
||||
const themeManager = await getThemeManager();
|
||||
await themeManager.saveTheme({
|
||||
...request.body,
|
||||
userEdited: true,
|
||||
@@ -44,65 +50,88 @@ export class MessageHandler {
|
||||
sendResponse({ status: "success" });
|
||||
sendThemeUpdate();
|
||||
};
|
||||
save();
|
||||
void save();
|
||||
} else {
|
||||
themeManager.updatePreview(request.body);
|
||||
sendResponse({ status: "success" });
|
||||
void getThemeManager().then((themeManager) => {
|
||||
themeManager.updatePreview(request.body);
|
||||
sendResponse({ status: "success" });
|
||||
});
|
||||
}
|
||||
return true;
|
||||
|
||||
case "GetTheme":
|
||||
themeManager.getTheme(request.body.themeID).then((theme) => {
|
||||
sendResponse(theme);
|
||||
void getThemeManager().then((themeManager) => {
|
||||
themeManager.getTheme(request.body.themeID).then((theme) => {
|
||||
sendResponse(theme);
|
||||
});
|
||||
});
|
||||
return true;
|
||||
|
||||
case "SetTheme":
|
||||
themeManager.setTheme(request.body.themeID).then(() => {
|
||||
sendResponse({ status: "success" });
|
||||
});
|
||||
break;
|
||||
|
||||
case "DisableTheme":
|
||||
themeManager.disableTheme().then(() => {
|
||||
sendResponse({ status: "success" });
|
||||
});
|
||||
break;
|
||||
|
||||
case "DeleteTheme":
|
||||
themeManager.deleteTheme(request.body.themeID).then(() => {
|
||||
sendResponse({ status: "success" });
|
||||
});
|
||||
break;
|
||||
|
||||
case "ListThemes":
|
||||
themeManager.getAvailableThemes().then((themes) => {
|
||||
sendResponse(themes);
|
||||
void getThemeManager().then((themeManager) => {
|
||||
themeManager.setTheme(request.body.themeID).then(() => {
|
||||
sendResponse({ status: "success" });
|
||||
});
|
||||
});
|
||||
return true;
|
||||
|
||||
case "OpenThemeCreator":
|
||||
case "DisableTheme":
|
||||
void getThemeManager().then((themeManager) => {
|
||||
themeManager.disableTheme().then(() => {
|
||||
sendResponse({ status: "success" });
|
||||
});
|
||||
});
|
||||
return true;
|
||||
|
||||
case "DeleteTheme":
|
||||
void getThemeManager().then((themeManager) => {
|
||||
themeManager.deleteTheme(request.body.themeID).then(() => {
|
||||
sendResponse({ status: "success" });
|
||||
});
|
||||
});
|
||||
return true;
|
||||
|
||||
case "ListThemes":
|
||||
void getThemeManager().then((themeManager) => {
|
||||
themeManager.getAvailableThemes().then((themes) => {
|
||||
sendResponse(themes);
|
||||
});
|
||||
});
|
||||
return true;
|
||||
|
||||
case "OpenThemeCreator": {
|
||||
const themeID = request?.body?.themeID;
|
||||
OpenThemeCreator(themeID ? themeID : "");
|
||||
void import("@/plugins/built-in/themes/ThemeCreator").then(
|
||||
({ OpenThemeCreator }) => {
|
||||
void OpenThemeCreator(themeID ? themeID : "");
|
||||
},
|
||||
);
|
||||
closeExtensionPopup();
|
||||
sendResponse({ status: "success" });
|
||||
break;
|
||||
}
|
||||
|
||||
case "ShareTheme":
|
||||
themeManager.shareTheme(request.body.themeID).then((id) => {
|
||||
sendResponse({ status: "success", id });
|
||||
void getThemeManager().then((themeManager) => {
|
||||
themeManager.shareTheme(request.body.themeID).then((id) => {
|
||||
sendResponse({ status: "success", id });
|
||||
});
|
||||
});
|
||||
return true;
|
||||
|
||||
case "CloseThemeCreator":
|
||||
try {
|
||||
CloseThemeCreator();
|
||||
} catch (error) {
|
||||
console.error("Error closing theme creator:", error);
|
||||
sendResponse({ status: "error" });
|
||||
}
|
||||
sendResponse({ status: "success" });
|
||||
break;
|
||||
void import("@/plugins/built-in/themes/ThemeCreator").then(
|
||||
({ CloseThemeCreator }) => {
|
||||
try {
|
||||
CloseThemeCreator();
|
||||
sendResponse({ status: "success" });
|
||||
} catch (error) {
|
||||
console.error("Error closing theme creator:", error);
|
||||
sendResponse({ status: "error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
return true;
|
||||
|
||||
case "HideSensitive":
|
||||
hideSensitiveContent();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user