mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
refactor: trim PR debloat and fix transformers build
Extract shared helpers for home notices, timetable subtitles, and theme images; dedupe global search, Select, and build scripts while preserving behaviour. Add @huggingface/transformers as a direct dependency and resolve ORT WASM paths via require.resolve so pnpm postinstall and Vite can bundle vector search. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+6
-45
@@ -1,59 +1,20 @@
|
|||||||
// ref: https://stackoverflow.com/a/76920975
|
// ref: https://stackoverflow.com/a/76920975
|
||||||
import type { Plugin } from "vite";
|
import type { Plugin } from "vite";
|
||||||
|
|
||||||
/**
|
/** Exit with code 1 on build failure; do not exit on success (multi-target builds). */
|
||||||
* Creates a Vite plugin designed to gracefully handle the conclusion of the build process.
|
|
||||||
* This plugin utilizes the `buildEnd` and `closeBundle` hooks provided by Vite.
|
|
||||||
* It checks for errors at the end of the build:
|
|
||||||
* - If an error occurred during the build (`buildEnd` hook receives an error), it logs the error
|
|
||||||
* and explicitly exits the Node.js process with a status code of 1 (indicating failure).
|
|
||||||
* - If the build completes without errors and the bundle is successfully generated
|
|
||||||
* (`closeBundle` hook is called), it logs a success message and exits the process
|
|
||||||
* with a status code of 0 (indicating success).
|
|
||||||
* This explicit process exiting can be useful in CI/CD environments or scripts that
|
|
||||||
* rely on the process status code to determine the build outcome.
|
|
||||||
* The core logic for using these hooks to exit the process is inspired by
|
|
||||||
* a solution found on StackOverflow (https://stackoverflow.com/a/76920975).
|
|
||||||
*
|
|
||||||
* @returns {Plugin} A Vite plugin object configured with `name`, `buildEnd`, and `closeBundle` hooks.
|
|
||||||
*/
|
|
||||||
export default function ClosePlugin(): Plugin {
|
export default function ClosePlugin(): Plugin {
|
||||||
return {
|
return {
|
||||||
/**
|
name: "ClosePlugin",
|
||||||
* The unique name of this Vite plugin. This name is used by Vite for identification
|
|
||||||
* purposes and will appear in warnings, errors, and logs related to this plugin.
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
name: "ClosePlugin", // required, will show up in warnings and errors
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Vite hook that is called when the build process has finished, regardless of
|
|
||||||
* whether it was successful or encountered an error.
|
|
||||||
*
|
|
||||||
* @param {Error} [error] An optional error object. If the build failed, this parameter
|
|
||||||
* will contain the error that occurred. If the build was successful,
|
|
||||||
* this parameter will be undefined or null.
|
|
||||||
*/
|
|
||||||
buildEnd(error) {
|
buildEnd(error) {
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Error bundling");
|
console.error("Error bundling", error);
|
||||||
console.error(error);
|
process.exit(1);
|
||||||
process.exit(1); // Exit with status 1 indicating an error
|
|
||||||
} else {
|
} else {
|
||||||
console.log("Build ended"); // Log successful completion of the build phase
|
console.log("Build ended");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* A Vite hook that is called after the `buildEnd` hook, but only if the build
|
|
||||||
* was successful (i.e., no errors were passed to `buildEnd`) and all output
|
|
||||||
* files have been generated and written to disk. This signifies the successful
|
|
||||||
* completion of the entire bundling process.
|
|
||||||
*/
|
|
||||||
closeBundle() {
|
closeBundle() {
|
||||||
console.log("Bundle closed"); // Log successful closure of the bundle
|
console.log("Bundle closed");
|
||||||
// Do not process.exit here — it can mask Vite render errors and break
|
|
||||||
// multi-target builds (`npm run build` runs chrome then firefox).
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import type { Plugin } from "vite";
|
import type { Plugin } from "vite";
|
||||||
|
|
||||||
/**
|
/** Relative chunk/CSS URLs via chrome.runtime.getURL for content-script dynamic imports. */
|
||||||
* Vite's default base (`/`) emits absolute chunk paths like `/assets/chunk.js`.
|
|
||||||
* In content scripts those resolve against the SEQTA page origin on Firefox,
|
|
||||||
* not the extension — causing MIME type / NS_ERROR_CORRUPTED_CONTENT failures.
|
|
||||||
*
|
|
||||||
* Use relative base plus `chrome.runtime.getURL` for dynamic import targets.
|
|
||||||
*/
|
|
||||||
export function extensionChunkUrls(): Plugin {
|
export function extensionChunkUrls(): Plugin {
|
||||||
return {
|
return {
|
||||||
name: "extension-chunk-urls",
|
name: "extension-chunk-urls",
|
||||||
@@ -17,16 +11,11 @@ export function extensionChunkUrls(): Plugin {
|
|||||||
renderBuiltUrl(filename, { hostType, type }) {
|
renderBuiltUrl(filename, { hostType, type }) {
|
||||||
const path = filename.replace(/^\//, "");
|
const path = filename.replace(/^\//, "");
|
||||||
if (type === "chunk" && hostType === "js") {
|
if (type === "chunk" && hostType === "js") {
|
||||||
return {
|
return { runtime: `chrome.runtime.getURL(${JSON.stringify(path)})` };
|
||||||
runtime: `chrome.runtime.getURL(${JSON.stringify(path)})`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
// Rewrite CSS preloads from JS dynamic imports (content scripts).
|
// JS-triggered CSS preloads only — extension HTML pages need static hrefs.
|
||||||
// Do not rewrite hostType "css" — extension HTML pages need static hrefs.
|
|
||||||
if (type === "asset" && hostType === "js" && path.endsWith(".css")) {
|
if (type === "asset" && hostType === "js" && path.endsWith(".css")) {
|
||||||
return {
|
return { runtime: `chrome.runtime.getURL(${JSON.stringify(path)})` };
|
||||||
runtime: `chrome.runtime.getURL(${JSON.stringify(path)})`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-43
@@ -1,47 +1,14 @@
|
|||||||
// vite-plugin-inline-worker-dev.ts
|
|
||||||
// vite-plugin-inline-worker-dev.ts
|
|
||||||
import { Plugin } from "vite";
|
import { Plugin } from "vite";
|
||||||
import fs from "fs/promises";
|
|
||||||
import { build } from "esbuild";
|
import { build } from "esbuild";
|
||||||
|
|
||||||
/**
|
/** Bundle worker entry points imported with `?inlineWorker` as Blob-backed Workers in dev. */
|
||||||
* Creates a Vite plugin designed for bundling and inlining web worker scripts during development.
|
|
||||||
* This plugin specifically targets module imports that include a `?inlineWorker` query parameter.
|
|
||||||
* When such an import is encountered, the plugin bundles the worker script using `esbuild`
|
|
||||||
* and then generates JavaScript code that inlines this bundled worker as a Blob,
|
|
||||||
* creating the worker instance via `URL.createObjectURL()`.
|
|
||||||
* The name "vite:inline-worker-dev" suggests it's primarily intended for development builds.
|
|
||||||
*
|
|
||||||
* @returns {Plugin} A Vite plugin object with `name` and `load` properties.
|
|
||||||
*/
|
|
||||||
export default function InlineWorkerDevPlugin(): Plugin {
|
export default function InlineWorkerDevPlugin(): Plugin {
|
||||||
return {
|
return {
|
||||||
/**
|
|
||||||
* The unique name of this Vite plugin.
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
name: "vite:inline-worker-dev",
|
name: "vite:inline-worker-dev",
|
||||||
/**
|
|
||||||
* The Vite hook responsible for loading and transforming modules.
|
|
||||||
* This function intercepts modules imported with `?inlineWorker`.
|
|
||||||
* For such modules, it bundles the worker script and returns JavaScript code
|
|
||||||
* that, when executed, will create an instance of this worker from an inlined Blob.
|
|
||||||
*
|
|
||||||
* @async
|
|
||||||
* @param {string} id The path or ID of the module Vite is attempting to load,
|
|
||||||
* potentially including query parameters (e.g., "/path/to/worker.ts?inlineWorker").
|
|
||||||
* @returns {Promise<string | null>} A promise that resolves to:
|
|
||||||
* - `null` if the module ID does not include `?inlineWorker`.
|
|
||||||
* - A string of JavaScript code if the module is an inline worker.
|
|
||||||
* This code will define a default export function (e.g., `InlineWorker`)
|
|
||||||
* that, when called, creates and returns a new `Worker` instance
|
|
||||||
* from the bundled and inlined worker script.
|
|
||||||
*/
|
|
||||||
async load(id) {
|
async load(id) {
|
||||||
if (id.includes("?inlineWorker")) {
|
if (!id.includes("?inlineWorker")) return null;
|
||||||
|
|
||||||
const [cleanPath] = id.split("?");
|
const [cleanPath] = id.split("?");
|
||||||
// Note: Original code had `await fs.readFile(cleanPath, "utf-8");` but `code` wasn't used.
|
|
||||||
// `esbuild` directly takes `cleanPath` as an entry point.
|
|
||||||
const result = await build({
|
const result = await build({
|
||||||
entryPoints: [cleanPath],
|
entryPoints: [cleanPath],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
@@ -53,19 +20,13 @@ export default function InlineWorkerDevPlugin(): Plugin {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const workerCode = result.outputFiles[0].text;
|
const workerCode = result.outputFiles[0].text;
|
||||||
|
return `
|
||||||
// Construct JavaScript code that will create the worker from a Blob.
|
|
||||||
// This code is what gets returned to Vite and replaces the original import.
|
|
||||||
const workerBlobCode = `
|
|
||||||
const code = ${JSON.stringify(workerCode)};
|
const code = ${JSON.stringify(workerCode)};
|
||||||
export default function InlineWorker() {
|
export default function InlineWorker() {
|
||||||
const blob = new Blob([code], { type: 'application/javascript' });
|
const blob = new Blob([code], { type: 'application/javascript' });
|
||||||
return new Worker(URL.createObjectURL(blob), { type: 'module' });
|
return new Worker(URL.createObjectURL(blob), { type: 'module' });
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
return workerBlobCode;
|
|
||||||
}
|
|
||||||
return null; // Let Vite handle other modules normally
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"d3-shape": "^3.2.0",
|
"d3-shape": "^3.2.0",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.4",
|
||||||
|
"@huggingface/transformers": "^3.8.1",
|
||||||
"embeddia": "^1.3.0",
|
"embeddia": "^1.3.0",
|
||||||
"embla-carousel-autoplay": "^8.5.2",
|
"embla-carousel-autoplay": "^8.5.2",
|
||||||
"embla-carousel-svelte": "^8.5.2",
|
"embla-carousel-svelte": "^8.5.2",
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* layerchart ships raw `.svelte` sources in `dist/`. Vite/Svelte compilation is
|
* Pre-compile layerchart `.svelte` sources to `.js` so Rollup/Vite CI builds succeed.
|
||||||
* unreliable for this package on CI (Rollup parses vendor sources as JS). Compile
|
|
||||||
* to plain `.js` at install/build time and rewrite internal imports.
|
|
||||||
*/
|
*/
|
||||||
import { compile } from "svelte/compiler";
|
import { compile } from "svelte/compiler";
|
||||||
import {
|
import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||||
readFileSync,
|
|
||||||
readdirSync,
|
|
||||||
statSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
@@ -17,97 +10,71 @@ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|||||||
const layerchartRoot = join(root, "node_modules", "layerchart");
|
const layerchartRoot = join(root, "node_modules", "layerchart");
|
||||||
const layerchartDist = join(layerchartRoot, "dist");
|
const layerchartDist = join(layerchartRoot, "dist");
|
||||||
const stampPath = join(layerchartDist, ".bsplus-compiled");
|
const stampPath = join(layerchartDist, ".bsplus-compiled");
|
||||||
|
const COMPILE_ALGO_VERSION = "2";
|
||||||
|
const importSuffixPattern = /\.svelte(?=['"])/g;
|
||||||
|
|
||||||
function exists(path) {
|
const exists = (path) => {
|
||||||
try {
|
try {
|
||||||
statSync(path);
|
statSync(path);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (!exists(layerchartDist)) {
|
if (!exists(layerchartDist)) {
|
||||||
console.log("compile-layerchart-vendor: layerchart not installed, skipping");
|
console.log("compile-layerchart-vendor: layerchart not installed, skipping");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMPILE_ALGO_VERSION = "2";
|
|
||||||
|
|
||||||
const layerchartVersion = JSON.parse(
|
const layerchartVersion = JSON.parse(
|
||||||
readFileSync(join(layerchartRoot, "package.json"), "utf8"),
|
readFileSync(join(layerchartRoot, "package.json"), "utf8"),
|
||||||
).version;
|
).version;
|
||||||
|
|
||||||
const stampContent = `${layerchartVersion}\n${COMPILE_ALGO_VERSION}`;
|
const stampContent = `${layerchartVersion}\n${COMPILE_ALGO_VERSION}`;
|
||||||
|
|
||||||
if (
|
if (exists(stampPath) && readFileSync(stampPath, "utf8").trim() === stampContent) {
|
||||||
exists(stampPath) &&
|
console.log(`compile-layerchart-vendor: layerchart@${layerchartVersion} already compiled, skipping`);
|
||||||
readFileSync(stampPath, "utf8").trim() === stampContent
|
|
||||||
) {
|
|
||||||
console.log(
|
|
||||||
`compile-layerchart-vendor: layerchart@${layerchartVersion} already compiled, skipping`,
|
|
||||||
);
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function walkFiles(dir, files = []) {
|
const walkFiles = (dir, files = []) => {
|
||||||
for (const name of readdirSync(dir)) {
|
for (const name of readdirSync(dir)) {
|
||||||
if (name === "node_modules") continue;
|
if (name === "node_modules") continue;
|
||||||
const path = join(dir, name);
|
const path = join(dir, name);
|
||||||
if (statSync(path).isDirectory()) {
|
if (statSync(path).isDirectory()) walkFiles(path, files);
|
||||||
walkFiles(path, files);
|
else files.push(path);
|
||||||
} else {
|
|
||||||
files.push(path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
}
|
};
|
||||||
|
|
||||||
const importSuffixPattern = /\.svelte(?=['"])/g;
|
const patchSvelteImports = (content) => content.replace(importSuffixPattern, ".js");
|
||||||
|
const stripRollupBreakingSyntax = (code) =>
|
||||||
function patchSvelteImports(content) {
|
code
|
||||||
return content.replace(importSuffixPattern, ".js");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rollup CJS resolver chokes on TS optional params (`name?`) in vendor `.js`. */
|
|
||||||
function stripRollupBreakingSyntax(code) {
|
|
||||||
return code
|
|
||||||
.replace(/(\w+)\?(?=\s*[,)\]])/g, "$1")
|
.replace(/(\w+)\?(?=\s*[,)\]])/g, "$1")
|
||||||
.replace(/(\w+)\?(?=\s*:)/g, "$1");
|
.replace(/(\w+)\?(?=\s*:)/g, "$1");
|
||||||
}
|
|
||||||
|
|
||||||
const svelteFiles = walkFiles(layerchartDist).filter((f) => f.endsWith(".svelte"));
|
const svelteFiles = walkFiles(layerchartDist).filter((f) => f.endsWith(".svelte"));
|
||||||
|
|
||||||
for (const sveltePath of svelteFiles) {
|
for (const sveltePath of svelteFiles) {
|
||||||
const source = readFileSync(sveltePath, "utf8");
|
const source = readFileSync(sveltePath, "utf8");
|
||||||
if (!source.includes("<script")) continue;
|
if (!source.includes("<script")) continue;
|
||||||
|
|
||||||
const compiled = compile(source, {
|
const compiled = compile(source, {
|
||||||
filename: sveltePath,
|
filename: sveltePath,
|
||||||
generate: "client",
|
generate: "client",
|
||||||
css: "injected",
|
css: "injected",
|
||||||
});
|
});
|
||||||
|
writeFileSync(
|
||||||
const jsPath = sveltePath.replace(/\.svelte$/, ".js");
|
sveltePath.replace(/\.svelte$/, ".js"),
|
||||||
const jsCode = stripRollupBreakingSyntax(patchSvelteImports(compiled.js.code));
|
stripRollupBreakingSyntax(patchSvelteImports(compiled.js.code)),
|
||||||
writeFileSync(jsPath, jsCode);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const patchable = walkFiles(layerchartDist).filter((f) =>
|
for (const filePath of walkFiles(layerchartDist).filter((f) => /\.(js|svelte|ts|mjs)$/.test(f))) {
|
||||||
/\.(js|svelte|ts|mjs)$/.test(f),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const filePath of patchable) {
|
|
||||||
const content = readFileSync(filePath, "utf8");
|
const content = readFileSync(filePath, "utf8");
|
||||||
if (!content.includes(".svelte")) continue;
|
if (!content.includes(".svelte")) continue;
|
||||||
const patched = patchSvelteImports(content);
|
const patched = patchSvelteImports(content);
|
||||||
if (patched !== content) {
|
if (patched !== content) writeFileSync(filePath, patched);
|
||||||
writeFileSync(filePath, patched);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(stampPath, stampContent);
|
writeFileSync(stampPath, stampContent);
|
||||||
|
console.log(`compile-layerchart-vendor: compiled ${svelteFiles.length} Svelte files`);
|
||||||
console.log(
|
|
||||||
`compile-layerchart-vendor: compiled ${svelteFiles.length} Svelte files`,
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { copyFileSync, mkdirSync } from "node:fs";
|
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const transformersDist = join(
|
const require = createRequire(import.meta.url);
|
||||||
root,
|
|
||||||
"node_modules",
|
const transformersDist = dirname(require.resolve("@huggingface/transformers"));
|
||||||
"@huggingface",
|
|
||||||
"transformers",
|
|
||||||
"dist",
|
|
||||||
);
|
|
||||||
const outDir = join(root, "src", "public", "resources", "ort");
|
const outDir = join(root, "src", "public", "resources", "ort");
|
||||||
|
|
||||||
mkdirSync(outDir, { recursive: true });
|
mkdirSync(outDir, { recursive: true });
|
||||||
@@ -20,5 +17,12 @@ const ortFiles = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (const file of ortFiles) {
|
for (const file of ortFiles) {
|
||||||
copyFileSync(join(transformersDist, file), join(outDir, file));
|
const src = join(transformersDist, file);
|
||||||
|
if (!existsSync(src)) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing ONNX Runtime WASM asset: ${src}\n` +
|
||||||
|
"Ensure @huggingface/transformers is installed (direct dependency).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
copyFileSync(src, join(outDir, file));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Package Chrome/Firefox build folders into zip files Windows Explorer can open.
|
* Package Chrome/Firefox build folders into Windows-friendly zip files.
|
||||||
* Git Bash `tar -a` on CI often produces zips that fail to unzip on Windows.
|
|
||||||
*/
|
*/
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import {
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
||||||
appendFileSync,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
unlinkSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
@@ -18,29 +11,27 @@ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|||||||
function zipDirectory(sourceRel, outRel) {
|
function zipDirectory(sourceRel, outRel) {
|
||||||
const sourceDir = join(root, sourceRel);
|
const sourceDir = join(root, sourceRel);
|
||||||
const outZip = join(root, outRel);
|
const outZip = join(root, outRel);
|
||||||
|
if (!existsSync(sourceDir)) throw new Error(`Missing build output: ${sourceRel}`);
|
||||||
if (!existsSync(sourceDir)) {
|
|
||||||
throw new Error(`Missing build output: ${sourceRel}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(dirname(outZip), { recursive: true });
|
mkdirSync(dirname(outZip), { recursive: true });
|
||||||
if (existsSync(outZip)) {
|
if (existsSync(outZip)) unlinkSync(outZip);
|
||||||
unlinkSync(outZip);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
const ps = [
|
const esc = (s) => s.replace(/'/g, "''");
|
||||||
|
execFileSync(
|
||||||
|
"powershell",
|
||||||
|
[
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
[
|
||||||
"Add-Type -AssemblyName System.IO.Compression.FileSystem",
|
"Add-Type -AssemblyName System.IO.Compression.FileSystem",
|
||||||
`[IO.Compression.ZipFile]::CreateFromDirectory('${sourceDir.replace(/'/g, "''")}', '${outZip.replace(/'/g, "''")}')`,
|
`[IO.Compression.ZipFile]::CreateFromDirectory('${esc(sourceDir)}', '${esc(outZip)}')`,
|
||||||
].join("; ");
|
].join("; "),
|
||||||
execFileSync("powershell", ["-NoProfile", "-Command", ps], {
|
],
|
||||||
stdio: "inherit",
|
{ stdio: "inherit" },
|
||||||
});
|
);
|
||||||
} else {
|
} else {
|
||||||
execFileSync("zip", ["-r", "-q", outZip, "."], {
|
execFileSync("zip", ["-r", "-q", outZip, "."], { cwd: sourceDir, stdio: "inherit" });
|
||||||
cwd: sourceDir,
|
|
||||||
stdio: "inherit",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +39,6 @@ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|||||||
const version = process.argv[2] || pkg.version;
|
const version = process.argv[2] || pkg.version;
|
||||||
const updateChannel = process.env.UPDATE_CHANNEL || "stable";
|
const updateChannel = process.env.UPDATE_CHANNEL || "stable";
|
||||||
const buildLabel = process.env.BUILD_LABEL || "";
|
const buildLabel = process.env.BUILD_LABEL || "";
|
||||||
|
|
||||||
const base =
|
const base =
|
||||||
updateChannel === "nightly" && buildLabel
|
updateChannel === "nightly" && buildLabel
|
||||||
? `betterseqtaplus-nightly-${buildLabel}`
|
? `betterseqtaplus-nightly-${buildLabel}`
|
||||||
@@ -59,13 +49,8 @@ const firefoxZip = `dist/${base}-firefox.zip`;
|
|||||||
|
|
||||||
zipDirectory("dist/chrome", chromeZip);
|
zipDirectory("dist/chrome", chromeZip);
|
||||||
zipDirectory("dist/firefox", firefoxZip);
|
zipDirectory("dist/firefox", firefoxZip);
|
||||||
|
console.log(`Packaged ${chromeZip}\nPackaged ${firefoxZip}`);
|
||||||
console.log(`Packaged ${chromeZip}`);
|
|
||||||
console.log(`Packaged ${firefoxZip}`);
|
|
||||||
|
|
||||||
if (process.env.GITHUB_OUTPUT) {
|
if (process.env.GITHUB_OUTPUT) {
|
||||||
appendFileSync(
|
appendFileSync(process.env.GITHUB_OUTPUT, `chrome_zip=${chromeZip}\nfirefox_zip=${firefoxZip}\n`);
|
||||||
process.env.GITHUB_OUTPUT,
|
|
||||||
`chrome_zip=${chromeZip}\nfirefox_zip=${firefoxZip}\n`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ select[size="1"] {
|
|||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
-moz-appearance: none;
|
-moz-appearance: none;
|
||||||
|
color-scheme: light;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23999'%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") !important;
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23999'%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") !important;
|
||||||
background-position: right 0.9rem center !important;
|
background-position: right 0.9rem center !important;
|
||||||
background-repeat: no-repeat !important;
|
background-repeat: no-repeat !important;
|
||||||
@@ -101,16 +102,10 @@ select[size="1"] {
|
|||||||
padding-right: 2.6rem !important;
|
padding-right: 2.6rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html:not(.dark) select:not([multiple]):not([size]),
|
|
||||||
html:not(.dark) select[size="1"] {
|
|
||||||
color-scheme: light;
|
|
||||||
}
|
|
||||||
|
|
||||||
select::-ms-expand {
|
select::-ms-expand {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* OS option panels on Windows/Edge are often light even in dark mode */
|
|
||||||
select option {
|
select option {
|
||||||
background-color: #ffffff !important;
|
background-color: #ffffff !important;
|
||||||
color: #18181b !important;
|
color: #18181b !important;
|
||||||
@@ -123,8 +118,8 @@ select option {
|
|||||||
|
|
||||||
.dark select:not([multiple]):not([size]),
|
.dark select:not([multiple]):not([size]),
|
||||||
.dark select[size="1"] {
|
.dark select[size="1"] {
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23c9c9c9'%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") !important;
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23c9c9c9'%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") !important;
|
||||||
}
|
}
|
||||||
#container {
|
#container {
|
||||||
background: var(--auto-background) !important;
|
background: var(--auto-background) !important;
|
||||||
|
|||||||
@@ -11,20 +11,14 @@
|
|||||||
let activeIndex = $state(0);
|
let activeIndex = $state(0);
|
||||||
let root: HTMLDivElement | undefined = $state();
|
let root: HTMLDivElement | undefined = $state();
|
||||||
let trigger: HTMLButtonElement | undefined = $state();
|
let trigger: HTMLButtonElement | undefined = $state();
|
||||||
let listbox: HTMLUListElement | undefined = $state();
|
let listbox: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
const selectedLabel = $derived(
|
const selectedLabel = $derived(
|
||||||
options.find((option) => option.value === value)?.label ?? value,
|
options.find((option) => option.value === value)?.label ?? value,
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedIndex = $derived(
|
|
||||||
options.findIndex((option) => option.value === value),
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeDescendantId = $derived(
|
const activeDescendantId = $derived(
|
||||||
isOpen && options[activeIndex]
|
isOpen && options[activeIndex] ? optionId(options[activeIndex].value) : undefined,
|
||||||
? optionId(options[activeIndex].value)
|
|
||||||
: undefined,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function optionId(optionValue: string): string {
|
function optionId(optionValue: string): string {
|
||||||
@@ -33,24 +27,13 @@
|
|||||||
|
|
||||||
function openMenu(preferredIndex?: number) {
|
function openMenu(preferredIndex?: number) {
|
||||||
isOpen = true;
|
isOpen = true;
|
||||||
activeIndex =
|
const selectedIndex = options.findIndex((option) => option.value === value);
|
||||||
preferredIndex ??
|
activeIndex = preferredIndex ?? (selectedIndex >= 0 ? selectedIndex : 0);
|
||||||
(selectedIndex >= 0 ? selectedIndex : 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMenu(returnFocus = true) {
|
function closeMenu(returnFocus = true) {
|
||||||
isOpen = false;
|
isOpen = false;
|
||||||
if (returnFocus) {
|
if (returnFocus) trigger?.focus();
|
||||||
trigger?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleOpen() {
|
|
||||||
if (isOpen) {
|
|
||||||
closeMenu();
|
|
||||||
} else {
|
|
||||||
openMenu();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectValue(nextValue: string) {
|
function selectValue(nextValue: string) {
|
||||||
@@ -58,41 +41,27 @@
|
|||||||
closeMenu();
|
closeMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectActive() {
|
|
||||||
const option = options[activeIndex];
|
|
||||||
if (option) {
|
|
||||||
selectValue(option.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveActive(delta: number) {
|
function moveActive(delta: number) {
|
||||||
if (!options.length) return;
|
if (!options.length) return;
|
||||||
activeIndex = (activeIndex + delta + options.length) % options.length;
|
activeIndex = (activeIndex + delta + options.length) % options.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTriggerKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent, inListbox = false) {
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case "ArrowDown":
|
case "ArrowDown":
|
||||||
|
case "ArrowUp": {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (isOpen) {
|
const delta = event.key === "ArrowDown" ? 1 : -1;
|
||||||
moveActive(1);
|
if (isOpen || inListbox) moveActive(delta);
|
||||||
} else {
|
else openMenu();
|
||||||
openMenu();
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case "ArrowUp":
|
|
||||||
event.preventDefault();
|
|
||||||
if (isOpen) {
|
|
||||||
moveActive(-1);
|
|
||||||
} else {
|
|
||||||
openMenu();
|
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
case "Enter":
|
case "Enter":
|
||||||
case " ":
|
case " ":
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
selectActive();
|
const option = options[activeIndex];
|
||||||
|
if (option) selectValue(option.value);
|
||||||
} else {
|
} else {
|
||||||
openMenu();
|
openMenu();
|
||||||
}
|
}
|
||||||
@@ -103,38 +72,20 @@
|
|||||||
closeMenu();
|
closeMenu();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onListboxKeydown(event: KeyboardEvent) {
|
|
||||||
switch (event.key) {
|
|
||||||
case "ArrowDown":
|
|
||||||
event.preventDefault();
|
|
||||||
moveActive(1);
|
|
||||||
break;
|
|
||||||
case "ArrowUp":
|
|
||||||
event.preventDefault();
|
|
||||||
moveActive(-1);
|
|
||||||
break;
|
|
||||||
case "Home":
|
case "Home":
|
||||||
|
if (inListbox) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
activeIndex = 0;
|
activeIndex = 0;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "End":
|
case "End":
|
||||||
|
if (inListbox) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
activeIndex = Math.max(0, options.length - 1);
|
activeIndex = Math.max(0, options.length - 1);
|
||||||
break;
|
}
|
||||||
case "Enter":
|
|
||||||
case " ":
|
|
||||||
event.preventDefault();
|
|
||||||
selectActive();
|
|
||||||
break;
|
|
||||||
case "Escape":
|
|
||||||
event.preventDefault();
|
|
||||||
closeMenu();
|
|
||||||
break;
|
break;
|
||||||
case "Tab":
|
case "Tab":
|
||||||
closeMenu(false);
|
if (inListbox) closeMenu(false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,8 +96,7 @@
|
|||||||
queueMicrotask(() => listbox?.focus());
|
queueMicrotask(() => listbox?.focus());
|
||||||
|
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
const path = event.composedPath();
|
if (root && event.composedPath().includes(root)) return;
|
||||||
if (root && path.includes(root)) return;
|
|
||||||
closeMenu(false);
|
closeMenu(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,8 +113,8 @@
|
|||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-controls={listboxId}
|
aria-controls={listboxId}
|
||||||
onclick={toggleOpen}
|
onclick={() => (isOpen ? closeMenu() : openMenu())}
|
||||||
onkeydown={onTriggerKeydown}
|
onkeydown={(event) => handleKeydown(event)}
|
||||||
>
|
>
|
||||||
<span class="select-label">{selectedLabel}</span>
|
<span class="select-label">{selectedLabel}</span>
|
||||||
<span class="select-icon" aria-hidden="true">
|
<span class="select-icon" aria-hidden="true">
|
||||||
@@ -179,23 +129,21 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<ul
|
<div
|
||||||
bind:this={listbox}
|
bind:this={listbox}
|
||||||
id={listboxId}
|
id={listboxId}
|
||||||
class="select-menu"
|
class="select-menu"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-activedescendant={activeDescendantId}
|
aria-activedescendant={activeDescendantId}
|
||||||
onkeydown={onListboxKeydown}
|
onkeydown={(event) => handleKeydown(event, true)}
|
||||||
>
|
>
|
||||||
{#each options as option, index (option.value)}
|
{#each options as option, index (option.value)}
|
||||||
<li
|
<button
|
||||||
|
type="button"
|
||||||
id={optionId(option.value)}
|
id={optionId(option.value)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={option.value === value}
|
aria-selected={option.value === value}
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="select-option"
|
class="select-option"
|
||||||
class:is-selected={option.value === value}
|
class:is-selected={option.value === value}
|
||||||
class:is-active={index === activeIndex}
|
class:is-active={index === activeIndex}
|
||||||
@@ -205,9 +153,8 @@
|
|||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -239,14 +186,14 @@
|
|||||||
box-shadow 180ms ease;
|
box-shadow 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-trigger:hover {
|
.select-trigger:hover,
|
||||||
|
.select-trigger:focus-visible {
|
||||||
|
outline: none;
|
||||||
background: var(--theme-secondary, #e5e7eb);
|
background: var(--theme-secondary, #e5e7eb);
|
||||||
border-color: var(--theme-offset-bg, var(--theme-secondary, #d4d4d8));
|
border-color: var(--theme-offset-bg, var(--theme-secondary, #d4d4d8));
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-trigger:focus-visible {
|
.select-trigger:focus-visible {
|
||||||
outline: none;
|
|
||||||
background: var(--theme-secondary, #e5e7eb);
|
|
||||||
border-color: color-mix(in srgb, var(--text-primary) 22%, var(--theme-secondary, #e5e7eb) 78%);
|
border-color: color-mix(in srgb, var(--text-primary) 22%, var(--theme-secondary, #e5e7eb) 78%);
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent);
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||||
}
|
}
|
||||||
@@ -268,9 +215,11 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
list-style: none;
|
|
||||||
border: 1px solid var(--theme-offset-bg, var(--theme-secondary, #e5e7eb));
|
border: 1px solid var(--theme-offset-bg, var(--theme-secondary, #e5e7eb));
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: var(--theme-primary, #ffffff);
|
background: var(--theme-primary, #ffffff);
|
||||||
@@ -279,9 +228,6 @@
|
|||||||
0 8px 10px -6px rgb(0 0 0 / 0.2);
|
0 8px 10px -6px rgb(0 0 0 / 0.2);
|
||||||
max-height: 18rem;
|
max-height: 18rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-menu:focus-visible {
|
.select-menu:focus-visible {
|
||||||
@@ -310,12 +256,9 @@
|
|||||||
|
|
||||||
.select-option:hover,
|
.select-option:hover,
|
||||||
.select-option:focus-visible,
|
.select-option:focus-visible,
|
||||||
.select-option.is-active {
|
.select-option.is-active,
|
||||||
|
.select-option.is-selected {
|
||||||
outline: none;
|
outline: none;
|
||||||
background: var(--theme-secondary, #e5e7eb);
|
background: var(--theme-secondary, #e5e7eb);
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-option.is-selected {
|
|
||||||
background: var(--theme-secondary, #e5e7eb);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { LUCIDE_MOON_PATH } from "@/lib/icons/lucideMoon";
|
||||||
|
|
||||||
let { class: className = "w-5 h-5" }: { class?: string } = $props();
|
let { class: className = "w-5 h-5" }: { class?: string } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -9,5 +11,5 @@
|
|||||||
class={className}
|
class={className}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path d="M12,3C7.03,3 3,7.03 3,12C3,16.97 7.03,21 12,21C16.97,21 21,16.97 21,12C21,11.54 20.96,11.08 20.9,10.64C19.92,12.01 18.32,12.9 16.5,12.9C13.52,12.9 11.1,10.48 11.1,7.5C11.1,5.68 11.99,4.08 13.36,3.1C12.92,3.04 12.46,3 12,3Z" />
|
<path d={LUCIDE_MOON_PATH} />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { LUCIDE_SUN_PATH } from "@/lib/icons/lucideSun";
|
||||||
|
|
||||||
let { class: className = "w-5 h-5" }: { class?: string } = $props();
|
let { class: className = "w-5 h-5" }: { class?: string } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -9,5 +11,5 @@
|
|||||||
class={className}
|
class={className}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path d="M12,7C9.24,7 7,9.24 7,12C7,14.76 9.24,17 12,17C14.76,17 17,14.76 17,12C17,9.24 14.76,7 12,7M2,13H4C4.55,13 5,12.55 5,12C5,11.45 4.55,11 4,11H2C1.45,11 1,11.45 1,12C1,12.55 1.45,13 2,13M20,13H22C22.55,13 23,12.55 23,12C23,11.45 22.55,11 22,11H20C19.45,11 19,11.45 19,12C19,12.55 19.45,13 20,13M11,2V4C11,4.55 11.45,5 12,5C12.55,5 13,4.55 13,4V2C13,1.45 12.55,1 12,1C11.45,1 11,1.45 11,2M11,20V22C11,22.55 11.45,23 12,23C12.55,23 13,22.55 13,22V20C13,19.45 12.55,19 12,19C11.45,19 11,19.45 11,20M5.99,4.58C5.6,4.19 4.96,4.19 4.58,4.58C4.19,4.96 4.19,5.6 4.58,5.99L5.64,7.05C6.03,7.44 6.67,7.44 7.05,7.05C7.44,6.67 7.44,6.03 7.05,5.64L5.99,4.58M18.36,16.95C17.97,16.56 17.33,16.56 16.95,16.95C16.56,17.33 16.56,17.97 16.95,18.36L18.01,19.42C18.4,19.81 19.04,19.81 19.42,19.42C19.81,19.04 19.81,18.4 19.42,18.01L18.36,16.95M19.42,5.99C19.81,5.6 19.81,4.96 19.42,4.58C19.04,4.19 18.4,4.19 18.01,4.58L16.95,5.64C16.56,6.03 16.56,6.67 16.95,7.05C17.33,7.44 17.97,7.44 18.36,7.05L19.42,5.99M7.05,18.36C7.44,17.97 7.44,17.33 7.05,16.95C6.67,16.56 6.03,16.56 5.64,16.95L4.58,18.01C4.19,18.4 4.19,19.04 4.58,19.42C4.96,19.81 5.6,19.81 5.99,19.42L7.05,18.36Z" />
|
<path d={LUCIDE_SUN_PATH} />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,978 +1,472 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|
||||||
import type { Theme } from '@/interface/types/Theme'
|
import type { Theme } from '@/interface/types/Theme'
|
||||||
|
|
||||||
import { fade } from 'svelte/transition'
|
import { fade } from 'svelte/transition'
|
||||||
|
|
||||||
import { animate } from 'motion'
|
import { animate } from 'motion'
|
||||||
|
|
||||||
import emblaCarouselSvelte from 'embla-carousel-svelte'
|
import emblaCarouselSvelte from 'embla-carousel-svelte'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
||||||
buildModalHeroSlides,
|
buildModalHeroSlides,
|
||||||
|
|
||||||
flavourCarouselImageUrl,
|
flavourCarouselImageUrl,
|
||||||
|
|
||||||
masterCarouselImageUrl,
|
masterCarouselImageUrl,
|
||||||
|
|
||||||
masterGridDisplayDownloadCount,
|
masterGridDisplayDownloadCount,
|
||||||
|
|
||||||
} from '@/interface/utils/themeStoreFlavours'
|
} from '@/interface/utils/themeStoreFlavours'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
||||||
theme,
|
theme,
|
||||||
|
|
||||||
currentThemes,
|
currentThemes,
|
||||||
|
|
||||||
setDisplayTheme,
|
setDisplayTheme,
|
||||||
|
|
||||||
onInstall,
|
onInstall,
|
||||||
|
|
||||||
onRemove,
|
onRemove,
|
||||||
|
|
||||||
allThemes,
|
allThemes,
|
||||||
|
|
||||||
allStoreThemeRows,
|
allStoreThemeRows,
|
||||||
|
|
||||||
displayTheme,
|
displayTheme,
|
||||||
|
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
|
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
|
|
||||||
onRequestSignIn,
|
onRequestSignIn,
|
||||||
|
|
||||||
} = $props<{
|
} = $props<{
|
||||||
|
|
||||||
theme: Theme | null
|
theme: Theme | null
|
||||||
|
|
||||||
currentThemes: string[]
|
currentThemes: string[]
|
||||||
|
|
||||||
setDisplayTheme: (theme: Theme | null) => void
|
setDisplayTheme: (theme: Theme | null) => void
|
||||||
|
|
||||||
onInstall: (themeId: string) => void | Promise<void>
|
onInstall: (themeId: string) => void | Promise<void>
|
||||||
|
|
||||||
onRemove: (themeId: string) => void | Promise<void>
|
onRemove: (themeId: string) => void | Promise<void>
|
||||||
|
|
||||||
allThemes: Theme[]
|
allThemes: Theme[]
|
||||||
|
|
||||||
/** Raw API themes (includes slaves) — same aggregation as grid download count */
|
/** Raw API themes (includes slaves) — same aggregation as grid download count */
|
||||||
|
|
||||||
allStoreThemeRows?: Theme[]
|
allStoreThemeRows?: Theme[]
|
||||||
|
|
||||||
displayTheme: Theme | null
|
displayTheme: Theme | null
|
||||||
|
|
||||||
toggleFavorite?: (theme: Theme) => void
|
toggleFavorite?: (theme: Theme) => void
|
||||||
|
|
||||||
isLoggedIn?: boolean
|
isLoggedIn?: boolean
|
||||||
|
|
||||||
onRequestSignIn?: () => void
|
onRequestSignIn?: () => void
|
||||||
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const modalDisplayDownloadCount = $derived.by(() => {
|
const modalDisplayDownloadCount = $derived.by(() => {
|
||||||
|
|
||||||
const t = theme
|
const t = theme
|
||||||
|
|
||||||
if (!t) return 0
|
if (!t) return 0
|
||||||
|
|
||||||
if (allStoreThemeRows != null) return masterGridDisplayDownloadCount(t, allStoreThemeRows)
|
if (allStoreThemeRows != null) return masterGridDisplayDownloadCount(t, allStoreThemeRows)
|
||||||
|
|
||||||
return t.download_count ?? 0
|
return t.download_count ?? 0
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let installingId = $state<string | null>(null)
|
let installingId = $state<string | null>(null)
|
||||||
|
|
||||||
let modalElement: HTMLElement
|
let modalElement: HTMLElement
|
||||||
|
|
||||||
/** Embla CarouselInstance — scrollTo from embla-carousel */
|
/** Embla CarouselInstance — scrollTo from embla-carousel */
|
||||||
|
|
||||||
let heroEmblaApi = $state<{
|
let heroEmblaApi = $state<{
|
||||||
|
|
||||||
scrollTo: (index: number, jump?: boolean) => void
|
scrollTo: (index: number, jump?: boolean) => void
|
||||||
|
|
||||||
scrollPrev: () => void
|
scrollPrev: () => void
|
||||||
|
|
||||||
scrollNext: () => void
|
scrollNext: () => void
|
||||||
|
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function handleFavoriteClick() {
|
function handleFavoriteClick() {
|
||||||
|
|
||||||
if (isLoggedIn && toggleFavorite && theme) {
|
if (isLoggedIn && toggleFavorite && theme) {
|
||||||
|
|
||||||
toggleFavorite(theme)
|
toggleFavorite(theme)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
onRequestSignIn?.()
|
onRequestSignIn?.()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function tagsOverlap(a: string[] | undefined, b: string[] | undefined): boolean {
|
function tagsOverlap(a: string[] | undefined, b: string[] | undefined): boolean {
|
||||||
|
|
||||||
const lowerB = new Set((b ?? []).map((t) => t.toLowerCase()))
|
const lowerB = new Set((b ?? []).map((t) => t.toLowerCase()))
|
||||||
|
|
||||||
return (a ?? []).some((t) => lowerB.has(t.toLowerCase()))
|
return (a ?? []).some((t) => lowerB.has(t.toLowerCase()))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const flavourIdsAvoidForRelated = $derived.by(() => {
|
const flavourIdsAvoidForRelated = $derived.by(() => {
|
||||||
|
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
|
|
||||||
for (const f of theme?.flavours ?? []) {
|
for (const f of theme?.flavours ?? []) {
|
||||||
|
|
||||||
set.add(f.id)
|
set.add(f.id)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return set
|
return set
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const relatedThemes = $derived.by(() => {
|
const relatedThemes = $derived.by(() => {
|
||||||
|
|
||||||
const t = theme
|
const t = theme
|
||||||
|
|
||||||
if (!t) return [] as Theme[]
|
if (!t) return [] as Theme[]
|
||||||
|
|
||||||
if ((t.tags ?? []).length === 0) return []
|
if ((t.tags ?? []).length === 0) return []
|
||||||
|
|
||||||
return allThemes
|
return allThemes
|
||||||
|
|
||||||
.filter((x: Theme) => {
|
.filter((x: Theme) => {
|
||||||
|
|
||||||
if (!x || x.id === t.id) return false
|
if (!x || x.id === t.id) return false
|
||||||
|
|
||||||
if (flavourIdsAvoidForRelated.has(x.id)) return false
|
if (flavourIdsAvoidForRelated.has(x.id)) return false
|
||||||
|
|
||||||
if (x.master_id === t.id) return false
|
if (x.master_id === t.id) return false
|
||||||
|
|
||||||
return tagsOverlap(t.tags, x.tags)
|
return tagsOverlap(t.tags, x.tags)
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
.sort((a: Theme, b: Theme) => {
|
.sort((a: Theme, b: Theme) => {
|
||||||
|
|
||||||
const diff = (b.download_count ?? 0) - (a.download_count ?? 0)
|
const diff = (b.download_count ?? 0) - (a.download_count ?? 0)
|
||||||
|
|
||||||
if (diff !== 0) return diff
|
if (diff !== 0) return diff
|
||||||
|
|
||||||
const byName = a.name.localeCompare(b.name)
|
const byName = a.name.localeCompare(b.name)
|
||||||
|
|
||||||
if (byName !== 0) return byName
|
if (byName !== 0) return byName
|
||||||
|
|
||||||
return a.id.localeCompare(b.id)
|
return a.id.localeCompare(b.id)
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const hasFlavours = $derived((theme?.flavours?.length ?? 0) > 0)
|
const hasFlavours = $derived((theme?.flavours?.length ?? 0) > 0)
|
||||||
|
|
||||||
const heroSlides = $derived(theme ? buildModalHeroSlides(theme) : [])
|
const heroSlides = $derived(theme ? buildModalHeroSlides(theme) : [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const heroCarouselOpts = $derived({ loop: heroSlides.length > 1 })
|
const heroCarouselOpts = $derived({ loop: heroSlides.length > 1 })
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function heroInit(ev: CustomEvent) {
|
function heroInit(ev: CustomEvent) {
|
||||||
|
|
||||||
heroEmblaApi = ev.detail
|
heroEmblaApi = ev.detail
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const heroPrev = () => heroEmblaApi?.scrollPrev?.()
|
const heroPrev = () => heroEmblaApi?.scrollPrev?.()
|
||||||
|
|
||||||
const heroNext = () => heroEmblaApi?.scrollNext?.()
|
const heroNext = () => heroEmblaApi?.scrollNext?.()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** Carousel slide 0 = master; flavours start at slide 1 + flavourIndex */
|
/** Carousel slide 0 = master; flavours start at slide 1 + flavourIndex */
|
||||||
|
|
||||||
function scrollHeroToMasterSlide() {
|
function scrollHeroToMasterSlide() {
|
||||||
|
|
||||||
heroEmblaApi?.scrollTo?.(0, true)
|
heroEmblaApi?.scrollTo?.(0, true)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function scrollHeroToFlavourIndex(flavourIndex: number) {
|
function scrollHeroToFlavourIndex(flavourIndex: number) {
|
||||||
|
|
||||||
heroEmblaApi?.scrollTo?.(flavourIndex + 1, true)
|
heroEmblaApi?.scrollTo?.(flavourIndex + 1, true)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
||||||
if (displayTheme && modalElement) {
|
if (displayTheme && modalElement) {
|
||||||
|
|
||||||
animate(
|
animate(
|
||||||
|
|
||||||
modalElement,
|
modalElement,
|
||||||
|
|
||||||
{ y: [500, 0], opacity: [0, 1] },
|
{ y: [500, 0], opacity: [0, 1] },
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
type: 'spring',
|
type: 'spring',
|
||||||
|
|
||||||
stiffness: 150,
|
stiffness: 150,
|
||||||
|
|
||||||
damping: 20,
|
damping: 20,
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const hideModal = (relatedTheme = null) => {
|
const hideModal = (relatedTheme = null) => {
|
||||||
|
|
||||||
animate(
|
animate(
|
||||||
|
|
||||||
modalElement,
|
modalElement,
|
||||||
|
|
||||||
{ y: [10, 500], opacity: [1, 0] },
|
{ y: [10, 500], opacity: [1, 0] },
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
type: 'spring',
|
type: 'spring',
|
||||||
|
|
||||||
stiffness: 150,
|
stiffness: 150,
|
||||||
|
|
||||||
damping: 20,
|
damping: 20,
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
||||||
setDisplayTheme(relatedTheme ?? null)
|
setDisplayTheme(relatedTheme ?? null)
|
||||||
|
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function runInstall(id: string) {
|
async function runInstall(id: string) {
|
||||||
|
|
||||||
installingId = id
|
installingId = id
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
await onInstall(id)
|
await onInstall(id)
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|
||||||
installingId = null
|
installingId = null
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function runRemove(id: string) {
|
async function runRemove(id: string) {
|
||||||
|
|
||||||
installingId = id
|
installingId = id
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
await onRemove(id)
|
await onRemove(id)
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|
||||||
installingId = null
|
installingId = null
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function onFlavourClick(flIdx: number, themeId: string, action: 'install' | 'remove') {
|
async function onFlavourClick(flIdx: number, themeId: string, action: 'install' | 'remove') {
|
||||||
|
|
||||||
scrollHeroToFlavourIndex(flIdx)
|
scrollHeroToFlavourIndex(flIdx)
|
||||||
|
|
||||||
if (action === 'install') await runInstall(themeId)
|
if (action === 'install') await runInstall(themeId)
|
||||||
|
|
||||||
else await runRemove(themeId)
|
else await runRemove(themeId)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function onMasterVariantClick(action: 'install' | 'remove') {
|
async function onMasterVariantClick(action: 'install' | 'remove') {
|
||||||
|
|
||||||
if (!theme) return
|
if (!theme) return
|
||||||
|
|
||||||
scrollHeroToMasterSlide()
|
scrollHeroToMasterSlide()
|
||||||
|
|
||||||
if (action === 'install') await runInstall(theme.id)
|
if (action === 'install') await runInstall(theme.id)
|
||||||
|
|
||||||
else await runRemove(theme.id)
|
else await runRemove(theme.id)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
||||||
class="flex fixed inset-0 z-50 justify-center items-end bg-black/70 backdrop-blur-sm"
|
class="flex fixed inset-0 z-50 justify-center items-end bg-black/70 backdrop-blur-sm"
|
||||||
|
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
|
|
||||||
if (e.target === e.currentTarget) hideModal()
|
if (e.target === e.currentTarget) hideModal()
|
||||||
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
|
|
||||||
if (e.target === e.currentTarget && e.key === 'Escape') hideModal()
|
if (e.target === e.currentTarget && e.key === 'Escape') hideModal()
|
||||||
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
role="presentation"
|
role="presentation"
|
||||||
|
|
||||||
transition:fade
|
transition:fade
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
||||||
bind:this={modalElement}
|
bind:this={modalElement}
|
||||||
|
|
||||||
class="w-full max-w-[600px] h-[95%] p-4 bg-white rounded-t-2xl dark:bg-zinc-800 overflow-y-auto overflow-x-hidden rounded-xl border border-zinc-200 dark:border-zinc-700 cursor-auto transition-colors duration-200"
|
class="w-full max-w-[600px] h-[95%] p-4 bg-white rounded-t-2xl dark:bg-zinc-800 overflow-y-auto overflow-x-hidden rounded-xl border border-zinc-200 dark:border-zinc-700 cursor-auto transition-colors duration-200"
|
||||||
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
|
||||||
onkeydown={(e) => e.stopPropagation()}
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{#if theme}
|
{#if theme}
|
||||||
|
|
||||||
<div class="relative h-auto">
|
<div class="relative h-auto">
|
||||||
|
|
||||||
<div class="absolute top-0 right-0 flex gap-1 items-center">
|
<div class="absolute top-0 right-0 flex gap-1 items-center">
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
class="p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200 transition-colors duration-200 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
class="p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200 transition-colors duration-200 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||||
|
|
||||||
onclick={() => hideModal()}
|
onclick={() => hideModal()}
|
||||||
|
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{'\ued8a'}
|
{'\ued8a'}
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 pr-12 mb-2">
|
<div class="flex flex-wrap items-center gap-2 pr-12 mb-2">
|
||||||
|
|
||||||
<h2 class="text-2xl font-bold text-zinc-900 dark:text-white">
|
<h2 class="text-2xl font-bold text-zinc-900 dark:text-white">
|
||||||
|
|
||||||
{theme.name}
|
{theme.name}
|
||||||
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{#if theme.featured === true}
|
{#if theme.featured === true}
|
||||||
|
|
||||||
<span
|
<span
|
||||||
|
|
||||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100"
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100"
|
||||||
|
|
||||||
aria-label="Featured theme"
|
aria-label="Featured theme"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
|
||||||
|
|
||||||
<path
|
<path
|
||||||
|
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
|
|
||||||
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
|
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
|
||||||
|
|
||||||
clip-rule="evenodd"
|
clip-rule="evenodd"
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
Featured
|
Featured
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if theme.author}
|
{#if theme.author}
|
||||||
|
|
||||||
<p class="mb-2 text-sm text-zinc-600 dark:text-zinc-400">
|
<p class="mb-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
|
||||||
By {theme.author}
|
By {theme.author}
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex gap-4 mb-4 text-sm text-zinc-600 dark:text-zinc-400">
|
<div class="flex gap-4 mb-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
|
||||||
<span class="flex items-center gap-1.5">
|
<span class="flex items-center gap-1.5">
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{modalDisplayDownloadCount.toLocaleString()} downloads
|
{modalDisplayDownloadCount.toLocaleString()} downloads
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="flex items-center gap-1.5">
|
<span class="flex items-center gap-1.5">
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-4 h-4">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-4 h-4">
|
||||||
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{(theme.favorite_count ?? 0).toLocaleString()} favorites
|
{(theme.favorite_count ?? 0).toLocaleString()} favorites
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{#if heroSlides.length > 0}
|
{#if heroSlides.length > 0}
|
||||||
|
|
||||||
{#key theme?.id}
|
{#key theme?.id}
|
||||||
|
|
||||||
<div class="relative mb-4 w-full overflow-hidden rounded-xl">
|
<div class="relative mb-4 w-full overflow-hidden rounded-xl">
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
||||||
class="w-full max-h-[280px]"
|
class="w-full max-h-[280px]"
|
||||||
|
|
||||||
use:emblaCarouselSvelte={{ options: heroCarouselOpts, plugins: [] }}
|
use:emblaCarouselSvelte={{ options: heroCarouselOpts, plugins: [] }}
|
||||||
|
|
||||||
onemblaInit={heroInit}
|
onemblaInit={heroInit}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
|
|
||||||
{#each heroSlides as slide, slideIdx (slideIdx)}
|
{#each heroSlides as slide, slideIdx (slideIdx)}
|
||||||
|
|
||||||
<div class="relative flex-[0_0_100%] shrink-0 min-w-0">
|
<div class="relative flex-[0_0_100%] shrink-0 min-w-0">
|
||||||
|
|
||||||
<img src={slide.imageUrl} alt={slide.caption} class="object-cover w-full max-h-[280px] rounded-xl" />
|
<img src={slide.imageUrl} alt={slide.caption} class="object-cover w-full max-h-[280px] rounded-xl" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if heroSlides.length > 1}
|
{#if heroSlides.length > 1}
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 mt-2">
|
<div class="flex justify-end gap-2 mt-2">
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
onclick={heroPrev}
|
onclick={heroPrev}
|
||||||
|
|
||||||
class="p-2 rounded-full bg-zinc-200 dark:bg-zinc-700 transition-all duration-200 hover:scale-105 active:scale-95 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
class="p-2 rounded-full bg-zinc-200 dark:bg-zinc-700 transition-all duration-200 hover:scale-105 active:scale-95 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
||||||
|
|
||||||
aria-label="Previous hero slide"
|
aria-label="Previous hero slide"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-5 h-5">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-5 h-5">
|
||||||
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
|
||||||
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
onclick={heroNext}
|
onclick={heroNext}
|
||||||
|
|
||||||
class="p-2 rounded-full bg-zinc-200 dark:bg-zinc-700 transition-all duration-200 hover:scale-105 active:scale-95 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
class="p-2 rounded-full bg-zinc-200 dark:bg-zinc-700 transition-all duration-200 hover:scale-105 active:scale-95 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
||||||
|
|
||||||
aria-label="Next hero slide"
|
aria-label="Next hero slide"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-5 h-5">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-5 h-5">
|
||||||
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||||
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/key}
|
{/key}
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{#if hasFlavours}
|
{#if hasFlavours}
|
||||||
|
|
||||||
{@const masterThumb = masterCarouselImageUrl(theme)}
|
{@const masterThumb = masterCarouselImageUrl(theme)}
|
||||||
|
|
||||||
<p class="mb-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">Variants</p>
|
<p class="mb-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">Variants</p>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6 w-full">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6 w-full">
|
||||||
|
|
||||||
{#if currentThemes.includes(theme.id)}
|
{#if currentThemes.includes(theme.id)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
onclick={() => onMasterVariantClick('remove')}
|
onclick={() => onMasterVariantClick('remove')}
|
||||||
|
|
||||||
disabled={installingId !== null}
|
disabled={installingId !== null}
|
||||||
|
|
||||||
class="relative w-full overflow-hidden rounded-2xl min-h-[9.5rem] sm:min-h-[11rem] text-left shadow-md border border-zinc-400/70 dark:border-zinc-500 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 disabled:opacity-70 group ring-1 ring-black/10 dark:ring-white/10"
|
class="relative w-full overflow-hidden rounded-2xl min-h-[9.5rem] sm:min-h-[11rem] text-left shadow-md border border-zinc-400/70 dark:border-zinc-500 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 disabled:opacity-70 group ring-1 ring-black/10 dark:ring-white/10"
|
||||||
|
|
||||||
title="Remove {theme.name} (master)"
|
title="Remove {theme.name} (master)"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{#if masterThumb}
|
{#if masterThumb}
|
||||||
<img src={masterThumb} alt="" class="absolute inset-0 w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.03]" draggable="false" />
|
<img src={masterThumb} alt="" class="absolute inset-0 w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.03]" draggable="false" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="absolute inset-0 bg-zinc-700" role="presentation"></div>
|
<div class="absolute inset-0 bg-zinc-700" role="presentation"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="absolute inset-0 bg-neutral-950/50" role="presentation"></div>
|
<div class="absolute inset-0 bg-neutral-950/50" role="presentation"></div>
|
||||||
|
|
||||||
<div class="relative z-10 flex flex-col justify-end min-h-[9.5rem] sm:min-h-[11rem] p-5">
|
<div class="relative z-10 flex flex-col justify-end min-h-[9.5rem] sm:min-h-[11rem] p-5">
|
||||||
|
|
||||||
{#if installingId === theme.id}
|
{#if installingId === theme.id}
|
||||||
|
|
||||||
<span class="flex justify-center mb-3">
|
<span class="flex justify-center mb-3">
|
||||||
|
|
||||||
<span class="inline-block w-10 h-10 animate-spin rounded-full border-2 border-white border-t-transparent align-middle"></span>
|
<span class="inline-block w-10 h-10 animate-spin rounded-full border-2 border-white border-t-transparent align-middle"></span>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="text-lg sm:text-xl font-bold text-white drop-shadow-md tracking-tight leading-snug">
|
<span class="text-lg sm:text-xl font-bold text-white drop-shadow-md tracking-tight leading-snug">
|
||||||
|
|
||||||
Remove · {theme.name}
|
Remove · {theme.name}
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="mt-1 text-sm font-medium text-white/85">Master</span>
|
<span class="mt-1 text-sm font-medium text-white/85">Master</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
onclick={() => onMasterVariantClick('install')}
|
onclick={() => onMasterVariantClick('install')}
|
||||||
|
|
||||||
disabled={installingId !== null}
|
disabled={installingId !== null}
|
||||||
|
|
||||||
class="relative w-full overflow-hidden rounded-2xl min-h-[9.5rem] sm:min-h-[11rem] text-left shadow-md transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 disabled:opacity-70 group ring-1 ring-black/10 dark:ring-white/10"
|
class="relative w-full overflow-hidden rounded-2xl min-h-[9.5rem] sm:min-h-[11rem] text-left shadow-md transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 disabled:opacity-70 group ring-1 ring-black/10 dark:ring-white/10"
|
||||||
|
|
||||||
title="Install {theme.name} (master)"
|
title="Install {theme.name} (master)"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{#if masterThumb}
|
{#if masterThumb}
|
||||||
<img src={masterThumb} alt="" class="absolute inset-0 w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.03]" draggable="false" />
|
<img src={masterThumb} alt="" class="absolute inset-0 w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.03]" draggable="false" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="absolute inset-0 bg-zinc-600" role="presentation"></div>
|
<div class="absolute inset-0 bg-zinc-600" role="presentation"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="absolute inset-0 bg-neutral-950/50" role="presentation"></div>
|
<div class="absolute inset-0 bg-neutral-950/50" role="presentation"></div>
|
||||||
|
|
||||||
<div class="relative z-10 flex flex-col justify-end min-h-[9.5rem] sm:min-h-[11rem] p-5">
|
<div class="relative z-10 flex flex-col justify-end min-h-[9.5rem] sm:min-h-[11rem] p-5">
|
||||||
|
|
||||||
{#if installingId === theme.id}
|
{#if installingId === theme.id}
|
||||||
|
|
||||||
<span class="flex justify-center mb-3">
|
<span class="flex justify-center mb-3">
|
||||||
|
|
||||||
<span class="inline-block w-10 h-10 animate-spin rounded-full border-2 border-white border-t-transparent align-middle"></span>
|
<span class="inline-block w-10 h-10 animate-spin rounded-full border-2 border-white border-t-transparent align-middle"></span>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="text-lg sm:text-xl font-bold text-white drop-shadow-md tracking-tight leading-snug">{theme.name}</span>
|
<span class="text-lg sm:text-xl font-bold text-white drop-shadow-md tracking-tight leading-snug">{theme.name}</span>
|
||||||
|
|
||||||
<span class="mt-1 text-sm font-medium text-white/85">Master</span>
|
<span class="mt-1 text-sm font-medium text-white/85">Master</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each theme.flavours ?? [] as f, flavourIdx (f.id)}
|
{#each theme.flavours ?? [] as f, flavourIdx (f.id)}
|
||||||
|
|
||||||
{@const thumb = flavourCarouselImageUrl(f)}
|
{@const thumb = flavourCarouselImageUrl(f)}
|
||||||
|
|
||||||
{#if currentThemes.includes(f.id)}
|
{#if currentThemes.includes(f.id)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
onclick={() => onFlavourClick(flavourIdx, f.id, 'remove')}
|
onclick={() => onFlavourClick(flavourIdx, f.id, 'remove')}
|
||||||
|
|
||||||
disabled={installingId !== null}
|
disabled={installingId !== null}
|
||||||
|
|
||||||
class="relative w-full overflow-hidden rounded-2xl min-h-[9.5rem] sm:min-h-[11rem] text-left shadow-md border border-zinc-400/70 dark:border-zinc-500 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 disabled:opacity-70 group ring-1 ring-black/10 dark:ring-white/10"
|
class="relative w-full overflow-hidden rounded-2xl min-h-[9.5rem] sm:min-h-[11rem] text-left shadow-md border border-zinc-400/70 dark:border-zinc-500 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 disabled:opacity-70 group ring-1 ring-black/10 dark:ring-white/10"
|
||||||
|
|
||||||
title="Remove {f.name}"
|
title="Remove {f.name}"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{#if thumb}
|
{#if thumb}
|
||||||
<img src={thumb} alt="" class="absolute inset-0 w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.03]" draggable="false" />
|
<img src={thumb} alt="" class="absolute inset-0 w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.03]" draggable="false" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="absolute inset-0 bg-zinc-700" role="presentation"></div>
|
<div class="absolute inset-0 bg-zinc-700" role="presentation"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="absolute inset-0 bg-neutral-950/50" role="presentation"></div>
|
<div class="absolute inset-0 bg-neutral-950/50" role="presentation"></div>
|
||||||
|
|
||||||
<div class="relative z-10 flex flex-col justify-end min-h-[9.5rem] sm:min-h-[11rem] p-5">
|
<div class="relative z-10 flex flex-col justify-end min-h-[9.5rem] sm:min-h-[11rem] p-5">
|
||||||
|
|
||||||
{#if installingId === f.id}
|
{#if installingId === f.id}
|
||||||
|
|
||||||
<span class="flex justify-center mb-3">
|
<span class="flex justify-center mb-3">
|
||||||
|
|
||||||
<span class="inline-block w-10 h-10 animate-spin rounded-full border-2 border-white border-t-transparent align-middle"></span>
|
<span class="inline-block w-10 h-10 animate-spin rounded-full border-2 border-white border-t-transparent align-middle"></span>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="text-lg sm:text-xl font-bold text-white drop-shadow-md tracking-tight leading-snug">
|
<span class="text-lg sm:text-xl font-bold text-white drop-shadow-md tracking-tight leading-snug">
|
||||||
|
|
||||||
Remove · {f.name}
|
Remove · {f.name}
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
onclick={() => onFlavourClick(flavourIdx, f.id, 'install')}
|
onclick={() => onFlavourClick(flavourIdx, f.id, 'install')}
|
||||||
|
|
||||||
disabled={installingId !== null}
|
disabled={installingId !== null}
|
||||||
|
|
||||||
class="relative w-full overflow-hidden rounded-2xl min-h-[9.5rem] sm:min-h-[11rem] text-left shadow-md transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 disabled:opacity-70 group ring-1 ring-black/10 dark:ring-white/10"
|
class="relative w-full overflow-hidden rounded-2xl min-h-[9.5rem] sm:min-h-[11rem] text-left shadow-md transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 disabled:opacity-70 group ring-1 ring-black/10 dark:ring-white/10"
|
||||||
|
|
||||||
title="Install {f.name}"
|
title="Install {f.name}"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{#if thumb}
|
{#if thumb}
|
||||||
<img src={thumb} alt="" class="absolute inset-0 w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.03]" draggable="false" />
|
<img src={thumb} alt="" class="absolute inset-0 w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.03]" draggable="false" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="absolute inset-0 bg-zinc-600" role="presentation"></div>
|
<div class="absolute inset-0 bg-zinc-600" role="presentation"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="absolute inset-0 bg-neutral-950/50" role="presentation"></div>
|
<div class="absolute inset-0 bg-neutral-950/50" role="presentation"></div>
|
||||||
|
|
||||||
<div class="relative z-10 flex flex-col justify-end min-h-[9.5rem] sm:min-h-[11rem] p-5">
|
<div class="relative z-10 flex flex-col justify-end min-h-[9.5rem] sm:min-h-[11rem] p-5">
|
||||||
|
|
||||||
{#if installingId === f.id}
|
{#if installingId === f.id}
|
||||||
|
|
||||||
<span class="flex justify-center mb-3">
|
<span class="flex justify-center mb-3">
|
||||||
|
|
||||||
<span class="inline-block w-10 h-10 animate-spin rounded-full border-2 border-white border-t-transparent align-middle"></span>
|
<span class="inline-block w-10 h-10 animate-spin rounded-full border-2 border-white border-t-transparent align-middle"></span>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="text-lg sm:text-xl font-bold text-white drop-shadow-md tracking-tight leading-snug">{f.name}</span>
|
<span class="text-lg sm:text-xl font-bold text-white drop-shadow-md tracking-tight leading-snug">{f.name}</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<p class="mb-4 text-gray-700 dark:text-gray-300">
|
<p class="mb-4 text-gray-700 dark:text-gray-300">
|
||||||
|
|
||||||
{theme.description}
|
{theme.description}
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 mt-4 justify-start sm:justify-end items-center">
|
<div class="flex flex-wrap gap-2 mt-4 justify-start sm:justify-end items-center">
|
||||||
|
|
||||||
{#if toggleFavorite && theme}
|
{#if toggleFavorite && theme}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
class="flex items-center gap-2 px-4 py-2 rounded-full transition-all duration-200 hover:scale-105 active:scale-95 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 {theme.is_favorited ? 'text-red-500 bg-red-500/10 dark:bg-red-500/20' : 'bg-zinc-200 dark:bg-zinc-700 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600'}"
|
class="flex items-center gap-2 px-4 py-2 rounded-full transition-all duration-200 hover:scale-105 active:scale-95 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 {theme.is_favorited ? 'text-red-500 bg-red-500/10 dark:bg-red-500/20' : 'bg-zinc-200 dark:bg-zinc-700 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600'}"
|
||||||
|
|
||||||
onclick={handleFavoriteClick}
|
onclick={handleFavoriteClick}
|
||||||
|
|
||||||
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
|
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
|
||||||
|
|
||||||
aria-label={theme.is_favorited ? 'Unfavorite' : 'Favorite'}
|
aria-label={theme.is_favorited ? 'Unfavorite' : 'Favorite'}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
|
||||||
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{theme.is_favorited ? 'Favorited' : 'Favorite'}
|
{theme.is_favorited ? 'Favorited' : 'Favorite'}
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{#if !hasFlavours}
|
{#if !hasFlavours}
|
||||||
|
|
||||||
{#if currentThemes.includes(theme.id)}
|
{#if currentThemes.includes(theme.id)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
onclick={() => runRemove(theme.id)}
|
onclick={() => runRemove(theme.id)}
|
||||||
|
|
||||||
disabled={installingId !== null}
|
disabled={installingId !== null}
|
||||||
|
|
||||||
class="relative flex justify-center items-center px-4 py-2 min-w-[8rem] text-black rounded-lg dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:opacity-70"
|
class="relative flex justify-center items-center px-4 py-2 min-w-[8rem] text-black rounded-lg dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:opacity-70"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{#if installingId === theme.id}
|
{#if installingId === theme.id}
|
||||||
|
|
||||||
<svg class="absolute w-4 h-4 animate-spin" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<svg class="absolute w-4 h-4 animate-spin" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
|
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
|
||||||
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="{installingId === theme.id ? 'opacity-0' : 'opacity-100'}">Remove</span>
|
<span class="{installingId === theme.id ? 'opacity-0' : 'opacity-100'}">Remove</span>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
onclick={() => runInstall(theme.id)}
|
onclick={() => runInstall(theme.id)}
|
||||||
|
|
||||||
disabled={installingId !== null}
|
disabled={installingId !== null}
|
||||||
|
|
||||||
class="relative flex justify-center items-center px-4 py-2 min-w-[8rem] text-black rounded-lg dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:opacity-70"
|
class="relative flex justify-center items-center px-4 py-2 min-w-[8rem] text-black rounded-lg dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:opacity-70"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{#if installingId === theme.id}
|
{#if installingId === theme.id}
|
||||||
|
|
||||||
<svg class="absolute w-4 h-4 animate-spin" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<svg class="absolute w-4 h-4 animate-spin" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
|
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
|
||||||
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="{installingId === theme.id ? 'opacity-0' : 'opacity-100'}">Install</span>
|
<span class="{installingId === theme.id ? 'opacity-0' : 'opacity-100'}">Install</span>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{#if relatedThemes.length > 0}
|
{#if relatedThemes.length > 0}
|
||||||
|
|
||||||
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
|
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<h3 class="mb-4 text-lg font-bold text-zinc-900 dark:text-white">
|
<h3 class="mb-4 text-lg font-bold text-zinc-900 dark:text-white">
|
||||||
|
|
||||||
Related themes
|
Related themes
|
||||||
|
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
|
||||||
{#each relatedThemes as relatedTheme (relatedTheme.id)}
|
{#each relatedThemes as relatedTheme (relatedTheme.id)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
|
||||||
hideModal(relatedTheme)
|
hideModal(relatedTheme)
|
||||||
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
class="relative z-0 hover:z-20 w-full cursor-pointer rounded-xl overflow-hidden transition-all duration-200 hover:scale-[1.02]"
|
class="relative z-0 hover:z-20 w-full cursor-pointer rounded-xl overflow-hidden transition-all duration-200 hover:scale-[1.02]"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<div class="bg-gray-50 w-full transition-all duration-500 ease-out relative group group/card flex flex-col hover:shadow-xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border border-zinc-200 dark:border-zinc-700">
|
<div class="bg-gray-50 w-full transition-all duration-500 ease-out relative group group/card flex flex-col hover:shadow-xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border border-zinc-200 dark:border-zinc-700">
|
||||||
|
|
||||||
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white transition-all duration-500 group-hover:-translate-y-0.5">
|
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white transition-all duration-500 group-hover:-translate-y-0.5">
|
||||||
|
|
||||||
{relatedTheme.name}
|
{relatedTheme.name}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute bottom-0 z-0 w-full h-3/4 to-transparent from-black/80 bg-linear-to-t"></div>
|
<div class="absolute bottom-0 z-0 w-full h-3/4 to-transparent from-black/80 bg-linear-to-t"></div>
|
||||||
|
|
||||||
<img src={relatedTheme.marqueeImage || relatedTheme.coverImage} alt="" class="object-cover w-full h-48" />
|
<img src={relatedTheme.marqueeImage || relatedTheme.coverImage} alt="" class="object-cover w-full h-48" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
|
|
||||||
<div class="flex justify-center items-center h-full text-zinc-600 dark:text-zinc-300">
|
<div class="flex justify-center items-center h-full text-zinc-600 dark:text-zinc-300">
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
class="px-4 py-2 rounded-lg bg-zinc-200 dark:bg-zinc-700 transition-all duration-200 hover:scale-105 active:scale-95 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
class="px-4 py-2 rounded-lg bg-zinc-200 dark:bg-zinc-700 transition-all duration-200 hover:scale-105 active:scale-95 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||||
|
|
||||||
onclick={() => hideModal()}
|
onclick={() => hideModal()}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
Close
|
Close
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
let imageBackgrounds = $derived(backgrounds.filter(bg => bg.type === 'image'));
|
let imageBackgrounds = $derived(backgrounds.filter(bg => bg.type === 'image'));
|
||||||
let videoBackgrounds = $derived(backgrounds.filter(bg => bg.type === 'video'));
|
let videoBackgrounds = $derived(backgrounds.filter(bg => bg.type === 'video'));
|
||||||
|
|
||||||
|
function setError(e: unknown) {
|
||||||
|
error = e instanceof Error ? e.message : 'An unknown error occurred';
|
||||||
|
}
|
||||||
|
|
||||||
async function getTheme() {
|
async function getTheme() {
|
||||||
return localStorage.getItem('selectedBackground');
|
return localStorage.getItem('selectedBackground');
|
||||||
}
|
}
|
||||||
@@ -41,11 +45,7 @@
|
|||||||
await writeData(fileId, fileType, blob);
|
await writeData(fileId, fileType, blob);
|
||||||
backgrounds = [...backgrounds, { id: fileId, type: fileType, blob, url: URL.createObjectURL(blob) }];
|
backgrounds = [...backgrounds, { id: fileId, type: fileType, blob, url: URL.createObjectURL(blob) }];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) {
|
setError(e);
|
||||||
error = e.message;
|
|
||||||
} else {
|
|
||||||
error = 'An unknown error occurred';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,11 +78,7 @@
|
|||||||
selectNoBackground();
|
selectNoBackground();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) {
|
setError(e);
|
||||||
error = e.message;
|
|
||||||
} else {
|
|
||||||
error = 'An unknown error occurred';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,11 +101,7 @@
|
|||||||
selectNoBackground();
|
selectNoBackground();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) {
|
error = e instanceof Error ? `Failed to delete background: ${e.message}` : 'An unknown error occurred';
|
||||||
error = `Failed to delete background: ${e.message}`;
|
|
||||||
} else {
|
|
||||||
error = 'An unknown error occurred';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { blobToDataUrl } from '@/plugins/built-in/themes/themeImageUrl'
|
import { blobToDataUrl } from '@/plugins/built-in/themes/themeImageUrl'
|
||||||
|
|
||||||
let {
|
let { source, alt = '', class: className = '' } = $props<{
|
||||||
source,
|
|
||||||
alt = '',
|
|
||||||
class: className = '',
|
|
||||||
} = $props<{
|
|
||||||
source: string | Blob | null | undefined
|
source: string | Blob | null | undefined
|
||||||
alt?: string
|
alt?: string
|
||||||
class?: string
|
class?: string
|
||||||
@@ -24,7 +20,7 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
blobToDataUrl(value).then((url) => {
|
void blobToDataUrl(value).then((url) => {
|
||||||
if (!cancelled) src = url
|
if (!cancelled) src = url
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
@@ -34,5 +30,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if src}
|
{#if src}
|
||||||
<img src={src} alt={alt} class={className} />
|
<img {src} {alt} class={className} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -315,7 +315,6 @@
|
|||||||
<h2 class="text-sm font-bold">Maximum Subjects</h2>
|
<h2 class="text-sm font-bold">Maximum Subjects</h2>
|
||||||
<p class="text-xs">Number of subjects to include, ordered by soonest due date</p>
|
<p class="text-xs">Number of subjects to include, ordered by soonest due date</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<Select
|
<Select
|
||||||
value={String($settingsState.homeUpcomingSubjectsMax ?? 5)}
|
value={String($settingsState.homeUpcomingSubjectsMax ?? 5)}
|
||||||
onChange={(value: string) => (settingsState.homeUpcomingSubjectsMax = Number(value))}
|
onChange={(value: string) => (settingsState.homeUpcomingSubjectsMax = Number(value))}
|
||||||
@@ -329,13 +328,11 @@
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
|
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
|
||||||
<div class="pr-4">
|
<div class="pr-4">
|
||||||
<h2 class="text-sm font-bold">Maximum Assessments per Subject</h2>
|
<h2 class="text-sm font-bold">Maximum Assessments per Subject</h2>
|
||||||
<p class="text-xs">Assessments shown for each included subject</p>
|
<p class="text-xs">Assessments shown for each included subject</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<Select
|
<Select
|
||||||
value={String($settingsState.homeUpcomingAssessmentsPerSubjectMax ?? 0)}
|
value={String($settingsState.homeUpcomingAssessmentsPerSubjectMax ?? 0)}
|
||||||
onChange={(value: string) => (settingsState.homeUpcomingAssessmentsPerSubjectMax = Number(value))}
|
onChange={(value: string) => (settingsState.homeUpcomingAssessmentsPerSubjectMax = Number(value))}
|
||||||
@@ -351,7 +348,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-none">
|
<div class="border-none">
|
||||||
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
|
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
|
||||||
|
|||||||
@@ -76,26 +76,17 @@
|
|||||||
await themeManager.disableTheme();
|
await themeManager.disableTheme();
|
||||||
|
|
||||||
if (themeID) {
|
if (themeID) {
|
||||||
const tempTheme = await themeManager.getTheme(themeID)
|
const tempTheme = await themeManager.getTheme(themeID);
|
||||||
|
if (!tempTheme) return;
|
||||||
if (!tempTheme) return
|
|
||||||
|
|
||||||
// convert temptheme to LoadedCustomTheme
|
|
||||||
const loadedTheme = {
|
|
||||||
...tempTheme,
|
|
||||||
CustomImages: tempTheme.CustomImages.map(image => ({
|
|
||||||
...image
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
theme = {
|
theme = {
|
||||||
...loadedTheme,
|
...tempTheme,
|
||||||
adaptiveCssVariables: loadedTheme.adaptiveCssVariables ?? [],
|
adaptiveCssVariables: tempTheme.adaptiveCssVariables ?? [],
|
||||||
forceTheme:
|
forceTheme:
|
||||||
loadedTheme.forceTheme ??
|
tempTheme.forceTheme ??
|
||||||
(loadedTheme.forceDark !== undefined ? true : undefined),
|
(tempTheme.forceDark !== undefined ? true : undefined),
|
||||||
}
|
};
|
||||||
themeLoaded = true
|
themeLoaded = true;
|
||||||
} else {
|
} else {
|
||||||
themeLoaded = true
|
themeLoaded = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/** Material "dark_mode" moon icon — filled style to match SEQTA menu bar icons. */
|
||||||
* Material "dark_mode" moon icon — filled style to match SEQTA menu bar icons.
|
export const LUCIDE_MOON_PATH =
|
||||||
*/
|
"M12,3C7.03,3 3,7.03 3,12C3,16.97 7.03,21 12,21C16.97,21 21,16.97 21,12C21,11.54 20.96,11.08 20.9,10.64C19.92,12.01 18.32,12.9 16.5,12.9C13.52,12.9 11.1,10.48 11.1,7.5C11.1,5.68 11.99,4.08 13.36,3.1C12.92,3.04 12.46,3 12,3Z";
|
||||||
export const LUCIDE_MOON_ICON_SVG = `
|
|
||||||
<path fill="currentColor" d="M12,3C7.03,3 3,7.03 3,12C3,16.97 7.03,21 12,21C16.97,21 21,16.97 21,12C21,11.54 20.96,11.08 20.9,10.64C19.92,12.01 18.32,12.9 16.5,12.9C13.52,12.9 11.1,10.48 11.1,7.5C11.1,5.68 11.99,4.08 13.36,3.1C12.92,3.04 12.46,3 12,3Z"/>
|
export const LUCIDE_MOON_ICON_SVG =
|
||||||
`.trim();
|
`<path fill="currentColor" d="${LUCIDE_MOON_PATH}"/>`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/** Material "light_mode" sun icon — filled style to match SEQTA menu bar icons. */
|
||||||
* Material "light_mode" sun icon — filled style to match SEQTA menu bar icons.
|
export const LUCIDE_SUN_PATH =
|
||||||
*/
|
"M12,7C9.24,7 7,9.24 7,12C7,14.76 9.24,17 12,17C14.76,17 17,14.76 17,12C17,9.24 14.76,7 12,7M2,13H4C4.55,13 5,12.55 5,12C5,11.45 4.55,11 4,11H2C1.45,11 1,11.45 1,12C1,12.55 1.45,13 2,13M20,13H22C22.55,13 23,12.55 23,12C23,11.45 22.55,11 22,11H20C19.45,11 19,11.45 19,12C19,12.55 19.45,13 20,13M11,2V4C11,4.55 11.45,5 12,5C12.55,5 13,4.55 13,4V2C13,1.45 12.55,1 12,1C11.45,1 11,1.45 11,2M11,20V22C11,22.55 11.45,23 12,23C12.55,23 13,22.55 13,22V20C13,19.45 12.55,19 12,19C11.45,19 11,19.45 11,20M5.99,4.58C5.6,4.19 4.96,4.19 4.58,4.58C4.19,4.96 4.19,5.6 4.58,5.99L5.64,7.05C6.03,7.44 6.67,7.44 7.05,7.05C7.44,6.67 7.44,6.03 7.05,5.64L5.99,4.58M18.36,16.95C17.97,16.56 17.33,16.56 16.95,16.95C16.56,17.33 16.56,17.97 16.95,18.36L18.01,19.42C18.4,19.81 19.04,19.81 19.42,19.42C19.81,19.04 19.81,18.4 19.42,18.01L18.36,16.95M19.42,5.99C19.81,5.6 19.81,4.96 19.42,4.58C19.04,4.19 18.4,4.19 18.01,4.58L16.95,5.64C16.56,6.03 16.56,6.67 16.95,7.05C17.33,7.44 17.97,7.44 18.36,7.05L19.42,5.99M7.05,18.36C7.44,17.97 7.44,17.33 7.05,16.95C6.67,16.56 6.03,16.56 5.64,16.95L4.58,18.01C4.19,18.4 4.19,19.04 4.58,19.42C4.96,19.81 5.6,19.81 5.99,19.42L7.05,18.36Z";
|
||||||
export const LUCIDE_SUN_ICON_SVG = `
|
|
||||||
<path fill="currentColor" d="M12,7C9.24,7 7,9.24 7,12C7,14.76 9.24,17 12,17C14.76,17 17,14.76 17,12C17,9.24 14.76,7 12,7M2,13H4C4.55,13 5,12.55 5,12C5,11.45 4.55,11 4,11H2C1.45,11 1,11.45 1,12C1,12.55 1.45,13 2,13M20,13H22C22.55,13 23,12.55 23,12C23,11.45 22.55,11 22,11H20C19.45,11 19,11.45 19,12C19,12.55 19.45,13 20,13M11,2V4C11,4.55 11.45,5 12,5C12.55,5 13,4.55 13,4V2C13,1.45 12.55,1 12,1C11.45,1 11,1.45 11,2M11,20V22C11,22.55 11.45,23 12,23C12.55,23 13,22.55 13,22V20C13,19.45 12.55,19 12,19C11.45,19 11,19.45 11,20M5.99,4.58C5.6,4.19 4.96,4.19 4.58,4.58C4.19,4.96 4.19,5.6 4.58,5.99L5.64,7.05C6.03,7.44 6.67,7.44 7.05,7.05C7.44,6.67 7.44,6.03 7.05,5.64L5.99,4.58M18.36,16.95C17.97,16.56 17.33,16.56 16.95,16.95C16.56,17.33 16.56,17.97 16.95,18.36L18.01,19.42C18.4,19.81 19.04,19.81 19.42,19.42C19.81,19.04 19.81,18.4 19.42,18.01L18.36,16.95M19.42,5.99C19.81,5.6 19.81,4.96 19.42,4.58C19.04,4.19 18.4,4.19 18.01,4.58L16.95,5.64C16.56,6.03 16.56,6.67 16.95,7.05C17.33,7.44 17.97,7.44 18.36,7.05L19.42,5.99M7.05,18.36C7.44,17.97 7.44,17.33 7.05,16.95C6.67,16.56 6.03,16.56 5.64,16.95L4.58,18.01C4.19,18.4 4.19,19.04 4.58,19.42C4.96,19.81 5.6,19.81 5.99,19.42L7.05,18.36Z"/>
|
export const LUCIDE_SUN_ICON_SVG =
|
||||||
`.trim();
|
`<path fill="currentColor" d="${LUCIDE_SUN_PATH}"/>`;
|
||||||
|
|||||||
@@ -9,32 +9,26 @@ const LAYER_CLASSES = [
|
|||||||
["bg", "bg3", ANIMATED_BG_MARKER],
|
["bg", "bg3", ANIMATED_BG_MARKER],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const layerSelector = `:scope > div.bg.${ANIMATED_BG_MARKER}`;
|
||||||
|
|
||||||
export function updateAnimationSpeed(speed: number) {
|
export function updateAnimationSpeed(speed: number) {
|
||||||
const bgElements = document.querySelectorAll(`.bg.${ANIMATED_BG_MARKER}`);
|
document.querySelectorAll(`.bg.${ANIMATED_BG_MARKER}`).forEach((element, index) => {
|
||||||
Array.from(bgElements).forEach((element, index) => {
|
|
||||||
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
|
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
|
||||||
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`;
|
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function countAnimatedLayers(container: HTMLElement): number {
|
|
||||||
return container.querySelectorAll(`:scope > div.bg.${ANIMATED_BG_MARKER}`).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureAnimatedBackgroundLayers(
|
export function ensureAnimatedBackgroundLayers(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
menu: HTMLElement,
|
menu: HTMLElement,
|
||||||
speed: number,
|
speed: number,
|
||||||
): void {
|
): void {
|
||||||
const count = countAnimatedLayers(container);
|
if (container.querySelectorAll(layerSelector).length >= 3) {
|
||||||
if (count >= 3) {
|
|
||||||
updateAnimationSpeed(speed);
|
updateAnimationSpeed(speed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container
|
container.querySelectorAll(layerSelector).forEach((el) => el.remove());
|
||||||
.querySelectorAll(`:scope > div.bg.${ANIMATED_BG_MARKER}`)
|
|
||||||
.forEach((el) => el.remove());
|
|
||||||
|
|
||||||
for (const classes of LAYER_CLASSES) {
|
for (const classes of LAYER_CLASSES) {
|
||||||
const bk = document.createElement("div");
|
const bk = document.createElement("div");
|
||||||
@@ -46,9 +40,7 @@ export function ensureAnimatedBackgroundLayers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function removeAnimatedBackgroundLayers(): void {
|
export function removeAnimatedBackgroundLayers(): void {
|
||||||
document
|
document.querySelectorAll(`div.bg.${ANIMATED_BG_MARKER}`).forEach((el) => el.remove());
|
||||||
.querySelectorAll(`div.bg.${ANIMATED_BG_MARKER}`)
|
|
||||||
.forEach((el) => el.remove());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncAnimatedBackground(
|
export async function syncAnimatedBackground(
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const instance = new AnimatedBackgroundPluginClass();
|
const instance = new AnimatedBackgroundPluginClass();
|
||||||
|
const resync = (api: PluginAPI<typeof settings>) => () => void syncAnimatedBackground(api);
|
||||||
|
|
||||||
const animatedBackgroundPlugin: Plugin<typeof settings> = {
|
const animatedBackgroundPlugin: Plugin<typeof settings> = {
|
||||||
id: "animated-background",
|
id: "animated-background",
|
||||||
@@ -43,23 +44,15 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
|
|||||||
await syncAnimatedBackground(api);
|
await syncAnimatedBackground(api);
|
||||||
|
|
||||||
const speedUnregister = api.settings.onChange("speed", updateAnimationSpeed);
|
const speedUnregister = api.settings.onChange("speed", updateAnimationSpeed);
|
||||||
|
const pageChangeUnregister = api.seqta.onPageChange(resync(api));
|
||||||
const pageChangeUnregister = api.seqta.onPageChange(() => {
|
|
||||||
void syncAnimatedBackground(api);
|
|
||||||
});
|
|
||||||
|
|
||||||
const pageshowHandler = (event: PageTransitionEvent) => {
|
const pageshowHandler = (event: PageTransitionEvent) => {
|
||||||
if (event.persisted) void syncAnimatedBackground(api);
|
if (event.persisted) void syncAnimatedBackground(api);
|
||||||
};
|
};
|
||||||
window.addEventListener("pageshow", pageshowHandler);
|
window.addEventListener("pageshow", pageshowHandler);
|
||||||
|
|
||||||
const containerObserver = new MutationObserver(() => {
|
const containerObserver = new MutationObserver(resync(api));
|
||||||
void syncAnimatedBackground(api);
|
|
||||||
});
|
|
||||||
const container = document.getElementById("container");
|
const container = document.getElementById("container");
|
||||||
if (container) {
|
if (container) containerObserver.observe(container, { childList: true });
|
||||||
containerObserver.observe(container, { childList: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
speedUnregister.unregister();
|
speedUnregister.unregister();
|
||||||
|
|||||||
@@ -36,132 +36,107 @@ const store = localforage.createInstance({
|
|||||||
storeName: "music",
|
storeName: "music",
|
||||||
});
|
});
|
||||||
|
|
||||||
const HINT_ID = "bsplus-bg-music-hint";
|
const GESTURE_EVENTS = ["pointerdown", "keydown", "touchstart"] as const;
|
||||||
|
const gestureOpts: AddEventListenerOptions = { capture: true, passive: true };
|
||||||
|
|
||||||
let currentAudio: HTMLAudioElement | null = null;
|
let audio: HTMLAudioElement | null = null;
|
||||||
let currentObjectUrl: string | null = null;
|
let objectUrl: string | null = null;
|
||||||
let pendingGestureCancel: (() => void) | null = null;
|
let gestureCleanup: (() => void) | null = null;
|
||||||
let visibilityResumeTimeout: number | null = null;
|
let resumeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let hintElement: HTMLElement | null = null;
|
let hintEl: HTMLElement | null = null;
|
||||||
let isPlaying = false;
|
let playing = false;
|
||||||
|
|
||||||
async function loadAudioBlob(): Promise<Blob | null> {
|
const clamp = (v: number) => Math.max(0, Math.min(1, v));
|
||||||
|
|
||||||
|
async function loadBlob(): Promise<Blob | null> {
|
||||||
const blob = await store.getItem<Blob>("audio-blob");
|
const blob = await store.getItem<Blob>("audio-blob");
|
||||||
return blob && blob instanceof Blob ? blob : null;
|
return blob instanceof Blob ? blob : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopAndCleanupAudio(): void {
|
function clearHint(): void {
|
||||||
if (currentAudio) {
|
hintEl?.remove();
|
||||||
currentAudio.pause();
|
hintEl = null;
|
||||||
currentAudio.src = "";
|
|
||||||
currentAudio.remove();
|
|
||||||
currentAudio = null;
|
|
||||||
}
|
|
||||||
if (currentObjectUrl) {
|
|
||||||
URL.revokeObjectURL(currentObjectUrl);
|
|
||||||
currentObjectUrl = null;
|
|
||||||
}
|
|
||||||
isPlaying = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideAutoplayHint(): void {
|
function disarmGesture(): void {
|
||||||
if (hintElement) {
|
gestureCleanup?.();
|
||||||
hintElement.remove();
|
gestureCleanup = null;
|
||||||
hintElement = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAutoplayHint(onActivate: () => void): void {
|
function onPlayStarted(): void {
|
||||||
hideAutoplayHint();
|
playing = true;
|
||||||
|
clearHint();
|
||||||
|
disarmGesture();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAudio(): void {
|
||||||
|
audio?.pause();
|
||||||
|
audio?.remove();
|
||||||
|
audio = null;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
objectUrl = null;
|
||||||
|
playing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHint(onActivate: () => void): void {
|
||||||
|
clearHint();
|
||||||
const hint = document.createElement("button");
|
const hint = document.createElement("button");
|
||||||
hint.id = HINT_ID;
|
hint.id = "bsplus-bg-music-hint";
|
||||||
hint.type = "button";
|
hint.type = "button";
|
||||||
hint.className = "bsplus-bg-music-hint";
|
hint.className = "bsplus-bg-music-hint";
|
||||||
hint.textContent = "Tap to start background music";
|
hint.textContent = "Tap to start background music";
|
||||||
hint.addEventListener("pointerdown", (event) => {
|
hint.addEventListener("pointerdown", (e) => {
|
||||||
event.preventDefault();
|
e.preventDefault();
|
||||||
onActivate();
|
onActivate();
|
||||||
});
|
});
|
||||||
document.body.appendChild(hint);
|
document.body.append(hint);
|
||||||
hintElement = hint;
|
hintEl = hint;
|
||||||
}
|
|
||||||
|
|
||||||
function disarmGesturePlayback(): void {
|
|
||||||
if (pendingGestureCancel) {
|
|
||||||
pendingGestureCancel();
|
|
||||||
pendingGestureCancel = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prepare <audio> so play() can run synchronously inside a user-gesture handler. */
|
/** Prepare <audio> so play() can run synchronously inside a user-gesture handler. */
|
||||||
async function prepareAudioElement(volume: number): Promise<boolean> {
|
async function prepareAudio(vol: number): Promise<boolean> {
|
||||||
const blob = await loadAudioBlob();
|
const blob = await loadBlob();
|
||||||
if (!blob) {
|
if (!blob) {
|
||||||
stopAndCleanupAudio();
|
stopAudio();
|
||||||
hideAutoplayHint();
|
clearHint();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!audio) {
|
||||||
if (!currentAudio) {
|
stopAudio();
|
||||||
stopAndCleanupAudio();
|
objectUrl = URL.createObjectURL(blob);
|
||||||
currentObjectUrl = URL.createObjectURL(blob);
|
audio = new Audio(objectUrl);
|
||||||
const audio = new Audio(currentObjectUrl);
|
|
||||||
audio.loop = true;
|
audio.loop = true;
|
||||||
audio.volume = Math.max(0, Math.min(1, volume));
|
|
||||||
audio.preload = "auto";
|
audio.preload = "auto";
|
||||||
audio.style.display = "none";
|
audio.style.display = "none";
|
||||||
document.body.appendChild(audio);
|
document.body.append(audio);
|
||||||
currentAudio = audio;
|
|
||||||
} else {
|
|
||||||
currentAudio.volume = Math.max(0, Math.min(1, volume));
|
|
||||||
}
|
}
|
||||||
|
audio.volume = clamp(vol);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Call synchronously from a user-gesture handler (no await before this). */
|
||||||
* Must be called synchronously from a user-gesture handler (no await before this).
|
function playPrepared(vol: number): void {
|
||||||
*/
|
if (!audio) return;
|
||||||
function playPreparedAudio(volume: number): boolean {
|
audio.volume = clamp(vol);
|
||||||
if (!currentAudio) return false;
|
void audio.play().then(onPlayStarted).catch(() => {
|
||||||
currentAudio.volume = Math.max(0, Math.min(1, volume));
|
playing = false;
|
||||||
try {
|
|
||||||
const result = currentAudio.play();
|
|
||||||
void result
|
|
||||||
.then(() => {
|
|
||||||
isPlaying = true;
|
|
||||||
hideAutoplayHint();
|
|
||||||
disarmGesturePlayback();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
isPlaying = false;
|
|
||||||
});
|
});
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
isPlaying = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tryAutoplay(volume: number): Promise<boolean> {
|
async function tryAutoplay(vol: number): Promise<boolean> {
|
||||||
const ready = await prepareAudioElement(volume);
|
if (!(await prepareAudio(vol)) || !audio) return false;
|
||||||
if (!ready || !currentAudio) return false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await currentAudio.play();
|
await audio.play();
|
||||||
isPlaying = true;
|
onPlayStarted();
|
||||||
hideAutoplayHint();
|
|
||||||
disarmGesturePlayback();
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
isPlaying = false;
|
playing = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function armGesturePlayback(onGesture: () => void): void {
|
function armGesture(onGesture: () => void): void {
|
||||||
disarmGesturePlayback();
|
disarmGesture();
|
||||||
|
|
||||||
const listener = (event: Event) => {
|
const listener = (event: Event) => {
|
||||||
if (event.type === "keydown") {
|
if (event.type === "keydown") {
|
||||||
const key = (event as KeyboardEvent).key;
|
const key = (event as KeyboardEvent).key;
|
||||||
@@ -169,21 +144,23 @@ function armGesturePlayback(onGesture: () => void): void {
|
|||||||
}
|
}
|
||||||
onGesture();
|
onGesture();
|
||||||
};
|
};
|
||||||
|
for (const type of GESTURE_EVENTS) {
|
||||||
const options: AddEventListenerOptions = { capture: true, passive: true };
|
document.addEventListener(type, listener, gestureOpts);
|
||||||
const types = ["pointerdown", "keydown", "touchstart"] as const;
|
|
||||||
for (const type of types) {
|
|
||||||
document.addEventListener(type, listener, options);
|
|
||||||
}
|
}
|
||||||
|
gestureCleanup = () => {
|
||||||
pendingGestureCancel = () => {
|
for (const type of GESTURE_EVENTS) {
|
||||||
for (const type of types) {
|
document.removeEventListener(type, listener, gestureOpts);
|
||||||
document.removeEventListener(type, listener, options);
|
|
||||||
}
|
}
|
||||||
hideAutoplayHint();
|
clearHint();
|
||||||
};
|
};
|
||||||
|
showHint(onGesture);
|
||||||
|
}
|
||||||
|
|
||||||
showAutoplayHint(onGesture);
|
function clearResumeTimer(): void {
|
||||||
|
if (resumeTimer !== null) {
|
||||||
|
clearTimeout(resumeTimer);
|
||||||
|
resumeTimer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const backgroundMusicPlugin: Plugin<typeof settings> = {
|
const backgroundMusicPlugin: Plugin<typeof settings> = {
|
||||||
@@ -199,41 +176,33 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
|
|||||||
run: async (api) => {
|
run: async (api) => {
|
||||||
await api.storage.loaded;
|
await api.storage.loaded;
|
||||||
|
|
||||||
const getVolume = () =>
|
type BgSettings = { volume?: number; pauseOnHidden?: boolean };
|
||||||
(api.settings as { volume?: number }).volume ?? 0.5;
|
const s = () => api.settings as BgSettings;
|
||||||
|
const vol = () => s().volume ?? 0.5;
|
||||||
|
const pauseOnHidden = () => s().pauseOnHidden ?? true;
|
||||||
|
|
||||||
const gestureStart = () => {
|
const gestureStart = () => {
|
||||||
if (!currentAudio) return;
|
if (audio) playPrepared(vol());
|
||||||
playPreparedAudio(getVolume());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensurePlayback = async () => {
|
const ensurePlayback = async () => {
|
||||||
const vol = getVolume();
|
if (!(await prepareAudio(vol()))) return;
|
||||||
const ready = await prepareAudioElement(vol);
|
if (playing && audio && !audio.paused) {
|
||||||
if (!ready) return;
|
clearHint();
|
||||||
|
disarmGesture();
|
||||||
if (isPlaying && currentAudio && !currentAudio.paused) {
|
|
||||||
hideAutoplayHint();
|
|
||||||
disarmGesturePlayback();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!(await tryAutoplay(vol()))) armGesture(gestureStart);
|
||||||
const autoplayed = await tryAutoplay(vol);
|
|
||||||
if (!autoplayed) {
|
|
||||||
armGesturePlayback(gestureStart);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
api.settings.onChange("volume" as never, (value: unknown) => {
|
api.settings.onChange("volume" as never, (value: unknown) => {
|
||||||
const vol = typeof value === "number" ? value : 0.5;
|
if (typeof value === "number" && audio) audio.volume = clamp(value);
|
||||||
if (currentAudio) currentAudio.volume = Math.max(0, Math.min(1, vol));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
api.settings.onChange("pauseOnHidden" as never, (value: unknown) => {
|
api.settings.onChange("pauseOnHidden" as never, (value: unknown) => {
|
||||||
const pauseOnHidden = typeof value === "boolean" ? value : true;
|
|
||||||
if (
|
if (
|
||||||
!pauseOnHidden &&
|
value === false &&
|
||||||
currentAudio?.paused &&
|
audio?.paused &&
|
||||||
document.visibilityState === "visible"
|
document.visibilityState === "visible"
|
||||||
) {
|
) {
|
||||||
void ensurePlayback();
|
void ensurePlayback();
|
||||||
@@ -242,73 +211,49 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
|
|||||||
|
|
||||||
await ensurePlayback();
|
await ensurePlayback();
|
||||||
|
|
||||||
const visHandler = () => {
|
const onVisibility = () => {
|
||||||
const pauseOnHidden =
|
|
||||||
(api.settings as { pauseOnHidden?: boolean }).pauseOnHidden ?? true;
|
|
||||||
|
|
||||||
if (document.visibilityState === "hidden") {
|
if (document.visibilityState === "hidden") {
|
||||||
if (!pauseOnHidden || !currentAudio) return;
|
if (!pauseOnHidden() || !audio) return;
|
||||||
if (visibilityResumeTimeout !== null) {
|
clearResumeTimer();
|
||||||
clearTimeout(visibilityResumeTimeout);
|
audio.pause();
|
||||||
visibilityResumeTimeout = null;
|
playing = false;
|
||||||
}
|
|
||||||
currentAudio.pause();
|
|
||||||
isPlaying = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!audio) {
|
||||||
if (!currentAudio) {
|
|
||||||
void ensurePlayback();
|
void ensurePlayback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!pauseOnHidden()) return;
|
||||||
if (!pauseOnHidden) return;
|
clearResumeTimer();
|
||||||
|
resumeTimer = setTimeout(() => {
|
||||||
if (visibilityResumeTimeout !== null) {
|
resumeTimer = null;
|
||||||
clearTimeout(visibilityResumeTimeout);
|
void tryAutoplay(vol());
|
||||||
}
|
|
||||||
visibilityResumeTimeout = window.setTimeout(() => {
|
|
||||||
visibilityResumeTimeout = null;
|
|
||||||
void tryAutoplay(getVolume());
|
|
||||||
}, 200);
|
}, 200);
|
||||||
};
|
};
|
||||||
document.addEventListener("visibilitychange", visHandler);
|
|
||||||
|
|
||||||
const pageshowHandler = () => void ensurePlayback();
|
const onUpdated = () => void ensurePlayback();
|
||||||
window.addEventListener("pageshow", pageshowHandler);
|
const onStop = () => {
|
||||||
|
disarmGesture();
|
||||||
const uploadedHandler = () => void ensurePlayback();
|
stopAudio();
|
||||||
window.addEventListener(
|
clearHint();
|
||||||
"betterseqta-background-music-updated",
|
|
||||||
uploadedHandler,
|
|
||||||
);
|
|
||||||
|
|
||||||
const stopHandler = () => {
|
|
||||||
disarmGesturePlayback();
|
|
||||||
stopAndCleanupAudio();
|
|
||||||
hideAutoplayHint();
|
|
||||||
};
|
};
|
||||||
window.addEventListener("betterseqta-background-music-stop", stopHandler);
|
const teardown = () => {
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
return () => {
|
window.removeEventListener("pageshow", onUpdated);
|
||||||
document.removeEventListener("visibilitychange", visHandler);
|
window.removeEventListener("betterseqta-background-music-updated", onUpdated);
|
||||||
window.removeEventListener("pageshow", pageshowHandler);
|
window.removeEventListener("betterseqta-background-music-stop", onStop);
|
||||||
window.removeEventListener(
|
clearResumeTimer();
|
||||||
"betterseqta-background-music-updated",
|
disarmGesture();
|
||||||
uploadedHandler,
|
clearHint();
|
||||||
);
|
stopAudio();
|
||||||
window.removeEventListener(
|
|
||||||
"betterseqta-background-music-stop",
|
|
||||||
stopHandler,
|
|
||||||
);
|
|
||||||
disarmGesturePlayback();
|
|
||||||
hideAutoplayHint();
|
|
||||||
if (visibilityResumeTimeout !== null) {
|
|
||||||
clearTimeout(visibilityResumeTimeout);
|
|
||||||
visibilityResumeTimeout = null;
|
|
||||||
}
|
|
||||||
stopAndCleanupAudio();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
window.addEventListener("pageshow", onUpdated);
|
||||||
|
window.addEventListener("betterseqta-background-music-updated", onUpdated);
|
||||||
|
window.addEventListener("betterseqta-background-music-stop", onStop);
|
||||||
|
|
||||||
|
return teardown;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,7 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: color-mix(in srgb, var(--theme-primary, #1a1a1a) 92%, black 8%);
|
background: color-mix(in srgb, var(--theme-primary, #1a1a1a) 92%, black 8%);
|
||||||
color: var(--text-primary, #fff);
|
color: var(--text-primary, #fff);
|
||||||
font-size: 0.8125rem;
|
font: 600 0.8125rem/1.25 system-ui, sans-serif;
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.25;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
|
box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
|
||||||
animation: bsplus-bg-music-hint-in 220ms ease-out;
|
animation: bsplus-bg-music-hint-in 220ms ease-out;
|
||||||
@@ -21,12 +19,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes bsplus-bg-music-hint-in {
|
@keyframes bsplus-bg-music-hint-in {
|
||||||
from {
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
opacity: 0;
|
to { opacity: 1; transform: translateY(0); }
|
||||||
transform: translateY(6px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,16 +11,11 @@ import {
|
|||||||
resetSearchIndexes,
|
resetSearchIndexes,
|
||||||
notifyOpenTabsResetSearchIndex,
|
notifyOpenTabsResetSearchIndex,
|
||||||
} from "./src/indexing/resetIndexes";
|
} from "./src/indexing/resetIndexes";
|
||||||
|
import { getDefaultSearchHotkey } from "./src/utils/hotkeyUtils";
|
||||||
// Platform-aware default hotkey
|
|
||||||
const getDefaultHotkey = () => {
|
|
||||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
|
||||||
return isMac ? "cmd+k" : "ctrl+k";
|
|
||||||
};
|
|
||||||
|
|
||||||
const settings = defineSettings({
|
const settings = defineSettings({
|
||||||
searchHotkey: hotkeySetting({
|
searchHotkey: hotkeySetting({
|
||||||
default: getDefaultHotkey(),
|
default: getDefaultSearchHotkey(),
|
||||||
title: "Search Hotkey",
|
title: "Search Hotkey",
|
||||||
description: "Keyboard shortcut to open the search",
|
description: "Keyboard shortcut to open the search",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
import HighlightedText from '../utils/HighlightedText.svelte';
|
import HighlightedText from '../utils/HighlightedText.svelte';
|
||||||
import { matchesHotkey } from '../utils/hotkeyUtils';
|
import { matchesHotkey } from '../utils/hotkeyUtils';
|
||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
import { verboseDebug } from '@/utils/verboseLog';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
transparencyEffects,
|
transparencyEffects,
|
||||||
@@ -33,12 +32,6 @@
|
|||||||
const dynamicIdToItemMap = $state(new Map<string, IndexItem>());
|
const dynamicIdToItemMap = $state(new Map<string, IndexItem>());
|
||||||
const commandIdToItemMap = $state(new Map<string, StaticCommandItem>());
|
const commandIdToItemMap = $state(new Map<string, StaticCommandItem>());
|
||||||
|
|
||||||
let isIndexing = $state(false);
|
|
||||||
let completedJobs = $state(0);
|
|
||||||
let totalJobs = $state(0);
|
|
||||||
let indexingStatus = $state<string | null>(null);
|
|
||||||
let indexingDetail = $state<string | null>(null);
|
|
||||||
|
|
||||||
let commandPalleteOpen = $state(false);
|
let commandPalleteOpen = $state(false);
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
let selectedIndex = $state(0);
|
let selectedIndex = $state(0);
|
||||||
@@ -119,17 +112,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const progressHandler = (event: CustomEvent) => {
|
|
||||||
const { completed, total, indexing, status, detail } = event.detail;
|
|
||||||
completedJobs = completed;
|
|
||||||
totalJobs = total;
|
|
||||||
isIndexing = indexing;
|
|
||||||
indexingStatus = status || null;
|
|
||||||
indexingDetail = detail || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
|
||||||
|
|
||||||
const itemsUpdatedHandler = (event: Event) => {
|
const itemsUpdatedHandler = (event: Event) => {
|
||||||
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail;
|
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail;
|
||||||
|
|
||||||
@@ -168,7 +150,6 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('indexing-progress', progressHandler as EventListener);
|
|
||||||
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -184,8 +165,6 @@
|
|||||||
|
|
||||||
dynamicItems.forEach(item => dynamicIdToItemMap.set(item.id, item));
|
dynamicItems.forEach(item => dynamicIdToItemMap.set(item.id, item));
|
||||||
commands.forEach(item => commandIdToItemMap.set(item.id, item));
|
commands.forEach(item => commandIdToItemMap.set(item.id, item));
|
||||||
|
|
||||||
verboseDebug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const performSearch = async () => {
|
const performSearch = async () => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
|||||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
|
||||||
export interface BaseCommandItem {
|
export interface BaseCommandItem {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -106,7 +105,6 @@ async function navigateToSpecificLesson(lesson: any) {
|
|||||||
if (lessonDate === todayDateString && lessonPeriod === normalizedLessonPeriod) {
|
if (lessonDate === todayDateString && lessonPeriod === normalizedLessonPeriod) {
|
||||||
// Found the exact matching lesson, click it
|
// Found the exact matching lesson, click it
|
||||||
(lessonElement as HTMLElement).click();
|
(lessonElement as HTMLElement).click();
|
||||||
verboseLog(`Navigated to exact lesson: ${lessonDate} ${lessonPeriod}`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import type { Plugin } from "@/plugins/core/types";
|
import type { Plugin } from "@/plugins/core/types";
|
||||||
import { BasePlugin } from "@/plugins/core/settings";
|
import { verboseDebug, verboseLog } from "@/utils/verboseLog";
|
||||||
import {
|
|
||||||
booleanSetting,
|
|
||||||
buttonSetting,
|
|
||||||
defineSettings,
|
|
||||||
hotkeySetting,
|
|
||||||
Setting,
|
|
||||||
} from "@/plugins/core/settingsHelpers";
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog";
|
|
||||||
import styles from "./styles.css?inline";
|
import styles from "./styles.css?inline";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
import { runIndexing, ensureSchemaCurrent } from "../indexing/indexer";
|
import { runIndexing, ensureSchemaCurrent } from "../indexing/indexer";
|
||||||
@@ -23,161 +15,21 @@ import {
|
|||||||
installPassiveObserver,
|
installPassiveObserver,
|
||||||
} from "../indexing/passiveObserver";
|
} from "../indexing/passiveObserver";
|
||||||
|
|
||||||
// Platform-aware default hotkey
|
const globalSearchPlugin: Plugin<{}> = {
|
||||||
const getDefaultHotkey = () => {
|
|
||||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
|
||||||
return isMac ? "cmd+k" : "ctrl+k";
|
|
||||||
};
|
|
||||||
|
|
||||||
const settings = defineSettings({
|
|
||||||
searchHotkey: hotkeySetting({
|
|
||||||
default: getDefaultHotkey(),
|
|
||||||
title: "Search Hotkey",
|
|
||||||
description: "Keyboard shortcut to open the search",
|
|
||||||
}),
|
|
||||||
showRecentFirst: booleanSetting({
|
|
||||||
default: true,
|
|
||||||
title: "Show Recent First",
|
|
||||||
description: "Sort dynamic content by most recent first",
|
|
||||||
}),
|
|
||||||
transparencyEffects: booleanSetting({
|
|
||||||
default: true,
|
|
||||||
title: "Transparency Effects",
|
|
||||||
description: "Enable transparency effects for the search bar",
|
|
||||||
}),
|
|
||||||
runIndexingOnLoad: booleanSetting({
|
|
||||||
default: true,
|
|
||||||
title: "Index on Page Load",
|
|
||||||
description: "Run content indexing when SEQTA loads",
|
|
||||||
}),
|
|
||||||
passiveIndexing: booleanSetting({
|
|
||||||
default: true,
|
|
||||||
title: "Index Browsed Content",
|
|
||||||
description:
|
|
||||||
"Capture safe text from SEQTA pages you visit so they're searchable. Sensitive routes (settings, files, login) are always excluded.",
|
|
||||||
}),
|
|
||||||
resetIndex: buttonSetting({
|
|
||||||
title: "Reset Index",
|
|
||||||
description: "Reset the search index and storage",
|
|
||||||
trigger: async () => {
|
|
||||||
const confirmed = confirm(
|
|
||||||
"Reset the search index and all stored Global Search data?\n\nAfter this, reload this SEQTA tab so indexing can run again and rebuild the index.",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed) {
|
|
||||||
try {
|
|
||||||
// Import resetDatabase function to properly close connections
|
|
||||||
const { resetDatabase } = await import("../indexing/db");
|
|
||||||
|
|
||||||
// Reset the vector worker first
|
|
||||||
try {
|
|
||||||
const workerManager = VectorWorkerManager.getInstance();
|
|
||||||
await workerManager.resetWorker();
|
|
||||||
verboseLog("Vector worker reset successfully");
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Failed to reset vector worker:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close all database connections properly before deletion
|
|
||||||
try {
|
|
||||||
await resetDatabase();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Failed to reset betterseqta-index database:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait a bit for connections to fully close
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
// Delete embeddiaDB (vector search database)
|
|
||||||
const deleteDb = (dbName: string) => {
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const req = indexedDB.deleteDatabase(dbName);
|
|
||||||
req.onsuccess = () => {
|
|
||||||
verboseLog(`Successfully deleted database: ${dbName}`);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
req.onerror = () => {
|
|
||||||
console.error(`Error deleting database ${dbName}:`, req.error);
|
|
||||||
reject(req.error);
|
|
||||||
};
|
|
||||||
req.onblocked = () => {
|
|
||||||
console.warn(`Database ${dbName} deletion blocked - connections still open`);
|
|
||||||
// Wait and retry once
|
|
||||||
setTimeout(() => {
|
|
||||||
const retryReq = indexedDB.deleteDatabase(dbName);
|
|
||||||
retryReq.onsuccess = () => {
|
|
||||||
verboseLog(`Successfully deleted database on retry: ${dbName}`);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
retryReq.onerror = () => reject(retryReq.error);
|
|
||||||
retryReq.onblocked = () => {
|
|
||||||
reject(new Error(`One database is open, failed to remove: ${dbName}. Please close other tabs and try again.`));
|
|
||||||
};
|
|
||||||
}, 500);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteDb("embeddiaDB");
|
|
||||||
await deleteDb("betterseqta-index");
|
|
||||||
alert(
|
|
||||||
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert("Failed to reset index: " + String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
class GlobalSearchPlugin extends BasePlugin<typeof settings> {
|
|
||||||
@Setting(settings.searchHotkey)
|
|
||||||
searchHotkey!: string;
|
|
||||||
|
|
||||||
@Setting(settings.showRecentFirst)
|
|
||||||
showRecentFirst!: boolean;
|
|
||||||
|
|
||||||
@Setting(settings.transparencyEffects)
|
|
||||||
transparencyEffects!: boolean;
|
|
||||||
|
|
||||||
@Setting(settings.runIndexingOnLoad)
|
|
||||||
runIndexingOnLoad!: boolean;
|
|
||||||
|
|
||||||
@Setting(settings.passiveIndexing)
|
|
||||||
passiveIndexing!: boolean;
|
|
||||||
|
|
||||||
@Setting(settings.resetIndex)
|
|
||||||
resetIndex!: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingsInstance = new GlobalSearchPlugin();
|
|
||||||
|
|
||||||
const globalSearchPlugin: Plugin<typeof settings> = {
|
|
||||||
id: "global-search",
|
id: "global-search",
|
||||||
name: "Global Search",
|
name: "Global Search",
|
||||||
description: "Quick search for everything in SEQTA",
|
description: "Quick search for everything in SEQTA",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
settings: settingsInstance.settings,
|
settings: {},
|
||||||
disableToggle: true,
|
disableToggle: true,
|
||||||
defaultEnabled: false,
|
defaultEnabled: false,
|
||||||
styles: styles,
|
styles,
|
||||||
|
|
||||||
run: async (api) => {
|
run: async (api) => {
|
||||||
const appRef = { current: null };
|
const appRef = { current: null };
|
||||||
|
|
||||||
installResetIndexMessageListener();
|
installResetIndexMessageListener();
|
||||||
|
|
||||||
// Run the version check BEFORE we open any IndexedDB connections.
|
|
||||||
// On a normal load (no version change) this is just a string compare
|
|
||||||
// and a manifest read, so the cost is negligible. On a real update,
|
|
||||||
// we want the database wipe to complete before `IndexedDbManager`
|
|
||||||
// grabs a handle on `embeddiaDB`, otherwise the delete request comes
|
|
||||||
// back blocked.
|
|
||||||
try {
|
try {
|
||||||
const wasUpdated = await checkAndHandleUpdate();
|
const wasUpdated = await checkAndHandleUpdate();
|
||||||
if (wasUpdated) {
|
if (wasUpdated) {
|
||||||
@@ -186,25 +38,21 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Firefox sometimes refuses CSS preloads or asset reads; we never
|
const msg = error?.message ?? "";
|
||||||
// want this path to take the whole plugin down.
|
|
||||||
if (
|
if (
|
||||||
error?.message?.includes("preload CSS") ||
|
msg.includes("preload CSS") ||
|
||||||
error?.message?.includes("MIME type") ||
|
msg.includes("MIME type") ||
|
||||||
error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")
|
msg.includes("NS_ERROR_CORRUPTED_CONTENT")
|
||||||
) {
|
) {
|
||||||
verboseDebug(
|
verboseDebug(
|
||||||
"[Global Search] Version check skipped due to asset loading restrictions:",
|
"[Global Search] Version check skipped due to asset loading restrictions:",
|
||||||
error.message,
|
msg,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.warn("[Global Search] Failed to check for updates:", error);
|
console.warn("[Global Search] Failed to check for updates:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run schema migration before any IndexedDB connections are opened.
|
|
||||||
// If this runs later (during indexing), embeddiaDB and betterseqta-index
|
|
||||||
// may already be open and delete requests come back blocked.
|
|
||||||
try {
|
try {
|
||||||
await ensureSchemaCurrent();
|
await ensureSchemaCurrent();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -218,69 +66,25 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create IndexedDB:", error);
|
console.error("Failed to create IndexedDB:", error);
|
||||||
// Continue execution - the search might still work without persistence
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initVectorSearch();
|
initVectorSearch();
|
||||||
|
|
||||||
// Warm up vector worker in background to improve initial response time (skip in Firefox)
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
// Only initialize worker if vector search is supported
|
|
||||||
const { isVectorSearchSupported } = await import("../utils/browserDetection");
|
const { isVectorSearchSupported } = await import("../utils/browserDetection");
|
||||||
if (isVectorSearchSupported()) {
|
if (isVectorSearchSupported()) VectorWorkerManager.getInstance();
|
||||||
VectorWorkerManager.getInstance();
|
|
||||||
} else {
|
|
||||||
verboseDebug("[Global Search] Skipping vector worker warm-up (Firefox detected - using text search only)");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Global Search] Vector worker warm-up failed:", error);
|
console.warn("[Global Search] Vector worker warm-up failed:", error);
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
// Add debug helpers to window for troubleshooting
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.globalSearchDebug = {
|
window.globalSearchDebug = {
|
||||||
resetWorker: async () => {
|
resetWorker: () => VectorWorkerManager.getInstance().resetWorker(),
|
||||||
const workerManager = VectorWorkerManager.getInstance();
|
passiveItems: getStoredPassiveItems,
|
||||||
await workerManager.resetWorker();
|
runSelfTests: async () =>
|
||||||
verboseLog("Vector worker reset via debug helper");
|
(await import("../indexing/selfTests")).runGlobalSearchSelfTests(),
|
||||||
},
|
|
||||||
checkWorkerStatus: () => {
|
|
||||||
const workerManager = VectorWorkerManager.getInstance();
|
|
||||||
verboseLog("Streaming active:", workerManager.isStreamingActive());
|
|
||||||
},
|
|
||||||
passiveItems: async () => {
|
|
||||||
const items = await getStoredPassiveItems();
|
|
||||||
verboseLog(`Captured ${items.length} passive items`);
|
|
||||||
return items;
|
|
||||||
},
|
|
||||||
runSelfTests: async () => {
|
|
||||||
const { runGlobalSearchSelfTests } = await import(
|
|
||||||
"../indexing/selfTests"
|
|
||||||
);
|
|
||||||
return runGlobalSearchSelfTests();
|
|
||||||
},
|
|
||||||
checkIndexedDBSize: async () => {
|
|
||||||
try {
|
|
||||||
const estimate = await navigator.storage.estimate();
|
|
||||||
verboseLog("Storage estimate:", estimate);
|
|
||||||
|
|
||||||
// Check embeddiaDB size
|
|
||||||
const dbRequest = indexedDB.open("embeddiaDB");
|
|
||||||
dbRequest.onsuccess = () => {
|
|
||||||
const db = dbRequest.result;
|
|
||||||
const transaction = db.transaction(["embeddiaObjectStore"], "readonly");
|
|
||||||
const store = transaction.objectStore("embeddiaObjectStore");
|
|
||||||
const countRequest = store.count();
|
|
||||||
countRequest.onsuccess = () => {
|
|
||||||
verboseLog("embeddiaDB item count:", countRequest.result);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error checking storage:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (api.settings.passiveIndexing) {
|
if (api.settings.passiveIndexing) {
|
||||||
@@ -293,23 +97,18 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
|||||||
|
|
||||||
if (api.settings.runIndexingOnLoad && !isIndexingPaused()) {
|
if (api.settings.runIndexingOnLoad && !isIndexingPaused()) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
if (isIndexingPaused()) return;
|
if (!isIndexingPaused()) await runIndexing();
|
||||||
await runIndexing();
|
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = document.querySelector("#title");
|
const title = document.querySelector("#title");
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
void mountSearchBar(title, api, appRef);
|
void mountSearchBar(title, api, appRef);
|
||||||
} else {
|
} else {
|
||||||
const titleElement = await waitForElm("#title", true, 100, 60);
|
void mountSearchBar(await waitForElm("#title", true, 100, 60), api, appRef);
|
||||||
void mountSearchBar(titleElement, api, appRef);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => cleanupSearchBar(appRef);
|
||||||
cleanupSearchBar(appRef);
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -36,29 +36,9 @@ export async function mountSearchBar(
|
|||||||
const searchButton = document.createElement("div");
|
const searchButton = document.createElement("div");
|
||||||
searchButton.className = "search-trigger";
|
searchButton.className = "search-trigger";
|
||||||
|
|
||||||
const searchIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
const searchIcon = document.createElement("span");
|
||||||
searchIcon.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
searchIcon.innerHTML =
|
||||||
searchIcon.setAttribute("width", "16");
|
'<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"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
|
||||||
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");
|
const searchLabel = document.createElement("p");
|
||||||
searchLabel.textContent = "Quick search...";
|
searchLabel.textContent = "Quick search...";
|
||||||
@@ -245,9 +225,7 @@ export async function mountSearchBar(
|
|||||||
|
|
||||||
const updateSearchButtonDisplay = () => {
|
const updateSearchButtonDisplay = () => {
|
||||||
hotkeySpan.textContent = hotkeyDisplay;
|
hotkeySpan.textContent = hotkeyDisplay;
|
||||||
if (!searchButton.contains(searchIcon)) {
|
|
||||||
searchButton.replaceChildren(searchIcon, searchLabel, hotkeySpan);
|
searchButton.replaceChildren(searchIcon, searchLabel, hotkeySpan);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
updateSearchButtonDisplay();
|
updateSearchButtonDisplay();
|
||||||
@@ -282,7 +260,7 @@ export async function mountSearchBar(
|
|||||||
try {
|
try {
|
||||||
const { default: renderSvelte } = await import("@/interface/renderInShadow");
|
const { default: renderSvelte } = await import("@/interface/renderInShadow");
|
||||||
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
|
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
|
||||||
transparencyEffects: api.settings.transparencyEffects ? true : false,
|
transparencyEffects: api.settings.transparencyEffects,
|
||||||
showRecentFirst: api.settings.showRecentFirst,
|
showRecentFirst: api.settings.showRecentFirst,
|
||||||
searchHotkey: currentHotkey,
|
searchHotkey: currentHotkey,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { IndexItem } from "./types";
|
|||||||
import ReactFiber from "@/seqta/utils/ReactFiber";
|
import ReactFiber from "@/seqta/utils/ReactFiber";
|
||||||
import { delay } from "@/seqta/utils/delay";
|
import { delay } from "@/seqta/utils/delay";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseLog } from '@/utils/verboseLog';
|
||||||
interface MessageMetadata {
|
interface MessageMetadata {
|
||||||
messageId: number;
|
messageId: number;
|
||||||
author: string;
|
author: string;
|
||||||
|
|||||||
@@ -55,39 +55,24 @@ function setupUpgradeHandler(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAtVersion(version: number, extraStore?: string): Promise<IDBDatabase> {
|
function openDatabase(version?: number, extraStore?: string): Promise<IDBDatabase> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let request: IDBOpenDBRequest;
|
let request: IDBOpenDBRequest;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
request = indexedDB.open(DB_NAME, version);
|
request =
|
||||||
|
version != null
|
||||||
|
? indexedDB.open(DB_NAME, version)
|
||||||
|
: indexedDB.open(DB_NAME);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setupUpgradeHandler(request, extraStore);
|
setupUpgradeHandler(request, extraStore);
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
attachConnection(request.result);
|
attachConnection(request.result);
|
||||||
resolve(request.result);
|
resolve(request.result);
|
||||||
};
|
};
|
||||||
|
|
||||||
request.onerror = () => reject(request.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAtCurrentVersion(): Promise<IDBDatabase> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const request = indexedDB.open(DB_NAME);
|
|
||||||
|
|
||||||
setupUpgradeHandler(request);
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
attachConnection(request.result);
|
|
||||||
resolve(request.result);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onerror = () => reject(request.error);
|
request.onerror = () => reject(request.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -154,7 +139,7 @@ async function openDBInternal(): Promise<IDBDatabase> {
|
|||||||
const storedVersion = getCurrentVersion();
|
const storedVersion = getCurrentVersion();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await openAtVersion(storedVersion);
|
return await openDatabase(storedVersion);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const domError = error as DOMException | undefined;
|
const domError = error as DOMException | undefined;
|
||||||
|
|
||||||
@@ -164,7 +149,7 @@ async function openDBInternal(): Promise<IDBDatabase> {
|
|||||||
);
|
);
|
||||||
invalidateConnection();
|
invalidateConnection();
|
||||||
try {
|
try {
|
||||||
return await openAtCurrentVersion();
|
return await openDatabase();
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
console.warn("[DB] Fallback open failed, recreating database:", fallbackError);
|
console.warn("[DB] Fallback open failed, recreating database:", fallbackError);
|
||||||
}
|
}
|
||||||
@@ -173,7 +158,7 @@ async function openDBInternal(): Promise<IDBDatabase> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await wipeDatabase();
|
await wipeDatabase();
|
||||||
return openAtVersion(1);
|
return openDatabase(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,19 +173,26 @@ function openDB(): Promise<IDBDatabase> {
|
|||||||
return dbPromise;
|
return dbPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getStore(store: string, mode: IDBTransactionMode = "readonly") {
|
function idbRequest<T>(request: IDBRequest<T>): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function objectStore(
|
||||||
|
store: string,
|
||||||
|
mode: IDBTransactionMode = "readonly",
|
||||||
|
): Promise<IDBObjectStore> {
|
||||||
const db = await openDB();
|
const db = await openDB();
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(store)) {
|
if (!db.objectStoreNames.contains(store)) {
|
||||||
await upgradeDB(store);
|
await upgradeDB(store);
|
||||||
|
|
||||||
const upgradedDb = await openDB();
|
const upgradedDb = await openDB();
|
||||||
const tx = upgradedDb.transaction(store, mode);
|
return upgradedDb.transaction(store, mode).objectStore(store);
|
||||||
return tx.objectStore(store);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tx = db.transaction(store, mode);
|
return db.transaction(store, mode).objectStore(store);
|
||||||
return tx.objectStore(store);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upgradeDB(newStore: string): Promise<void> {
|
async function upgradeDB(newStore: string): Promise<void> {
|
||||||
@@ -209,7 +201,7 @@ async function upgradeDB(newStore: string): Promise<void> {
|
|||||||
let baseVersion = 0;
|
let baseVersion = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = await openAtCurrentVersion();
|
const db = await openDatabase();
|
||||||
baseVersion = db.version;
|
baseVersion = db.version;
|
||||||
db.close();
|
db.close();
|
||||||
cachedDb = null;
|
cachedDb = null;
|
||||||
@@ -218,10 +210,8 @@ async function upgradeDB(newStore: string): Promise<void> {
|
|||||||
console.warn("[DB] Could not probe database version before upgrade:", error);
|
console.warn("[DB] Could not probe database version before upgrade:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newVersion = baseVersion + 1;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await openAtVersion(newVersion, newStore);
|
await openDatabase(baseVersion + 1, newStore);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error upgrading database:", error);
|
console.error("Error upgrading database:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -230,12 +220,8 @@ async function upgradeDB(newStore: string): Promise<void> {
|
|||||||
|
|
||||||
export async function getAll(store: string): Promise<any[]> {
|
export async function getAll(store: string): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
const s = await getStore(store);
|
const s = await objectStore(store);
|
||||||
return new Promise((resolve, reject) => {
|
return await idbRequest(s.getAll());
|
||||||
const req = s.getAll();
|
|
||||||
req.onsuccess = () => resolve(req.result);
|
|
||||||
req.onerror = () => reject(req.error);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error in getAll for store ${store}:`, error);
|
console.error(`Error in getAll for store ${store}:`, error);
|
||||||
return [];
|
return [];
|
||||||
@@ -244,12 +230,8 @@ export async function getAll(store: string): Promise<any[]> {
|
|||||||
|
|
||||||
export async function get(store: string, key: string): Promise<any> {
|
export async function get(store: string, key: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const s = await getStore(store);
|
const s = await objectStore(store);
|
||||||
return new Promise((resolve, reject) => {
|
return await idbRequest(s.get(key));
|
||||||
const req = s.get(key);
|
|
||||||
req.onsuccess = () => resolve(req.result);
|
|
||||||
req.onerror = () => reject(req.error);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error in get for store ${store}, key ${key}:`, error);
|
console.error(`Error in get for store ${store}, key ${key}:`, error);
|
||||||
return null;
|
return null;
|
||||||
@@ -262,21 +244,14 @@ export async function put(
|
|||||||
key?: string,
|
key?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const s = await getStore(store, "readwrite");
|
const s = await objectStore(store, "readwrite");
|
||||||
return new Promise((resolve, reject) => {
|
await idbRequest(key ? s.put(value, key) : s.put(value));
|
||||||
const req = key ? s.put(value, key) : s.put(value);
|
|
||||||
req.onsuccess = () => resolve();
|
|
||||||
req.onerror = () => reject(req.error);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error in put for store ${store}:`, error);
|
console.error(`Error in put for store ${store}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply puts and deletes in a single readwrite transaction.
|
|
||||||
*/
|
|
||||||
export async function applyStoreDiff(
|
export async function applyStoreDiff(
|
||||||
store: string,
|
store: string,
|
||||||
puts: Array<{ key: string; value: any }>,
|
puts: Array<{ key: string; value: any }>,
|
||||||
@@ -286,15 +261,10 @@ export async function applyStoreDiff(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const db = await openDB();
|
const db = await openDB();
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(store)) {
|
if (!db.objectStoreNames.contains(store)) {
|
||||||
await upgradeDB(store);
|
await upgradeDB(store);
|
||||||
const upgradedDb = await openDB();
|
|
||||||
await runStoreDiffTransaction(upgradedDb, store, puts, removeKeys);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
await runStoreDiffTransaction(await openDB(), store, puts, removeKeys);
|
||||||
await runStoreDiffTransaction(db, store, puts, removeKeys);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error in applyStoreDiff for store ${store}:`, error);
|
console.error(`Error in applyStoreDiff for store ${store}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -309,13 +279,13 @@ function runStoreDiffTransaction(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = db.transaction(store, "readwrite");
|
const tx = db.transaction(store, "readwrite");
|
||||||
const objectStore = tx.objectStore(store);
|
const objectStoreRef = tx.objectStore(store);
|
||||||
|
|
||||||
for (const key of removeKeys) {
|
for (const key of removeKeys) {
|
||||||
objectStore.delete(key);
|
objectStoreRef.delete(key);
|
||||||
}
|
}
|
||||||
for (const { key, value } of puts) {
|
for (const { key, value } of puts) {
|
||||||
objectStore.put(value, key);
|
objectStoreRef.put(value, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.oncomplete = () => resolve();
|
tx.oncomplete = () => resolve();
|
||||||
@@ -326,12 +296,8 @@ function runStoreDiffTransaction(
|
|||||||
|
|
||||||
export async function remove(store: string, key: string): Promise<void> {
|
export async function remove(store: string, key: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const s = await getStore(store, "readwrite");
|
const s = await objectStore(store, "readwrite");
|
||||||
return new Promise((resolve, reject) => {
|
await idbRequest(s.delete(key));
|
||||||
const req = s.delete(key);
|
|
||||||
req.onsuccess = () => resolve();
|
|
||||||
req.onerror = () => reject(req.error);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error in remove for store ${store}, key ${key}:`, error);
|
console.error(`Error in remove for store ${store}, key ${key}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -340,12 +306,8 @@ export async function remove(store: string, key: string): Promise<void> {
|
|||||||
|
|
||||||
export async function clear(store: string): Promise<void> {
|
export async function clear(store: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const s = await getStore(store, "readwrite");
|
const s = await objectStore(store, "readwrite");
|
||||||
return new Promise((resolve, reject) => {
|
await idbRequest(s.clear());
|
||||||
const req = s.clear();
|
|
||||||
req.onsuccess = () => resolve();
|
|
||||||
req.onerror = () => reject(req.error);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error in clear for store ${store}:`, error);
|
console.error(`Error in clear for store ${store}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -358,7 +320,7 @@ export async function resetDatabase(): Promise<void> {
|
|||||||
const db = await dbPromise;
|
const db = await dbPromise;
|
||||||
db.close();
|
db.close();
|
||||||
} catch {
|
} catch {
|
||||||
// Database might not be open yet, that's okay
|
// Database might not be open yet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { applyStoreDiff, get, getAll, put, remove } from "./db";
|
import { applyStoreDiff, get, getAll, put, remove } from "./db";
|
||||||
import { jobs } from "./jobs";
|
import { jobs } from "./jobs";
|
||||||
import { decorateIndexItems } from "./renderComponents";
|
import { decorateIndexItems, publishDynamicItemsUpdate } from "./renderComponents";
|
||||||
import type { IndexItem, Job, JobContext } from "./types";
|
import type { IndexItem, Job, JobContext } from "./types";
|
||||||
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
||||||
import { loadDynamicItems } from "../utils/dynamicItems";
|
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||||
@@ -9,7 +9,7 @@ import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion";
|
|||||||
import { resetSearchIndexes } from "./resetIndexes";
|
import { resetSearchIndexes } from "./resetIndexes";
|
||||||
import { isIndexingPaused } from "./indexingPause";
|
import { isIndexingPaused } from "./indexingPause";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseDebug } from '@/utils/verboseLog';
|
||||||
const META_STORE = "meta";
|
const META_STORE = "meta";
|
||||||
const LOCK_KEY = "bsq-indexer-lock";
|
const LOCK_KEY = "bsq-indexer-lock";
|
||||||
const HEARTBEAT_INTERVAL = 10000;
|
const HEARTBEAT_INTERVAL = 10000;
|
||||||
@@ -51,7 +51,6 @@ async function ensureSchemaCurrent(): Promise<void> {
|
|||||||
|
|
||||||
export { ensureSchemaCurrent };
|
export { ensureSchemaCurrent };
|
||||||
|
|
||||||
/* ─────────── Progress‑meta helpers ─────────── */
|
|
||||||
async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
|
async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
|
||||||
const rec = await get(META_STORE, `progress:${jobId}`);
|
const rec = await get(META_STORE, `progress:${jobId}`);
|
||||||
return rec?.progress as T | undefined;
|
return rec?.progress as T | undefined;
|
||||||
@@ -60,7 +59,6 @@ async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
|
|||||||
async function saveProgress<T = any>(jobId: string, progress: T): Promise<void> {
|
async function saveProgress<T = any>(jobId: string, progress: T): Promise<void> {
|
||||||
await put(META_STORE, { progress }, `progress:${jobId}`);
|
await put(META_STORE, { progress }, `progress:${jobId}`);
|
||||||
}
|
}
|
||||||
/* ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let isIndexingActive = false;
|
let isIndexingActive = false;
|
||||||
@@ -145,6 +143,16 @@ async function updateLastRunMeta(jobId: string): Promise<void> {
|
|||||||
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
|
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function tryClaimLock(lockId: string): Promise<boolean> {
|
||||||
|
localStorage.setItem(LOCK_KEY, lockId);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
if (localStorage.getItem(LOCK_KEY) === lockId) {
|
||||||
|
isIndexingActive = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function acquireLock(): Promise<boolean> {
|
async function acquireLock(): Promise<boolean> {
|
||||||
if (isIndexingActive) {
|
if (isIndexingActive) {
|
||||||
verboseDebug("[Indexer] Already indexing in this tab");
|
verboseDebug("[Indexer] Already indexing in this tab");
|
||||||
@@ -159,30 +167,20 @@ async function acquireLock(): Promise<boolean> {
|
|||||||
const currentTime = Date.now();
|
const currentTime = Date.now();
|
||||||
|
|
||||||
if (!currentLock) {
|
if (!currentLock) {
|
||||||
localStorage.setItem(LOCK_KEY, lockId);
|
if (await tryClaimLock(lockId)) return true;
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
|
||||||
if (localStorage.getItem(LOCK_KEY) === lockId) {
|
|
||||||
isIndexingActive = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const [timestamp] = currentLock.split('-');
|
const [timestamp] = currentLock.split("-");
|
||||||
const lockTime = parseInt(timestamp, 10);
|
const lockTime = parseInt(timestamp, 10);
|
||||||
if (isNaN(lockTime) || currentTime - lockTime > LOCK_TIMEOUT) {
|
if (isNaN(lockTime) || currentTime - lockTime > LOCK_TIMEOUT) {
|
||||||
localStorage.setItem(LOCK_KEY, lockId);
|
if (await tryClaimLock(lockId)) return true;
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
|
||||||
if (localStorage.getItem(LOCK_KEY) === lockId) {
|
|
||||||
isIndexingActive = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[Indexer] Error parsing lock:", e);
|
console.warn("[Indexer] Error parsing lock:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -252,6 +250,69 @@ export async function loadAllStoredItems(): Promise<IndexItem[]> {
|
|||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispatchVectorProgress(
|
||||||
|
progress: {
|
||||||
|
status?: string;
|
||||||
|
total?: number;
|
||||||
|
processed?: number;
|
||||||
|
message?: string;
|
||||||
|
},
|
||||||
|
completedJobs: number,
|
||||||
|
totalSteps: number,
|
||||||
|
): number {
|
||||||
|
let detailMessage = progress.message || "";
|
||||||
|
let completed = completedJobs;
|
||||||
|
|
||||||
|
if (
|
||||||
|
progress.status === "processing" &&
|
||||||
|
progress.total &&
|
||||||
|
progress.processed !== undefined
|
||||||
|
) {
|
||||||
|
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
|
||||||
|
} else if (progress.status === "complete") {
|
||||||
|
detailMessage = "Vectorization complete";
|
||||||
|
completed++;
|
||||||
|
dispatchProgress(completed, totalSteps, false, "Indexing finished", detailMessage);
|
||||||
|
return completed;
|
||||||
|
} else if (progress.status === "error") {
|
||||||
|
dispatchProgress(
|
||||||
|
completed,
|
||||||
|
totalSteps,
|
||||||
|
false,
|
||||||
|
"Vectorization failed",
|
||||||
|
`Vectorization error: ${progress.message}`,
|
||||||
|
);
|
||||||
|
return completed;
|
||||||
|
} else if (progress.status === "cancelled") {
|
||||||
|
dispatchProgress(
|
||||||
|
completed,
|
||||||
|
totalSteps,
|
||||||
|
false,
|
||||||
|
"Vectorization cancelled",
|
||||||
|
`Vectorization cancelled: ${progress.message}`,
|
||||||
|
);
|
||||||
|
return completed;
|
||||||
|
} else if (progress.status === "started") {
|
||||||
|
detailMessage = `Vectorization started for ${progress.total} items`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
progress.status !== "complete" &&
|
||||||
|
progress.status !== "error" &&
|
||||||
|
progress.status !== "cancelled"
|
||||||
|
) {
|
||||||
|
dispatchProgress(
|
||||||
|
completed,
|
||||||
|
totalSteps,
|
||||||
|
true,
|
||||||
|
"Vectorization in progress",
|
||||||
|
detailMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return completed;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runIndexing(): Promise<void> {
|
export async function runIndexing(): Promise<void> {
|
||||||
if (isIndexingPaused()) {
|
if (isIndexingPaused()) {
|
||||||
verboseDebug(
|
verboseDebug(
|
||||||
@@ -415,54 +476,7 @@ export async function runIndexing(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const workerManager = VectorWorkerManager.getInstance();
|
const workerManager = VectorWorkerManager.getInstance();
|
||||||
await workerManager.processItems(newItemsToVectorize, (progress) => {
|
await workerManager.processItems(newItemsToVectorize, (progress) => {
|
||||||
let detailMessage = progress.message || "";
|
completedJobs = dispatchVectorProgress(progress, completedJobs, totalSteps);
|
||||||
if (
|
|
||||||
progress.status === "processing" &&
|
|
||||||
progress.total &&
|
|
||||||
progress.processed !== undefined
|
|
||||||
) {
|
|
||||||
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
|
|
||||||
} else if (progress.status === "complete") {
|
|
||||||
detailMessage = "Vectorization complete";
|
|
||||||
completedJobs++;
|
|
||||||
dispatchProgress(
|
|
||||||
completedJobs,
|
|
||||||
totalSteps,
|
|
||||||
false,
|
|
||||||
"Indexing finished",
|
|
||||||
detailMessage
|
|
||||||
);
|
|
||||||
} else if (progress.status === "error") {
|
|
||||||
detailMessage = `Vectorization error: ${progress.message}`;
|
|
||||||
dispatchProgress(
|
|
||||||
completedJobs,
|
|
||||||
totalSteps,
|
|
||||||
false,
|
|
||||||
"Vectorization failed",
|
|
||||||
detailMessage,
|
|
||||||
);
|
|
||||||
} else if (progress.status === "started") {
|
|
||||||
detailMessage = `Vectorization started for ${progress.total} items`;
|
|
||||||
} else if (progress.status === "cancelled") {
|
|
||||||
detailMessage = `Vectorization cancelled: ${progress.message}`;
|
|
||||||
dispatchProgress(
|
|
||||||
completedJobs,
|
|
||||||
totalSteps,
|
|
||||||
false,
|
|
||||||
"Vectorization cancelled",
|
|
||||||
detailMessage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progress.status !== "complete" && progress.status !== "error" && progress.status !== "cancelled") {
|
|
||||||
dispatchProgress(
|
|
||||||
completedJobs,
|
|
||||||
totalSteps,
|
|
||||||
true,
|
|
||||||
"Vectorization in progress",
|
|
||||||
detailMessage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
verboseDebug(
|
verboseDebug(
|
||||||
"%c[Indexer] Vectorization task for stored items sent to worker.",
|
"%c[Indexer] Vectorization task for stored items sent to worker.",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IndexItem, Job } from "../types";
|
import type { IndexItem, Job } from "../types";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseDebug } from '@/utils/verboseLog';
|
||||||
const fetchJSON = async (url: string, body: any) => {
|
const fetchJSON = async (url: string, body: any) => {
|
||||||
const res = await fetch(`${location.origin}${url}`, {
|
const res = await fetch(`${location.origin}${url}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { seqtaFetchPayload } from "../api";
|
|||||||
import { buildIndexItem } from "../extract";
|
import { buildIndexItem } from "../extract";
|
||||||
import { htmlToPlainText } from "../utils";
|
import { htmlToPlainText } from "../utils";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseDebug } from '@/utils/verboseLog';
|
||||||
/**
|
/**
|
||||||
* Indexes per-subject course content from `/seqta/student/load/courses`.
|
* Indexes per-subject course content from `/seqta/student/load/courses`.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { IndexItem, Job } from "../types";
|
import type { IndexItem, Job } from "../types";
|
||||||
import { seqtaFetchPayload } from "../api";
|
import { seqtaFetchPayload } from "../api";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseDebug } from '@/utils/verboseLog';
|
||||||
/**
|
/**
|
||||||
* Indexes file metadata from `/seqta/student/load/documents`.
|
* Indexes file metadata from `/seqta/student/load/documents`.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { seqtaFetchPayload } from "../api";
|
|||||||
import { htmlToPlainText } from "../utils";
|
import { htmlToPlainText } from "../utils";
|
||||||
import { delay } from "@/seqta/utils/delay";
|
import { delay } from "@/seqta/utils/delay";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseDebug } from '@/utils/verboseLog';
|
||||||
/**
|
/**
|
||||||
* Indexes student folio entries from `/seqta/student/folio`.
|
* Indexes student folio entries from `/seqta/student/folio`.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { seqtaFetchPayload } from "../api";
|
|||||||
import { extractTextFromValue } from "../extract";
|
import { extractTextFromValue } from "../extract";
|
||||||
import { delay } from "@/seqta/utils/delay";
|
import { delay } from "@/seqta/utils/delay";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseDebug } from '@/utils/verboseLog';
|
||||||
/**
|
/**
|
||||||
* Indexes student goals from `/seqta/student/load/goals`.
|
* Indexes student goals from `/seqta/student/load/goals`.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import type { IndexItem, Job } from "../types";
|
|||||||
import { htmlToPlainText } from "../utils";
|
import { htmlToPlainText } from "../utils";
|
||||||
import { delay } from "@/seqta/utils/delay";
|
import { delay } from "@/seqta/utils/delay";
|
||||||
import { VectorWorkerManager } from "../worker/vectorWorkerManager";
|
import { VectorWorkerManager } from "../worker/vectorWorkerManager";
|
||||||
import { loadDynamicItems } from "../../utils/dynamicItems";
|
|
||||||
import { loadAllStoredItems } from "../indexer";
|
import { loadAllStoredItems } from "../indexer";
|
||||||
import { renderComponentMap } from "../renderComponents";
|
import { publishDynamicItemsUpdate } from "../renderComponents";
|
||||||
import { jobs } from "../jobs";
|
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
||||||
const RATE_LIMIT_CONFIG = {
|
const RATE_LIMIT_CONFIG = {
|
||||||
@@ -605,44 +603,10 @@ export const messagesJob: Job = {
|
|||||||
|
|
||||||
if (processedItems.length > 0) {
|
if (processedItems.length > 0) {
|
||||||
try {
|
try {
|
||||||
const currentItems = await loadAllStoredItems();
|
publishDynamicItemsUpdate(
|
||||||
// Create new objects to avoid XrayWrapper issues in Firefox
|
await loadAllStoredItems(),
|
||||||
const itemsWithComponents = currentItems.map((item) => {
|
"messages",
|
||||||
try {
|
processedItems.length,
|
||||||
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
|
|
||||||
try {
|
|
||||||
const cloned = JSON.parse(JSON.stringify(item));
|
|
||||||
cloned.renderComponent = renderComponent;
|
|
||||||
return cloned;
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to shallow copy if deep clone fails
|
|
||||||
return { ...item, renderComponent };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
loadDynamicItems(itemsWithComponents);
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("dynamic-items-updated", {
|
|
||||||
detail: {
|
|
||||||
incremental: true,
|
|
||||||
jobId: "messages",
|
|
||||||
newItemCount: processedItems.length,
|
|
||||||
streaming: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { seqtaFetchPayload } from "../api";
|
|||||||
import { htmlToPlainText } from "../utils";
|
import { htmlToPlainText } from "../utils";
|
||||||
import { delay } from "@/seqta/utils/delay";
|
import { delay } from "@/seqta/utils/delay";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseDebug } from '@/utils/verboseLog';
|
||||||
/**
|
/**
|
||||||
* Indexes daily notices from `/seqta/student/load/notices`.
|
* Indexes daily notices from `/seqta/student/load/notices`.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ import { htmlToPlainText } from "../utils";
|
|||||||
import { fetchMessageContent } from "./messages";
|
import { fetchMessageContent } from "./messages";
|
||||||
import { delay } from "@/seqta/utils/delay";
|
import { delay } from "@/seqta/utils/delay";
|
||||||
import { VectorWorkerManager } from "../worker/vectorWorkerManager";
|
import { VectorWorkerManager } from "../worker/vectorWorkerManager";
|
||||||
import { loadDynamicItems } from "../../utils/dynamicItems";
|
|
||||||
import { loadAllStoredItems } from "../indexer";
|
import { loadAllStoredItems } from "../indexer";
|
||||||
import { renderComponentMap } from "../renderComponents";
|
import { publishDynamicItemsUpdate } from "../renderComponents";
|
||||||
import { jobs } from "../jobs";
|
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseLog } from '@/utils/verboseLog';
|
||||||
const NOTIFICATIONS_RATE_LIMIT = {
|
const NOTIFICATIONS_RATE_LIMIT = {
|
||||||
baseDelay: 150,
|
baseDelay: 150,
|
||||||
maxDelay: 3000,
|
maxDelay: 3000,
|
||||||
@@ -373,44 +371,10 @@ export const notificationsJob: Job = {
|
|||||||
|
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
try {
|
try {
|
||||||
const currentItems = await loadAllStoredItems();
|
publishDynamicItemsUpdate(
|
||||||
// Create new objects to avoid XrayWrapper issues in Firefox
|
await loadAllStoredItems(),
|
||||||
const itemsWithComponents = currentItems.map((item) => {
|
"notifications",
|
||||||
try {
|
items.length,
|
||||||
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
|
|
||||||
try {
|
|
||||||
const cloned = JSON.parse(JSON.stringify(item));
|
|
||||||
cloned.renderComponent = renderComponent;
|
|
||||||
return cloned;
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to shallow copy if deep clone fails
|
|
||||||
return { ...item, renderComponent };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
loadDynamicItems(itemsWithComponents);
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("dynamic-items-updated", {
|
|
||||||
detail: {
|
|
||||||
incremental: true,
|
|
||||||
jobId: "notifications",
|
|
||||||
newItemCount: items.length,
|
|
||||||
streaming: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { IndexItem, Job } from "../types";
|
import type { IndexItem, Job } from "../types";
|
||||||
import { seqtaFetchPayload } from "../api";
|
import { seqtaFetchPayload } from "../api";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseDebug } from '@/utils/verboseLog';
|
||||||
/**
|
/**
|
||||||
* Indexes the user's external portal entries from `/seqta/student/load/portals`.
|
* Indexes the user's external portal entries from `/seqta/student/load/portals`.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { IndexItem, Job } from "../types";
|
import type { IndexItem, Job } from "../types";
|
||||||
import { seqtaFetchPayload } from "../api";
|
import { seqtaFetchPayload } from "../api";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseDebug } from '@/utils/verboseLog';
|
||||||
/**
|
/**
|
||||||
* Indexes report metadata from `/seqta/student/load/reports`.
|
* Indexes report metadata from `/seqta/student/load/reports`.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IndexItem, Job } from "../types";
|
import type { IndexItem, Job } from "../types";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseDebug } from '@/utils/verboseLog';
|
||||||
const fetchSubjects = async () => {
|
const fetchSubjects = async () => {
|
||||||
const res = await fetch(`${location.origin}/seqta/student/load/subjects`, {
|
const res = await fetch(`${location.origin}/seqta/student/load/subjects`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
pickId,
|
pickId,
|
||||||
pickTitle,
|
pickTitle,
|
||||||
} from "./extract";
|
} from "./extract";
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog";
|
import { verboseDebug } from "@/utils/verboseLog";
|
||||||
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
|
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
|
||||||
import { mergeDynamicItems } from "../utils/dynamicItems";
|
import { mergeDynamicItems } from "../utils/dynamicItems";
|
||||||
import { decorateIndexItems } from "./renderComponents";
|
import { decorateIndexItems } from "./renderComponents";
|
||||||
@@ -453,6 +453,28 @@ async function flushDynamicItems(): Promise<void> {
|
|||||||
/* fetch hook */
|
/* fetch hook */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function handleCapturedPayload(
|
||||||
|
route: string,
|
||||||
|
requestBody: unknown,
|
||||||
|
payload: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
const items = synthesizeItems(
|
||||||
|
{ route, requestBody, observedAt: Date.now() },
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
if (items.length > 0) {
|
||||||
|
await persistItems(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSeqtaPayload(json: unknown): unknown | null {
|
||||||
|
if (!json || typeof json !== "object") return null;
|
||||||
|
const body = json as { status?: string; payload?: unknown };
|
||||||
|
if (body.status && body.status !== "200") return null;
|
||||||
|
if (body.payload === undefined || body.payload === null) return null;
|
||||||
|
return body.payload;
|
||||||
|
}
|
||||||
|
|
||||||
async function consumeResponse(
|
async function consumeResponse(
|
||||||
response: Response,
|
response: Response,
|
||||||
url: string,
|
url: string,
|
||||||
@@ -462,35 +484,18 @@ async function consumeResponse(
|
|||||||
|
|
||||||
const route = normalizeSeqtaPath(url);
|
const route = normalizeSeqtaPath(url);
|
||||||
if (isSensitiveSeqtaPath(route)) return;
|
if (isSensitiveSeqtaPath(route)) return;
|
||||||
|
if (!looksLikeJsonContentType(response.headers.get("content-type"))) return;
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type");
|
let body: unknown;
|
||||||
if (!looksLikeJsonContentType(contentType)) return;
|
|
||||||
|
|
||||||
let body: any;
|
|
||||||
try {
|
try {
|
||||||
body = await response.clone().json();
|
body = await response.clone().json();
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!body || typeof body !== "object") return;
|
const payload = parseSeqtaPayload(body);
|
||||||
if (body.status && body.status !== "200") return;
|
if (payload === null) return;
|
||||||
|
await handleCapturedPayload(route, requestBody, payload);
|
||||||
const payload = body.payload;
|
|
||||||
if (payload === undefined || payload === null) return;
|
|
||||||
|
|
||||||
const items = synthesizeItems(
|
|
||||||
{
|
|
||||||
route,
|
|
||||||
requestBody,
|
|
||||||
observedAt: Date.now(),
|
|
||||||
},
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (items.length > 0) {
|
|
||||||
await persistItems(items);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryParseJson(value: unknown): unknown {
|
function tryParseJson(value: unknown): unknown {
|
||||||
@@ -578,31 +583,20 @@ export function installPassiveObserver(): void {
|
|||||||
this.addEventListener("load", () => {
|
this.addEventListener("load", () => {
|
||||||
try {
|
try {
|
||||||
if (this.status < 200 || this.status >= 300) return;
|
if (this.status < 200 || this.status >= 300) return;
|
||||||
const ct = this.getResponseHeader("content-type");
|
if (!looksLikeJsonContentType(this.getResponseHeader("content-type"))) {
|
||||||
if (!looksLikeJsonContentType(ct)) return;
|
return;
|
||||||
|
}
|
||||||
const route = normalizeSeqtaPath(url);
|
const route = normalizeSeqtaPath(url);
|
||||||
if (isSensitiveSeqtaPath(route)) return;
|
if (isSensitiveSeqtaPath(route)) return;
|
||||||
let json: any;
|
let json: unknown;
|
||||||
try {
|
try {
|
||||||
json = JSON.parse(this.responseText);
|
json = JSON.parse(this.responseText);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!json || typeof json !== "object") return;
|
const payload = parseSeqtaPayload(json);
|
||||||
if (json.status && json.status !== "200") return;
|
if (payload === null) return;
|
||||||
const payload = json.payload;
|
void handleCapturedPayload(route, parsed, payload);
|
||||||
if (payload === undefined || payload === null) return;
|
|
||||||
const items = synthesizeItems(
|
|
||||||
{
|
|
||||||
route,
|
|
||||||
requestBody: parsed,
|
|
||||||
observedAt: Date.now(),
|
|
||||||
},
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
if (items.length > 0) {
|
|
||||||
void persistItems(items);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
verboseDebug("[Passive Observer] xhr load error:", e);
|
verboseDebug("[Passive Observer] xhr load error:", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SubjectItem from "../components/items/SubjectItem.svelte";
|
|||||||
import GenericItem from "../components/items/GenericItem.svelte";
|
import GenericItem from "../components/items/GenericItem.svelte";
|
||||||
import type { IndexItem } from "./types";
|
import type { IndexItem } from "./types";
|
||||||
import { jobs } from "./jobs";
|
import { jobs } from "./jobs";
|
||||||
|
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||||
|
|
||||||
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
||||||
assessment: AssessmentItem as unknown as typeof SvelteComponent,
|
assessment: AssessmentItem as unknown as typeof SvelteComponent,
|
||||||
@@ -58,3 +59,21 @@ export function decorateIndexItems(items: IndexItem[]): IndexItem[] {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function publishDynamicItemsUpdate(
|
||||||
|
items: IndexItem[],
|
||||||
|
jobId: string,
|
||||||
|
newItemCount: number,
|
||||||
|
): void {
|
||||||
|
loadDynamicItems(decorateIndexItems(items));
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("dynamic-items-updated", {
|
||||||
|
detail: {
|
||||||
|
incremental: true,
|
||||||
|
jobId,
|
||||||
|
newItemCount,
|
||||||
|
streaming: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export const RESET_INDEX_MESSAGE = "global-search-reset-index";
|
|||||||
|
|
||||||
let resetMessageListenerInstalled = false;
|
let resetMessageListenerInstalled = false;
|
||||||
|
|
||||||
/** Notify open SEQTA tabs to pause indexing and wipe page-origin stores. */
|
|
||||||
export async function notifyOpenTabsResetSearchIndex(): Promise<void> {
|
export async function notifyOpenTabsResetSearchIndex(): Promise<void> {
|
||||||
const tabs = await browser.tabs.query({});
|
const tabs = await browser.tabs.query({});
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
@@ -19,7 +18,6 @@ export async function notifyOpenTabsResetSearchIndex(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Content scripts: handle reset broadcast from the settings popup. */
|
|
||||||
export function installResetIndexMessageListener(): void {
|
export function installResetIndexMessageListener(): void {
|
||||||
if (resetMessageListenerInstalled) return;
|
if (resetMessageListenerInstalled) return;
|
||||||
resetMessageListenerInstalled = true;
|
resetMessageListenerInstalled = true;
|
||||||
@@ -44,36 +42,6 @@ export function installResetIndexMessageListener(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hard-reset of all global-search persistence.
|
|
||||||
*
|
|
||||||
* This module is intentionally dependency-free (no imports from `db.ts`,
|
|
||||||
* the worker manager, embeddia, or any heavy bundle) so it can be
|
|
||||||
* statically imported from:
|
|
||||||
*
|
|
||||||
* - The always-loaded plugin shell (`lazy.ts`) for the manual
|
|
||||||
* "Reset Index" settings button. Statically importing means the button
|
|
||||||
* keeps working across extension updates — there's no chunk hash to
|
|
||||||
* chase via dynamic import, which previously produced
|
|
||||||
* `Failed to fetch dynamically imported module: .../assets/<chunk>.js`
|
|
||||||
* when an older settings page tried to load a chunk that the new build
|
|
||||||
* had already replaced.
|
|
||||||
*
|
|
||||||
* - The version-check path (`utils/versionCheck.ts`) for the auto-reset
|
|
||||||
* that fires whenever the extension's manifest version changes.
|
|
||||||
*
|
|
||||||
* The function:
|
|
||||||
* 1. Notifies in-process modules to drop in-memory caches and any open
|
|
||||||
* IndexedDB connections via custom DOM events (best effort).
|
|
||||||
* 2. Deletes the structured `betterseqta-index` and the vector
|
|
||||||
* `embeddiaDB` databases.
|
|
||||||
* 3. Clears version-tracking localStorage keys so the next indexing
|
|
||||||
* pass treats the world as fresh.
|
|
||||||
*
|
|
||||||
* It never throws on partial failure: each step is wrapped in try/catch
|
|
||||||
* so a stuck connection on one DB doesn't block the other.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const STRUCTURED_DB = "betterseqta-index";
|
const STRUCTURED_DB = "betterseqta-index";
|
||||||
const VECTOR_DB = "embeddiaDB";
|
const VECTOR_DB = "embeddiaDB";
|
||||||
const STRUCTURED_VERSION_KEY = "betterseqta-index-version";
|
const STRUCTURED_VERSION_KEY = "betterseqta-index-version";
|
||||||
@@ -137,11 +105,9 @@ export async function resetSearchIndexes(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore — events are best-effort */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give listeners a tick to close any open IDB connections; otherwise
|
|
||||||
// the delete request below comes back with `onblocked`.
|
|
||||||
await delay(300);
|
await delay(300);
|
||||||
|
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
pickId,
|
pickId,
|
||||||
buildIndexItem,
|
buildIndexItem,
|
||||||
} from "./extract";
|
} from "./extract";
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog";
|
|
||||||
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
|
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
|
||||||
import {
|
import {
|
||||||
coursesPayload,
|
coursesPayload,
|
||||||
@@ -318,10 +317,6 @@ export async function runGlobalSearchSelfTests(): Promise<SelfTestReport> {
|
|||||||
`[Global Search Self-Tests] ${report.failed} failed / ${report.passed} passed`,
|
`[Global Search Self-Tests] ${report.failed} failed / ${report.passed} passed`,
|
||||||
report.failures,
|
report.failures,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
verboseInfo(
|
|
||||||
`[Global Search Self-Tests] All ${report.passed} cases passed`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,112 +1,82 @@
|
|||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseDebug } from '@/utils/verboseLog';
|
||||||
/**
|
|
||||||
* Check which items are already vectorized in embeddia's IndexedDB
|
|
||||||
* Returns a Set of item IDs that are already indexed
|
|
||||||
*/
|
|
||||||
export async function getVectorizedItemIds(): Promise<Set<string>> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const request = indexedDB.open("embeddiaDB");
|
|
||||||
|
|
||||||
request.onerror = () => {
|
|
||||||
verboseDebug("Could not open embeddiaDB, assuming no items are vectorized");
|
|
||||||
resolve(new Set());
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onsuccess = (event) => {
|
|
||||||
const db = (event.target as IDBOpenDBRequest).result;
|
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains("embeddiaObjectStore")) {
|
|
||||||
verboseDebug("embeddiaObjectStore not found, assuming no items are vectorized");
|
|
||||||
db.close();
|
|
||||||
resolve(new Set());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const transaction = db.transaction(["embeddiaObjectStore"], "readonly");
|
|
||||||
const store = transaction.objectStore("embeddiaObjectStore");
|
|
||||||
const getAllRequest = store.getAllKeys();
|
|
||||||
|
|
||||||
getAllRequest.onsuccess = () => {
|
|
||||||
const vectorizedIds = new Set<string>();
|
|
||||||
getAllRequest.result.forEach(key => {
|
|
||||||
if (typeof key === 'string') {
|
|
||||||
vectorizedIds.add(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
verboseDebug(`Found ${vectorizedIds.size} already vectorized items in embeddia DB`);
|
|
||||||
db.close();
|
|
||||||
resolve(vectorizedIds);
|
|
||||||
};
|
|
||||||
|
|
||||||
getAllRequest.onerror = () => {
|
|
||||||
console.warn("Error reading vectorized item keys, assuming no items are vectorized");
|
|
||||||
db.close();
|
|
||||||
resolve(new Set());
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Error accessing embeddia store, assuming no items are vectorized:", error);
|
|
||||||
db.close();
|
|
||||||
resolve(new Set());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMBEDDIA_DB = "embeddiaDB";
|
const EMBEDDIA_DB = "embeddiaDB";
|
||||||
const EMBEDDIA_STORE = "embeddiaObjectStore";
|
const EMBEDDIA_STORE = "embeddiaObjectStore";
|
||||||
|
|
||||||
/**
|
function openEmbeddiaDb(): Promise<IDBDatabase | null> {
|
||||||
* Remove vector embeddings for the given item ids from embeddiaDB.
|
return new Promise((resolve) => {
|
||||||
*/
|
const request = indexedDB.open(EMBEDDIA_DB);
|
||||||
|
request.onerror = () => resolve(null);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVectorizedItemIds(): Promise<Set<string>> {
|
||||||
|
const db = await openEmbeddiaDb();
|
||||||
|
if (!db) {
|
||||||
|
verboseDebug("Could not open embeddiaDB, assuming no items are vectorized");
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
|
||||||
|
verboseDebug("embeddiaObjectStore not found, assuming no items are vectorized");
|
||||||
|
db.close();
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = db
|
||||||
|
.transaction([EMBEDDIA_STORE], "readonly")
|
||||||
|
.objectStore(EMBEDDIA_STORE);
|
||||||
|
const keys = await new Promise<IDBValidKey[]>((resolve, reject) => {
|
||||||
|
const req = store.getAllKeys();
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const vectorizedIds = new Set<string>();
|
||||||
|
for (const key of keys) {
|
||||||
|
if (typeof key === "string") vectorizedIds.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
verboseDebug(`Found ${vectorizedIds.size} already vectorized items in embeddia DB`);
|
||||||
|
db.close();
|
||||||
|
return vectorizedIds;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Error accessing embeddia store, assuming no items are vectorized:", error);
|
||||||
|
db.close();
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function removeVectorEmbeddings(ids: string[]): Promise<void> {
|
export async function removeVectorEmbeddings(ids: string[]): Promise<void> {
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
const db = await openEmbeddiaDb();
|
||||||
const request = indexedDB.open(EMBEDDIA_DB);
|
if (!db) return;
|
||||||
|
|
||||||
request.onerror = () => resolve();
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
const db = request.result;
|
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
|
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
|
||||||
db.close();
|
db.close();
|
||||||
resolve();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const transaction = db.transaction([EMBEDDIA_STORE], "readwrite");
|
const tx = db.transaction([EMBEDDIA_STORE], "readwrite");
|
||||||
const store = transaction.objectStore(EMBEDDIA_STORE);
|
const store = tx.objectStore(EMBEDDIA_STORE);
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
store.delete(id);
|
store.delete(id);
|
||||||
}
|
}
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
transaction.oncomplete = () => {
|
tx.oncomplete = () => resolve();
|
||||||
db.close();
|
tx.onerror = () => resolve();
|
||||||
resolve();
|
});
|
||||||
};
|
|
||||||
|
|
||||||
transaction.onerror = () => {
|
|
||||||
db.close();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Indexer] Failed to remove vector embeddings:", error);
|
console.warn("[Indexer] Failed to remove vector embeddings:", error);
|
||||||
|
} finally {
|
||||||
db.close();
|
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(
|
export async function pruneOrphanVectorEmbeddings(
|
||||||
liveItemIds: Set<string>,
|
liveItemIds: Set<string>,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
|
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
|
||||||
import type { IndexItem } from "../types";
|
import type { IndexItem } from "../types";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from "./workerVerboseLog";
|
import { verboseDebug } from "./workerVerboseLog";
|
||||||
|
|
||||||
let ortWasmBase: string | null = null;
|
let ortWasmBase: string | null = null;
|
||||||
|
|
||||||
@@ -16,20 +16,26 @@ let initializationFailed = false;
|
|||||||
let currentAbortController: AbortController | null = null;
|
let currentAbortController: AbortController | null = null;
|
||||||
let loadedItemIds = new Set<string>();
|
let loadedItemIds = new Set<string>();
|
||||||
|
|
||||||
// Detect Firefox in worker context
|
|
||||||
function isFirefoxWorker(): boolean {
|
function isFirefoxWorker(): boolean {
|
||||||
try {
|
try {
|
||||||
// Check for Firefox-specific APIs or user agent
|
return typeof navigator !== "undefined" &&
|
||||||
if (typeof navigator !== "undefined") {
|
navigator.userAgent.toLowerCase().includes("firefox");
|
||||||
return navigator.userAgent.toLowerCase().includes("firefox");
|
|
||||||
}
|
|
||||||
// In worker context, check for Firefox-specific behavior
|
|
||||||
return false;
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function postVectorUnavailable(message: string): void {
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: { status: "complete", message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function vectorUnavailable(): boolean {
|
||||||
|
return initializationFailed || isFirefoxWorker();
|
||||||
|
}
|
||||||
|
|
||||||
let streamingSession: {
|
let streamingSession: {
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
totalExpected: number;
|
totalExpected: number;
|
||||||
@@ -118,14 +124,10 @@ async function startStreamingSession(
|
|||||||
totalExpected: number,
|
totalExpected: number,
|
||||||
batchSize: number = 5,
|
batchSize: number = 5,
|
||||||
) {
|
) {
|
||||||
if (initializationFailed || isFirefoxWorker()) {
|
if (vectorUnavailable()) {
|
||||||
self.postMessage({
|
postVectorUnavailable(
|
||||||
type: "progress",
|
"Vector search not available in Firefox - using text search only",
|
||||||
data: {
|
);
|
||||||
status: "complete",
|
|
||||||
message: "Vector search not available in Firefox - using text search only",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,14 +137,7 @@ async function startStreamingSession(
|
|||||||
);
|
);
|
||||||
await initWorker();
|
await initWorker();
|
||||||
if (!vectorIndex || initializationFailed) {
|
if (!vectorIndex || initializationFailed) {
|
||||||
self.postMessage({
|
postVectorUnavailable("Vector index not available - using text search only");
|
||||||
type: "progress",
|
|
||||||
data: {
|
|
||||||
status: "complete",
|
|
||||||
message:
|
|
||||||
"Vector index not available - using text search only",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,14 +350,8 @@ async function endStreamingSession() {
|
|||||||
async function processItems(items: IndexItem[], signal: AbortSignal) {
|
async function processItems(items: IndexItem[], signal: AbortSignal) {
|
||||||
verboseDebug("Worker received process request.");
|
verboseDebug("Worker received process request.");
|
||||||
|
|
||||||
if (initializationFailed || isFirefoxWorker()) {
|
if (vectorUnavailable()) {
|
||||||
self.postMessage({
|
postVectorUnavailable("Vector search not available - using text search only");
|
||||||
type: "progress",
|
|
||||||
data: {
|
|
||||||
status: "complete",
|
|
||||||
message: "Vector search not available - using text search only",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,14 +361,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) {
|
|||||||
);
|
);
|
||||||
await initWorker();
|
await initWorker();
|
||||||
if (!vectorIndex || initializationFailed) {
|
if (!vectorIndex || initializationFailed) {
|
||||||
self.postMessage({
|
postVectorUnavailable("Vector index not available - using text search only");
|
||||||
type: "progress",
|
|
||||||
data: {
|
|
||||||
status: "complete",
|
|
||||||
message:
|
|
||||||
"Vector index not available - using text search only",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { isVectorSearchSupported } from "../../utils/browserDetection";
|
|||||||
import { getOrtWasmBaseUrl } from "@/lib/transformersExtension";
|
import { getOrtWasmBaseUrl } from "@/lib/transformersExtension";
|
||||||
import vectorWorker from "./vectorWorker.ts?inlineWorker";
|
import vectorWorker from "./vectorWorker.ts?inlineWorker";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
import { verboseDebug, verboseLog } from '@/utils/verboseLog';
|
||||||
export type ProgressCallback = (data: {
|
export type ProgressCallback = (data: {
|
||||||
status: "started" | "processing" | "complete" | "error" | "cancelled";
|
status: "started" | "processing" | "complete" | "error" | "cancelled";
|
||||||
total?: number;
|
total?: number;
|
||||||
|
|||||||
@@ -3,11 +3,3 @@
|
|||||||
export function verboseDebug(...args: unknown[]): void {
|
export function verboseDebug(...args: unknown[]): void {
|
||||||
if (typeof console !== "undefined") console.debug(...args);
|
if (typeof console !== "undefined") console.debug(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verboseInfo(...args: unknown[]): void {
|
|
||||||
if (typeof console !== "undefined") console.info(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verboseLog(...args: unknown[]): void {
|
|
||||||
if (typeof console !== "undefined") console.log(...args);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ function toFiniteNumber(value: unknown): number | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same SPA destination as handlers for `course` / `subjectcourse` / passive `courses`. */
|
|
||||||
function shouldDedupeAsSameCourseSPA(item: IndexItem): boolean {
|
function shouldDedupeAsSameCourseSPA(item: IndexItem): boolean {
|
||||||
if (item.actionId === "subjectassessment") return false;
|
if (item.actionId === "subjectassessment") return false;
|
||||||
if (item.metadata?.type === "assessments") return false;
|
if (item.metadata?.type === "assessments") return false;
|
||||||
@@ -90,7 +89,6 @@ function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem {
|
|||||||
const bP = isPassiveLike(b);
|
const bP = isPassiveLike(b);
|
||||||
if (aP && !bP) return b;
|
if (aP && !bP) return b;
|
||||||
if (!aP && bP) return a;
|
if (!aP && bP) return a;
|
||||||
// Prefer curated job row (courses store) vs other categories
|
|
||||||
if (a.category === "courses" && b.category !== "courses") return a;
|
if (a.category === "courses" && b.category !== "courses") return a;
|
||||||
if (b.category === "courses" && a.category !== "courses") return b;
|
if (b.category === "courses" && a.category !== "courses") return b;
|
||||||
if (a.renderComponentId === "course" && b.renderComponentId !== "course")
|
if (a.renderComponentId === "course" && b.renderComponentId !== "course")
|
||||||
@@ -107,15 +105,12 @@ function pickBetterAssessmentDuplicate(a: IndexItem, b: IndexItem): IndexItem {
|
|||||||
const bP = isPassiveLike(b);
|
const bP = isPassiveLike(b);
|
||||||
if (aP && !bP) return b;
|
if (aP && !bP) return b;
|
||||||
if (!aP && bP) return a;
|
if (!aP && bP) return a;
|
||||||
|
|
||||||
if (a.category === "assignments" && b.category !== "assignments") return a;
|
if (a.category === "assignments" && b.category !== "assignments") return a;
|
||||||
if (b.category === "assignments" && a.category !== "assignments") return b;
|
if (b.category === "assignments" && a.category !== "assignments") return b;
|
||||||
|
|
||||||
const aPm = hasProgrammeMetaclass(a);
|
const aPm = hasProgrammeMetaclass(a);
|
||||||
const bPm = hasProgrammeMetaclass(b);
|
const bPm = hasProgrammeMetaclass(b);
|
||||||
if (aPm && !bPm) return a;
|
if (aPm && !bPm) return a;
|
||||||
if (!aPm && bPm) return b;
|
if (!aPm && bPm) return b;
|
||||||
|
|
||||||
const ad = typeof a.dateAdded === "number" ? a.dateAdded : 0;
|
const ad = typeof a.dateAdded === "number" ? a.dateAdded : 0;
|
||||||
const bd = typeof b.dateAdded === "number" ? b.dateAdded : 0;
|
const bd = typeof b.dateAdded === "number" ? b.dateAdded : 0;
|
||||||
return ad >= bd ? a : b;
|
return ad >= bd ? a : b;
|
||||||
@@ -126,35 +121,30 @@ function pickBetterSearchDuplicate(
|
|||||||
b: IndexItem,
|
b: IndexItem,
|
||||||
key: string,
|
key: string,
|
||||||
): IndexItem {
|
): IndexItem {
|
||||||
if (key.startsWith("assessment:")) {
|
return key.startsWith("assessment:")
|
||||||
return pickBetterAssessmentDuplicate(a, b);
|
? pickBetterAssessmentDuplicate(a, b)
|
||||||
}
|
: pickBetterCourseNavDuplicate(a, b);
|
||||||
return pickBetterCourseNavDuplicate(a, b);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function dedupeByCanonicalKey<T>(
|
||||||
* Collapses multiple index rows that open the same course or assessment hash
|
items: T[],
|
||||||
* route (e.g. `course` job + passive `/load/courses`, or assignments job +
|
getKey: (item: T) => string | undefined,
|
||||||
* passive `/assessment/list/past`) so search shows one hit.
|
pickWinner: (a: T, b: T, key: string) => T,
|
||||||
*/
|
): T[] {
|
||||||
export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
|
const winners = new Map<string, T>();
|
||||||
const winners = new Map<string, IndexItem>();
|
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const key = searchDedupeKey(item);
|
const key = getKey(item);
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
const prev = winners.get(key);
|
const prev = winners.get(key);
|
||||||
winners.set(
|
winners.set(key, prev ? pickWinner(prev, item, key) : item);
|
||||||
key,
|
|
||||||
prev ? pickBetterSearchDuplicate(prev, item, key) : item,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const seenCanon = new Set<string>();
|
const seenCanon = new Set<string>();
|
||||||
const out: IndexItem[] = [];
|
const out: T[] = [];
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const key = searchDedupeKey(item);
|
const key = getKey(item);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
out.push(item);
|
out.push(item);
|
||||||
continue;
|
continue;
|
||||||
@@ -167,15 +157,15 @@ export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
|
||||||
|
return dedupeByCanonicalKey(items, searchDedupeKey, pickBetterSearchDuplicate);
|
||||||
|
}
|
||||||
|
|
||||||
function dynamicSearchKey(row: CombinedResult): string | undefined {
|
function dynamicSearchKey(row: CombinedResult): string | undefined {
|
||||||
if (row.type !== "dynamic") return undefined;
|
if (row.type !== "dynamic") return undefined;
|
||||||
return searchDedupeKey(row.item as IndexItem);
|
return searchDedupeKey(row.item as IndexItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Final pass after hybrid expansion: vector-only recall can still surface a
|
|
||||||
* second row for the same SPA route using a stale passive id.
|
|
||||||
*/
|
|
||||||
export function dedupeCombinedResultsByCourseNav(
|
export function dedupeCombinedResultsByCourseNav(
|
||||||
results: CombinedResult[],
|
results: CombinedResult[],
|
||||||
): CombinedResult[] {
|
): CombinedResult[] {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
isStrongLexicalMatch,
|
isStrongLexicalMatch,
|
||||||
STRONG_LEXICAL_THRESHOLD,
|
STRONG_LEXICAL_THRESHOLD,
|
||||||
} from "./lexicalMatch";
|
} from "./lexicalMatch";
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog";
|
import { verboseDebug } from "@/utils/verboseLog";
|
||||||
|
|
||||||
/** Same normalization as lexical matching (trim + lowercase). */
|
/** Same normalization as lexical matching (trim + lowercase). */
|
||||||
function normSearchKey(s: string): string {
|
function normSearchKey(s: string): string {
|
||||||
@@ -63,26 +63,19 @@ function syntheticIndexFromCommand(cmd: StaticCommandItem): IndexItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search result cache for better performance
|
|
||||||
const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>();
|
const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>();
|
||||||
const CACHE_TTL = 1000 * 60 * 5; // 5 minutes
|
const CACHE_TTL = 1000 * 60 * 5;
|
||||||
const MAX_CACHE_SIZE = 100;
|
const MAX_CACHE_SIZE = 100;
|
||||||
|
|
||||||
function getCachedResults(query: string): CombinedResult[] | null {
|
function getCachedResults(query: string): CombinedResult[] | null {
|
||||||
const cached = searchCache.get(query);
|
const cached = searchCache.get(query);
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
return cached && Date.now() - cached.timestamp < CACHE_TTL ? cached.results : null;
|
||||||
return cached.results;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCachedResults(query: string, results: CombinedResult[]) {
|
function setCachedResults(query: string, results: CombinedResult[]) {
|
||||||
// Limit cache size
|
|
||||||
if (searchCache.size >= MAX_CACHE_SIZE) {
|
if (searchCache.size >= MAX_CACHE_SIZE) {
|
||||||
const firstKey = searchCache.keys().next().value;
|
const firstKey = searchCache.keys().next().value;
|
||||||
if (firstKey !== undefined) {
|
if (firstKey !== undefined) searchCache.delete(firstKey);
|
||||||
searchCache.delete(firstKey);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
searchCache.set(query, { results, timestamp: Date.now() });
|
searchCache.set(query, { results, timestamp: Date.now() });
|
||||||
}
|
}
|
||||||
@@ -95,11 +88,8 @@ export function clearSearchCache(): void {
|
|||||||
verboseDebug("[Search] Search result cache cleared");
|
verboseDebug("[Search] Search result cache cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for cache clear events (e.g., on extension update)
|
if (typeof window !== "undefined") {
|
||||||
if (typeof window !== 'undefined') {
|
window.addEventListener("betterseqta-clear-search-cache", clearSearchCache);
|
||||||
window.addEventListener('betterseqta-clear-search-cache', () => {
|
|
||||||
clearSearchCache();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rebuild Fuse when incremental delta exceeds this count. */
|
/** Rebuild Fuse when incremental delta exceeds this count. */
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import type { IndexItem } from "../../indexing/types";
|
|||||||
import type { SearchResult } from "embeddia";
|
import type { SearchResult } from "embeddia";
|
||||||
import { isVectorSearchSupported } from "../../utils/browserDetection";
|
import { isVectorSearchSupported } from "../../utils/browserDetection";
|
||||||
import { ensureTransformersEnv } from "@/lib/transformersExtension";
|
import { ensureTransformersEnv } from "@/lib/transformersExtension";
|
||||||
|
import { verboseDebug } from "@/utils/verboseLog";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
|
||||||
let vectorIndex: EmbeddingIndex | null = null;
|
let vectorIndex: EmbeddingIndex | null = null;
|
||||||
let initializationAttempted = false;
|
let initializationAttempted = false;
|
||||||
let initializationFailed = false;
|
let initializationFailed = false;
|
||||||
|
|
||||||
export async function initVectorSearch() {
|
export async function initVectorSearch() {
|
||||||
// Skip initialization if already attempted and failed, or if not supported
|
|
||||||
if (initializationFailed || !isVectorSearchSupported()) {
|
if (initializationFailed || !isVectorSearchSupported()) {
|
||||||
if (!isVectorSearchSupported()) {
|
if (!isVectorSearchSupported()) {
|
||||||
verboseDebug("[Vector Search] Vector search not supported in Firefox - using text search only");
|
verboseDebug("[Vector Search] Vector search not supported in Firefox - using text search only");
|
||||||
@@ -18,9 +17,7 @@ export async function initVectorSearch() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initializationAttempted) {
|
if (initializationAttempted) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
initializationAttempted = true;
|
initializationAttempted = true;
|
||||||
|
|
||||||
@@ -41,65 +38,39 @@ export interface VectorSearchResult extends SearchResult {
|
|||||||
object: IndexItem & { embedding: number[] };
|
object: IndexItem & { embedding: number[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for query embeddings to avoid recomputing
|
|
||||||
const embeddingCache = new Map<string, number[]>();
|
const embeddingCache = new Map<string, number[]>();
|
||||||
const MAX_EMBEDDING_CACHE_SIZE = 50;
|
const MAX_EMBEDDING_CACHE_SIZE = 50;
|
||||||
|
|
||||||
function getCachedEmbedding(query: string): number[] | null {
|
|
||||||
const cached = embeddingCache.get(query);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCachedEmbedding(query: string, embedding: number[]) {
|
function setCachedEmbedding(query: string, embedding: number[]) {
|
||||||
// Limit cache size
|
|
||||||
if (embeddingCache.size >= MAX_EMBEDDING_CACHE_SIZE) {
|
if (embeddingCache.size >= MAX_EMBEDDING_CACHE_SIZE) {
|
||||||
const firstKey = embeddingCache.keys().next().value;
|
const firstKey = embeddingCache.keys().next().value;
|
||||||
if (firstKey !== undefined) {
|
if (firstKey !== undefined) embeddingCache.delete(firstKey);
|
||||||
embeddingCache.delete(firstKey);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
embeddingCache.set(query, embedding);
|
embeddingCache.set(query, embedding);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the embedding cache
|
|
||||||
*/
|
|
||||||
export function clearEmbeddingCache(): void {
|
export function clearEmbeddingCache(): void {
|
||||||
embeddingCache.clear();
|
embeddingCache.clear();
|
||||||
verboseDebug("[Vector Search] Embedding cache cleared");
|
verboseDebug("[Vector Search] Embedding cache cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for cache clear events (e.g., on extension update)
|
if (typeof window !== "undefined") {
|
||||||
if (typeof window !== 'undefined') {
|
window.addEventListener("betterseqta-clear-embedding-cache", clearEmbeddingCache);
|
||||||
window.addEventListener('betterseqta-clear-embedding-cache', () => {
|
|
||||||
clearEmbeddingCache();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchVectors(
|
export async function searchVectors(
|
||||||
query: string,
|
query: string,
|
||||||
topK: number = 20,
|
topK: number = 20,
|
||||||
): Promise<VectorSearchResult[]> {
|
): Promise<VectorSearchResult[]> {
|
||||||
// Return empty array if vector search is not supported or failed to initialize
|
if (!isVectorSearchSupported() || initializationFailed) return [];
|
||||||
if (!isVectorSearchSupported() || initializationFailed) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!vectorIndex) {
|
if (!vectorIndex) {
|
||||||
await initVectorSearch();
|
await initVectorSearch();
|
||||||
if (!vectorIndex) {
|
if (!vectorIndex) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize query for caching
|
|
||||||
const normalizedQuery = query.trim().toLowerCase().slice(0, 100);
|
const normalizedQuery = query.trim().toLowerCase().slice(0, 100);
|
||||||
|
let queryEmbedding = embeddingCache.get(normalizedQuery);
|
||||||
// Check cache first
|
|
||||||
let queryEmbedding = getCachedEmbedding(normalizedQuery);
|
|
||||||
|
|
||||||
if (!queryEmbedding) {
|
if (!queryEmbedding) {
|
||||||
try {
|
try {
|
||||||
@@ -113,19 +84,15 @@ export async function searchVectors(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await vectorIndex!.search(queryEmbedding, {
|
const results = await vectorIndex!.search(queryEmbedding, {
|
||||||
topK: Math.min(topK * 2, 30), // Get more results, filter later
|
topK: Math.min(topK * 2, 30),
|
||||||
useStorage: "indexedDB",
|
useStorage: "indexedDB",
|
||||||
dedupeEntries: true,
|
dedupeEntries: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter results with a similarity below 0.80 (slightly more permissive)
|
return results
|
||||||
// and sort by similarity descending
|
|
||||||
const filteredResults = results
|
|
||||||
.filter((r) => r.similarity > 0.80)
|
.filter((r) => r.similarity > 0.80)
|
||||||
.sort((a, b) => b.similarity - a.similarity)
|
.sort((a, b) => b.similarity - a.similarity)
|
||||||
.slice(0, topK);
|
.slice(0, topK) as VectorSearchResult[];
|
||||||
|
|
||||||
return filteredResults as VectorSearchResult[];
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[Vector Search] Search failed:", e);
|
console.warn("[Vector Search] Search failed:", e);
|
||||||
return [];
|
return [];
|
||||||
@@ -133,13 +100,9 @@ export async function searchVectors(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshVectorCache() {
|
export async function refreshVectorCache() {
|
||||||
if (!isVectorSearchSupported() || initializationFailed) {
|
if (!isVectorSearchSupported() || initializationFailed) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!vectorIndex) {
|
if (!vectorIndex) await initVectorSearch();
|
||||||
await initVectorSearch();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vectorIndex) {
|
if (vectorIndex) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,11 +7,10 @@
|
|||||||
matches?: readonly FuseResultMatch[];
|
matches?: readonly FuseResultMatch[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const segments = $derived(getSegments(text, term, matches));
|
const segments = $derived(buildSegments(text, term, matches));
|
||||||
|
|
||||||
// Build highlight map (copied and adapted from highlightMatch)
|
function buildSegments(text: string, term: string, matches = undefined) {
|
||||||
function getSegments(text: string, term: string, matches = undefined) {
|
if (!term.trim() || !matches?.length) return [{ text, highlight: false }];
|
||||||
if (!term.trim() || !matches || matches.length === 0) return [{ text, highlight: false }];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fieldMatches = matches.find(
|
const fieldMatches = matches.find(
|
||||||
@@ -19,39 +18,29 @@
|
|||||||
match.key === 'text' ||
|
match.key === 'text' ||
|
||||||
(match.key === 'allContent' && match.value?.includes(text)),
|
(match.key === 'allContent' && match.value?.includes(text)),
|
||||||
);
|
);
|
||||||
if (!fieldMatches || !fieldMatches.indices || fieldMatches.indices.length === 0) {
|
if (!fieldMatches?.indices?.length) return [{ text, highlight: false }];
|
||||||
return [{ text, highlight: false }];
|
|
||||||
}
|
const highlightMap = new Array<boolean>(text.length).fill(false);
|
||||||
const highlightMap = new Array(text.length).fill(false);
|
for (const [start, end] of fieldMatches.indices) {
|
||||||
fieldMatches.indices.forEach((indices) => {
|
|
||||||
const start = indices[0];
|
|
||||||
const end = indices[1];
|
|
||||||
if (fieldMatches.key === 'allContent') {
|
if (fieldMatches.key === 'allContent') {
|
||||||
const allContent = fieldMatches.value;
|
const textPos = fieldMatches.value?.indexOf(text) ?? -1;
|
||||||
const textPos = allContent?.indexOf(text) ?? -1;
|
if (textPos < 0) continue;
|
||||||
if (textPos >= 0) {
|
|
||||||
const relStart = start - textPos;
|
const relStart = start - textPos;
|
||||||
const relEnd = end - textPos;
|
const relEnd = end - textPos;
|
||||||
if (relEnd >= 0 && relStart < text.length) {
|
if (relEnd < 0 || relStart >= text.length) continue;
|
||||||
for (let i = Math.max(0, relStart); i <= Math.min(text.length - 1, relEnd); i++) {
|
for (let i = Math.max(0, relStart); i <= Math.min(text.length - 1, relEnd); i++) {
|
||||||
highlightMap[i] = true;
|
highlightMap[i] = true;
|
||||||
}
|
}
|
||||||
|
} else if (start >= 0 && end < text.length) {
|
||||||
|
for (let i = start; i <= end; i++) highlightMap[i] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if (start >= 0 && end < text.length) {
|
|
||||||
for (let i = start; i <= end; i++) {
|
|
||||||
highlightMap[i] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Build segments
|
|
||||||
const segments: { text: string; highlight: boolean }[] = [];
|
const segments: { text: string; highlight: boolean }[] = [];
|
||||||
let current = '';
|
let current = '';
|
||||||
let currentHighlight = highlightMap[0] || false;
|
let currentHighlight = highlightMap[0] ?? false;
|
||||||
for (let i = 0; i < text.length; i++) {
|
for (let i = 0; i < text.length; i++) {
|
||||||
const isHighlight = highlightMap[i] || false;
|
const isHighlight = highlightMap[i] ?? false;
|
||||||
if (isHighlight !== currentHighlight) {
|
if (isHighlight !== currentHighlight) {
|
||||||
segments.push({ text: current, highlight: currentHighlight });
|
segments.push({ text: current, highlight: currentHighlight });
|
||||||
current = '';
|
current = '';
|
||||||
@@ -59,18 +48,16 @@
|
|||||||
}
|
}
|
||||||
current += text[i];
|
current += text[i];
|
||||||
}
|
}
|
||||||
if (current) {
|
if (current) segments.push({ text: current, highlight: currentHighlight });
|
||||||
segments.push({ text: current, highlight: currentHighlight });
|
|
||||||
}
|
|
||||||
return segments;
|
return segments;
|
||||||
} catch (e) {
|
} catch {
|
||||||
return [{ text, highlight: false }];
|
return [{ text, highlight: false }];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
{#each segments as segment}
|
{#each segments as segment, i (i)}
|
||||||
{#if segment.highlight}
|
{#if segment.highlight}
|
||||||
<span class="highlight">{segment.text}</span>
|
<span class="highlight">{segment.text}</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
export function getDefaultSearchHotkey(): string {
|
||||||
|
return navigator.platform.toUpperCase().includes("MAC") ? "cmd+k" : "ctrl+k";
|
||||||
|
}
|
||||||
|
|
||||||
export interface ParsedHotkey {
|
export interface ParsedHotkey {
|
||||||
ctrl: boolean;
|
ctrl: boolean;
|
||||||
meta: boolean;
|
meta: boolean;
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import { resetSearchIndexes } from "../indexing/resetIndexes";
|
import { resetSearchIndexes } from "../indexing/resetIndexes";
|
||||||
|
import { verboseDebug, verboseLog } from "@/utils/verboseLog";
|
||||||
|
|
||||||
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
|
|
||||||
const VERSION_STORAGE_KEY = "betterseqta-global-search-version";
|
const VERSION_STORAGE_KEY = "betterseqta-global-search-version";
|
||||||
const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version";
|
const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version";
|
||||||
|
|
||||||
/**
|
const isAssetLoadError = (e: unknown) => {
|
||||||
* Gets the current extension version from the manifest
|
const msg = (e as { message?: string })?.message ?? "";
|
||||||
*/
|
return msg.includes("preload CSS") || msg.includes("MIME type");
|
||||||
|
};
|
||||||
|
|
||||||
export function getCurrentVersion(): string {
|
export function getCurrentVersion(): string {
|
||||||
try {
|
try {
|
||||||
return browser.runtime.getManifest().version;
|
return browser.runtime.getManifest().version;
|
||||||
@@ -17,9 +19,6 @@ export function getCurrentVersion(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the last stored version from localStorage
|
|
||||||
*/
|
|
||||||
export function getStoredVersion(): string | null {
|
export function getStoredVersion(): string | null {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(VERSION_STORAGE_KEY);
|
return localStorage.getItem(VERSION_STORAGE_KEY);
|
||||||
@@ -29,9 +28,6 @@ export function getStoredVersion(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores the current version in localStorage
|
|
||||||
*/
|
|
||||||
export function storeVersion(version: string): void {
|
export function storeVersion(version: string): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(VERSION_STORAGE_KEY, version);
|
localStorage.setItem(VERSION_STORAGE_KEY, version);
|
||||||
@@ -43,34 +39,19 @@ export function storeVersion(version: string): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the extension has been updated and clears caches + resets the
|
* Checks if the extension has been updated and clears caches + resets the
|
||||||
* search index if needed.
|
* search index if needed. Returns true if an update was detected.
|
||||||
*
|
|
||||||
* The reset is intentionally aggressive: every manifest version bump
|
|
||||||
* triggers a full IndexedDB wipe so changes to indexer extraction logic,
|
|
||||||
* job sets, or item shape can never serve stale results from an older
|
|
||||||
* build. The next indexing pass will repopulate from scratch in the
|
|
||||||
* background. Re-population is bounded by the per-job rate limits in
|
|
||||||
* `api.ts` so it can't hammer SEQTA after an update.
|
|
||||||
*
|
|
||||||
* Returns true if an update was detected.
|
|
||||||
*/
|
*/
|
||||||
export async function checkAndHandleUpdate(): Promise<boolean> {
|
export async function checkAndHandleUpdate(): Promise<boolean> {
|
||||||
const currentVersion = getCurrentVersion();
|
const currentVersion = getCurrentVersion();
|
||||||
const storedVersion = getStoredVersion();
|
const storedVersion = getStoredVersion();
|
||||||
|
|
||||||
// First run: just remember the version, don't reset (the user likely
|
|
||||||
// just installed the extension; the index is already empty).
|
|
||||||
if (!storedVersion) {
|
if (!storedVersion) {
|
||||||
verboseDebug(
|
verboseDebug(`[Version Check] First run detected, storing version ${currentVersion}`);
|
||||||
`[Version Check] First run detected, storing version ${currentVersion}`,
|
|
||||||
);
|
|
||||||
storeVersion(currentVersion);
|
storeVersion(currentVersion);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storedVersion === currentVersion) {
|
if (storedVersion === currentVersion) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
verboseLog(
|
verboseLog(
|
||||||
`[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, resetting search index...`,
|
`[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, resetting search index...`,
|
||||||
@@ -80,51 +61,35 @@ export async function checkAndHandleUpdate(): Promise<boolean> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await resetSearchIndexes();
|
await resetSearchIndexes();
|
||||||
verboseLog(
|
verboseLog("[Version Check] Search index reset; next indexing pass will repopulate from scratch.");
|
||||||
"[Version Check] Search index reset; next indexing pass will repopulate from scratch.",
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[Version Check] resetSearchIndexes failed:", e);
|
console.warn("[Version Check] resetSearchIndexes failed:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
storeVersion(currentVersion);
|
storeVersion(currentVersion);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears all search-related caches
|
|
||||||
*/
|
|
||||||
export async function clearAllCaches(): Promise<void> {
|
export async function clearAllCaches(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Clear search result cache (in-memory Map)
|
if (typeof window !== "undefined") {
|
||||||
if (typeof window !== 'undefined') {
|
window.dispatchEvent(new CustomEvent("betterseqta-clear-search-cache"));
|
||||||
// Dispatch event to clear caches in other modules
|
window.dispatchEvent(new CustomEvent("betterseqta-clear-embedding-cache"));
|
||||||
window.dispatchEvent(new CustomEvent('betterseqta-clear-search-cache'));
|
|
||||||
window.dispatchEvent(new CustomEvent('betterseqta-clear-embedding-cache'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also try to directly clear caches if modules are already loaded
|
|
||||||
// Use setTimeout to avoid blocking and handle CSS preload errors
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const { clearSearchCache } = await import("../search/searchUtils");
|
const { clearSearchCache } = await import("../search/searchUtils");
|
||||||
clearSearchCache();
|
clearSearchCache();
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
// Module might not be loaded yet, or CSS preload error - that's okay
|
if (!isAssetLoadError(e)) verboseDebug("[Version Check] Could not clear search cache:", e);
|
||||||
if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) {
|
|
||||||
verboseDebug("[Version Check] Could not clear search cache:", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { clearEmbeddingCache } = await import("../search/vector/vectorSearch");
|
const { clearEmbeddingCache } = await import("../search/vector/vectorSearch");
|
||||||
clearEmbeddingCache();
|
clearEmbeddingCache();
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
// Module might not be loaded yet, or CSS preload error - that's okay
|
if (!isAssetLoadError(e)) verboseDebug("[Version Check] Could not clear embedding cache:", e);
|
||||||
if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) {
|
|
||||||
verboseDebug("[Version Check] Could not clear embedding cache:", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
@@ -133,4 +98,3 @@ export async function clearAllCaches(): Promise<void> {
|
|||||||
console.error("[Version Check] Error clearing caches:", e);
|
console.error("[Version Check] Error clearing caches:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,11 @@ import {
|
|||||||
MenuOptionsOpen,
|
MenuOptionsOpen,
|
||||||
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import {
|
import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
|
||||||
applyMenuItemVisibility,
|
|
||||||
} from "@/seqta/utils/menuItemVisibility";
|
|
||||||
import { loadAnalyticsPage } from "../loadAnalyticsPage";
|
import { loadAnalyticsPage } from "../loadAnalyticsPage";
|
||||||
import styles from "../styles.css?inline";
|
import styles from "../styles.css?inline";
|
||||||
|
|
||||||
const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics;
|
const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics;
|
||||||
|
|
||||||
const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item";
|
const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item";
|
||||||
|
|
||||||
const gradeAnalyticsPlugin: Plugin<{}> = {
|
const gradeAnalyticsPlugin: Plugin<{}> = {
|
||||||
@@ -48,30 +45,21 @@ const gradeAnalyticsPlugin: Plugin<{}> = {
|
|||||||
analyticsItem.dataset.betterseqta = "true";
|
analyticsItem.dataset.betterseqta = "true";
|
||||||
analyticsItem.innerHTML = `<label>${ANALYTICS_MENU_ICON}<span>Analytics</span></label>`;
|
analyticsItem.innerHTML = `<label>${ANALYTICS_MENU_ICON}<span>Analytics</span></label>`;
|
||||||
|
|
||||||
const placeAnalyticsItem = () => {
|
const syncAnalyticsMenu = () => {
|
||||||
insertMenuItemAfterKey(menuList, analyticsItem, "courses");
|
insertMenuItemAfterKey(menuList, analyticsItem, "courses");
|
||||||
|
ensureAnalyticsMenuOrder();
|
||||||
|
if (settingsState.menuorder.length > 0) {
|
||||||
|
ChangeMenuItemPositions(settingsState.menuorder);
|
||||||
|
}
|
||||||
|
processMenuItemNode(analyticsItem);
|
||||||
|
applyMenuItemVisibility();
|
||||||
};
|
};
|
||||||
|
|
||||||
placeAnalyticsItem();
|
syncAnalyticsMenu();
|
||||||
ensureAnalyticsMenuOrder();
|
|
||||||
if (settingsState.menuorder.length > 0) {
|
|
||||||
ChangeMenuItemPositions(settingsState.menuorder);
|
|
||||||
}
|
|
||||||
|
|
||||||
processMenuItemNode(analyticsItem);
|
|
||||||
applyMenuItemVisibility();
|
|
||||||
|
|
||||||
const menuObserver = new MutationObserver(() => {
|
const menuObserver = new MutationObserver(() => {
|
||||||
if (MenuOptionsOpen) return;
|
if (MenuOptionsOpen || menuList.contains(analyticsItem)) return;
|
||||||
if (!menuList.contains(analyticsItem)) {
|
syncAnalyticsMenu();
|
||||||
placeAnalyticsItem();
|
|
||||||
ensureAnalyticsMenuOrder();
|
|
||||||
if (settingsState.menuorder.length > 0) {
|
|
||||||
ChangeMenuItemPositions(settingsState.menuorder);
|
|
||||||
}
|
|
||||||
processMenuItemNode(analyticsItem);
|
|
||||||
applyMenuItemVisibility();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
menuObserver.observe(menuList, { childList: true });
|
menuObserver.observe(menuList, { childList: true });
|
||||||
|
|
||||||
|
|||||||
@@ -89,11 +89,10 @@ function syncThemeFromPage(target: HTMLElement) {
|
|||||||
const computed = getComputedStyle(document.documentElement);
|
const computed = getComputedStyle(document.documentElement);
|
||||||
|
|
||||||
for (const name of THEME_CSS_VARS) {
|
for (const name of THEME_CSS_VARS) {
|
||||||
let value = computed.getPropertyValue(name).trim();
|
const value =
|
||||||
value = document.documentElement.style.getPropertyValue(name).trim();
|
document.documentElement.style.getPropertyValue(name).trim() ||
|
||||||
if (value) {
|
computed.getPropertyValue(name).trim();
|
||||||
target.style.setProperty(name, value);
|
if (value) target.style.setProperty(name, value);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const accent = resolvePageAccentColor();
|
const accent = resolvePageAccentColor();
|
||||||
@@ -113,11 +112,7 @@ function syncThemeFromPage(target: HTMLElement) {
|
|||||||
target.style.setProperty("--better-main", palette.accent);
|
target.style.setProperty("--better-main", palette.accent);
|
||||||
target.style.setProperty("--bsplus-theme-btn-primary-bg", palette.accent);
|
target.style.setProperty("--bsplus-theme-btn-primary-bg", palette.accent);
|
||||||
target.style.setProperty("--bsplus-theme-btn-primary-color", palette.onAccent);
|
target.style.setProperty("--bsplus-theme-btn-primary-color", palette.onAccent);
|
||||||
|
target.classList.toggle("dark", document.documentElement.classList.contains("dark"));
|
||||||
target.classList.toggle(
|
|
||||||
"dark",
|
|
||||||
document.documentElement.classList.contains("dark"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncThemeToAnalyticsUi() {
|
function syncThemeToAnalyticsUi() {
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ import {
|
|||||||
validateThemeDom,
|
validateThemeDom,
|
||||||
validateThemeScript,
|
validateThemeScript,
|
||||||
} from "./theme-runtime";
|
} from "./theme-runtime";
|
||||||
|
import {
|
||||||
|
base64ToBlob,
|
||||||
|
blobToBase64Data,
|
||||||
|
stripBase64Prefix,
|
||||||
|
} from "./themeImageUrl";
|
||||||
|
|
||||||
type ThemeContent = {
|
type ThemeContent = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -652,10 +657,10 @@ export class ThemeManager {
|
|||||||
let coverImageBlob = null;
|
let coverImageBlob = null;
|
||||||
if (themeData.coverImage) {
|
if (themeData.coverImage) {
|
||||||
try {
|
try {
|
||||||
const strippedCoverImage = this.stripBase64Prefix(
|
coverImageBlob = base64ToBlob(
|
||||||
themeData.coverImage,
|
stripBase64Prefix(themeData.coverImage),
|
||||||
|
"image/png",
|
||||||
);
|
);
|
||||||
coverImageBlob = this.base64ToBlob(strippedCoverImage);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[ThemeManager] Failed to process cover image:", e);
|
console.warn("[ThemeManager] Failed to process cover image:", e);
|
||||||
// Continue without cover image
|
// Continue without cover image
|
||||||
@@ -673,7 +678,7 @@ export class ThemeManager {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...image,
|
...image,
|
||||||
blob: this.base64ToBlob(this.stripBase64Prefix(image.data)),
|
blob: base64ToBlob(stripBase64Prefix(image.data), "image/png"),
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[ThemeManager] Failed to process image:", e);
|
console.warn("[ThemeManager] Failed to process image:", e);
|
||||||
@@ -858,13 +863,13 @@ export class ThemeManager {
|
|||||||
CustomImages.map(async (image) => ({
|
CustomImages.map(async (image) => ({
|
||||||
id: image.id,
|
id: image.id,
|
||||||
variableName: image.variableName,
|
variableName: image.variableName,
|
||||||
data: await this.blobToBase64(image.blob),
|
data: await blobToBase64Data(image.blob),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert cover image to base64
|
// Convert cover image to base64
|
||||||
const coverImageBase64 = coverImage
|
const coverImageBase64 = coverImage
|
||||||
? await this.blobToBase64(coverImage)
|
? await blobToBase64Data(coverImage)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Create shareable theme data with only necessary fields
|
// Create shareable theme data with only necessary fields
|
||||||
@@ -1044,51 +1049,6 @@ export class ThemeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility methods
|
|
||||||
private stripBase64Prefix(base64String: string): string {
|
|
||||||
if (!base64String) return "";
|
|
||||||
|
|
||||||
const prefixRegex = /^data:[^;]+;base64,/;
|
|
||||||
try {
|
|
||||||
return prefixRegex.test(base64String)
|
|
||||||
? base64String.replace(prefixRegex, "")
|
|
||||||
: base64String;
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[ThemeManager] Error stripping base64 prefix:", err);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private base64ToBlob(base64: string): Blob {
|
|
||||||
try {
|
|
||||||
const byteString = atob(base64);
|
|
||||||
const ab = new ArrayBuffer(byteString.length);
|
|
||||||
const ia = new Uint8Array(ab);
|
|
||||||
|
|
||||||
for (let i = 0; i < byteString.length; i++) {
|
|
||||||
ia[i] = byteString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Blob([ab], { type: "image/png" });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[ThemeManager] Error converting base64 to blob:", err);
|
|
||||||
return new Blob();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async blobToBase64(blob: Blob): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onloadend = () => {
|
|
||||||
const base64String = reader.result as string;
|
|
||||||
const base64Data = base64String.split(",")[1];
|
|
||||||
resolve(base64Data);
|
|
||||||
};
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private saveThemeFile(data: object, fileName: string): void {
|
private saveThemeFile(data: object, fileName: string): void {
|
||||||
try {
|
try {
|
||||||
const fileData = JSON.stringify(data, null, 2);
|
const fileData = JSON.stringify(data, null, 2);
|
||||||
|
|||||||
@@ -3,15 +3,16 @@
|
|||||||
* blob: URLs are tied to the origin where createObjectURL ran (page), while
|
* blob: URLs are tied to the origin where createObjectURL ran (page), while
|
||||||
* settings UI runs in extension shadow DOM (moz-extension://).
|
* settings UI runs in extension shadow DOM (moz-extension://).
|
||||||
*/
|
*/
|
||||||
|
import base64ToBlob from "@/seqta/utils/base64ToBlob";
|
||||||
|
|
||||||
|
export { base64ToBlob };
|
||||||
|
|
||||||
export function blobToDataUrl(blob: Blob): Promise<string> {
|
export function blobToDataUrl(blob: Blob): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
if (typeof reader.result === "string") {
|
if (typeof reader.result === "string") resolve(reader.result);
|
||||||
resolve(reader.result);
|
else reject(new Error("FileReader did not return a string"));
|
||||||
} else {
|
|
||||||
reject(new Error("FileReader did not return a string"));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
reader.onerror = () =>
|
reader.onerror = () =>
|
||||||
reject(reader.error ?? new Error("FileReader failed"));
|
reject(reader.error ?? new Error("FileReader failed"));
|
||||||
@@ -27,12 +28,7 @@ export function blobToBase64Data(blob: Blob): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function themeCssUrlValue(url: string): string {
|
export function stripBase64Prefix(base64String: string): string {
|
||||||
return `url("${url.replace(/"/g, "%22")}")`;
|
if (!base64String) return "";
|
||||||
}
|
return base64String.replace(/^data:[^;]+;base64,/, "");
|
||||||
|
|
||||||
export function releaseThemeImageUrl(url: string): void {
|
|
||||||
if (url.startsWith("blob:")) {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import type { Plugin } from "../../core/types";
|
import type { Plugin } from "../../core/types";
|
||||||
import { attachTimetableColorisRecovery } from "@/seqta/utils/timetableColoris";
|
import { attachTimetableColorisRecovery } from "@/seqta/utils/patchSeqtaMenuUpdateColours";
|
||||||
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
|
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
import { verboseLog } from "@/utils/verboseLog";
|
import { verboseLog } from "@/utils/verboseLog";
|
||||||
|
|||||||
@@ -250,13 +250,16 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
|
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
|
||||||
}
|
}
|
||||||
|
|
||||||
const captureClick = () => {
|
entry.addEventListener(
|
||||||
|
"click",
|
||||||
|
() => {
|
||||||
lastClickedCi = ci;
|
lastClickedCi = ci;
|
||||||
lastClickedEntry = { roomEl, teacherEl, item };
|
lastClickedEntry = { roomEl, teacherEl, item };
|
||||||
lastSyncedQuickbarCi = null;
|
lastSyncedQuickbarCi = null;
|
||||||
scheduleQuickbarSync();
|
scheduleQuickbarSync();
|
||||||
};
|
},
|
||||||
entry.addEventListener("click", captureClick, true);
|
true,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const processAllEntries = () => {
|
const processAllEntries = () => {
|
||||||
@@ -266,9 +269,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getVisibleClassQuickbar = (): HTMLElement | null => {
|
const getVisibleClassQuickbar = (): HTMLElement | null => {
|
||||||
const quickbar = document.querySelector(
|
const quickbar = document.querySelector(".timetablepage .quickbar.visible");
|
||||||
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.above.visible, .timetablepage .quickbar.visible",
|
|
||||||
);
|
|
||||||
if (!quickbar || quickbar.getAttribute("data-type") !== "class") return null;
|
if (!quickbar || quickbar.getAttribute("data-type") !== "class") return null;
|
||||||
return quickbar as HTMLElement;
|
return quickbar as HTMLElement;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import type { Plugin, PluginSettings } from "./types";
|
import type { Plugin, PluginSettings } from "./types";
|
||||||
import { verboseInfo } from "@/utils/verboseLog";
|
import { verboseInfo } from "@/utils/verboseLog";
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for lazy-loaded plugin definitions
|
|
||||||
*/
|
|
||||||
export interface LazyPlugin<T extends PluginSettings = PluginSettings, S = any> {
|
export interface LazyPlugin<T extends PluginSettings = PluginSettings, S = any> {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -14,70 +11,45 @@ export interface LazyPlugin<T extends PluginSettings = PluginSettings, S = any>
|
|||||||
disableToggle?: boolean;
|
disableToggle?: boolean;
|
||||||
defaultEnabled?: boolean;
|
defaultEnabled?: boolean;
|
||||||
beta?: boolean;
|
beta?: boolean;
|
||||||
|
|
||||||
// Instead of a run function, we have a loader that imports the actual plugin
|
|
||||||
loader: () => Promise<{ default: Plugin<T, S> }>;
|
loader: () => Promise<{ default: Plugin<T, S> }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const ASSET_LOAD_ERRORS = ["MIME type", "NS_ERROR_CORRUPTED_CONTENT", "preload CSS"];
|
||||||
* Converts a lazy plugin into a regular plugin by wrapping the run function
|
|
||||||
* with dynamic import logic
|
|
||||||
*/
|
|
||||||
export function createLazyPlugin<T extends PluginSettings = PluginSettings, S = any>(
|
|
||||||
lazyPlugin: LazyPlugin<T, S>
|
|
||||||
): Plugin<T, S> {
|
|
||||||
return {
|
|
||||||
id: lazyPlugin.id,
|
|
||||||
name: lazyPlugin.name,
|
|
||||||
description: lazyPlugin.description,
|
|
||||||
version: lazyPlugin.version,
|
|
||||||
settings: lazyPlugin.settings,
|
|
||||||
styles: lazyPlugin.styles,
|
|
||||||
disableToggle: lazyPlugin.disableToggle,
|
|
||||||
defaultEnabled: lazyPlugin.defaultEnabled,
|
|
||||||
beta: lazyPlugin.beta,
|
|
||||||
|
|
||||||
|
function isAssetLoadError(error: unknown): boolean {
|
||||||
|
const msg = (error as { message?: string })?.message ?? "";
|
||||||
|
return ASSET_LOAD_ERRORS.some((token) => msg.includes(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLazyPlugin<T extends PluginSettings = PluginSettings, S = any>(
|
||||||
|
lazyPlugin: LazyPlugin<T, S>,
|
||||||
|
): Plugin<T, S> {
|
||||||
|
const { loader, ...meta } = lazyPlugin;
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
run: async (api) => {
|
run: async (api) => {
|
||||||
verboseInfo(`[BetterSEQTA+] Dynamically loading plugin "${lazyPlugin.id}"...`);
|
verboseInfo(`[BetterSEQTA+] Dynamically loading plugin "${lazyPlugin.id}"...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Dynamically import the actual plugin implementation
|
const { default: actualPlugin } = await loader();
|
||||||
const { default: actualPlugin } = await lazyPlugin.loader();
|
|
||||||
|
|
||||||
verboseInfo(`[BetterSEQTA+] Successfully loaded plugin "${lazyPlugin.id}"`);
|
verboseInfo(`[BetterSEQTA+] Successfully loaded plugin "${lazyPlugin.id}"`);
|
||||||
|
|
||||||
// Execute the actual plugin's run function
|
|
||||||
return await actualPlugin.run(api);
|
return await actualPlugin.run(api);
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
const msg = error?.message ?? "";
|
if (isAssetLoadError(error)) {
|
||||||
// Handle content-script asset loading failures gracefully (Firefox MIME
|
|
||||||
// errors, CSS preload blocked on host page, corrupted chunks).
|
|
||||||
if (
|
|
||||||
msg.includes("MIME type") ||
|
|
||||||
msg.includes("NS_ERROR_CORRUPTED_CONTENT") ||
|
|
||||||
msg.includes("preload CSS")
|
|
||||||
) {
|
|
||||||
console.error(
|
console.error(
|
||||||
`[BetterSEQTA+] Failed to load plugin "${lazyPlugin.id}" due to module/asset loading restrictions. ` +
|
`[BetterSEQTA+] Failed to load plugin "${lazyPlugin.id}" due to module/asset loading restrictions:`,
|
||||||
`This may be a build configuration issue. Error:`,
|
error,
|
||||||
error
|
|
||||||
);
|
);
|
||||||
// Don't throw - allow the extension to continue functioning without this plugin
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error(`[BetterSEQTA+] Failed to dynamically load plugin "${lazyPlugin.id}":`, error);
|
console.error(`[BetterSEQTA+] Failed to dynamically load plugin "${lazyPlugin.id}":`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to create a lazy plugin definition
|
|
||||||
*/
|
|
||||||
export function defineLazyPlugin<T extends PluginSettings = PluginSettings, S = any>(
|
export function defineLazyPlugin<T extends PluginSettings = PluginSettings, S = any>(
|
||||||
config: LazyPlugin<T, S>
|
config: LazyPlugin<T, S>,
|
||||||
): Plugin<T, S> {
|
): Plugin<T, S> {
|
||||||
return createLazyPlugin(config);
|
return createLazyPlugin(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
|||||||
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
|
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
|
||||||
|
|
||||||
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
||||||
import { attachTimetableColorisRecovery } from "@/seqta/utils/timetableColoris";
|
import { attachTimetableColorisRecovery } from "@/seqta/utils/patchSeqtaMenuUpdateColours";
|
||||||
|
|
||||||
// JSON content
|
// JSON content
|
||||||
import { observeMenuItemPosition } from "@/seqta/utils/sidebarMenuIcons";
|
import { observeMenuItemPosition } from "@/seqta/utils/sidebarMenuIcons";
|
||||||
|
|||||||
@@ -464,14 +464,11 @@ function GetLightDarkModeString() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addDarkLightToggle(parent?: Element) {
|
async function addDarkLightToggle(parent?: Element) {
|
||||||
const SUN_ICON_SVG = LUCIDE_SUN_ICON_SVG;
|
|
||||||
const MOON_ICON_SVG = LUCIDE_MOON_ICON_SVG;
|
|
||||||
|
|
||||||
const toggleTarget = parent ?? document.getElementById("content")!;
|
const toggleTarget = parent ?? document.getElementById("content")!;
|
||||||
toggleTarget.append(
|
toggleTarget.append(
|
||||||
stringToHTML(/* html */ `
|
stringToHTML(/* html */ `
|
||||||
<button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton">
|
<button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">${settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG}</svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">${settingsState.DarkMode ? LUCIDE_SUN_ICON_SVG : LUCIDE_MOON_ICON_SVG}</svg>
|
||||||
<div class="tooltiptext topmenutooltip" id="darklighttooliptext">${GetLightDarkModeString()}</div>
|
<div class="tooltiptext topmenutooltip" id="darklighttooliptext">${GetLightDarkModeString()}</div>
|
||||||
</button>
|
</button>
|
||||||
`).firstChild!,
|
`).firstChild!,
|
||||||
@@ -508,8 +505,8 @@ async function addDarkLightToggle(parent?: Element) {
|
|||||||
|
|
||||||
const svgElement = lightDarkModeButtonElement.querySelector("svg")!;
|
const svgElement = lightDarkModeButtonElement.querySelector("svg")!;
|
||||||
svgElement.innerHTML = settingsState.DarkMode
|
svgElement.innerHTML = settingsState.DarkMode
|
||||||
? SUN_ICON_SVG
|
? LUCIDE_SUN_ICON_SVG
|
||||||
: MOON_ICON_SVG;
|
: LUCIDE_MOON_ICON_SVG;
|
||||||
darklightText!.innerText = GetLightDarkModeString();
|
darklightText!.innerText = GetLightDarkModeString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -553,10 +550,7 @@ function scheduleSidebarAccessibilityUpdate() {
|
|||||||
cancelAnimationFrame(sidebarTabOrderAnimationFrame);
|
cancelAnimationFrame(sidebarTabOrderAnimationFrame);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Double rAF: SEQTA applies `.active` / updates `.sub` on the next frame
|
// Double rAF: SEQTA applies drill state on the next frame after click.
|
||||||
// after a click. Running earlier hid the submenu with `aria-hidden` while
|
|
||||||
// focus was still on a <label> inside it, which broke routing and sent
|
|
||||||
// the SPA back to home.
|
|
||||||
sidebarTabOrderAnimationFrame = requestAnimationFrame(() => {
|
sidebarTabOrderAnimationFrame = requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
sidebarTabOrderAnimationFrame = null;
|
sidebarTabOrderAnimationFrame = null;
|
||||||
@@ -619,13 +613,7 @@ function handleSidebarKeyboardActivation(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Folder rows on the path to the currently open sidebar list. */
|
||||||
* Keyboard tab order for the drilled-in sidebar only.
|
|
||||||
* SEQTA already sets `aria-hidden` on off-screen menu rows; we must not
|
|
||||||
* override that or hide `.sub` ourselves — doing so while a <label> inside
|
|
||||||
* the submenu still has focus breaks SEQTA's router and navigates to home.
|
|
||||||
*/
|
|
||||||
/** Every folder row on the path to the open list (e.g. Assessments → 2026_S1). */
|
|
||||||
function getDrillFolderChain(
|
function getDrillFolderChain(
|
||||||
menu: HTMLElement,
|
menu: HTMLElement,
|
||||||
visibleList: HTMLElement | null,
|
visibleList: HTMLElement | null,
|
||||||
@@ -722,15 +710,6 @@ function updateSidebarAccessibility() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVisibleSidebarEntries(menu = document.getElementById("menu")) {
|
|
||||||
if (!menu) return [] as HTMLElement[];
|
|
||||||
|
|
||||||
const visibleList = getVisibleSidebarList(menu);
|
|
||||||
if (!visibleList) return [] as HTMLElement[];
|
|
||||||
|
|
||||||
return getDirectSidebarEntries(visibleList);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDirectSidebarEntries(list: HTMLElement) {
|
function getDirectSidebarEntries(list: HTMLElement) {
|
||||||
return Array.from(list.querySelectorAll(":scope > li, :scope > section")).filter(
|
return Array.from(list.querySelectorAll(":scope > li, :scope > section")).filter(
|
||||||
(entry): entry is HTMLElement => entry instanceof HTMLElement,
|
(entry): entry is HTMLElement => entry instanceof HTMLElement,
|
||||||
@@ -764,9 +743,8 @@ function getVisibleSidebarList(menu: HTMLElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSidebarListParentEntry(list: HTMLElement) {
|
function getSidebarListParentEntry(list: HTMLElement) {
|
||||||
return list.closest(".sub")?.parentElement instanceof HTMLElement
|
const sub = list.closest(".sub");
|
||||||
? (list.closest(".sub")!.parentElement as HTMLElement)
|
return sub?.parentElement instanceof HTMLElement ? sub.parentElement : null;
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusFirstSidebarSubmenuEntry(parentEntry: HTMLElement) {
|
function focusFirstSidebarSubmenuEntry(parentEntry: HTMLElement) {
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { animate } from "motion";
|
|
||||||
import browser from "webextension-polyfill";
|
|
||||||
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
||||||
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
||||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
|
||||||
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
|
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
|
||||||
import debounce from "@/seqta/utils/debounce";
|
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
|
||||||
import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
|
import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
|
||||||
|
import { lessonsSubtitleForViewDate } from "@/seqta/utils/Loaders/timetableSubtitle";
|
||||||
import {
|
import {
|
||||||
type EngageParentChild,
|
type EngageParentChild,
|
||||||
type EngageParentTimetableItem,
|
type EngageParentTimetableItem,
|
||||||
@@ -19,10 +15,8 @@ import {
|
|||||||
toISODate,
|
toISODate,
|
||||||
weekRangeContaining,
|
weekRangeContaining,
|
||||||
} from "@/seqta/utils/Loaders/engageParentTimetable";
|
} from "@/seqta/utils/Loaders/engageParentTimetable";
|
||||||
import {
|
import { resolveNoticeFilterTokens } from "@/seqta/utils/notices/noticeLabelFilters";
|
||||||
noticeMatchesLabelFilter,
|
import { setupNoticesSection } from "@/seqta/utils/notices/noticeHomeUi";
|
||||||
resolveNoticeFilterTokens,
|
|
||||||
} from "@/seqta/utils/notices/noticeLabelFilters";
|
|
||||||
|
|
||||||
export function updateEngageHomeMenuActive(isHome: boolean): void {
|
export function updateEngageHomeMenuActive(isHome: boolean): void {
|
||||||
const home = document.getElementById("homebutton");
|
const home = document.getElementById("homebutton");
|
||||||
@@ -47,37 +41,10 @@ let engageWeekItems: EngageParentTimetableItem[] = [];
|
|||||||
let engageSelectedStudentId: string | null = null;
|
let engageSelectedStudentId: string | null = null;
|
||||||
let engageListenersCleanup: (() => void) | null = null;
|
let engageListenersCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
function formatDateString(date: Date): string {
|
|
||||||
return `${date.toLocaleString("en-us", { weekday: "short" })} ${date.toLocaleDateString("en-au")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setEngageTimetableSubtitle(): void {
|
function setEngageTimetableSubtitle(): void {
|
||||||
const el = document.getElementById("engage-home-lesson-subtitle");
|
const el = document.getElementById("engage-home-lesson-subtitle");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
el.textContent = lessonsSubtitleForViewDate(engageViewDate);
|
||||||
const today = new Date();
|
|
||||||
const isSameMonth =
|
|
||||||
today.getFullYear() === engageViewDate.getFullYear() &&
|
|
||||||
today.getMonth() === engageViewDate.getMonth();
|
|
||||||
|
|
||||||
if (isSameMonth) {
|
|
||||||
const dayDiff = today.getDate() - engageViewDate.getDate();
|
|
||||||
switch (dayDiff) {
|
|
||||||
case 0:
|
|
||||||
el.textContent = "Today's Lessons";
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
el.textContent = "Yesterday's Lessons";
|
|
||||||
break;
|
|
||||||
case -1:
|
|
||||||
el.textContent = "Tomorrow's Lessons";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
el.textContent = formatDateString(engageViewDate);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
el.textContent = formatDateString(engageViewDate);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeEngageLessonDiv(
|
function makeEngageLessonDiv(
|
||||||
@@ -254,422 +221,9 @@ function bindEngageTimetableUi(): void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ——— Notices (duplicated from Learn `LoadHomePage`; fetch uses `/seqta/parent/load/notices`.) ——— */
|
|
||||||
|
|
||||||
const ENGAGE_NOTICE_CONTAINER_ID = "engage-notice-container";
|
const ENGAGE_NOTICE_CONTAINER_ID = "engage-notice-container";
|
||||||
const ENGAGE_NOTICES_DATE_ID = "engage-notices-date";
|
const ENGAGE_NOTICES_DATE_ID = "engage-notices-date";
|
||||||
|
|
||||||
function processEngageNoticeColor(colour: unknown): string | undefined {
|
|
||||||
if (typeof colour !== "string") return undefined;
|
|
||||||
const rgb = GetThresholdOfColor(colour);
|
|
||||||
if (rgb < 100 && settingsState.DarkMode) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return colour;
|
|
||||||
}
|
|
||||||
|
|
||||||
function processEngageNotices(response: any, labelArray: string[]): void {
|
|
||||||
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
|
|
||||||
if (!noticeContainer) return;
|
|
||||||
|
|
||||||
noticeContainer.classList.remove("loading");
|
|
||||||
noticeContainer.innerHTML = "";
|
|
||||||
|
|
||||||
const notices = response?.payload;
|
|
||||||
if (!Array.isArray(notices)) {
|
|
||||||
appendEngageNoticeEmptyState(noticeContainer, "No notices for today.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!notices.length) {
|
|
||||||
appendEngageNoticeEmptyState(noticeContainer, "No notices for today.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
|
|
||||||
notices.forEach((notice: any) => {
|
|
||||||
const shouldInclude =
|
|
||||||
settingsState.mockNotices || noticeMatchesLabelFilter(notice, labelArray);
|
|
||||||
|
|
||||||
if (shouldInclude) {
|
|
||||||
const colour = processEngageNoticeColor(notice.colour);
|
|
||||||
const noticeElement = createEngageNoticeElement(notice, colour);
|
|
||||||
fragment.appendChild(noticeElement);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fragment.childNodes.length === 0) {
|
|
||||||
appendEngageNoticeEmptyState(noticeContainer, "No notices for today.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
noticeContainer.appendChild(fragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendEngageNoticeEmptyState(container: HTMLElement, message: string) {
|
|
||||||
const emptyState = document.createElement("div");
|
|
||||||
emptyState.classList.add("day-empty");
|
|
||||||
const img = document.createElement("img");
|
|
||||||
img.src = resolveExtensionAssetUrl(LogoLight);
|
|
||||||
const text = document.createElement("p");
|
|
||||||
text.innerText = message;
|
|
||||||
emptyState.append(img, text);
|
|
||||||
container.append(emptyState);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEngageNoticeElement(
|
|
||||||
notice: any,
|
|
||||||
colour: string | undefined,
|
|
||||||
): Node {
|
|
||||||
const textPreview =
|
|
||||||
notice.contents
|
|
||||||
.replace(/<[^>]*>/g, "")
|
|
||||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim()
|
|
||||||
.substring(0, 150) + (notice.contents.length > 150 ? "..." : "");
|
|
||||||
|
|
||||||
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
|
|
||||||
const htmlContent = `
|
|
||||||
<div class="notice-unified-content notice-card-state" data-notice-id="${noticeId}" style="--colour: ${colour || "#8e8e8e"}; position: relative; background: var(--background-primary); cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);">
|
|
||||||
<div class="notice-header">
|
|
||||||
<div class="notice-badge-row">
|
|
||||||
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
|
|
||||||
${notice.label_title || "General"}
|
|
||||||
</span>
|
|
||||||
<span class="notice-staff">${notice.staff}</span>
|
|
||||||
</div>
|
|
||||||
<button class="notice-close-btn" style="opacity: 0; pointer-events: none;">×</button>
|
|
||||||
</div>
|
|
||||||
<h2 class="notice-content-title">${notice.title}</h2>
|
|
||||||
<div class="notice-content-body">${textPreview}</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
|
|
||||||
element.addEventListener("click", () =>
|
|
||||||
openEngageNoticeModal(notice, colour, element),
|
|
||||||
);
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEngageNoticeModal(
|
|
||||||
notice: any,
|
|
||||||
colour: string | undefined,
|
|
||||||
sourceElement: HTMLElement,
|
|
||||||
) {
|
|
||||||
const cleanContent = notice.contents
|
|
||||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
|
||||||
.replace(/ +/, " ");
|
|
||||||
|
|
||||||
document.getElementById("notice-modal")?.remove();
|
|
||||||
|
|
||||||
const sourceRect = sourceElement.getBoundingClientRect();
|
|
||||||
let scrollY = Math.round(window.scrollY);
|
|
||||||
let scrollX = Math.round(window.scrollX);
|
|
||||||
let sourceLeft = sourceRect.left;
|
|
||||||
let sourceTop = sourceRect.top;
|
|
||||||
let sourceWidth = sourceRect.width;
|
|
||||||
let sourceHeight = sourceRect.height;
|
|
||||||
|
|
||||||
const modalHtml = `
|
|
||||||
<div id="notice-modal" class="notice-modal-overlay" style="opacity: 0;">
|
|
||||||
<div class="notice-modal-transition" style="
|
|
||||||
position: fixed;
|
|
||||||
left: ${sourceLeft + scrollX}px;
|
|
||||||
top: ${sourceTop + scrollY}px;
|
|
||||||
width: ${sourceWidth}px;
|
|
||||||
height: ${sourceHeight}px;
|
|
||||||
transform-origin: center;
|
|
||||||
z-index: 10001;
|
|
||||||
">
|
|
||||||
<div class="notice-modal-content notice-transitioning">
|
|
||||||
<div class="notice-unified-content notice-card-state">
|
|
||||||
<div class="notice-header">
|
|
||||||
<div class="notice-badge-row">
|
|
||||||
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
|
|
||||||
${notice.label_title || "General"}
|
|
||||||
</span>
|
|
||||||
<span class="notice-staff">${notice.staff}</span>
|
|
||||||
</div>
|
|
||||||
<button class="notice-close-btn">×</button>
|
|
||||||
</div>
|
|
||||||
<h2 class="notice-content-title">${notice.title}</h2>
|
|
||||||
<div class="notice-content-body">${cleanContent}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const modal = stringToHTML(modalHtml).firstChild as HTMLElement;
|
|
||||||
const transitionContainer = modal.querySelector(
|
|
||||||
".notice-modal-transition",
|
|
||||||
) as HTMLElement;
|
|
||||||
const unifiedContent = modal.querySelector(
|
|
||||||
".notice-unified-content",
|
|
||||||
) as HTMLElement;
|
|
||||||
const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
|
|
||||||
sourceElement.setAttribute("data-transitioning", "true");
|
|
||||||
sourceElement.style.opacity = "0";
|
|
||||||
sourceElement.style.transform = "scale(0.95)";
|
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
let targetWidth = Math.round(
|
|
||||||
Math.min(Math.max(sourceWidth, 800), viewportWidth - 40),
|
|
||||||
);
|
|
||||||
|
|
||||||
const tempMeasureDiv = document.createElement("div");
|
|
||||||
tempMeasureDiv.style.position = "absolute";
|
|
||||||
tempMeasureDiv.style.left = "-9999px";
|
|
||||||
tempMeasureDiv.style.width = targetWidth + "px";
|
|
||||||
tempMeasureDiv.style.visibility = "hidden";
|
|
||||||
tempMeasureDiv.innerHTML = `
|
|
||||||
<div class="notice-unified-content notice-modal-state" style="position: relative; width: 100%; padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1);">
|
|
||||||
<div class="notice-header">
|
|
||||||
<div class="notice-badge-row">
|
|
||||||
<span class="notice-badge">${notice.label_title || "General"}</span>
|
|
||||||
<span class="notice-staff">${notice.staff}</span>
|
|
||||||
</div>
|
|
||||||
<button class="notice-close-btn">×</button>
|
|
||||||
</div>
|
|
||||||
<h2 class="notice-content-title">${notice.title}</h2>
|
|
||||||
<div class="notice-content-body">${cleanContent}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(tempMeasureDiv);
|
|
||||||
const measuredHeight =
|
|
||||||
tempMeasureDiv.firstElementChild!.getBoundingClientRect().height;
|
|
||||||
document.body.removeChild(tempMeasureDiv);
|
|
||||||
|
|
||||||
let targetHeight = Math.round(
|
|
||||||
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
|
|
||||||
);
|
|
||||||
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
|
|
||||||
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
document.removeEventListener("keydown", handleEscape);
|
|
||||||
|
|
||||||
if (!settingsState.animations) {
|
|
||||||
modal.remove();
|
|
||||||
sourceElement.style.opacity = "1";
|
|
||||||
sourceElement.style.transform = "";
|
|
||||||
sourceElement.removeAttribute("data-transitioning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
animate(
|
|
||||||
modal,
|
|
||||||
{
|
|
||||||
backgroundColor: ["rgba(0, 0, 0, 0.5)", "rgba(0, 0, 0, 0)"],
|
|
||||||
backdropFilter: ["blur(4px)", "blur(0px)"],
|
|
||||||
},
|
|
||||||
{ duration: 0.2 },
|
|
||||||
);
|
|
||||||
|
|
||||||
animate(
|
|
||||||
transitionContainer,
|
|
||||||
{ opacity: [1, 0] },
|
|
||||||
{ duration: 0.2, delay: 0.3 },
|
|
||||||
);
|
|
||||||
|
|
||||||
sourceElement.style.opacity = "1";
|
|
||||||
sourceElement.style.transform = "";
|
|
||||||
|
|
||||||
modal.style.pointerEvents = "none";
|
|
||||||
|
|
||||||
animate(
|
|
||||||
transitionContainer,
|
|
||||||
{
|
|
||||||
left: [targetLeft + scrollX, sourceLeft + scrollX],
|
|
||||||
top: [targetTop, sourceTop + scrollY],
|
|
||||||
width: [targetWidth, sourceWidth],
|
|
||||||
height: [targetHeight, sourceHeight],
|
|
||||||
scale: [1, 1],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
duration: 0.35,
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 400,
|
|
||||||
damping: 35,
|
|
||||||
},
|
|
||||||
).finished.then(async () => {
|
|
||||||
modal.remove();
|
|
||||||
sourceElement.removeAttribute("data-transitioning");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
closeBtn?.addEventListener("click", closeModal);
|
|
||||||
modal?.addEventListener("click", (e) => {
|
|
||||||
if (e.target === modal) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
closeModal();
|
|
||||||
document.removeEventListener("keydown", handleEscape);
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleEscape);
|
|
||||||
|
|
||||||
const handleResize = () => {
|
|
||||||
const newSourceRect = sourceElement.getBoundingClientRect();
|
|
||||||
const newScrollY = Math.round(window.scrollY);
|
|
||||||
const newScrollX = Math.round(window.scrollX);
|
|
||||||
|
|
||||||
const computedStyle = getComputedStyle(sourceElement);
|
|
||||||
const transform = computedStyle.transform;
|
|
||||||
let scaleX = 1,
|
|
||||||
scaleY = 1;
|
|
||||||
|
|
||||||
if (transform && transform !== "none") {
|
|
||||||
const matrix = transform.match(/matrix.*\((.+)\)/);
|
|
||||||
if (matrix) {
|
|
||||||
const values = matrix[1].split(", ");
|
|
||||||
scaleX = parseFloat(values[0]);
|
|
||||||
scaleY = parseFloat(values[3]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newSourceWidth = newSourceRect.width / scaleX;
|
|
||||||
const newSourceHeight = newSourceRect.height / scaleY;
|
|
||||||
|
|
||||||
const deltaX = (newSourceWidth - newSourceRect.width) / 2;
|
|
||||||
const deltaY = (newSourceHeight - newSourceRect.height) / 2;
|
|
||||||
|
|
||||||
const newSourceLeft = newSourceRect.left - deltaX;
|
|
||||||
const newSourceTop = newSourceRect.top - deltaY;
|
|
||||||
|
|
||||||
const newViewportWidth = window.innerWidth;
|
|
||||||
const newViewportHeight = window.innerHeight;
|
|
||||||
const newTargetWidth = Math.round(
|
|
||||||
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
|
|
||||||
);
|
|
||||||
const currentHeight = unifiedContent.getBoundingClientRect().height;
|
|
||||||
const newTargetHeight = Math.round(
|
|
||||||
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
|
|
||||||
);
|
|
||||||
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
|
|
||||||
const newTargetTop =
|
|
||||||
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
|
|
||||||
|
|
||||||
transitionContainer.style.left =
|
|
||||||
Math.round(newTargetLeft + newScrollX) + "px";
|
|
||||||
transitionContainer.style.top = Math.round(newTargetTop) + "px";
|
|
||||||
transitionContainer.style.width = Math.round(newTargetWidth) + "px";
|
|
||||||
transitionContainer.style.height = Math.round(newTargetHeight) + "px";
|
|
||||||
|
|
||||||
sourceLeft = newSourceLeft;
|
|
||||||
sourceTop = newSourceTop;
|
|
||||||
sourceWidth = newSourceWidth;
|
|
||||||
sourceHeight = newSourceHeight;
|
|
||||||
targetLeft = newTargetLeft;
|
|
||||||
targetTop = newTargetTop;
|
|
||||||
targetWidth = newTargetWidth;
|
|
||||||
targetHeight = newTargetHeight;
|
|
||||||
scrollY = newScrollY;
|
|
||||||
scrollX = newScrollX;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
|
|
||||||
if (settingsState.animations) {
|
|
||||||
animate(modal, { opacity: [0, 1] }, { duration: 0.2 });
|
|
||||||
|
|
||||||
animate(
|
|
||||||
transitionContainer,
|
|
||||||
{
|
|
||||||
left: [sourceLeft + scrollX, targetLeft + scrollX],
|
|
||||||
top: [sourceTop + scrollY, targetTop],
|
|
||||||
width: [sourceWidth, targetWidth],
|
|
||||||
height: [sourceHeight, targetHeight],
|
|
||||||
scale: [1, 1],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
duration: 0.5,
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 280,
|
|
||||||
damping: 24,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
unifiedContent.classList.remove("notice-card-state");
|
|
||||||
unifiedContent.classList.add("notice-modal-state");
|
|
||||||
} else {
|
|
||||||
modal.style.opacity = "1";
|
|
||||||
transitionContainer.style.left = Math.round(targetLeft + scrollX) + "px";
|
|
||||||
transitionContainer.style.top = Math.round(targetTop) + "px";
|
|
||||||
transitionContainer.style.width = Math.round(targetWidth) + "px";
|
|
||||||
transitionContainer.style.height = Math.round(targetHeight) + "px";
|
|
||||||
unifiedContent.classList.remove("notice-card-state");
|
|
||||||
unifiedContent.classList.add("notice-modal-state");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchEngageNoticesFromApi(
|
|
||||||
date: string,
|
|
||||||
labelTokens: string[],
|
|
||||||
): Promise<void> {
|
|
||||||
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
|
|
||||||
if (noticeContainer) {
|
|
||||||
noticeContainer.classList.add("loading");
|
|
||||||
noticeContainer.innerHTML = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = settingsState.mockNotices
|
|
||||||
? getMockNotices()
|
|
||||||
: await (
|
|
||||||
await fetch(`${location.origin}/seqta/parent/load/notices`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ date }),
|
|
||||||
})
|
|
||||||
).json();
|
|
||||||
|
|
||||||
processEngageNotices(data, labelTokens);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[BetterSEQTA+] Engage notices request failed:", e);
|
|
||||||
processEngageNotices({ payload: [] }, labelTokens);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindEngageNoticesDateInput(
|
|
||||||
labelTokens: string[],
|
|
||||||
initialDate: string,
|
|
||||||
): () => void {
|
|
||||||
const dateControl = document.getElementById(
|
|
||||||
ENGAGE_NOTICES_DATE_ID,
|
|
||||||
) as HTMLInputElement | null;
|
|
||||||
|
|
||||||
if (!dateControl) {
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
dateControl.value = initialDate;
|
|
||||||
|
|
||||||
const debouncedInputChange = debounce((e: Event) => {
|
|
||||||
void fetchEngageNoticesFromApi(
|
|
||||||
(e.target as HTMLInputElement).value,
|
|
||||||
labelTokens,
|
|
||||||
);
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
dateControl.addEventListener("input", debouncedInputChange);
|
|
||||||
|
|
||||||
return () => dateControl.removeEventListener("input", debouncedInputChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initEngageNoticesUi(todayFormatted: string): Promise<void> {
|
async function initEngageNoticesUi(todayFormatted: string): Promise<void> {
|
||||||
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
|
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
|
||||||
if (!noticeContainer) return;
|
if (!noticeContainer) return;
|
||||||
@@ -693,14 +247,13 @@ async function initEngageNoticesUi(todayFormatted: string): Promise<void> {
|
|||||||
`${location.origin}/seqta/parent/load/notices`,
|
`${location.origin}/seqta/parent/load/notices`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const dateControl = document.getElementById(ENGAGE_NOTICES_DATE_ID);
|
const cleanup = setupNoticesSection({
|
||||||
if (dateControl) {
|
containerId: ENGAGE_NOTICE_CONTAINER_ID,
|
||||||
(dateControl as HTMLInputElement).value = todayFormatted;
|
dateInput: `#${ENGAGE_NOTICES_DATE_ID}`,
|
||||||
}
|
noticesUrl: `${location.origin}/seqta/parent/load/notices`,
|
||||||
|
labelTokens,
|
||||||
await fetchEngageNoticesFromApi(todayFormatted, labelTokens);
|
initialDate: todayFormatted,
|
||||||
|
});
|
||||||
const cleanup = bindEngageNoticesDateInput(labelTokens, todayFormatted);
|
|
||||||
engageMergeNoticeCleanup(cleanup);
|
engageMergeNoticeCleanup(cleanup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { animate, stagger } from "motion";
|
import { animate, stagger } from "motion";
|
||||||
import browser from "webextension-polyfill";
|
|
||||||
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
||||||
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
||||||
import assessmentsicon from "@/seqta/icons/assessmentsIcon";
|
import assessmentsicon from "@/seqta/icons/assessmentsIcon";
|
||||||
@@ -12,7 +11,6 @@ import stringToHTML from "../stringToHTML";
|
|||||||
import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
|
import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
|
||||||
import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
|
import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
|
||||||
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
||||||
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
|
||||||
import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip";
|
import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip";
|
||||||
import { verboseInfo } from "@/utils/verboseLog";
|
import { verboseInfo } from "@/utils/verboseLog";
|
||||||
import {
|
import {
|
||||||
@@ -21,10 +19,9 @@ import {
|
|||||||
filterAssessmentsForActiveSubjects,
|
filterAssessmentsForActiveSubjects,
|
||||||
subjectsWithUpcomingAssessments,
|
subjectsWithUpcomingAssessments,
|
||||||
} from "@/plugins/built-in/assessmentsOverview/utils";
|
} from "@/plugins/built-in/assessmentsOverview/utils";
|
||||||
import {
|
import { resolveNoticeFilterTokens } from "@/seqta/utils/notices/noticeLabelFilters";
|
||||||
noticeMatchesLabelFilter,
|
import { setupNoticesSection } from "@/seqta/utils/notices/noticeHomeUi";
|
||||||
resolveNoticeFilterTokens,
|
import { lessonsSubtitleForViewDate } from "@/seqta/utils/Loaders/timetableSubtitle";
|
||||||
} from "@/seqta/utils/notices/noticeLabelFilters";
|
|
||||||
|
|
||||||
let LessonInterval: any;
|
let LessonInterval: any;
|
||||||
let currentSelectedDate = new Date();
|
let currentSelectedDate = new Date();
|
||||||
@@ -141,15 +138,14 @@ export async function loadHomePage() {
|
|||||||
`${location.origin}/seqta/student/load/notices?`,
|
`${location.origin}/seqta/student/load/notices?`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const noticeContainer = document.getElementById("notice-container");
|
if (document.getElementById("notice-container")) {
|
||||||
if (noticeContainer) {
|
setupNoticesSection({
|
||||||
const dateControl = document.querySelector(
|
containerId: "notice-container",
|
||||||
'input[type="date"]',
|
dateInput: 'input[type="date"]',
|
||||||
) as HTMLInputElement;
|
noticesUrl: `${location.origin}/seqta/student/load/notices?`,
|
||||||
if (dateControl) {
|
labelTokens,
|
||||||
dateControl.value = TodayFormatted;
|
initialDate: TodayFormatted,
|
||||||
}
|
});
|
||||||
setupNotices(labelTokens, TodayFormatted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanup;
|
return cleanup;
|
||||||
@@ -283,57 +279,6 @@ async function GetActiveClasses() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupNotices(labelArray: string[], date: string) {
|
|
||||||
const dateControl = document.querySelector(
|
|
||||||
'input[type="date"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
const fetchNotices = async (date: string) => {
|
|
||||||
const container = document.getElementById("notice-container");
|
|
||||||
if (container) {
|
|
||||||
container.classList.add("loading");
|
|
||||||
container.innerHTML = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = settingsState.mockNotices
|
|
||||||
? getMockNotices()
|
|
||||||
: await (
|
|
||||||
await fetch(`${location.origin}/seqta/student/load/notices?`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ date }),
|
|
||||||
})
|
|
||||||
).json();
|
|
||||||
|
|
||||||
processNotices(data, labelArray);
|
|
||||||
} catch {
|
|
||||||
processNotices({ payload: [] }, labelArray);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const debouncedInputChange = debounce((e: Event) => {
|
|
||||||
fetchNotices((e.target as HTMLInputElement).value);
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
dateControl?.addEventListener("input", debouncedInputChange);
|
|
||||||
fetchNotices(date);
|
|
||||||
|
|
||||||
return () => dateControl?.removeEventListener("input", debouncedInputChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
function debounce<T extends (...args: any[]) => any>(
|
|
||||||
func: T,
|
|
||||||
wait: number,
|
|
||||||
): (...args: Parameters<T>) => void {
|
|
||||||
let timeout: any;
|
|
||||||
return (...args: Parameters<T>) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(() => func(...args), wait);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function comparedate(obj1: any, obj2: any) {
|
function comparedate(obj1: any, obj2: any) {
|
||||||
const d1 = new Date(obj1.due || obj1.date || 0).getTime();
|
const d1 = new Date(obj1.due || obj1.date || 0).getTime();
|
||||||
const d2 = new Date(obj2.due || obj2.date || 0).getTime();
|
const d2 = new Date(obj2.due || obj2.date || 0).getTime();
|
||||||
@@ -343,362 +288,6 @@ function comparedate(obj1: any, obj2: any) {
|
|||||||
function startOfDay(date: Date): Date {
|
function startOfDay(date: Date): Date {
|
||||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
}
|
}
|
||||||
function processNotices(response: any, labelArray: string[]) {
|
|
||||||
const NoticeContainer = document.getElementById("notice-container");
|
|
||||||
if (!NoticeContainer) return;
|
|
||||||
|
|
||||||
NoticeContainer.classList.remove("loading");
|
|
||||||
NoticeContainer.innerHTML = "";
|
|
||||||
|
|
||||||
const notices = response?.payload;
|
|
||||||
if (!Array.isArray(notices)) {
|
|
||||||
appendNoticeEmptyState(NoticeContainer, "No notices for today.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!notices.length) {
|
|
||||||
appendNoticeEmptyState(NoticeContainer, "No notices for today.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
|
|
||||||
notices.forEach((notice: any) => {
|
|
||||||
const shouldInclude =
|
|
||||||
settingsState.mockNotices || noticeMatchesLabelFilter(notice, labelArray);
|
|
||||||
|
|
||||||
if (shouldInclude) {
|
|
||||||
const colour = processNoticeColor(notice.colour);
|
|
||||||
const noticeElement = createNoticeElement(notice, colour);
|
|
||||||
fragment.appendChild(noticeElement);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fragment.childNodes.length === 0) {
|
|
||||||
appendNoticeEmptyState(NoticeContainer, "No notices for today.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
NoticeContainer.appendChild(fragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendNoticeEmptyState(container: HTMLElement, message: string) {
|
|
||||||
const emptyState = document.createElement("div");
|
|
||||||
emptyState.classList.add("day-empty");
|
|
||||||
const img = document.createElement("img");
|
|
||||||
img.src = resolveExtensionAssetUrl(LogoLight);
|
|
||||||
const text = document.createElement("p");
|
|
||||||
text.innerText = message;
|
|
||||||
emptyState.append(img, text);
|
|
||||||
container.append(emptyState);
|
|
||||||
}
|
|
||||||
|
|
||||||
function processNoticeColor(colour: string): string | undefined {
|
|
||||||
if (typeof colour === "string") {
|
|
||||||
const rgb = GetThresholdOfColor(colour);
|
|
||||||
if (rgb < 100 && settingsState.DarkMode) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return colour;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createNoticeElement(notice: any, colour: string | undefined): Node {
|
|
||||||
const textPreview =
|
|
||||||
notice.contents
|
|
||||||
.replace(/<[^>]*>/g, "")
|
|
||||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim()
|
|
||||||
.substring(0, 150) + (notice.contents.length > 150 ? "..." : "");
|
|
||||||
|
|
||||||
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
|
|
||||||
const htmlContent = `
|
|
||||||
<div class="notice-unified-content notice-card-state" data-notice-id="${noticeId}" style="--colour: ${colour || "#8e8e8e"}; position: relative; background: var(--background-primary); cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);">
|
|
||||||
<div class="notice-header">
|
|
||||||
<div class="notice-badge-row">
|
|
||||||
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
|
|
||||||
${notice.label_title || "General"}
|
|
||||||
</span>
|
|
||||||
<span class="notice-staff">${notice.staff}</span>
|
|
||||||
</div>
|
|
||||||
<button class="notice-close-btn" style="opacity: 0; pointer-events: none;">×</button>
|
|
||||||
</div>
|
|
||||||
<h2 class="notice-content-title">${notice.title}</h2>
|
|
||||||
<div class="notice-content-body">${textPreview}</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
|
|
||||||
element.addEventListener("click", () =>
|
|
||||||
openNoticeModal(notice, colour, element),
|
|
||||||
);
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openNoticeModal(
|
|
||||||
notice: any,
|
|
||||||
colour: string | undefined,
|
|
||||||
sourceElement: HTMLElement,
|
|
||||||
) {
|
|
||||||
const cleanContent = notice.contents
|
|
||||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
|
||||||
.replace(/ +/, " ");
|
|
||||||
|
|
||||||
document.getElementById("notice-modal")?.remove();
|
|
||||||
|
|
||||||
const sourceRect = sourceElement.getBoundingClientRect();
|
|
||||||
let scrollY = Math.round(window.scrollY);
|
|
||||||
let scrollX = Math.round(window.scrollX);
|
|
||||||
let sourceLeft = sourceRect.left;
|
|
||||||
let sourceTop = sourceRect.top;
|
|
||||||
let sourceWidth = sourceRect.width;
|
|
||||||
let sourceHeight = sourceRect.height;
|
|
||||||
|
|
||||||
const modalHtml = `
|
|
||||||
<div id="notice-modal" class="notice-modal-overlay" style="opacity: 0;">
|
|
||||||
<div class="notice-modal-transition" style="
|
|
||||||
position: fixed;
|
|
||||||
left: ${sourceLeft + scrollX}px;
|
|
||||||
top: ${sourceTop + scrollY}px;
|
|
||||||
width: ${sourceWidth}px;
|
|
||||||
height: ${sourceHeight}px;
|
|
||||||
transform-origin: center;
|
|
||||||
z-index: 10001;
|
|
||||||
">
|
|
||||||
<div class="notice-modal-content notice-transitioning">
|
|
||||||
<div class="notice-unified-content notice-card-state">
|
|
||||||
<div class="notice-header">
|
|
||||||
<div class="notice-badge-row">
|
|
||||||
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
|
|
||||||
${notice.label_title || "General"}
|
|
||||||
</span>
|
|
||||||
<span class="notice-staff">${notice.staff}</span>
|
|
||||||
</div>
|
|
||||||
<button class="notice-close-btn">×</button>
|
|
||||||
</div>
|
|
||||||
<h2 class="notice-content-title">${notice.title}</h2>
|
|
||||||
<div class="notice-content-body">${cleanContent}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const modal = stringToHTML(modalHtml).firstChild as HTMLElement;
|
|
||||||
const transitionContainer = modal.querySelector(
|
|
||||||
".notice-modal-transition",
|
|
||||||
) as HTMLElement;
|
|
||||||
const unifiedContent = modal.querySelector(
|
|
||||||
".notice-unified-content",
|
|
||||||
) as HTMLElement;
|
|
||||||
const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
|
|
||||||
sourceElement.setAttribute("data-transitioning", "true");
|
|
||||||
sourceElement.style.opacity = "0";
|
|
||||||
sourceElement.style.transform = "scale(0.95)";
|
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
let targetWidth = Math.round(
|
|
||||||
Math.min(Math.max(sourceWidth, 800), viewportWidth - 40),
|
|
||||||
);
|
|
||||||
|
|
||||||
const tempMeasureDiv = document.createElement("div");
|
|
||||||
tempMeasureDiv.style.position = "absolute";
|
|
||||||
tempMeasureDiv.style.left = "-9999px";
|
|
||||||
tempMeasureDiv.style.width = targetWidth + "px";
|
|
||||||
tempMeasureDiv.style.visibility = "hidden";
|
|
||||||
tempMeasureDiv.innerHTML = `
|
|
||||||
<div class="notice-unified-content notice-modal-state" style="position: relative; width: 100%; padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1);">
|
|
||||||
<div class="notice-header">
|
|
||||||
<div class="notice-badge-row">
|
|
||||||
<span class="notice-badge">${notice.label_title || "General"}</span>
|
|
||||||
<span class="notice-staff">${notice.staff}</span>
|
|
||||||
</div>
|
|
||||||
<button class="notice-close-btn">×</button>
|
|
||||||
</div>
|
|
||||||
<h2 class="notice-content-title">${notice.title}</h2>
|
|
||||||
<div class="notice-content-body">${cleanContent}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(tempMeasureDiv);
|
|
||||||
const measuredHeight =
|
|
||||||
tempMeasureDiv.firstElementChild!.getBoundingClientRect().height;
|
|
||||||
document.body.removeChild(tempMeasureDiv);
|
|
||||||
|
|
||||||
let targetHeight = Math.round(
|
|
||||||
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
|
|
||||||
);
|
|
||||||
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
|
|
||||||
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
document.removeEventListener("keydown", handleEscape);
|
|
||||||
|
|
||||||
if (!settingsState.animations) {
|
|
||||||
modal.remove();
|
|
||||||
sourceElement.style.opacity = "1";
|
|
||||||
sourceElement.style.transform = "";
|
|
||||||
sourceElement.removeAttribute("data-transitioning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
animate(
|
|
||||||
modal,
|
|
||||||
{
|
|
||||||
backgroundColor: ["rgba(0, 0, 0, 0.5)", "rgba(0, 0, 0, 0)"],
|
|
||||||
backdropFilter: ["blur(4px)", "blur(0px)"],
|
|
||||||
},
|
|
||||||
{ duration: 0.2 },
|
|
||||||
);
|
|
||||||
|
|
||||||
animate(
|
|
||||||
transitionContainer,
|
|
||||||
{ opacity: [1, 0] },
|
|
||||||
{ duration: 0.2, delay: 0.3 },
|
|
||||||
);
|
|
||||||
|
|
||||||
sourceElement.style.opacity = "1";
|
|
||||||
sourceElement.style.transform = "";
|
|
||||||
|
|
||||||
modal.style.pointerEvents = "none";
|
|
||||||
|
|
||||||
animate(
|
|
||||||
transitionContainer,
|
|
||||||
{
|
|
||||||
left: [targetLeft + scrollX, sourceLeft + scrollX],
|
|
||||||
top: [targetTop, sourceTop + scrollY],
|
|
||||||
width: [targetWidth, sourceWidth],
|
|
||||||
height: [targetHeight, sourceHeight],
|
|
||||||
scale: [1, 1],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
duration: 0.35,
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 400,
|
|
||||||
damping: 35,
|
|
||||||
},
|
|
||||||
).finished.then(async () => {
|
|
||||||
modal.remove();
|
|
||||||
sourceElement.removeAttribute("data-transitioning");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
closeBtn?.addEventListener("click", closeModal);
|
|
||||||
modal?.addEventListener("click", (e) => {
|
|
||||||
if (e.target === modal) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
closeModal();
|
|
||||||
document.removeEventListener("keydown", handleEscape);
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleEscape);
|
|
||||||
|
|
||||||
const handleResize = () => {
|
|
||||||
const newSourceRect = sourceElement.getBoundingClientRect();
|
|
||||||
const newScrollY = Math.round(window.scrollY);
|
|
||||||
const newScrollX = Math.round(window.scrollX);
|
|
||||||
|
|
||||||
// Get the current scale applied to the source element and compensate for it
|
|
||||||
const computedStyle = getComputedStyle(sourceElement);
|
|
||||||
const transform = computedStyle.transform;
|
|
||||||
let scaleX = 1,
|
|
||||||
scaleY = 1;
|
|
||||||
|
|
||||||
if (transform && transform !== "none") {
|
|
||||||
const matrix = transform.match(/matrix.*\((.+)\)/);
|
|
||||||
if (matrix) {
|
|
||||||
const values = matrix[1].split(", ");
|
|
||||||
scaleX = parseFloat(values[0]);
|
|
||||||
scaleY = parseFloat(values[3]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply inverse scale to get true original dimensions and positions
|
|
||||||
const newSourceWidth = newSourceRect.width / scaleX;
|
|
||||||
const newSourceHeight = newSourceRect.height / scaleY;
|
|
||||||
|
|
||||||
// Calculate position shift due to center-based scaling
|
|
||||||
const deltaX = (newSourceWidth - newSourceRect.width) / 2;
|
|
||||||
const deltaY = (newSourceHeight - newSourceRect.height) / 2;
|
|
||||||
|
|
||||||
const newSourceLeft = newSourceRect.left - deltaX;
|
|
||||||
const newSourceTop = newSourceRect.top - deltaY;
|
|
||||||
|
|
||||||
const newViewportWidth = window.innerWidth;
|
|
||||||
const newViewportHeight = window.innerHeight;
|
|
||||||
const newTargetWidth = Math.round(
|
|
||||||
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
|
|
||||||
);
|
|
||||||
const currentHeight = unifiedContent.getBoundingClientRect().height;
|
|
||||||
const newTargetHeight = Math.round(
|
|
||||||
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
|
|
||||||
);
|
|
||||||
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
|
|
||||||
const newTargetTop =
|
|
||||||
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
|
|
||||||
|
|
||||||
transitionContainer.style.left =
|
|
||||||
Math.round(newTargetLeft + newScrollX) + "px";
|
|
||||||
transitionContainer.style.top = Math.round(newTargetTop) + "px";
|
|
||||||
transitionContainer.style.width = Math.round(newTargetWidth) + "px";
|
|
||||||
transitionContainer.style.height = Math.round(newTargetHeight) + "px";
|
|
||||||
|
|
||||||
sourceLeft = newSourceLeft;
|
|
||||||
sourceTop = newSourceTop;
|
|
||||||
sourceWidth = newSourceWidth;
|
|
||||||
sourceHeight = newSourceHeight;
|
|
||||||
targetLeft = newTargetLeft;
|
|
||||||
targetTop = newTargetTop;
|
|
||||||
targetWidth = newTargetWidth;
|
|
||||||
targetHeight = newTargetHeight;
|
|
||||||
scrollY = newScrollY;
|
|
||||||
scrollX = newScrollX;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
|
|
||||||
if (settingsState.animations) {
|
|
||||||
animate(modal, { opacity: [0, 1] }, { duration: 0.2 });
|
|
||||||
|
|
||||||
animate(
|
|
||||||
transitionContainer,
|
|
||||||
{
|
|
||||||
left: [sourceLeft + scrollX, targetLeft + scrollX],
|
|
||||||
top: [sourceTop + scrollY, targetTop],
|
|
||||||
width: [sourceWidth, targetWidth],
|
|
||||||
height: [sourceHeight, targetHeight],
|
|
||||||
scale: [1, 1],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
duration: 0.5,
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 280,
|
|
||||||
damping: 24,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
unifiedContent.classList.remove("notice-card-state");
|
|
||||||
unifiedContent.classList.add("notice-modal-state");
|
|
||||||
} else {
|
|
||||||
modal.style.opacity = "1";
|
|
||||||
transitionContainer.style.left = Math.round(targetLeft + scrollX) + "px";
|
|
||||||
transitionContainer.style.top = Math.round(targetTop) + "px";
|
|
||||||
transitionContainer.style.width = Math.round(targetWidth) + "px";
|
|
||||||
transitionContainer.style.height = Math.round(targetHeight) + "px";
|
|
||||||
unifiedContent.classList.remove("notice-card-state");
|
|
||||||
unifiedContent.classList.add("notice-modal-state");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function callHomeTimetable(date: string, change?: any) {
|
function callHomeTimetable(date: string, change?: any) {
|
||||||
var xhr = new XMLHttpRequest();
|
var xhr = new XMLHttpRequest();
|
||||||
@@ -1287,32 +876,5 @@ function CreateSubjectFilter(
|
|||||||
function SetTimetableSubtitle() {
|
function SetTimetableSubtitle() {
|
||||||
const homelessonsubtitle = document.getElementById("home-lesson-subtitle");
|
const homelessonsubtitle = document.getElementById("home-lesson-subtitle");
|
||||||
if (!homelessonsubtitle) return;
|
if (!homelessonsubtitle) return;
|
||||||
|
homelessonsubtitle.innerText = lessonsSubtitleForViewDate(currentSelectedDate);
|
||||||
const date = new Date();
|
|
||||||
const isSameMonth =
|
|
||||||
date.getFullYear() === currentSelectedDate.getFullYear() &&
|
|
||||||
date.getMonth() === currentSelectedDate.getMonth();
|
|
||||||
|
|
||||||
if (isSameMonth) {
|
|
||||||
const dayDiff = date.getDate() - currentSelectedDate.getDate();
|
|
||||||
switch (dayDiff) {
|
|
||||||
case 0:
|
|
||||||
homelessonsubtitle.innerText = "Today's Lessons";
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
homelessonsubtitle.innerText = "Yesterday's Lessons";
|
|
||||||
break;
|
|
||||||
case -1:
|
|
||||||
homelessonsubtitle.innerText = "Tomorrow's Lessons";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
homelessonsubtitle.innerText = formatDateString(currentSelectedDate);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
homelessonsubtitle.innerText = formatDateString(currentSelectedDate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateString(date: Date): string {
|
|
||||||
return `${date.toLocaleString("en-us", { weekday: "short" })} ${date.toLocaleDateString("en-au")}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** Shared "Today's Lessons" / relative day labels for Learn and Engage home timetables. */
|
||||||
|
export function formatTimetableDayLabel(date: Date): string {
|
||||||
|
return `${date.toLocaleString("en-us", { weekday: "short" })} ${date.toLocaleDateString("en-au")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lessonsSubtitleForViewDate(viewDate: Date): string {
|
||||||
|
const today = new Date();
|
||||||
|
const isSameMonth =
|
||||||
|
today.getFullYear() === viewDate.getFullYear() &&
|
||||||
|
today.getMonth() === viewDate.getMonth();
|
||||||
|
|
||||||
|
if (isSameMonth) {
|
||||||
|
const dayDiff = today.getDate() - viewDate.getDate();
|
||||||
|
switch (dayDiff) {
|
||||||
|
case 0:
|
||||||
|
return "Today's Lessons";
|
||||||
|
case 1:
|
||||||
|
return "Yesterday's Lessons";
|
||||||
|
case -1:
|
||||||
|
return "Tomorrow's Lessons";
|
||||||
|
default:
|
||||||
|
return formatTimetableDayLabel(viewDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatTimetableDayLabel(viewDate);
|
||||||
|
}
|
||||||
@@ -3,32 +3,21 @@ import { verboseInfo } from "@/utils/verboseLog";
|
|||||||
|
|
||||||
const STYLE_ID = "bsplus-menuitem-visibility";
|
const STYLE_ID = "bsplus-menuitem-visibility";
|
||||||
|
|
||||||
function isEditSidebarOpen(): boolean {
|
|
||||||
return document.querySelector(".editmenuoption-container") != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideRule(menuItem: string): string {
|
|
||||||
return `li[data-key=${menuItem}],section[data-key=${menuItem}]{display:var(--menuHidden) !important;transition:1s;}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a sidebar key is hidden via Edit Sidebar toggles. */
|
/** Whether a sidebar key is hidden via Edit Sidebar toggles. */
|
||||||
export function isMenuItemHidden(key: string): boolean {
|
export function isMenuItemHidden(key: string): boolean {
|
||||||
const items = settingsState.menuitems as Record<string, { toggle?: boolean }>;
|
const entry = (settingsState.menuitems as Record<string, { toggle?: boolean }>)?.[key];
|
||||||
const entry = items?.[key];
|
|
||||||
return entry != null && entry.toggle === false;
|
return entry != null && entry.toggle === false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply hide rules from `menuitems` (re-runnable after edit / storage sync). */
|
/** Apply hide rules from `menuitems` (re-runnable after edit / storage sync). */
|
||||||
export function applyMenuItemVisibility(): void {
|
export function applyMenuItemVisibility(): void {
|
||||||
if (isEditSidebarOpen()) return;
|
if (document.querySelector(".editmenuoption-container")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let css = "";
|
let css = "";
|
||||||
for (const [menuItem, config] of Object.entries(
|
for (const [menuItem, config] of Object.entries(settingsState.menuitems ?? {})) {
|
||||||
settingsState.menuitems ?? {},
|
|
||||||
)) {
|
|
||||||
if (config && !config.toggle) {
|
if (config && !config.toggle) {
|
||||||
css += hideRule(menuItem);
|
css += `li[data-key=${menuItem}],section[data-key=${menuItem}]{display:var(--menuHidden) !important;transition:1s;}`;
|
||||||
verboseInfo(`[BetterSEQTA+] Hiding ${menuItem} menu item`);
|
verboseInfo(`[BetterSEQTA+] Hiding ${menuItem} menu item`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import { animate } from "motion";
|
||||||
|
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
||||||
|
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
||||||
|
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||||
|
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
||||||
|
import debounce from "@/seqta/utils/debounce";
|
||||||
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
import { noticeMatchesLabelFilter } from "@/seqta/utils/notices/noticeLabelFilters";
|
||||||
|
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||||
|
|
||||||
|
export function processNoticeColor(colour: unknown): string | undefined {
|
||||||
|
if (typeof colour !== "string") return undefined;
|
||||||
|
const rgb = GetThresholdOfColor(colour);
|
||||||
|
if (rgb < 100 && settingsState.DarkMode) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return colour;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendNoticeEmptyState(container: HTMLElement, message: string) {
|
||||||
|
const emptyState = document.createElement("div");
|
||||||
|
emptyState.classList.add("day-empty");
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = resolveExtensionAssetUrl(LogoLight);
|
||||||
|
const text = document.createElement("p");
|
||||||
|
text.innerText = message;
|
||||||
|
emptyState.append(img, text);
|
||||||
|
container.append(emptyState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNoticeElement(notice: any, colour: string | undefined): Node {
|
||||||
|
const textPreview =
|
||||||
|
notice.contents
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.substring(0, 150) + (notice.contents.length > 150 ? "..." : "");
|
||||||
|
|
||||||
|
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
const htmlContent = `
|
||||||
|
<div class="notice-unified-content notice-card-state" data-notice-id="${noticeId}" style="--colour: ${colour || "#8e8e8e"}; position: relative; background: var(--background-primary); cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);">
|
||||||
|
<div class="notice-header">
|
||||||
|
<div class="notice-badge-row">
|
||||||
|
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
|
||||||
|
${notice.label_title || "General"}
|
||||||
|
</span>
|
||||||
|
<span class="notice-staff">${notice.staff}</span>
|
||||||
|
</div>
|
||||||
|
<button class="notice-close-btn" style="opacity: 0; pointer-events: none;">×</button>
|
||||||
|
</div>
|
||||||
|
<h2 class="notice-content-title">${notice.title}</h2>
|
||||||
|
<div class="notice-content-body">${textPreview}</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
|
||||||
|
element.addEventListener("click", () =>
|
||||||
|
openNoticeModal(notice, colour, element),
|
||||||
|
);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openNoticeModal(
|
||||||
|
notice: any,
|
||||||
|
colour: string | undefined,
|
||||||
|
sourceElement: HTMLElement,
|
||||||
|
) {
|
||||||
|
const cleanContent = notice.contents
|
||||||
|
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||||
|
.replace(/ +/, " ");
|
||||||
|
|
||||||
|
document.getElementById("notice-modal")?.remove();
|
||||||
|
|
||||||
|
const sourceRect = sourceElement.getBoundingClientRect();
|
||||||
|
let scrollY = Math.round(window.scrollY);
|
||||||
|
let scrollX = Math.round(window.scrollX);
|
||||||
|
let sourceLeft = sourceRect.left;
|
||||||
|
let sourceTop = sourceRect.top;
|
||||||
|
let sourceWidth = sourceRect.width;
|
||||||
|
let sourceHeight = sourceRect.height;
|
||||||
|
|
||||||
|
const modalHtml = `
|
||||||
|
<div id="notice-modal" class="notice-modal-overlay" style="opacity: 0;">
|
||||||
|
<div class="notice-modal-transition" style="
|
||||||
|
position: fixed;
|
||||||
|
left: ${sourceLeft + scrollX}px;
|
||||||
|
top: ${sourceTop + scrollY}px;
|
||||||
|
width: ${sourceWidth}px;
|
||||||
|
height: ${sourceHeight}px;
|
||||||
|
transform-origin: center;
|
||||||
|
z-index: 10001;
|
||||||
|
">
|
||||||
|
<div class="notice-modal-content notice-transitioning">
|
||||||
|
<div class="notice-unified-content notice-card-state">
|
||||||
|
<div class="notice-header">
|
||||||
|
<div class="notice-badge-row">
|
||||||
|
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
|
||||||
|
${notice.label_title || "General"}
|
||||||
|
</span>
|
||||||
|
<span class="notice-staff">${notice.staff}</span>
|
||||||
|
</div>
|
||||||
|
<button class="notice-close-btn">×</button>
|
||||||
|
</div>
|
||||||
|
<h2 class="notice-content-title">${notice.title}</h2>
|
||||||
|
<div class="notice-content-body">${cleanContent}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const modal = stringToHTML(modalHtml).firstChild as HTMLElement;
|
||||||
|
const transitionContainer = modal.querySelector(
|
||||||
|
".notice-modal-transition",
|
||||||
|
) as HTMLElement;
|
||||||
|
const unifiedContent = modal.querySelector(
|
||||||
|
".notice-unified-content",
|
||||||
|
) as HTMLElement;
|
||||||
|
const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
sourceElement.setAttribute("data-transitioning", "true");
|
||||||
|
sourceElement.style.opacity = "0";
|
||||||
|
sourceElement.style.transform = "scale(0.95)";
|
||||||
|
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
let targetWidth = Math.round(
|
||||||
|
Math.min(Math.max(sourceWidth, 800), viewportWidth - 40),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tempMeasureDiv = document.createElement("div");
|
||||||
|
tempMeasureDiv.style.position = "absolute";
|
||||||
|
tempMeasureDiv.style.left = "-9999px";
|
||||||
|
tempMeasureDiv.style.width = targetWidth + "px";
|
||||||
|
tempMeasureDiv.style.visibility = "hidden";
|
||||||
|
tempMeasureDiv.innerHTML = `
|
||||||
|
<div class="notice-unified-content notice-modal-state" style="position: relative; width: 100%; padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1);">
|
||||||
|
<div class="notice-header">
|
||||||
|
<div class="notice-badge-row">
|
||||||
|
<span class="notice-badge">${notice.label_title || "General"}</span>
|
||||||
|
<span class="notice-staff">${notice.staff}</span>
|
||||||
|
</div>
|
||||||
|
<button class="notice-close-btn">×</button>
|
||||||
|
</div>
|
||||||
|
<h2 class="notice-content-title">${notice.title}</h2>
|
||||||
|
<div class="notice-content-body">${cleanContent}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(tempMeasureDiv);
|
||||||
|
const measuredHeight =
|
||||||
|
tempMeasureDiv.firstElementChild!.getBoundingClientRect().height;
|
||||||
|
document.body.removeChild(tempMeasureDiv);
|
||||||
|
|
||||||
|
let targetHeight = Math.round(
|
||||||
|
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
|
||||||
|
);
|
||||||
|
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
|
||||||
|
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
|
||||||
|
if (!settingsState.animations) {
|
||||||
|
modal.remove();
|
||||||
|
sourceElement.style.opacity = "1";
|
||||||
|
sourceElement.style.transform = "";
|
||||||
|
sourceElement.removeAttribute("data-transitioning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
animate(
|
||||||
|
modal,
|
||||||
|
{
|
||||||
|
backgroundColor: ["rgba(0, 0, 0, 0.5)", "rgba(0, 0, 0, 0)"],
|
||||||
|
backdropFilter: ["blur(4px)", "blur(0px)"],
|
||||||
|
},
|
||||||
|
{ duration: 0.2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
animate(
|
||||||
|
transitionContainer,
|
||||||
|
{ opacity: [1, 0] },
|
||||||
|
{ duration: 0.2, delay: 0.3 },
|
||||||
|
);
|
||||||
|
|
||||||
|
sourceElement.style.opacity = "1";
|
||||||
|
sourceElement.style.transform = "";
|
||||||
|
|
||||||
|
modal.style.pointerEvents = "none";
|
||||||
|
|
||||||
|
animate(
|
||||||
|
transitionContainer,
|
||||||
|
{
|
||||||
|
left: [targetLeft + scrollX, sourceLeft + scrollX],
|
||||||
|
top: [targetTop, sourceTop + scrollY],
|
||||||
|
width: [targetWidth, sourceWidth],
|
||||||
|
height: [targetHeight, sourceHeight],
|
||||||
|
scale: [1, 1],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
duration: 0.35,
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 400,
|
||||||
|
damping: 35,
|
||||||
|
},
|
||||||
|
).finished.then(async () => {
|
||||||
|
modal.remove();
|
||||||
|
sourceElement.removeAttribute("data-transitioning");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
closeBtn?.addEventListener("click", closeModal);
|
||||||
|
modal?.addEventListener("click", (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
closeModal();
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
const newSourceRect = sourceElement.getBoundingClientRect();
|
||||||
|
const newScrollY = Math.round(window.scrollY);
|
||||||
|
const newScrollX = Math.round(window.scrollX);
|
||||||
|
|
||||||
|
const computedStyle = getComputedStyle(sourceElement);
|
||||||
|
const transform = computedStyle.transform;
|
||||||
|
let scaleX = 1,
|
||||||
|
scaleY = 1;
|
||||||
|
|
||||||
|
if (transform && transform !== "none") {
|
||||||
|
const matrix = transform.match(/matrix.*\((.+)\)/);
|
||||||
|
if (matrix) {
|
||||||
|
const values = matrix[1].split(", ");
|
||||||
|
scaleX = parseFloat(values[0]);
|
||||||
|
scaleY = parseFloat(values[3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSourceWidth = newSourceRect.width / scaleX;
|
||||||
|
const newSourceHeight = newSourceRect.height / scaleY;
|
||||||
|
|
||||||
|
const deltaX = (newSourceWidth - newSourceRect.width) / 2;
|
||||||
|
const deltaY = (newSourceHeight - newSourceRect.height) / 2;
|
||||||
|
|
||||||
|
const newSourceLeft = newSourceRect.left - deltaX;
|
||||||
|
const newSourceTop = newSourceRect.top - deltaY;
|
||||||
|
|
||||||
|
const newViewportWidth = window.innerWidth;
|
||||||
|
const newViewportHeight = window.innerHeight;
|
||||||
|
const newTargetWidth = Math.round(
|
||||||
|
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
|
||||||
|
);
|
||||||
|
const currentHeight = unifiedContent.getBoundingClientRect().height;
|
||||||
|
const newTargetHeight = Math.round(
|
||||||
|
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
|
||||||
|
);
|
||||||
|
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
|
||||||
|
const newTargetTop =
|
||||||
|
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
|
||||||
|
|
||||||
|
transitionContainer.style.left =
|
||||||
|
Math.round(newTargetLeft + newScrollX) + "px";
|
||||||
|
transitionContainer.style.top = Math.round(newTargetTop) + "px";
|
||||||
|
transitionContainer.style.width = Math.round(newTargetWidth) + "px";
|
||||||
|
transitionContainer.style.height = Math.round(newTargetHeight) + "px";
|
||||||
|
|
||||||
|
sourceLeft = newSourceLeft;
|
||||||
|
sourceTop = newSourceTop;
|
||||||
|
sourceWidth = newSourceWidth;
|
||||||
|
sourceHeight = newSourceHeight;
|
||||||
|
targetLeft = newTargetLeft;
|
||||||
|
targetTop = newTargetTop;
|
||||||
|
targetWidth = newTargetWidth;
|
||||||
|
targetHeight = newTargetHeight;
|
||||||
|
scrollY = newScrollY;
|
||||||
|
scrollX = newScrollX;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
if (settingsState.animations) {
|
||||||
|
animate(modal, { opacity: [0, 1] }, { duration: 0.2 });
|
||||||
|
|
||||||
|
animate(
|
||||||
|
transitionContainer,
|
||||||
|
{
|
||||||
|
left: [sourceLeft + scrollX, targetLeft + scrollX],
|
||||||
|
top: [sourceTop + scrollY, targetTop],
|
||||||
|
width: [sourceWidth, targetWidth],
|
||||||
|
height: [sourceHeight, targetHeight],
|
||||||
|
scale: [1, 1],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
duration: 0.5,
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 280,
|
||||||
|
damping: 24,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
unifiedContent.classList.remove("notice-card-state");
|
||||||
|
unifiedContent.classList.add("notice-modal-state");
|
||||||
|
} else {
|
||||||
|
modal.style.opacity = "1";
|
||||||
|
transitionContainer.style.left = Math.round(targetLeft + scrollX) + "px";
|
||||||
|
transitionContainer.style.top = Math.round(targetTop) + "px";
|
||||||
|
transitionContainer.style.width = Math.round(targetWidth) + "px";
|
||||||
|
transitionContainer.style.height = Math.round(targetHeight) + "px";
|
||||||
|
unifiedContent.classList.remove("notice-card-state");
|
||||||
|
unifiedContent.classList.add("notice-modal-state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderNoticesIntoContainer(
|
||||||
|
containerId: string,
|
||||||
|
response: { payload?: unknown },
|
||||||
|
labelTokens: string[],
|
||||||
|
emptyMessage = "No notices for today.",
|
||||||
|
): void {
|
||||||
|
const noticeContainer = document.getElementById(containerId);
|
||||||
|
if (!noticeContainer) return;
|
||||||
|
|
||||||
|
noticeContainer.classList.remove("loading");
|
||||||
|
noticeContainer.innerHTML = "";
|
||||||
|
|
||||||
|
const notices = response?.payload;
|
||||||
|
if (!Array.isArray(notices) || !notices.length) {
|
||||||
|
appendNoticeEmptyState(noticeContainer, emptyMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
notices.forEach((notice: any) => {
|
||||||
|
const shouldInclude =
|
||||||
|
settingsState.mockNotices || noticeMatchesLabelFilter(notice, labelTokens);
|
||||||
|
|
||||||
|
if (shouldInclude) {
|
||||||
|
const colour = processNoticeColor(notice.colour);
|
||||||
|
fragment.appendChild(createNoticeElement(notice, colour));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fragment.childNodes.length === 0) {
|
||||||
|
appendNoticeEmptyState(noticeContainer, emptyMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
noticeContainer.appendChild(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNoticesForDate(
|
||||||
|
containerId: string,
|
||||||
|
date: string,
|
||||||
|
noticesUrl: string,
|
||||||
|
labelTokens: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (container) {
|
||||||
|
container.classList.add("loading");
|
||||||
|
container.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = settingsState.mockNotices
|
||||||
|
? getMockNotices()
|
||||||
|
: await (
|
||||||
|
await fetch(noticesUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ date }),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
|
||||||
|
renderNoticesIntoContainer(containerId, data, labelTokens);
|
||||||
|
} catch {
|
||||||
|
renderNoticesIntoContainer(containerId, { payload: [] }, labelTokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SetupNoticesSectionOptions = {
|
||||||
|
containerId: string;
|
||||||
|
dateInput: HTMLInputElement | string;
|
||||||
|
noticesUrl: string;
|
||||||
|
labelTokens: string[];
|
||||||
|
initialDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Wire date picker + initial fetch for a home-page notices block. Returns cleanup. */
|
||||||
|
export function setupNoticesSection(options: SetupNoticesSectionOptions): () => void {
|
||||||
|
const dateControl =
|
||||||
|
typeof options.dateInput === "string"
|
||||||
|
? (document.querySelector(options.dateInput) as HTMLInputElement | null)
|
||||||
|
: options.dateInput;
|
||||||
|
|
||||||
|
if (dateControl) {
|
||||||
|
dateControl.value = options.initialDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedInputChange = debounce((e: Event) => {
|
||||||
|
void fetchNoticesForDate(
|
||||||
|
options.containerId,
|
||||||
|
(e.target as HTMLInputElement).value,
|
||||||
|
options.noticesUrl,
|
||||||
|
options.labelTokens,
|
||||||
|
);
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
dateControl?.addEventListener("input", debouncedInputChange);
|
||||||
|
void fetchNoticesForDate(
|
||||||
|
options.containerId,
|
||||||
|
options.initialDate,
|
||||||
|
options.noticesUrl,
|
||||||
|
options.labelTokens,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => dateControl?.removeEventListener("input", debouncedInputChange);
|
||||||
|
}
|
||||||
@@ -1,29 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* SEQTA Learn bug (vanilla too): MainMenu.updateColours uses
|
* Timetable colour save recovery (#221): broken menu.update.colours in PAGE context
|
||||||
* `.each(function (item) { this.options... }).bind(this)` — the bind is on
|
* (injected script) plus Coloris / overlay cleanup in the content script.
|
||||||
* `.each()`'s return value, not the callback. Saving a timetable subject colour
|
|
||||||
* sends `menu.update.colours` and throws.
|
|
||||||
*
|
|
||||||
* Also: ColourChooser (SlidePane + Modaliser) can leave a full-screen
|
|
||||||
* uiSlidePane / empty modaliser-container that blocks timetable clicks.
|
|
||||||
*
|
|
||||||
* Must run in the PAGE JavaScript context — inject via web_accessible script URL.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import browser from "webextension-polyfill";
|
|
||||||
import patchScript from "@/seqta/utils/seqtaMenuColourPatch.js?url";
|
import patchScript from "@/seqta/utils/seqtaMenuColourPatch.js?url";
|
||||||
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
||||||
|
import { verboseInfo } from "@/utils/verboseLog";
|
||||||
|
|
||||||
const PAGE_PATCH_LOADER_ID = "bsplus-seqta-menu-colour-patch-loader";
|
const PAGE_PATCH_LOADER_ID = "bsplus-seqta-menu-colour-patch-loader";
|
||||||
|
|
||||||
/** Remove empty or hidden modaliser shells left after colour dialog teardown. */
|
|
||||||
export function dismissStaleModaliserContainers(): number {
|
export function dismissStaleModaliserContainers(): number {
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
for (const container of document.querySelectorAll(".modaliser-container")) {
|
for (const container of document.querySelectorAll(".modaliser-container")) {
|
||||||
const modal = container.querySelector(".modaliser");
|
const modal = container.querySelector(".modaliser");
|
||||||
const empty = !modal || modal.childElementCount === 0;
|
if (!modal?.childElementCount || !container.classList.contains("visible")) {
|
||||||
const hidden = !container.classList.contains("visible");
|
|
||||||
if (empty || hidden) {
|
|
||||||
container.remove();
|
container.remove();
|
||||||
removed++;
|
removed++;
|
||||||
}
|
}
|
||||||
@@ -31,14 +21,12 @@ export function dismissStaleModaliserContainers(): number {
|
|||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove stuck SEQTA colour chooser slide panes that intercept timetable clicks. */
|
|
||||||
export function dismissStaleColourSlidePanes(
|
export function dismissStaleColourSlidePanes(
|
||||||
forceColourChooser = false,
|
forceColourChooser = false,
|
||||||
): number {
|
): number {
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
for (const pane of document.querySelectorAll(".uiSlidePane")) {
|
for (const pane of document.querySelectorAll(".uiSlidePane")) {
|
||||||
const isColourChooser = pane.querySelector(".pane.colourChooser");
|
if (pane.querySelector(".pane.colourChooser")) {
|
||||||
if (isColourChooser) {
|
|
||||||
pane.remove();
|
pane.remove();
|
||||||
removed++;
|
removed++;
|
||||||
continue;
|
continue;
|
||||||
@@ -63,6 +51,91 @@ export function dismissStaleColourDialogs(forceColourChooser = false): {
|
|||||||
return { slideRemoved, modalRemoved };
|
return { slideRemoved, modalRemoved };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setClrPickerState(reset: boolean): void {
|
||||||
|
document.body.classList.remove("clr-open");
|
||||||
|
document.documentElement.classList.remove("clr-open");
|
||||||
|
for (const picker of document.querySelectorAll(".clr-picker")) {
|
||||||
|
picker.classList.remove("clr-open");
|
||||||
|
if (!(picker instanceof HTMLElement)) continue;
|
||||||
|
if (reset) {
|
||||||
|
picker.style.removeProperty("display");
|
||||||
|
picker.style.removeProperty("pointer-events");
|
||||||
|
picker.style.removeProperty("visibility");
|
||||||
|
} else {
|
||||||
|
picker.style.display = "none";
|
||||||
|
picker.style.pointerEvents = "none";
|
||||||
|
picker.style.visibility = "hidden";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hide colour-picker / modal layers that intercept clicks after a colour save. */
|
||||||
|
export function dismissTimetableUiBlockers(): {
|
||||||
|
slideRemoved: number;
|
||||||
|
modalRemoved: number;
|
||||||
|
} {
|
||||||
|
document.body.style.removeProperty("overflow");
|
||||||
|
setClrPickerState(false);
|
||||||
|
return dismissStaleColourDialogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear inline styles that can prevent Coloris from reopening. */
|
||||||
|
export function prepareColorisPickerOpen(): void {
|
||||||
|
setClrPickerState(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let colorisRecoveryAttached = false;
|
||||||
|
let dismissTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export function attachTimetableColorisRecovery(): void {
|
||||||
|
if (colorisRecoveryAttached) return;
|
||||||
|
colorisRecoveryAttached = true;
|
||||||
|
|
||||||
|
const scheduleDismiss = () => {
|
||||||
|
if (dismissTimer !== null) clearTimeout(dismissTimer);
|
||||||
|
dismissTimer = setTimeout(() => {
|
||||||
|
dismissTimer = null;
|
||||||
|
dismissTimetableUiBlockers();
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("coloris:close", scheduleDismiss);
|
||||||
|
document.addEventListener("coloris:pick", scheduleDismiss);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest(".timetablepage")) return;
|
||||||
|
|
||||||
|
if (target.closest("[title='Choose a colour']")) {
|
||||||
|
if (dismissTimer !== null) {
|
||||||
|
clearTimeout(dismissTimer);
|
||||||
|
dismissTimer = null;
|
||||||
|
}
|
||||||
|
prepareColorisPickerOpen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target.closest(".entry")) return;
|
||||||
|
|
||||||
|
const pickerOpen =
|
||||||
|
document.body.classList.contains("clr-open") &&
|
||||||
|
document.querySelector(".clr-picker.clr-open");
|
||||||
|
if (!pickerOpen) {
|
||||||
|
const result = dismissTimetableUiBlockers();
|
||||||
|
if (result.slideRemoved > 0 || result.modalRemoved > 0) {
|
||||||
|
verboseInfo(
|
||||||
|
"[BetterSEQTA+] timetable colour: content-script cleanup",
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function installSeqtaMenuColourPatch(): void {
|
export function installSeqtaMenuColourPatch(): void {
|
||||||
if (document.getElementById(PAGE_PATCH_LOADER_ID)) return;
|
if (document.getElementById(PAGE_PATCH_LOADER_ID)) return;
|
||||||
|
|
||||||
|
|||||||
@@ -46,41 +46,25 @@ function ensureBridgeElements(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bumpBridgeRevision(): void {
|
|
||||||
const bridge = document.getElementById(BRIDGE_ID);
|
|
||||||
if (!bridge) return;
|
|
||||||
const rev = Number(bridge.getAttribute("data-rev") || "0") + 1;
|
|
||||||
bridge.setAttribute("data-rev", String(rev));
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendPayload(payload: Record<string, unknown>): void {
|
function sendPayload(payload: Record<string, unknown>): void {
|
||||||
installThemeImagePagePatch();
|
installThemeImagePagePatch();
|
||||||
ensureBridgeElements();
|
ensureBridgeElements();
|
||||||
const payloadEl = document.getElementById(PAYLOAD_ID) as HTMLTextAreaElement;
|
const payloadEl = document.getElementById(PAYLOAD_ID) as HTMLTextAreaElement;
|
||||||
payloadEl.value = JSON.stringify(payload);
|
payloadEl.value = JSON.stringify(payload);
|
||||||
bumpBridgeRevision();
|
const bridge = document.getElementById(BRIDGE_ID)!;
|
||||||
|
bridge.setAttribute("data-rev", String(Number(bridge.getAttribute("data-rev") || "0") + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncThemeToPage(input: ThemePageSyncInput): Promise<void> {
|
export async function syncThemeToPage(input: ThemePageSyncInput): Promise<void> {
|
||||||
const payload: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
if (input.clear) {
|
if (input.clear) {
|
||||||
sendPayload({ clear: true });
|
sendPayload({ clear: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.clearPreview) {
|
const payload: Record<string, unknown> = {};
|
||||||
payload.clearPreview = true;
|
if (input.clearPreview) payload.clearPreview = true;
|
||||||
}
|
if (input.customCss !== undefined) payload.customCss = input.customCss;
|
||||||
|
if (input.previewCss !== undefined) payload.previewCss = input.previewCss;
|
||||||
if (input.customCss !== undefined) {
|
|
||||||
payload.customCss = input.customCss;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.previewCss !== undefined) {
|
|
||||||
payload.previewCss = input.previewCss;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.images !== undefined) {
|
if (input.images !== undefined) {
|
||||||
payload.images = await Promise.all(
|
payload.images = await Promise.all(
|
||||||
input.images.map(async (image) => ({
|
input.images.map(async (image) => ({
|
||||||
@@ -98,15 +82,3 @@ export async function syncThemeToPage(input: ThemePageSyncInput): Promise<void>
|
|||||||
export function clearThemeInPage(): void {
|
export function clearThemeInPage(): void {
|
||||||
sendPayload({ clear: true });
|
sendPayload({ clear: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use clearThemeInPage */
|
|
||||||
export function clearThemeImagesInPage(): void {
|
|
||||||
clearThemeInPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated Use syncThemeToPage */
|
|
||||||
export async function syncThemeImagesToPage(
|
|
||||||
images: Array<{ variableName: string; blob: Blob }>,
|
|
||||||
): Promise<void> {
|
|
||||||
await syncThemeToPage({ images });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,121 +1,95 @@
|
|||||||
/**
|
/**
|
||||||
* PAGE context only — patches SEQTA menu.update.colours and removes stuck colour-dialog
|
* PAGE context — patches broken menu.update.colours and removes stuck colour-dialog layers.
|
||||||
* layers (uiSlidePane + modaliser) that block timetable entry clicks after a colour save.
|
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
if (window.__bsplusMenuColoursPatched) return;
|
if (window.__bsplusMenuColoursPatched) return;
|
||||||
|
|
||||||
var LOG = "[BetterSEQTA+] timetable colour:";
|
|
||||||
var MENU_UPDATE_COLOURS = "menu.update.colours";
|
var MENU_UPDATE_COLOURS = "menu.update.colours";
|
||||||
var SUBJECT_COLOUR_PREF_PREFIX = "timetable.subject.colour.";
|
var SUBJECT_PREFIX = "timetable.subject.colour.";
|
||||||
var TUTOR_COLOUR_PREF_PREFIX = "timetable.tutor.";
|
var TUTOR_PREFIX = "timetable.tutor.";
|
||||||
|
|
||||||
function log(event, detail) {
|
function isTesStyling() {
|
||||||
if (!document.documentElement.hasAttribute("data-bsplus-verbose-log")) return;
|
var el = document.getElementById("logo-style");
|
||||||
if (detail !== undefined) {
|
return el && el.textContent.indexOf("tesSeqta") !== -1;
|
||||||
console.info(LOG, event, detail);
|
}
|
||||||
} else {
|
|
||||||
console.info(LOG, event);
|
function dismissModalisers() {
|
||||||
|
var n = 0;
|
||||||
|
var containers = document.querySelectorAll(".modaliser-container");
|
||||||
|
for (var i = 0; i < containers.length; i++) {
|
||||||
|
var c = containers[i];
|
||||||
|
var m = c.querySelector(".modaliser");
|
||||||
|
if (!m || !m.childElementCount || !c.classList.contains("visible")) {
|
||||||
|
c.remove();
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissSlidePanes(forceColour) {
|
||||||
|
var n = 0;
|
||||||
|
var panes = document.querySelectorAll(".uiSlidePane");
|
||||||
|
for (var i = 0; i < panes.length; i++) {
|
||||||
|
var p = panes[i];
|
||||||
|
if (p.querySelector(".pane.colourChooser")) {
|
||||||
|
p.remove();
|
||||||
|
n++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!forceColour && p.classList.contains("shown")) continue;
|
||||||
|
if (!p.classList.contains("shown")) {
|
||||||
|
p.remove();
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissStaleDialogs(forceColour) {
|
||||||
|
var slide = dismissSlidePanes(forceColour);
|
||||||
|
var modal = dismissModalisers();
|
||||||
|
document.body.classList.remove("clr-open");
|
||||||
|
document.documentElement.classList.remove("clr-open");
|
||||||
|
return { slideRemoved: slide, modalRemoved: modal };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCleanup() {
|
||||||
|
var delays = [0, 100, 300, 600];
|
||||||
|
for (var i = 0; i < delays.length; i++) {
|
||||||
|
(function (d) {
|
||||||
|
setTimeout(function () {
|
||||||
|
dismissStaleDialogs(true);
|
||||||
|
}, d);
|
||||||
|
})(delays[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTesStylingEnabled() {
|
function applyMenuColours() {
|
||||||
var logoStyle = document.getElementById("logo-style");
|
|
||||||
return logoStyle && logoStyle.textContent.indexOf("tesSeqta") !== -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function countOverlayState() {
|
|
||||||
return {
|
|
||||||
slidePanes: document.querySelectorAll(".uiSlidePane").length,
|
|
||||||
slidePanesShown: document.querySelectorAll(".uiSlidePane.shown").length,
|
|
||||||
colourChoosers: document.querySelectorAll(
|
|
||||||
".uiSlidePane .pane.colourChooser",
|
|
||||||
).length,
|
|
||||||
modalisers: document.querySelectorAll(".modaliser-container").length,
|
|
||||||
modalisersVisible: document.querySelectorAll(
|
|
||||||
".modaliser-container.visible",
|
|
||||||
).length,
|
|
||||||
quickbarsVisible: document.querySelectorAll(
|
|
||||||
".timetablepage .quickbar.visible",
|
|
||||||
).length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMenuSubjectColours() {
|
|
||||||
if (!window.user) return;
|
if (!window.user) return;
|
||||||
var defaultColour = isTesStylingEnabled() ? "#2b3547" : "#dddddd";
|
var def = isTesStyling() ? "#2b3547" : "#dddddd";
|
||||||
var items = document.querySelectorAll("#menu li[data-colour]");
|
var items = document.querySelectorAll("#menu li[data-colour]");
|
||||||
for (var i = 0; i < items.length; i++) {
|
for (var i = 0; i < items.length; i++) {
|
||||||
var item = items[i];
|
var item = items[i];
|
||||||
var prefName = item.getAttribute("data-colour");
|
var prefName = item.getAttribute("data-colour");
|
||||||
if (!prefName) continue;
|
if (!prefName) continue;
|
||||||
var pref = window.user.getPreference(prefName);
|
var pref = window.user.getPreference(prefName);
|
||||||
var colour = (pref && pref.value) || defaultColour;
|
item.style.setProperty("--item-colour", (pref && pref.value) || def);
|
||||||
item.style.setProperty("--item-colour", colour);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissStaleModaliserContainers() {
|
function reconcileQuickbars() {
|
||||||
var removed = 0;
|
|
||||||
var containers = document.querySelectorAll(".modaliser-container");
|
|
||||||
for (var i = 0; i < containers.length; i++) {
|
|
||||||
var container = containers[i];
|
|
||||||
var modal = container.querySelector(".modaliser");
|
|
||||||
var empty = !modal || modal.childElementCount === 0;
|
|
||||||
var hidden = !container.classList.contains("visible");
|
|
||||||
if (empty || hidden) {
|
|
||||||
container.remove();
|
|
||||||
removed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissStaleColourSlidePanes(forceColourChooser) {
|
|
||||||
var removed = 0;
|
|
||||||
var panes = document.querySelectorAll(".uiSlidePane");
|
|
||||||
for (var i = 0; i < panes.length; i++) {
|
|
||||||
var pane = panes[i];
|
|
||||||
var isColourChooser = pane.querySelector(".pane.colourChooser");
|
|
||||||
if (isColourChooser) {
|
|
||||||
pane.remove();
|
|
||||||
removed++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!forceColourChooser && pane.classList.contains("shown")) continue;
|
|
||||||
if (!pane.classList.contains("shown")) {
|
|
||||||
pane.remove();
|
|
||||||
removed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissStaleColourDialogs(forceColourChooser) {
|
|
||||||
var slideRemoved = dismissStaleColourSlidePanes(forceColourChooser);
|
|
||||||
var modalRemoved = dismissStaleModaliserContainers();
|
|
||||||
document.body.classList.remove("clr-open");
|
|
||||||
document.documentElement.classList.remove("clr-open");
|
|
||||||
return {
|
|
||||||
slideRemoved: slideRemoved,
|
|
||||||
modalRemoved: modalRemoved,
|
|
||||||
overlays: countOverlayState(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function reconcileStuckQuickbars(reason) {
|
|
||||||
var fixed = 0;
|
var fixed = 0;
|
||||||
var quickbars = document.querySelectorAll(".timetablepage .quickbar.visible");
|
var quickbars = document.querySelectorAll(".timetablepage .quickbar.visible");
|
||||||
for (var i = 0; i < quickbars.length; i++) {
|
for (var i = 0; i < quickbars.length; i++) {
|
||||||
var qb = quickbars[i];
|
var qb = quickbars[i];
|
||||||
var wrapper = qb.querySelector(".wrapper");
|
var w = qb.querySelector(".wrapper");
|
||||||
if (!wrapper || !wrapper.childElementCount) {
|
if (!w || !w.childElementCount) {
|
||||||
qb.classList.remove("visible");
|
qb.classList.remove("visible");
|
||||||
fixed++;
|
fixed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fixed > 0) {
|
if (fixed) {
|
||||||
log("cleared stuck quickbar shell (" + reason + ")", { fixed: fixed });
|
|
||||||
try {
|
try {
|
||||||
window.msg.send("calendar.quickbar.hide");
|
window.msg.send("calendar.quickbar.hide");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -125,14 +99,14 @@
|
|||||||
return fixed;
|
return fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findEntryElement(calendarId, code) {
|
function findEntry(calendarId, code) {
|
||||||
if (calendarId) {
|
if (calendarId) {
|
||||||
var byCalendar = document.querySelector(
|
var byId = document.querySelector(
|
||||||
".timetablepage .entry[data-calendarid=\"" + calendarId + "\"]",
|
".timetablepage .entry[data-calendarid=\"" + calendarId + "\"]",
|
||||||
);
|
);
|
||||||
if (byCalendar) return byCalendar;
|
if (byId) return byId;
|
||||||
}
|
}
|
||||||
if (code) {
|
if (!code) return null;
|
||||||
var entries = document.querySelectorAll(".timetablepage .entry.class");
|
var entries = document.querySelectorAll(".timetablepage .entry.class");
|
||||||
for (var i = 0; i < entries.length; i++) {
|
for (var i = 0; i < entries.length; i++) {
|
||||||
var entry = entries[i];
|
var entry = entries[i];
|
||||||
@@ -141,14 +115,13 @@
|
|||||||
titleEl && titleEl.textContent ? titleEl.textContent.trim() : "";
|
titleEl && titleEl.textContent ? titleEl.textContent.trim() : "";
|
||||||
if (title && title.indexOf(code) !== -1) return entry;
|
if (title && title.indexOf(code) !== -1) return entry;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeQuickbarOpenContext(contents) {
|
function normalizeQuickbarContext(contents) {
|
||||||
if (!contents) return contents;
|
if (!contents) return contents;
|
||||||
|
|
||||||
reconcileStuckQuickbars("before-open");
|
reconcileQuickbars();
|
||||||
|
|
||||||
var element = contents.element;
|
var element = contents.element;
|
||||||
var calendarId =
|
var calendarId =
|
||||||
@@ -162,190 +135,74 @@
|
|||||||
document.contains(element);
|
document.contains(element);
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
var replacement = findEntryElement(calendarId, code);
|
var replacement = findEntry(calendarId, code);
|
||||||
if (replacement) {
|
if (replacement) contents.element = replacement;
|
||||||
contents.element = replacement;
|
|
||||||
log("replaced detached quickbar entry element", {
|
|
||||||
calendarId: calendarId,
|
|
||||||
code: code,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
log("quickbar entry element detached, no replacement found", {
|
|
||||||
calendarId: calendarId,
|
|
||||||
code: code,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return contents;
|
return contents;
|
||||||
}
|
}
|
||||||
|
|
||||||
function logQuickbarOpenResult(phase) {
|
function isColourPref(handle) {
|
||||||
var qb = document.querySelector(".timetablepage .quickbar.visible");
|
|
||||||
var wrapper = qb && qb.querySelector(".wrapper");
|
|
||||||
log("quickbar open result (" + phase + ")", {
|
|
||||||
visible: !!qb,
|
|
||||||
hasWrapper: !!wrapper,
|
|
||||||
wrapperChildren: wrapper ? wrapper.childElementCount : 0,
|
|
||||||
overlays: countOverlayState(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleColourDialogCleanup(reason) {
|
|
||||||
var delays = [0, 100, 300, 600];
|
|
||||||
for (var i = 0; i < delays.length; i++) {
|
|
||||||
(function (delay) {
|
|
||||||
setTimeout(function () {
|
|
||||||
var result = dismissStaleColourDialogs(true);
|
|
||||||
if (
|
|
||||||
result.slideRemoved > 0 ||
|
|
||||||
result.modalRemoved > 0 ||
|
|
||||||
delay === 0
|
|
||||||
) {
|
|
||||||
log("cleanup (" + reason + ", +" + delay + "ms)", result);
|
|
||||||
}
|
|
||||||
}, delay);
|
|
||||||
})(delays[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSubjectOrTutorColourPref(handle) {
|
|
||||||
return (
|
return (
|
||||||
typeof handle === "string" &&
|
typeof handle === "string" &&
|
||||||
(handle.indexOf(SUBJECT_COLOUR_PREF_PREFIX) === 0 ||
|
(handle.indexOf(SUBJECT_PREFIX) === 0 || handle.indexOf(TUTOR_PREFIX) === 0)
|
||||||
handle.indexOf(TUTOR_COLOUR_PREF_PREFIX) === 0)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runMenuColourUpdate() {
|
function onMenuColourUpdate() {
|
||||||
log("menu.update.colours intercepted", countOverlayState());
|
|
||||||
try {
|
try {
|
||||||
applyMenuSubjectColours();
|
applyMenuColours();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[BetterSEQTA+] menu.update.colours failed:", err);
|
console.error("[BetterSEQTA+] menu.update.colours failed:", err);
|
||||||
}
|
}
|
||||||
scheduleColourDialogCleanup("menu.update.colours");
|
scheduleCleanup();
|
||||||
reconcileStuckQuickbars("after-colour-save");
|
reconcileQuickbars();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fixedMenuColourHandler() {
|
function neutralizeBrokenListeners(msg) {
|
||||||
runMenuColourUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
function neutralizeBrokenMenuColourListeners(msg) {
|
|
||||||
var listeners = msg.listeners && msg.listeners[MENU_UPDATE_COLOURS];
|
var listeners = msg.listeners && msg.listeners[MENU_UPDATE_COLOURS];
|
||||||
if (!listeners) return;
|
if (!listeners) return;
|
||||||
for (var i = 0; i < listeners.length; i++) {
|
for (var i = 0; i < listeners.length; i++) {
|
||||||
if (listeners[i]) {
|
if (listeners[i]) listeners[i].fn = onMenuColourUpdate;
|
||||||
listeners[i].fn = fixedMenuColourHandler;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchMsg(msg) {
|
function patchMsg(msg) {
|
||||||
if (!msg || msg.__bsplusPatched) return;
|
if (!msg || msg.__bsplusPatched) return;
|
||||||
|
|
||||||
var originalSend = msg.send.bind(msg);
|
var send = msg.send.bind(msg);
|
||||||
|
var register = msg.register.bind(msg);
|
||||||
|
|
||||||
msg.send = function (handle, contents, suppressLogs, noRecord) {
|
msg.send = function (handle, contents, suppressLogs, noRecord) {
|
||||||
if (handle === MENU_UPDATE_COLOURS) {
|
if (handle === MENU_UPDATE_COLOURS) {
|
||||||
runMenuColourUpdate();
|
onMenuColourUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isColourPref(handle)) {
|
||||||
if (isSubjectOrTutorColourPref(handle)) {
|
var result = send(handle, contents, suppressLogs, noRecord);
|
||||||
log("colour pref save detected", {
|
scheduleCleanup();
|
||||||
pref: handle,
|
reconcileQuickbars();
|
||||||
colour: contents,
|
return result;
|
||||||
overlays: countOverlayState(),
|
|
||||||
});
|
|
||||||
var prefResult = originalSend(
|
|
||||||
handle,
|
|
||||||
contents,
|
|
||||||
suppressLogs,
|
|
||||||
noRecord,
|
|
||||||
);
|
|
||||||
scheduleColourDialogCleanup("pref:" + handle);
|
|
||||||
reconcileStuckQuickbars("after-colour-save");
|
|
||||||
return prefResult;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (handle === "calendar.quickbar.class") {
|
if (handle === "calendar.quickbar.class") {
|
||||||
var openContext = normalizeQuickbarOpenContext(contents);
|
return send(
|
||||||
var label =
|
|
||||||
openContext &&
|
|
||||||
openContext.data &&
|
|
||||||
(openContext.data.description || openContext.data.code);
|
|
||||||
log("quickbar open msg.send", {
|
|
||||||
subject: label,
|
|
||||||
elementConnected:
|
|
||||||
openContext &&
|
|
||||||
openContext.element &&
|
|
||||||
openContext.element.isConnected,
|
|
||||||
overlays: countOverlayState(),
|
|
||||||
});
|
|
||||||
var openResult;
|
|
||||||
try {
|
|
||||||
openResult = originalSend(
|
|
||||||
handle,
|
handle,
|
||||||
openContext,
|
normalizeQuickbarContext(contents),
|
||||||
suppressLogs,
|
suppressLogs,
|
||||||
noRecord,
|
noRecord,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
|
||||||
console.error("[BetterSEQTA+] quickbar open failed:", err);
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
setTimeout(function () {
|
return send(handle, contents, suppressLogs, noRecord);
|
||||||
logQuickbarOpenResult("+50ms");
|
|
||||||
}, 50);
|
|
||||||
setTimeout(function () {
|
|
||||||
logQuickbarOpenResult("+200ms");
|
|
||||||
}, 200);
|
|
||||||
return openResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (handle === "calendar.quickbar.hide") {
|
|
||||||
log("quickbar hide msg.send", countOverlayState());
|
|
||||||
return originalSend(handle, contents, suppressLogs, noRecord);
|
|
||||||
}
|
|
||||||
|
|
||||||
return originalSend(handle, contents, suppressLogs, noRecord);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var originalRegister = msg.register.bind(msg);
|
|
||||||
msg.register = function (handle, callback, clear, ignoreHistory) {
|
msg.register = function (handle, callback, clear, ignoreHistory) {
|
||||||
if (handle === MENU_UPDATE_COLOURS) {
|
if (handle === MENU_UPDATE_COLOURS) {
|
||||||
return originalRegister(
|
return register(handle, onMenuColourUpdate, clear, ignoreHistory);
|
||||||
handle,
|
|
||||||
fixedMenuColourHandler,
|
|
||||||
clear,
|
|
||||||
ignoreHistory,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (handle === "calendar.quickbar.class") {
|
return register(handle, callback, clear, ignoreHistory);
|
||||||
return originalRegister(
|
|
||||||
handle,
|
|
||||||
function (context) {
|
|
||||||
log("quickbar class handler invoked", {
|
|
||||||
elementConnected:
|
|
||||||
context &&
|
|
||||||
context.element &&
|
|
||||||
context.element.isConnected,
|
|
||||||
subject:
|
|
||||||
context &&
|
|
||||||
context.data &&
|
|
||||||
(context.data.description || context.data.code),
|
|
||||||
});
|
|
||||||
return callback(context);
|
|
||||||
},
|
|
||||||
clear,
|
|
||||||
ignoreHistory,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return originalRegister(handle, callback, clear, ignoreHistory);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
neutralizeBrokenMenuColourListeners(msg);
|
neutralizeBrokenListeners(msg);
|
||||||
msg.__bsplusPatched = true;
|
msg.__bsplusPatched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,41 +211,15 @@
|
|||||||
if (!window.msg || !window.msg.send) return false;
|
if (!window.msg || !window.msg.send) return false;
|
||||||
patchMsg(window.msg);
|
patchMsg(window.msg);
|
||||||
window.__bsplusMenuColoursPatched = true;
|
window.__bsplusMenuColoursPatched = true;
|
||||||
log("patch active");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"click",
|
|
||||||
function (event) {
|
|
||||||
var target = event.target;
|
|
||||||
if (!target || !target.closest) return;
|
|
||||||
var entry = target.closest(".timetablepage .entry");
|
|
||||||
if (!entry) return;
|
|
||||||
|
|
||||||
var before = countOverlayState();
|
|
||||||
var cleanup = dismissStaleColourDialogs(false);
|
|
||||||
var calendarId = entry.getAttribute("data-calendarid");
|
|
||||||
var instance = entry.getAttribute("data-instance");
|
|
||||||
var titleEl = entry.querySelector(".title");
|
|
||||||
var title = titleEl && titleEl.textContent ? titleEl.textContent.trim() : "";
|
|
||||||
log("entry click (capture)", {
|
|
||||||
calendarId: calendarId,
|
|
||||||
instance: instance,
|
|
||||||
title: title,
|
|
||||||
before: before,
|
|
||||||
cleanup: cleanup,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!tryPatch()) {
|
if (!tryPatch()) {
|
||||||
var interval = setInterval(function () {
|
var interval = setInterval(function () {
|
||||||
if (tryPatch()) {
|
if (tryPatch()) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
} else if (window.msg) {
|
} else if (window.msg) {
|
||||||
neutralizeBrokenMenuColourListeners(window.msg);
|
neutralizeBrokenListeners(window.msg);
|
||||||
}
|
}
|
||||||
}, 25);
|
}, 25);
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
|
|||||||
@@ -45,22 +45,12 @@ export function insertKeyAfterInOrder(
|
|||||||
|
|
||||||
/** Default Analytics immediately below Courses in saved menu order. */
|
/** Default Analytics immediately below Courses in saved menu order. */
|
||||||
export function ensureAnalyticsMenuOrder(): void {
|
export function ensureAnalyticsMenuOrder(): void {
|
||||||
if (!settingsState.defaultmenuorder.includes("analytics")) {
|
for (const key of ["defaultmenuorder", "menuorder"] as const) {
|
||||||
settingsState.defaultmenuorder = insertKeyAfterInOrder(
|
const order = settingsState[key];
|
||||||
settingsState.defaultmenuorder,
|
if (key === "menuorder" && order.length === 0) continue;
|
||||||
"analytics",
|
if (!order.includes("analytics")) {
|
||||||
"courses",
|
settingsState[key] = insertKeyAfterInOrder(order, "analytics", "courses");
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
settingsState.menuorder.length > 0 &&
|
|
||||||
!settingsState.menuorder.includes("analytics")
|
|
||||||
) {
|
|
||||||
settingsState.menuorder = insertKeyAfterInOrder(
|
|
||||||
settingsState.menuorder,
|
|
||||||
"analytics",
|
|
||||||
"courses",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,139 +14,110 @@
|
|||||||
var THEME_STYLE_ID = "custom-theme";
|
var THEME_STYLE_ID = "custom-theme";
|
||||||
var PREVIEW_STYLE_ID = "custom-theme-preview";
|
var PREVIEW_STYLE_ID = "custom-theme-preview";
|
||||||
var urlCache = {};
|
var urlCache = {};
|
||||||
var state = {
|
var cssState = { custom: "", preview: "" };
|
||||||
customCss: "",
|
|
||||||
previewCss: "",
|
|
||||||
};
|
|
||||||
var headObserver = null;
|
var headObserver = null;
|
||||||
|
|
||||||
function log(event, detail) {
|
function log(event, detail) {
|
||||||
if (!document.documentElement.hasAttribute("data-bsplus-verbose-log")) return;
|
if (!document.documentElement.hasAttribute("data-bsplus-verbose-log")) return;
|
||||||
if (detail !== undefined) {
|
|
||||||
console.info(LOG, event, detail);
|
console.info(LOG, event, detail);
|
||||||
} else {
|
|
||||||
console.info(LOG, event);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64ToBlob(base64, mime) {
|
function base64ToBlob(base64, mime) {
|
||||||
var byteString = atob(base64);
|
var bytes = atob(base64);
|
||||||
var ab = new ArrayBuffer(byteString.length);
|
var ab = new ArrayBuffer(bytes.length);
|
||||||
var ia = new Uint8Array(ab);
|
var view = new Uint8Array(ab);
|
||||||
for (var i = 0; i < byteString.length; i++) {
|
for (var i = 0; i < bytes.length; i++) view[i] = bytes.charCodeAt(i);
|
||||||
ia[i] = byteString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return new Blob([ab], { type: mime || "image/png" });
|
return new Blob([ab], { type: mime || "image/png" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseCachedUrls() {
|
function releaseCachedUrls() {
|
||||||
for (var key in urlCache) {
|
for (var key in urlCache) {
|
||||||
if (!urlCache.hasOwnProperty(key)) continue;
|
if (!Object.prototype.hasOwnProperty.call(urlCache, key)) continue;
|
||||||
try {
|
try {
|
||||||
URL.revokeObjectURL(urlCache[key]);
|
URL.revokeObjectURL(urlCache[key]);
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
urlCache = {};
|
urlCache = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureStyleElement(id) {
|
function styleEl(id) {
|
||||||
var style = document.getElementById(id);
|
var el = document.getElementById(id);
|
||||||
if (!style) {
|
if (!el) {
|
||||||
style = document.createElement("style");
|
el = document.createElement("style");
|
||||||
style.id = id;
|
el.id = id;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(el);
|
||||||
}
|
}
|
||||||
return style;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureThemeStyleLast() {
|
function keepThemeStyleLast() {
|
||||||
var style = document.getElementById(THEME_STYLE_ID);
|
var themeStyle = document.getElementById(THEME_STYLE_ID);
|
||||||
if (!style || !document.head.contains(style)) return;
|
if (themeStyle && document.head.contains(themeStyle) && document.head.lastElementChild !== themeStyle) {
|
||||||
if (document.head.lastElementChild === style) return;
|
document.head.appendChild(themeStyle);
|
||||||
document.head.appendChild(style);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureHeadObserver() {
|
function watchHead() {
|
||||||
if (headObserver) return;
|
if (headObserver) return;
|
||||||
headObserver = new MutationObserver(function () {
|
headObserver = new MutationObserver(keepThemeStyleLast);
|
||||||
ensureThemeStyleLast();
|
|
||||||
});
|
|
||||||
headObserver.observe(document.head, { childList: true });
|
headObserver.observe(document.head, { childList: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setStyleText(id, text, watchThemeOrder) {
|
||||||
|
if (!text) {
|
||||||
|
document.getElementById(id)?.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
styleEl(id).textContent = text;
|
||||||
|
if (watchThemeOrder) {
|
||||||
|
watchHead();
|
||||||
|
keepThemeStyleLast();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
releaseCachedUrls();
|
releaseCachedUrls();
|
||||||
state.customCss = "";
|
cssState.custom = "";
|
||||||
state.previewCss = "";
|
cssState.preview = "";
|
||||||
var imagesStyle = document.getElementById(IMAGES_STYLE_ID);
|
setStyleText(IMAGES_STYLE_ID, "");
|
||||||
if (imagesStyle) imagesStyle.textContent = "";
|
document.getElementById(THEME_STYLE_ID)?.remove();
|
||||||
var themeStyle = document.getElementById(THEME_STYLE_ID);
|
document.getElementById(PREVIEW_STYLE_ID)?.remove();
|
||||||
if (themeStyle) themeStyle.remove();
|
headObserver?.disconnect();
|
||||||
var previewStyle = document.getElementById(PREVIEW_STYLE_ID);
|
|
||||||
if (previewStyle) previewStyle.remove();
|
|
||||||
if (headObserver) {
|
|
||||||
headObserver.disconnect();
|
|
||||||
headObserver = null;
|
headObserver = null;
|
||||||
}
|
|
||||||
log("cleared");
|
log("cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyThemeImages(images) {
|
function applyThemeImages(images) {
|
||||||
releaseCachedUrls();
|
releaseCachedUrls();
|
||||||
if (!images || !images.length) {
|
if (!images?.length) {
|
||||||
var emptyStyle = document.getElementById(IMAGES_STYLE_ID);
|
setStyleText(IMAGES_STYLE_ID, "");
|
||||||
if (emptyStyle) emptyStyle.textContent = "";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var lines = [":root {"];
|
var lines = [":root {"];
|
||||||
for (var i = 0; i < images.length; i++) {
|
for (var i = 0; i < images.length; i++) {
|
||||||
var img = images[i];
|
var img = images[i];
|
||||||
if (!img || !img.variableName || !img.data) continue;
|
if (!img?.variableName || !img.data) continue;
|
||||||
try {
|
try {
|
||||||
var blob = base64ToBlob(img.data, img.mime);
|
var url = URL.createObjectURL(base64ToBlob(img.data, img.mime));
|
||||||
var url = URL.createObjectURL(blob);
|
|
||||||
urlCache[img.variableName] = url;
|
urlCache[img.variableName] = url;
|
||||||
lines.push(" --" + img.variableName + ": url(\"" + url + "\");");
|
lines.push(' --' + img.variableName + ': url("' + url + '");');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(LOG, "skip image", img.variableName, e);
|
console.warn(LOG, "skip image", img.variableName, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines.push("}");
|
lines.push("}");
|
||||||
ensureStyleElement(IMAGES_STYLE_ID).textContent = lines.join("\n");
|
styleEl(IMAGES_STYLE_ID).textContent = lines.join("\n");
|
||||||
log("images applied", { count: images.length });
|
log("images applied", { count: images.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCustomCss(css) {
|
|
||||||
if (!css) {
|
|
||||||
var existing = document.getElementById(THEME_STYLE_ID);
|
|
||||||
if (existing) existing.remove();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ensureStyleElement(THEME_STYLE_ID).textContent = css;
|
|
||||||
ensureHeadObserver();
|
|
||||||
ensureThemeStyleLast();
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPreviewCss(css) {
|
|
||||||
if (!css) {
|
|
||||||
var existing = document.getElementById(PREVIEW_STYLE_ID);
|
|
||||||
if (existing) existing.remove();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ensureStyleElement(PREVIEW_STYLE_ID).textContent = css;
|
|
||||||
}
|
|
||||||
|
|
||||||
function processPayload() {
|
function processPayload() {
|
||||||
var payloadEl = document.getElementById(PAYLOAD_ID);
|
var raw = document.getElementById(PAYLOAD_ID)?.value;
|
||||||
if (!payloadEl) return;
|
|
||||||
var raw = payloadEl.value;
|
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
clearAll();
|
clearAll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var payload = JSON.parse(raw);
|
var payload = JSON.parse(raw);
|
||||||
if (!payload || payload.clear) {
|
if (!payload || payload.clear) {
|
||||||
@@ -154,24 +125,22 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.images !== undefined) {
|
if (payload.images !== undefined) applyThemeImages(payload.images);
|
||||||
applyThemeImages(payload.images);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.customCss !== undefined) {
|
if (payload.customCss !== undefined) {
|
||||||
state.customCss = payload.customCss || "";
|
cssState.custom = payload.customCss || "";
|
||||||
applyCustomCss(state.customCss);
|
setStyleText(THEME_STYLE_ID, cssState.custom, true);
|
||||||
log("custom css applied");
|
log("custom css applied");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.previewCss !== undefined) {
|
if (payload.previewCss !== undefined) {
|
||||||
state.previewCss = payload.previewCss || "";
|
cssState.preview = payload.previewCss || "";
|
||||||
applyPreviewCss(state.previewCss);
|
setStyleText(PREVIEW_STYLE_ID, cssState.preview);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.clearPreview) {
|
if (payload.clearPreview) {
|
||||||
state.previewCss = "";
|
cssState.preview = "";
|
||||||
applyPreviewCss("");
|
setStyleText(PREVIEW_STYLE_ID, "");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(LOG, "invalid payload", e);
|
console.warn(LOG, "invalid payload", e);
|
||||||
@@ -197,10 +166,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureBridge();
|
ensureBridge();
|
||||||
var bridge = document.getElementById(BRIDGE_ID);
|
new MutationObserver(processPayload).observe(document.getElementById(BRIDGE_ID), {
|
||||||
new MutationObserver(function () {
|
|
||||||
processPayload();
|
|
||||||
}).observe(bridge, {
|
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ["data-rev"],
|
attributeFilter: ["data-rev"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* SEQTA timetable colour picker (Coloris) recovery and click-blocker cleanup.
|
|
||||||
* Subject colour saves trigger SEQTA's broken menu.update.colours handler — see
|
|
||||||
* patchSeqtaMenuUpdateColours.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { dismissStaleColourDialogs } from "@/seqta/utils/patchSeqtaMenuUpdateColours";
|
|
||||||
import { verboseInfo } from "@/utils/verboseLog";
|
|
||||||
|
|
||||||
let attached = false;
|
|
||||||
let dismissTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const DISMISS_DELAY_MS = 100;
|
|
||||||
|
|
||||||
function scheduleDismiss(): void {
|
|
||||||
if (dismissTimer !== null) clearTimeout(dismissTimer);
|
|
||||||
dismissTimer = setTimeout(() => {
|
|
||||||
dismissTimer = null;
|
|
||||||
dismissTimetableUiBlockers();
|
|
||||||
}, DISMISS_DELAY_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hide colour-picker / modal layers that intercept clicks after a colour save. */
|
|
||||||
export function dismissTimetableUiBlockers(): {
|
|
||||||
slideRemoved: number;
|
|
||||||
modalRemoved: number;
|
|
||||||
} {
|
|
||||||
document.body.style.removeProperty("overflow");
|
|
||||||
|
|
||||||
for (const picker of document.querySelectorAll(".clr-picker")) {
|
|
||||||
picker.classList.remove("clr-open");
|
|
||||||
if (picker instanceof HTMLElement) {
|
|
||||||
picker.style.display = "none";
|
|
||||||
picker.style.pointerEvents = "none";
|
|
||||||
picker.style.visibility = "hidden";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dismissStaleColourDialogs();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear inline styles that can prevent Coloris from reopening. */
|
|
||||||
export function prepareColorisPickerOpen(): void {
|
|
||||||
document.body.classList.remove("clr-open");
|
|
||||||
document.documentElement.classList.remove("clr-open");
|
|
||||||
|
|
||||||
for (const picker of document.querySelectorAll(".clr-picker")) {
|
|
||||||
picker.classList.remove("clr-open");
|
|
||||||
if (picker instanceof HTMLElement) {
|
|
||||||
picker.style.removeProperty("display");
|
|
||||||
picker.style.removeProperty("pointer-events");
|
|
||||||
picker.style.removeProperty("visibility");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function attachTimetableColorisRecovery(): void {
|
|
||||||
if (attached) return;
|
|
||||||
attached = true;
|
|
||||||
|
|
||||||
document.addEventListener("coloris:close", scheduleDismiss);
|
|
||||||
document.addEventListener("coloris:pick", scheduleDismiss);
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"click",
|
|
||||||
(event) => {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (!target.closest(".timetablepage")) return;
|
|
||||||
|
|
||||||
if (target.closest("[title='Choose a colour']")) {
|
|
||||||
if (dismissTimer !== null) {
|
|
||||||
clearTimeout(dismissTimer);
|
|
||||||
dismissTimer = null;
|
|
||||||
}
|
|
||||||
prepareColorisPickerOpen();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!target.closest(".entry")) return;
|
|
||||||
|
|
||||||
const pickerOpen =
|
|
||||||
document.body.classList.contains("clr-open") &&
|
|
||||||
document.querySelector(".clr-picker.clr-open");
|
|
||||||
if (!pickerOpen) {
|
|
||||||
const result = dismissTimetableUiBlockers();
|
|
||||||
if (result.slideRemoved > 0 || result.modalRemoved > 0) {
|
|
||||||
verboseInfo(
|
|
||||||
"[BetterSEQTA+] timetable colour: content-script cleanup",
|
|
||||||
result,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -67,9 +67,6 @@ export default defineConfig(({ command, mode: viteMode }) => {
|
|||||||
const env = loadEnv(viteMode, repoRoot, "");
|
const env = loadEnv(viteMode, repoRoot, "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Content scripts run on the host page; absolute `/assets/...` URLs would
|
|
||||||
// resolve against SEQTA instead of chrome-extension://. Relative base makes
|
|
||||||
// Vite emit import.meta.url-relative chunk/CSS URLs at runtime.
|
|
||||||
base: command === "build" ? "./" : "/",
|
base: command === "build" ? "./" : "/",
|
||||||
define: {
|
define: {
|
||||||
__ENABLE_GH_RELEASE_UPDATE_CHECK__: JSON.stringify(
|
__ENABLE_GH_RELEASE_UPDATE_CHECK__: JSON.stringify(
|
||||||
|
|||||||
Reference in New Issue
Block a user