refactor: further trim PR #458 debloat across patches, notices, Select, music, archive, search

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 08:58:31 +09:30
parent 4fda63dedd
commit 3bac45032d
21 changed files with 497 additions and 924 deletions
+77 -138
View File
@@ -9,21 +9,7 @@
let isOpen = $state(false); let isOpen = $state(false);
let activeIndex = $state(0); let activeIndex = $state(0);
let root: HTMLDivElement | undefined = $state(); let trigger = $state<HTMLButtonElement>();
let trigger: HTMLButtonElement | undefined = $state();
let listbox: HTMLDivElement | undefined = $state();
const selectedLabel = $derived(
options.find((option) => option.value === value)?.label ?? value,
);
const activeDescendantId = $derived(
isOpen && options[activeIndex] ? optionId(options[activeIndex].value) : undefined,
);
function optionId(optionValue: string): string {
return `${listboxId}-option-${optionValue}`;
}
function openMenu(preferredIndex?: number) { function openMenu(preferredIndex?: number) {
isOpen = true; isOpen = true;
@@ -41,62 +27,59 @@
closeMenu(); closeMenu();
} }
function moveActive(delta: number) { function onKeydown(event: KeyboardEvent, inListbox = false) {
if (!options.length) return; const { key } = event;
activeIndex = (activeIndex + delta + options.length) % options.length;
}
function handleKeydown(event: KeyboardEvent, inListbox = false) { if (key === "ArrowDown" || key === "ArrowUp") {
switch (event.key) { event.preventDefault();
case "ArrowDown": const count = options.length;
case "ArrowUp": { if (!count) return;
event.preventDefault(); if (isOpen || inListbox) {
const delta = event.key === "ArrowDown" ? 1 : -1; activeIndex = (activeIndex + (key === "ArrowDown" ? 1 : -1) + count) % count;
if (isOpen || inListbox) moveActive(delta); } else {
else openMenu(); openMenu();
break;
} }
case "Enter": return;
case " ": }
event.preventDefault();
if (isOpen) { if (key === "Enter" || key === " ") {
const option = options[activeIndex]; event.preventDefault();
if (option) selectValue(option.value); if (isOpen) {
} else { const option = options[activeIndex];
openMenu(); if (option) selectValue(option.value);
} } else {
break; openMenu();
case "Escape": }
if (isOpen) { return;
event.preventDefault(); }
closeMenu();
} if (key === "Escape" && isOpen) {
break; event.preventDefault();
case "Home": closeMenu();
if (inListbox) { return;
event.preventDefault(); }
activeIndex = 0;
} if (!inListbox) return;
break;
case "End": if (key === "Home") {
if (inListbox) { event.preventDefault();
event.preventDefault(); activeIndex = 0;
activeIndex = Math.max(0, options.length - 1); } else if (key === "End") {
} event.preventDefault();
break; activeIndex = Math.max(0, options.length - 1);
case "Tab": } else if (key === "Tab") {
if (inListbox) closeMenu(false); closeMenu(false);
break;
} }
} }
$effect(() => { $effect(() => {
if (!isOpen) return; if (!isOpen) return;
queueMicrotask(() => listbox?.focus()); queueMicrotask(() => document.getElementById(listboxId)?.focus());
const wrapper = trigger?.parentElement;
const onPointerDown = (event: PointerEvent) => { const onPointerDown = (event: PointerEvent) => {
if (root && event.composedPath().includes(root)) return; if (wrapper && event.composedPath().includes(wrapper)) return;
closeMenu(false); closeMenu(false);
}; };
@@ -105,46 +88,47 @@
}); });
</script> </script>
<div class="select-wrapper" bind:this={root}> <div class="select relative w-full">
<button <button
bind:this={trigger} bind:this={trigger}
type="button" type="button"
class="select-trigger" class="select-trigger flex w-full items-center justify-between gap-3 rounded-[18px] border px-4 py-2.5 text-sm font-medium leading-tight shadow-2xl transition-[background-color,border-color,box-shadow] duration-200 cursor-pointer"
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={isOpen} aria-expanded={isOpen}
aria-controls={listboxId} aria-controls={listboxId}
onclick={() => (isOpen ? closeMenu() : openMenu())} onclick={() => (isOpen ? closeMenu() : openMenu())}
onkeydown={(event) => handleKeydown(event)} onkeydown={onKeydown}
> >
<span class="select-label">{selectedLabel}</span> <span class="truncate">
<span class="select-icon" aria-hidden="true"> {options.find((option) => option.value === value)?.label ?? value}
</span>
<span class="select-icon shrink-0" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4"> <svg viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
<path <path
fill-rule="evenodd" 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" 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" clip-rule="evenodd"
></path> />
</svg> </svg>
</span> </span>
</button> </button>
{#if isOpen} {#if isOpen}
<div <div
bind:this={listbox}
id={listboxId} id={listboxId}
class="select-menu" class="select-menu absolute inset-x-0 top-[calc(100%+0.35rem)] z-50 flex max-h-72 flex-col gap-0.5 p-2"
role="listbox" role="listbox"
tabindex="-1" tabindex="-1"
aria-activedescendant={activeDescendantId} aria-activedescendant={options[activeIndex] ? `${listboxId}-opt-${activeIndex}` : undefined}
onkeydown={(event) => handleKeydown(event, true)} onkeydown={(event) => onKeydown(event, true)}
> >
{#each options as option, index (option.value)} {#each options as option, index (option.value)}
<button <button
type="button" type="button"
id={optionId(option.value)} id={`${listboxId}-opt-${index}`}
role="option" role="option"
aria-selected={option.value === value} aria-selected={option.value === value}
class="select-option" class="select-option block w-full rounded-[10px] border-none px-3.5 py-2.5 text-left text-sm font-medium leading-snug transition-colors duration-150 cursor-pointer"
class:is-selected={option.value === value} class:is-selected={option.value === value}
class:is-active={index === activeIndex} class:is-active={index === activeIndex}
tabindex="-1" tabindex="-1"
@@ -159,99 +143,54 @@
</div> </div>
<style> <style>
.select-wrapper { .select {
position: relative; --sel-border: var(--theme-offset-bg, var(--theme-secondary, #e5e7eb));
width: 100%; --sel-bg: var(--theme-primary, #ffffff);
--sel-surface: var(--theme-secondary, #e5e7eb);
--sel-focus-border: color-mix(in srgb, var(--text-primary) 22%, var(--theme-secondary, #e5e7eb) 78%);
--sel-ring: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent);
--sel-menu-shadow:
0 10px 25px -5px rgb(0 0 0 / 0.25),
0 8px 10px -6px rgb(0 0 0 / 0.2);
} }
.select-trigger { .select-trigger {
display: flex; border-color: var(--sel-border);
align-items: center; background: var(--sel-bg);
justify-content: space-between;
gap: 0.75rem;
width: 100%;
border: 1px solid var(--theme-offset-bg, var(--theme-secondary, #e5e7eb));
border-radius: 18px;
background: var(--theme-primary, #ffffff);
color: var(--text-primary); color: var(--text-primary);
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25;
cursor: pointer;
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
transition:
background-color 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease;
} }
.select-trigger:hover, .select-trigger:hover,
.select-trigger:focus-visible { .select-trigger:focus-visible {
outline: none; outline: none;
background: var(--theme-secondary, #e5e7eb); background: var(--sel-surface);
border-color: var(--theme-offset-bg, var(--theme-secondary, #d4d4d8)); border-color: var(--sel-border);
} }
.select-trigger:focus-visible { .select-trigger:focus-visible {
border-color: color-mix(in srgb, var(--text-primary) 22%, var(--theme-secondary, #e5e7eb) 78%); border-color: var(--sel-focus-border);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent); box-shadow: var(--sel-ring);
}
.select-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.select-icon { .select-icon {
flex-shrink: 0;
color: color-mix(in srgb, var(--text-primary) 60%, transparent); color: color-mix(in srgb, var(--text-primary) 60%, transparent);
} }
.select-menu { .select-menu {
position: absolute; border: 1px solid var(--sel-border);
top: calc(100% + 0.35rem);
left: 0;
right: 0;
z-index: 50;
display: flex;
flex-direction: column;
gap: 0.125rem;
margin: 0;
padding: 0.5rem;
border: 1px solid var(--theme-offset-bg, var(--theme-secondary, #e5e7eb));
border-radius: 14px; border-radius: 14px;
background: var(--theme-primary, #ffffff); background: var(--sel-bg);
box-shadow: box-shadow: var(--sel-menu-shadow);
0 10px 25px -5px rgb(0 0 0 / 0.25),
0 8px 10px -6px rgb(0 0 0 / 0.2);
max-height: 18rem;
overflow-y: auto;
} }
.select-menu:focus-visible { .select-menu:focus-visible {
outline: none; outline: none;
box-shadow: box-shadow: var(--sel-menu-shadow), var(--sel-ring);
0 10px 25px -5px rgb(0 0 0 / 0.25),
0 8px 10px -6px rgb(0 0 0 / 0.2),
0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent);
} }
.select-option { .select-option {
display: block;
width: 100%;
border: none;
border-radius: 10px;
background: transparent; background: transparent;
color: var(--text-primary); color: var(--text-primary);
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.35;
text-align: left;
cursor: pointer;
transition: background-color 150ms ease;
} }
.select-option:hover, .select-option:hover,
@@ -259,6 +198,6 @@
.select-option.is-active, .select-option.is-active,
.select-option.is-selected { .select-option.is-selected {
outline: none; outline: none;
background: var(--theme-secondary, #e5e7eb); background: var(--sel-surface);
} }
</style> </style>
@@ -9,12 +9,14 @@ 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}`; const bgSel = `.bg.${ANIMATED_BG_MARKER}`;
const scopeSel = `:scope > div${bgSel}`;
const BASE_SPEEDS = [3, 4, 5] as const;
export function updateAnimationSpeed(speed: number) { export function updateAnimationSpeed(speed: number) {
document.querySelectorAll(`.bg.${ANIMATED_BG_MARKER}`).forEach((element, index) => { document.querySelectorAll(bgSel).forEach((element, index) => {
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5; const base = BASE_SPEEDS[index] ?? BASE_SPEEDS[2];
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`; (element as HTMLElement).style.animationDuration = `${base / speed}s`;
}); });
} }
@@ -23,12 +25,12 @@ export function ensureAnimatedBackgroundLayers(
menu: HTMLElement, menu: HTMLElement,
speed: number, speed: number,
): void { ): void {
if (container.querySelectorAll(layerSelector).length >= 3) { if (container.querySelectorAll(scopeSel).length >= 3) {
updateAnimationSpeed(speed); updateAnimationSpeed(speed);
return; return;
} }
container.querySelectorAll(layerSelector).forEach((el) => el.remove()); container.querySelectorAll(scopeSel).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");
@@ -40,7 +42,7 @@ export function ensureAnimatedBackgroundLayers(
} }
export function removeAnimatedBackgroundLayers(): void { export function removeAnimatedBackgroundLayers(): void {
document.querySelectorAll(`div.bg.${ANIMATED_BG_MARKER}`).forEach((el) => el.remove()); document.querySelectorAll(`div${bgSel}`).forEach((el) => el.remove());
} }
export async function syncAnimatedBackground( export async function syncAnimatedBackground(
@@ -29,7 +29,6 @@ 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",
@@ -42,22 +41,20 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
run: async (api) => { run: async (api) => {
await syncAnimatedBackground(api); await syncAnimatedBackground(api);
const resync = () => void 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(resync);
const pageshowHandler = (event: PageTransitionEvent) => { window.addEventListener("pageshow", resync);
if (event.persisted) void syncAnimatedBackground(api);
};
window.addEventListener("pageshow", pageshowHandler);
const containerObserver = new MutationObserver(resync(api)); const containerObserver = new MutationObserver(resync);
const container = document.getElementById("container"); const container = document.getElementById("container");
if (container) containerObserver.observe(container, { childList: true }); if (container) containerObserver.observe(container, { childList: true });
return () => { return () => {
speedUnregister.unregister(); speedUnregister.unregister();
pageChangeUnregister.unregister(); pageChangeUnregister.unregister();
window.removeEventListener("pageshow", pageshowHandler); window.removeEventListener("pageshow", resync);
containerObserver.disconnect(); containerObserver.disconnect();
removeAnimatedBackgroundLayers(); removeAnimatedBackgroundLayers();
}; };
+34 -70
View File
@@ -44,15 +44,9 @@ let objectUrl: string | null = null;
let gestureCleanup: (() => void) | null = null; let gestureCleanup: (() => void) | null = null;
let resumeTimer: ReturnType<typeof setTimeout> | null = null; let resumeTimer: ReturnType<typeof setTimeout> | null = null;
let hintEl: HTMLElement | null = null; let hintEl: HTMLElement | null = null;
let playing = false;
const clamp = (v: number) => Math.max(0, Math.min(1, v)); 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");
return blob instanceof Blob ? blob : null;
}
function clearHint(): void { function clearHint(): void {
hintEl?.remove(); hintEl?.remove();
hintEl = null; hintEl = null;
@@ -63,40 +57,18 @@ function disarmGesture(): void {
gestureCleanup = null; gestureCleanup = null;
} }
function onPlayStarted(): void {
playing = true;
clearHint();
disarmGesture();
}
function stopAudio(): void { function stopAudio(): void {
audio?.pause(); audio?.pause();
audio?.remove(); audio?.remove();
audio = null; audio = null;
if (objectUrl) URL.revokeObjectURL(objectUrl); if (objectUrl) URL.revokeObjectURL(objectUrl);
objectUrl = null; objectUrl = null;
playing = false;
}
function showHint(onActivate: () => void): void {
clearHint();
const hint = document.createElement("button");
hint.id = "bsplus-bg-music-hint";
hint.type = "button";
hint.className = "bsplus-bg-music-hint";
hint.textContent = "Tap to start background music";
hint.addEventListener("pointerdown", (e) => {
e.preventDefault();
onActivate();
});
document.body.append(hint);
hintEl = hint;
} }
/** 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 prepareAudio(vol: number): Promise<boolean> { async function prepareAudio(vol: number): Promise<boolean> {
const blob = await loadBlob(); const blob = await store.getItem<Blob>("audio-blob");
if (!blob) { if (!(blob instanceof Blob)) {
stopAudio(); stopAudio();
clearHint(); clearHint();
return false; return false;
@@ -114,25 +86,16 @@ async function prepareAudio(vol: number): Promise<boolean> {
return true; return true;
} }
/** Call synchronously from a user-gesture handler (no await before this). */ function attemptPlay(vol: number): Promise<boolean> {
function playPrepared(vol: number): void { if (!audio) return Promise.resolve(false);
if (!audio) return;
audio.volume = clamp(vol); audio.volume = clamp(vol);
void audio.play().then(onPlayStarted).catch(() => { return audio
playing = false; .play()
}); .then(() => {
} disarmGesture();
return true;
async function tryAutoplay(vol: number): Promise<boolean> { })
if (!(await prepareAudio(vol)) || !audio) return false; .catch(() => false);
try {
await audio.play();
onPlayStarted();
return true;
} catch {
playing = false;
return false;
}
} }
function armGesture(onGesture: () => void): void { function armGesture(onGesture: () => void): void {
@@ -153,14 +116,19 @@ function armGesture(onGesture: () => void): void {
} }
clearHint(); clearHint();
}; };
showHint(onGesture);
}
function clearResumeTimer(): void { clearHint();
if (resumeTimer !== null) { const hint = document.createElement("button");
clearTimeout(resumeTimer); hint.id = "bsplus-bg-music-hint";
resumeTimer = null; hint.type = "button";
} hint.className = "bsplus-bg-music-hint";
hint.textContent = "Tap to start background music";
hint.addEventListener("pointerdown", (e) => {
e.preventDefault();
onGesture();
});
document.body.append(hint);
hintEl = hint;
} }
const backgroundMusicPlugin: Plugin<typeof settings> = { const backgroundMusicPlugin: Plugin<typeof settings> = {
@@ -177,22 +145,20 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
await api.storage.loaded; await api.storage.loaded;
type BgSettings = { volume?: number; pauseOnHidden?: boolean }; type BgSettings = { volume?: number; pauseOnHidden?: boolean };
const s = () => api.settings as BgSettings; const vol = () => (api.settings as BgSettings).volume ?? 0.5;
const vol = () => s().volume ?? 0.5; const pauseOnHidden = () => (api.settings as BgSettings).pauseOnHidden ?? true;
const pauseOnHidden = () => s().pauseOnHidden ?? true;
const gestureStart = () => { const gesturePlay = () => {
if (audio) playPrepared(vol()); void attemptPlay(vol());
}; };
const ensurePlayback = async () => { const ensurePlayback = async () => {
if (!(await prepareAudio(vol()))) return; if (!(await prepareAudio(vol()))) return;
if (playing && audio && !audio.paused) { if (audio && !audio.paused) {
clearHint();
disarmGesture(); disarmGesture();
return; return;
} }
if (!(await tryAutoplay(vol()))) armGesture(gestureStart); if (!(await attemptPlay(vol()))) armGesture(gesturePlay);
}; };
api.settings.onChange("volume" as never, (value: unknown) => { api.settings.onChange("volume" as never, (value: unknown) => {
@@ -214,9 +180,9 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
const onVisibility = () => { const onVisibility = () => {
if (document.visibilityState === "hidden") { if (document.visibilityState === "hidden") {
if (!pauseOnHidden() || !audio) return; if (!pauseOnHidden() || !audio) return;
clearResumeTimer(); if (resumeTimer) clearTimeout(resumeTimer);
resumeTimer = null;
audio.pause(); audio.pause();
playing = false;
return; return;
} }
if (!audio) { if (!audio) {
@@ -224,10 +190,10 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
return; return;
} }
if (!pauseOnHidden()) return; if (!pauseOnHidden()) return;
clearResumeTimer(); if (resumeTimer) clearTimeout(resumeTimer);
resumeTimer = setTimeout(() => { resumeTimer = setTimeout(() => {
resumeTimer = null; resumeTimer = null;
void tryAutoplay(vol()); void attemptPlay(vol());
}, 200); }, 200);
}; };
@@ -235,16 +201,14 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
const onStop = () => { const onStop = () => {
disarmGesture(); disarmGesture();
stopAudio(); stopAudio();
clearHint();
}; };
const teardown = () => { const teardown = () => {
document.removeEventListener("visibilitychange", onVisibility); document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pageshow", onUpdated); window.removeEventListener("pageshow", onUpdated);
window.removeEventListener("betterseqta-background-music-updated", onUpdated); window.removeEventListener("betterseqta-background-music-updated", onUpdated);
window.removeEventListener("betterseqta-background-music-stop", onStop); window.removeEventListener("betterseqta-background-music-stop", onStop);
clearResumeTimer(); if (resumeTimer) clearTimeout(resumeTimer);
disarmGesture(); disarmGesture();
clearHint();
stopAudio(); stopAudio();
}; };
@@ -11,14 +11,4 @@
font: 600 0.8125rem/1.25 system-ui, sans-serif; font: 600 0.8125rem/1.25 system-ui, sans-serif;
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;
}
.bsplus-bg-music-hint:hover {
background: color-mix(in srgb, var(--theme-secondary, #2a2a2a) 90%, var(--better-main, #22c55e) 10%);
}
@keyframes bsplus-bg-music-hint-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
} }
@@ -260,11 +260,12 @@ export async function applyStoreDiff(
if (puts.length === 0 && removeKeys.length === 0) return; if (puts.length === 0 && removeKeys.length === 0) return;
try { try {
const db = await openDB(); let db = await openDB();
if (!db.objectStoreNames.contains(store)) { if (!db.objectStoreNames.contains(store)) {
await upgradeDB(store); await upgradeDB(store);
db = await openDB();
} }
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;
@@ -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, publishDynamicItemsUpdate } from "./renderComponents"; import { decorateIndexItems } 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";
@@ -260,54 +260,25 @@ function dispatchVectorProgress(
completedJobs: number, completedJobs: number,
totalSteps: number, totalSteps: number,
): number { ): number {
let detailMessage = progress.message || ""; const { status, total, processed, message = "" } = progress;
let detail = message;
let completed = completedJobs; let completed = completedJobs;
if ( if (status === "processing" && total != null && processed != null) {
progress.status === "processing" && detail = `Vectorizing: ${processed} / ${total}`;
progress.total && } else if (status === "started") {
progress.processed !== undefined detail = `Vectorization started for ${total} items`;
) { } else if (status === "complete") {
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`; dispatchProgress(++completed, totalSteps, false, "Indexing finished", "Vectorization complete");
} else if (progress.status === "complete") {
detailMessage = "Vectorization complete";
completed++;
dispatchProgress(completed, totalSteps, false, "Indexing finished", detailMessage);
return completed; return completed;
} else if (progress.status === "error") { } else if (status === "error") {
dispatchProgress( dispatchProgress(completed, totalSteps, false, "Vectorization failed", `Vectorization error: ${message}`);
completed,
totalSteps,
false,
"Vectorization failed",
`Vectorization error: ${progress.message}`,
);
return completed; return completed;
} else if (progress.status === "cancelled") { } else if (status === "cancelled") {
dispatchProgress( dispatchProgress(completed, totalSteps, false, "Vectorization cancelled", `Vectorization cancelled: ${message}`);
completed,
totalSteps,
false,
"Vectorization cancelled",
`Vectorization cancelled: ${progress.message}`,
);
return completed; return completed;
} else if (progress.status === "started") { } else {
detailMessage = `Vectorization started for ${progress.total} items`; dispatchProgress(completed, totalSteps, true, "Vectorization in progress", detail);
}
if (
progress.status !== "complete" &&
progress.status !== "error" &&
progress.status !== "cancelled"
) {
dispatchProgress(
completed,
totalSteps,
true,
"Vectorization in progress",
detailMessage,
);
} }
return completed; return completed;
@@ -322,10 +293,7 @@ export async function runIndexing(): Promise<void> {
} }
await ensureSchemaCurrent(); await ensureSchemaCurrent();
if (isIndexingPaused()) return;
if (isIndexingPaused()) {
return;
}
if (!(await acquireLock())) { if (!(await acquireLock())) {
verboseDebug( verboseDebug(
@@ -1,5 +1,3 @@
import { verboseDebug } from '@/utils/verboseLog';
const EMBEDDIA_DB = "embeddiaDB"; const EMBEDDIA_DB = "embeddiaDB";
const EMBEDDIA_STORE = "embeddiaObjectStore"; const EMBEDDIA_STORE = "embeddiaObjectStore";
@@ -13,13 +11,9 @@ function openEmbeddiaDb(): Promise<IDBDatabase | null> {
export async function getVectorizedItemIds(): Promise<Set<string>> { export async function getVectorizedItemIds(): Promise<Set<string>> {
const db = await openEmbeddiaDb(); const db = await openEmbeddiaDb();
if (!db) { if (!db) return new Set();
verboseDebug("Could not open embeddiaDB, assuming no items are vectorized");
return new Set();
}
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) { if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
verboseDebug("embeddiaObjectStore not found, assuming no items are vectorized");
db.close(); db.close();
return new Set(); return new Set();
} }
@@ -39,7 +33,6 @@ export async function getVectorizedItemIds(): Promise<Set<string>> {
if (typeof key === "string") vectorizedIds.add(key); if (typeof key === "string") vectorizedIds.add(key);
} }
verboseDebug(`Found ${vectorizedIds.size} already vectorized items in embeddia DB`);
db.close(); db.close();
return vectorizedIds; return vectorizedIds;
} catch (error) { } catch (error) {
@@ -28,15 +28,23 @@ function shouldDedupeAsSameCourseSPA(item: IndexItem): boolean {
return false; return false;
} }
function programmeMetaclassIds(
item: IndexItem,
): { programme?: number; metaclass?: number } {
const md = item.metadata ?? {};
return {
programme: toFiniteNumber(
md.programme ?? md.programmeId ?? md.programmeID,
),
metaclass: toFiniteNumber(
md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId,
),
};
}
export function courseDestinationKey(item: IndexItem): string | undefined { export function courseDestinationKey(item: IndexItem): string | undefined {
if (!shouldDedupeAsSameCourseSPA(item)) return undefined; if (!shouldDedupeAsSameCourseSPA(item)) return undefined;
const md = item.metadata ?? {}; const { programme, metaclass } = programmeMetaclassIds(item);
const programme = toFiniteNumber(
md.programme ?? md.programmeId ?? md.programmeID,
);
const metaclass = toFiniteNumber(
md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId,
);
if (programme === undefined || metaclass === undefined) return undefined; if (programme === undefined || metaclass === undefined) return undefined;
return `course:${programme}:${metaclass}`; return `course:${programme}:${metaclass}`;
} }
@@ -74,13 +82,7 @@ function isPassiveLike(item: IndexItem): boolean {
} }
function hasProgrammeMetaclass(item: IndexItem): boolean { function hasProgrammeMetaclass(item: IndexItem): boolean {
const md = item.metadata ?? {}; const { programme, metaclass } = programmeMetaclassIds(item);
const programme = toFiniteNumber(
md.programme ?? md.programmeId ?? md.programmeID,
);
const metaclass = toFiniteNumber(
md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId,
);
return programme !== undefined && metaclass !== undefined; return programme !== undefined && metaclass !== undefined;
} }
@@ -166,44 +168,29 @@ function dynamicSearchKey(row: CombinedResult): string | undefined {
return searchDedupeKey(row.item as IndexItem); return searchDedupeKey(row.item as IndexItem);
} }
function mergeCombinedDuplicates(
a: CombinedResult,
b: CombinedResult,
key: string,
): CombinedResult {
const aItem = a.item as IndexItem;
const bItem = b.item as IndexItem;
const winnerItem = pickBetterSearchDuplicate(aItem, bItem, key);
const envelope = winnerItem.id === aItem.id ? a : b;
return {
...envelope,
score: Math.max(a.score, b.score),
id: winnerItem.id,
item: winnerItem,
};
}
export function dedupeCombinedResultsByCourseNav( export function dedupeCombinedResultsByCourseNav(
results: CombinedResult[], results: CombinedResult[],
): CombinedResult[] { ): CombinedResult[] {
const best = new Map<string, CombinedResult>(); return dedupeByCanonicalKey(
results,
for (const r of results) { dynamicSearchKey,
const key = dynamicSearchKey(r); mergeCombinedDuplicates,
if (!key) continue; );
const prev = best.get(key);
if (!prev) {
best.set(key, r);
continue;
}
const aItem = prev.item as IndexItem;
const bItem = r.item as IndexItem;
const winnerItem = pickBetterSearchDuplicate(aItem, bItem, key);
const envelope = winnerItem.id === aItem.id ? prev : r;
best.set(key, {
...envelope,
score: Math.max(prev.score, r.score),
id: winnerItem.id,
item: winnerItem,
});
}
const seenCanon = new Set<string>();
const out: CombinedResult[] = [];
for (const r of results) {
const key = dynamicSearchKey(r);
if (!key) {
out.push(r);
continue;
}
if (seenCanon.has(key)) continue;
seenCanon.add(key);
out.push(best.get(key)!);
}
return out;
} }
@@ -63,7 +63,7 @@ const gradeAnalyticsPlugin: Plugin<{}> = {
}); });
menuObserver.observe(menuList, { childList: true }); menuObserver.observe(menuList, { childList: true });
const onClick = (e: Event) => { analyticsItem.addEventListener("click", (e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if ( if (
MenuOptionsOpen || MenuOptionsOpen ||
@@ -75,12 +75,10 @@ const gradeAnalyticsPlugin: Plugin<{}> = {
e.preventDefault(); e.preventDefault();
window.history.pushState({}, "", "/#?page=/analytics"); window.history.pushState({}, "", "/#?page=/analytics");
void loadAnalyticsPage(); void loadAnalyticsPage();
}; });
analyticsItem.addEventListener("click", onClick);
return () => { return () => {
menuObserver.disconnect(); menuObserver.disconnect();
analyticsItem.removeEventListener("click", onClick);
analyticsItem.remove(); analyticsItem.remove();
}; };
}, },
@@ -1,27 +1,17 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements"; import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
type RawNotification = Record<string, unknown>;
export interface ArchivedNotification { export interface ArchivedNotification {
notificationID: number; notificationID: number;
type: string;
timestamp: string;
title: string;
subtitle: string;
messageID?: number;
assessmentID?: number;
programmeID?: number;
metaclassID?: number;
subjectCode?: string;
firstSavedAt: string; firstSavedAt: string;
lastSeenAt: string; lastSeenAt: string;
raw: RawNotification; raw: RawNotification;
} }
export type ArchiveMap = Record<string, ArchivedNotification>; export type ArchiveMap = Record<string, ArchivedNotification>;
export type ArchivesByUser = Record<string, ArchiveMap>; export type ArchivesByUser = Record<string, ArchiveMap>;
type RawNotification = Record<string, unknown>;
export async function resolveNotificationUserKey(): Promise<string | null> { export async function resolveNotificationUserKey(): Promise<string | null> {
try { try {
const info = await getUserInfo(); const info = await getUserInfo();
@@ -45,165 +35,58 @@ export async function fetchAllNotifications(): Promise<RawNotification[]> {
hash: "#?page=/notifications", hash: "#?page=/notifications",
}), }),
}); });
if (!res.ok) return []; if (!res.ok) return [];
const json = (await res.json()) as { const json = (await res.json()) as {
notifications?: RawNotification[]; notifications?: RawNotification[];
payload?: { notifications?: RawNotification[] }; payload?: { notifications?: RawNotification[] };
}; };
const list = json.notifications ?? json.payload?.notifications; const list = json.notifications ?? json.payload?.notifications;
return Array.isArray(list) ? list : []; return Array.isArray(list) ? list : [];
} }
function readString(value: unknown): string { function archiveTimestamp(
if (value == null) return ""; item: ArchivedNotification & { timestamp?: string },
return String(value).trim(); ): number {
} const ms = new Date(
String(item.raw?.timestamp ?? item.timestamp ?? 0),
export function normalizeArchivedNotification( ).getTime();
raw: RawNotification, return Number.isNaN(ms) ? 0 : ms;
now = new Date().toISOString(),
): ArchivedNotification | null {
const notificationID = Number(raw.notificationID);
if (!notificationID || Number.isNaN(notificationID)) return null;
const type = readString(raw.type) || "unknown";
const timestamp = readString(raw.timestamp) || now;
if (type === "message" && raw.message && typeof raw.message === "object") {
const message = raw.message as Record<string, unknown>;
return {
notificationID,
type,
timestamp,
title: readString(message.title) || "Message",
subtitle: readString(message.subtitle),
messageID: Number(message.messageID) || undefined,
firstSavedAt: now,
lastSeenAt: now,
raw: { ...raw },
};
}
if (
type === "coneqtassessments" &&
raw.coneqtAssessments &&
typeof raw.coneqtAssessments === "object"
) {
const assessment = raw.coneqtAssessments as Record<string, unknown>;
return {
notificationID,
type,
timestamp,
title: readString(assessment.title) || "Assessment",
subtitle: readString(assessment.subtitle) || readString(assessment.subjectCode),
assessmentID: Number(assessment.assessmentID) || undefined,
programmeID: Number(assessment.programmeID) || undefined,
metaclassID: Number(assessment.metaclassID) || undefined,
subjectCode: readString(assessment.subjectCode) || undefined,
firstSavedAt: now,
lastSeenAt: now,
raw: { ...raw },
};
}
return {
notificationID,
type,
timestamp,
title: readString(raw.title) || "Notification",
subtitle: readString(raw.subtitle),
firstSavedAt: now,
lastSeenAt: now,
raw: { ...raw },
};
} }
export function mergeNotificationsIntoArchive( export function mergeNotificationsIntoArchive(
existing: ArchiveMap, existing: ArchiveMap,
notifications: RawNotification[], notifications: RawNotification[],
): ArchiveMap { ): { archive: ArchiveMap; changed: boolean } {
const now = new Date().toISOString(); const now = new Date().toISOString();
const merged: ArchiveMap = { ...existing }; let changed = false;
const archive = { ...existing };
for (const raw of notifications) { for (const raw of notifications) {
const normalized = normalizeArchivedNotification(raw, now); const notificationID = Number(raw.notificationID);
if (!normalized) continue; if (!notificationID || Number.isNaN(notificationID)) continue;
const key = String(normalized.notificationID); const key = String(notificationID);
const prev = merged[key]; const prev = archive[key];
if (prev) { archive[key] = prev
merged[key] = { ? { ...prev, lastSeenAt: now, raw: { ...prev.raw, ...raw } }
...prev, : { notificationID, firstSavedAt: now, lastSeenAt: now, raw: { ...raw } };
...normalized, changed = true;
timestamp: normalized.timestamp || prev.timestamp,
firstSavedAt: prev.firstSavedAt,
lastSeenAt: now,
raw: { ...prev.raw, ...raw },
};
} else {
merged[key] = normalized;
}
} }
return merged; return { archive, changed };
} }
export function listArchivedNotifications(archive: ArchiveMap): ArchivedNotification[] { export function listArchivedNotifications(
archive: ArchiveMap,
): ArchivedNotification[] {
return Object.values(archive).sort( return Object.values(archive).sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), (a, b) => archiveTimestamp(b) - archiveTimestamp(a),
); );
} }
export function archivedToApiNotification( export function archivedToApiNotification(
item: ArchivedNotification, item: ArchivedNotification,
): RawNotification { ): RawNotification {
if (item.raw && typeof item.raw === "object") { return { ...item.raw, notificationID: item.notificationID };
return {
...item.raw,
notificationID: item.notificationID,
type: item.type,
timestamp: item.timestamp,
};
}
if (item.type === "message") {
return {
notificationID: item.notificationID,
type: "message",
timestamp: item.timestamp,
message: {
title: item.title,
subtitle: item.subtitle,
messageID: item.messageID,
},
};
}
if (item.type === "coneqtassessments") {
return {
notificationID: item.notificationID,
type: "coneqtassessments",
timestamp: item.timestamp,
coneqtAssessments: {
title: item.title,
subtitle: item.subtitle,
assessmentID: item.assessmentID,
programmeID: item.programmeID,
metaclassID: item.metaclassID,
subjectCode: item.subjectCode,
term: "",
},
};
}
return {
notificationID: item.notificationID,
type: item.type,
timestamp: item.timestamp,
title: item.title,
subtitle: item.subtitle,
};
} }
@@ -14,6 +14,9 @@ import {
} from "./injectArchivedNotifications"; } from "./injectArchivedNotifications";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
const BUBBLE_SELECTOR = "[class*='notifications__bubble___']";
const LIST_SELECTOR = '[class*="notifications__list___"]';
const notificationCollectorSettings = { const notificationCollectorSettings = {
saveLocally: booleanSetting({ saveLocally: booleanSetting({
default: true, default: true,
@@ -60,15 +63,9 @@ const notificationCollectorPlugin: Plugin<
const baseInterval = 30000; const baseInterval = 30000;
const maxInterval = 300000; const maxInterval = 300000;
if (!api.storage.lastNotificationCount) { api.storage.lastNotificationCount ||= 0;
api.storage.lastNotificationCount = 0; api.storage.consecutiveErrors ||= 0;
} api.storage.archivesByUser ||= {};
if (!api.storage.consecutiveErrors) {
api.storage.consecutiveErrors = 0;
}
if (!api.storage.archivesByUser) {
api.storage.archivesByUser = {};
}
const syncArchive = async () => { const syncArchive = async () => {
if (!api.settings.saveLocally || archiveSyncInFlight) return; if (!api.settings.saveLocally || archiveSyncInFlight) return;
@@ -81,12 +78,15 @@ const notificationCollectorPlugin: Plugin<
const notifications = await fetchAllNotifications(); const notifications = await fetchAllNotifications();
const archivesByUser = { ...(api.storage.archivesByUser ?? {}) }; const archivesByUser = { ...(api.storage.archivesByUser ?? {}) };
const existing = archivesByUser[userKey] ?? {}; const existing = archivesByUser[userKey] ?? {};
const merged = mergeNotificationsIntoArchive(existing, notifications); const { archive: merged, changed } = mergeNotificationsIntoArchive(
existing,
notifications,
);
if (JSON.stringify(existing) !== JSON.stringify(merged)) { if (changed) {
archivesByUser[userKey] = merged; archivesByUser[userKey] = merged;
api.storage.archivesByUser = archivesByUser; api.storage.archivesByUser = archivesByUser;
} else if (document.querySelector('[class*="notifications__list___"]')) { } else if (document.querySelector(LIST_SELECTOR)) {
await injectArchivedForUser(merged); await injectArchivedForUser(merged);
} }
} catch (error) { } catch (error) {
@@ -100,9 +100,7 @@ const notificationCollectorPlugin: Plugin<
if (!isVisible) return; if (!isVisible) return;
try { try {
const alertDiv = document.querySelector( const alertDiv = document.querySelector(BUBBLE_SELECTOR) as HTMLElement;
"[class*='notifications__bubble___']",
) as HTMLElement;
if (alertDiv && api.storage.lastNotificationCount !== 0) { if (alertDiv && api.storage.lastNotificationCount !== 0) {
alertDiv.textContent = api.storage.lastNotificationCount.toString(); alertDiv.textContent = api.storage.lastNotificationCount.toString();
@@ -159,9 +157,7 @@ const notificationCollectorPlugin: Plugin<
if (pollInterval) { if (pollInterval) {
window.clearTimeout(pollInterval); window.clearTimeout(pollInterval);
pollInterval = null; pollInterval = null;
const alertDiv = document.querySelector( const alertDiv = document.querySelector(BUBBLE_SELECTOR) as HTMLElement;
"[class*='notifications__bubble___']",
) as HTMLElement;
if (alertDiv) { if (alertDiv) {
if (api.storage.lastNotificationCount > 9) { if (api.storage.lastNotificationCount > 9) {
alertDiv.textContent = "9+"; alertDiv.textContent = "9+";
@@ -175,10 +171,7 @@ const notificationCollectorPlugin: Plugin<
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
isVisible = !document.hidden; isVisible = !document.hidden;
if (isVisible && !pollInterval) { if (isVisible && !pollInterval) {
const alertDiv = document.querySelector( if (document.querySelector(BUBBLE_SELECTOR)) startPolling();
"[class*='notifications__bubble___']",
);
if (alertDiv) startPolling();
} }
}; };
@@ -195,18 +188,16 @@ const notificationCollectorPlugin: Plugin<
resolveNotificationUserKey, resolveNotificationUserKey,
); );
api.seqta.onMount("[class*='notifications__bubble___']", () => { const onBubbleMount = () => {
startPolling(); startPolling();
if (api.settings.saveLocally) { if (api.settings.saveLocally) void syncArchive();
void syncArchive(); };
} const onListMount = () => {
}); if (api.settings.saveLocally) void syncArchive();
};
api.seqta.onMount("[class*='notifications__list___']", () => { api.seqta.onMount(BUBBLE_SELECTOR, onBubbleMount);
if (api.settings.saveLocally) { api.seqta.onMount(LIST_SELECTOR, onListMount);
void syncArchive();
}
});
return () => { return () => {
stopPolling(); stopPolling();
@@ -14,9 +14,6 @@ const ITEM_SELECTOR = '[class*="notifications__item___"]';
const BACKED_UP_CLASS = "bsplus-notification-backed-up"; const BACKED_UP_CLASS = "bsplus-notification-backed-up";
const BACKUP_BADGE_CLASS = "bsplus-notification-backup-badge"; const BACKUP_BADGE_CLASS = "bsplus-notification-backup-badge";
const BACKUP_CHECK_SVG =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
function notificationTimestamp(item: Record<string, unknown>): number { function notificationTimestamp(item: Record<string, unknown>): number {
const ms = new Date(String(item.timestamp ?? 0)).getTime(); const ms = new Date(String(item.timestamp ?? 0)).getTime();
return Number.isNaN(ms) ? 0 : ms; return Number.isNaN(ms) ? 0 : ms;
@@ -38,17 +35,6 @@ function mergeLiveWithArchived(
); );
} }
function sameItemOrder(
current: Record<string, unknown>[],
merged: Record<string, unknown>[],
): boolean {
if (current.length !== merged.length) return false;
return current.every(
(item, index) =>
Number(item.notificationID) === Number(merged[index]?.notificationID),
);
}
async function tryInjectArchived(archive: ArchiveMap): Promise<boolean> { async function tryInjectArchived(archive: ArchiveMap): Promise<boolean> {
if (!document.querySelector(LIST_SELECTOR)) return false; if (!document.querySelector(LIST_SELECTOR)) return false;
@@ -58,16 +44,22 @@ async function tryInjectArchived(archive: ArchiveMap): Promise<boolean> {
const liveItems = state.items as Record<string, unknown>[]; const liveItems = state.items as Record<string, unknown>[];
const merged = mergeLiveWithArchived(liveItems, archive); const merged = mergeLiveWithArchived(liveItems, archive);
if (!merged) return true; if (!merged) return true;
if (sameItemOrder(liveItems, merged)) return true;
const sameOrder =
liveItems.length === merged.length &&
liveItems.every(
(item, index) =>
Number(item.notificationID) === Number(merged[index]?.notificationID),
);
if (sameOrder) return true;
await ReactFiber.find(LIST_SELECTOR).setState({ items: merged }); await ReactFiber.find(LIST_SELECTOR).setState({ items: merged });
return true; return true;
} }
async function injectWithRetries(archive: ArchiveMap, attempts = 10) { async function injectWithRetries(archive: ArchiveMap) {
for (let i = 0; i < attempts; i++) { for (let attempt = 0; attempt < 10; attempt++) {
const done = await tryInjectArchived(archive); if (await tryInjectArchived(archive)) break;
if (done) break;
await delay(120); await delay(120);
} }
applyBackupBadges(archive); applyBackupBadges(archive);
@@ -86,7 +78,7 @@ export function applyBackupBadges(archive: ArchiveMap) {
const badge = document.createElement("span"); const badge = document.createElement("span");
badge.className = BACKUP_BADGE_CLASS; badge.className = BACKUP_BADGE_CLASS;
badge.title = "Saved locally"; badge.title = "Saved locally";
badge.innerHTML = BACKUP_CHECK_SVG; badge.textContent = "✓";
itemEl.appendChild(badge); itemEl.appendChild(badge);
} }
} else { } else {
@@ -119,30 +111,23 @@ export function mountArchivedNotificationInjection(
const watchItemsContainer = () => { const watchItemsContainer = () => {
const itemsEl = document.querySelector(ITEMS_SELECTOR); const itemsEl = document.querySelector(ITEMS_SELECTOR);
if (!itemsEl) return; if (!itemsEl) return;
if (observer) observer.disconnect(); observer?.disconnect();
observer = new MutationObserver(() => scheduleInject()); observer = new MutationObserver(scheduleInject);
observer.observe(itemsEl, { childList: true }); observer.observe(itemsEl, { childList: true });
}; };
api.seqta.onMount(LIST_SELECTOR, () => { const onNotificationsMount = () => {
scheduleInject(); scheduleInject();
watchItemsContainer(); watchItemsContainer();
});
api.seqta.onMount(ITEMS_SELECTOR, () => {
watchItemsContainer();
scheduleInject();
});
api.storage.onChange("archivesByUser", () => scheduleInject());
return () => {
if (observer) observer.disconnect();
}; };
api.seqta.onMount(LIST_SELECTOR, onNotificationsMount);
api.seqta.onMount(ITEMS_SELECTOR, onNotificationsMount);
api.storage.onChange("archivesByUser", scheduleInject);
return () => observer?.disconnect();
} }
export async function injectArchivedForUser( export async function injectArchivedForUser(archive: ArchiveMap): Promise<void> {
archive: ArchiveMap,
): Promise<void> {
await injectWithRetries(archive); await injectWithRetries(archive);
} }
@@ -10,16 +10,11 @@
height: 14px; height: 14px;
border-radius: 50%; border-radius: 50%;
background: var(--better-main, #22c55e); background: var(--better-main, #22c55e);
display: flex; color: #fff;
align-items: center; font-size: 9px;
justify-content: center; line-height: 14px;
text-align: center;
pointer-events: none; pointer-events: none;
z-index: 2; z-index: 2;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
} }
.bsplus-notification-backup-badge svg {
width: 9px;
height: 9px;
color: #fff;
}
+3 -14
View File
@@ -1,7 +1,7 @@
import type { SettingsState } from "@/types/storage"; import type { SettingsState } from "@/types/storage";
import { settingsState } from "../listeners/SettingsState"; import { settingsState } from "../listeners/SettingsState";
import { applyMenuItemVisibility } from "../menuItemVisibility"; import { applyMenuItemVisibility } from "../menuItemVisibility";
import { insertKeyAfterInOrder } from "@/seqta/utils/sidebarMenuIcons"; import { ensureAnalyticsMenuOrder } from "@/seqta/utils/sidebarMenuIcons";
import stringToHTML from "../stringToHTML"; import stringToHTML from "../stringToHTML";
import Sortable from "sortablejs"; import Sortable from "sortablejs";
@@ -30,20 +30,10 @@ function syncDefaultMenuOrder(menu: HTMLElement) {
for (let i = 0; i < childnodes.length; i++) { for (let i = 0; i < childnodes.length; i++) {
const key = (childnodes[i] as HTMLElement).dataset.key; const key = (childnodes[i] as HTMLElement).dataset.key;
if (key && settingsState.defaultmenuorder.indexOf(key) === -1) { if (key && settingsState.defaultmenuorder.indexOf(key) === -1) {
if (key === "analytics") { settingsState.defaultmenuorder = [...settingsState.defaultmenuorder, key];
settingsState.defaultmenuorder = insertKeyAfterInOrder(
settingsState.defaultmenuorder,
key,
"courses",
);
} else {
settingsState.defaultmenuorder = [
...settingsState.defaultmenuorder,
key,
];
}
} }
} }
ensureAnalyticsMenuOrder();
} }
function mergeMenuItemsFromDom( function mergeMenuItemsFromDom(
@@ -242,7 +232,6 @@ export function OpenMenuOptions() {
if (sortable) { if (sortable) {
saveNewOrder(sortable); saveNewOrder(sortable);
} }
applyMenuItemVisibility();
closeAll(); closeAll();
}; };
-8
View File
@@ -1,14 +1,7 @@
import { settingsState } from "./listeners/SettingsState"; import { settingsState } from "./listeners/SettingsState";
import { verboseInfo } from "@/utils/verboseLog";
const STYLE_ID = "bsplus-menuitem-visibility"; const STYLE_ID = "bsplus-menuitem-visibility";
/** Whether a sidebar key is hidden via Edit Sidebar toggles. */
export function isMenuItemHidden(key: string): boolean {
const entry = (settingsState.menuitems as Record<string, { toggle?: boolean }>)?.[key];
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 (document.querySelector(".editmenuoption-container")) return; if (document.querySelector(".editmenuoption-container")) return;
@@ -18,7 +11,6 @@ export function applyMenuItemVisibility(): void {
for (const [menuItem, config] of Object.entries(settingsState.menuitems ?? {})) { for (const [menuItem, config] of Object.entries(settingsState.menuitems ?? {})) {
if (config && !config.toggle) { if (config && !config.toggle) {
css += `li[data-key=${menuItem}],section[data-key=${menuItem}]{display:var(--menuHidden) !important;transition:1s;}`; css += `li[data-key=${menuItem}],section[data-key=${menuItem}]{display:var(--menuHidden) !important;transition:1s;}`;
verboseInfo(`[BetterSEQTA+] Hiding ${menuItem} menu item`);
} }
} }
+173 -235
View File
@@ -8,12 +8,109 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { noticeMatchesLabelFilter } from "@/seqta/utils/notices/noticeLabelFilters"; import { noticeMatchesLabelFilter } from "@/seqta/utils/notices/noticeLabelFilters";
import stringToHTML from "@/seqta/utils/stringToHTML"; import stringToHTML from "@/seqta/utils/stringToHTML";
const PLACEHOLDER_RE = /\[\[[\w]+[:][\w]+[\]\]]+/g;
const SPRING_OPEN = { type: "spring" as const, stiffness: 280, damping: 24, duration: 0.5 };
const SPRING_CLOSE = { type: "spring" as const, stiffness: 400, damping: 35, duration: 0.35 };
const colourStr = (colour?: string) => colour || "#8e8e8e";
function stripPlaceholders(html: string): string {
return html.replace(PLACEHOLDER_RE, "");
}
function noticePreview(contents: string): string {
const text = stripPlaceholders(contents)
.replace(/<[^>]*>/g, "")
.replace(/\s+/g, " ")
.trim();
return text.substring(0, 150) + (contents.length > 150 ? "..." : "");
}
function noticeBody(contents: string): string {
return stripPlaceholders(contents).replace(/ +/, " ");
}
type NoticeCardOpts = {
wrapperClass?: string;
wrapperStyle?: string;
hideClose?: boolean;
};
function noticeCardHtml(
notice: { title: string; staff: string; label_title?: string },
colour: string | undefined,
body: string,
opts: NoticeCardOpts = {},
): string {
const c = colourStr(colour);
const closeBtn = opts.hideClose
? '<button class="notice-close-btn" style="opacity: 0; pointer-events: none;">&times;</button>'
: '<button class="notice-close-btn">&times;</button>';
return `<div class="notice-unified-content ${opts.wrapperClass ?? "notice-card-state"}" style="--colour: ${c}; ${opts.wrapperStyle ?? ""}">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge" style="background: linear-gradient(135deg, ${c}, ${c}dd); color: white;">${notice.label_title || "General"}</span>
<span class="notice-staff">${notice.staff}</span>
</div>
${closeBtn}
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${body}</div>
</div>`;
}
function modalTargetSize(sourceWidth: number, contentHeight: number) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const scrollY = Math.round(window.scrollY);
const width = Math.round(Math.min(Math.max(sourceWidth, 800), vw - 40));
const height = Math.round(Math.min(Math.max(contentHeight + 32, 200), vh * 0.9));
return {
width,
height,
left: Math.round((vw - width) / 2),
top: Math.round((vh - height) / 2) + scrollY,
scrollX: Math.round(window.scrollX),
scrollY,
};
}
function measureNoticeHeight(
notice: { title: string; staff: string; label_title?: string },
body: string,
targetWidth: number,
): number {
const measure = document.createElement("div");
measure.style.cssText = `position:absolute;left:-9999px;width:${targetWidth}px;visibility:hidden`;
measure.innerHTML = noticeCardHtml(notice, undefined, body, {
wrapperClass: "notice-modal-state",
wrapperStyle:
"position:relative;width:100%;padding:16px;border:1px solid rgba(255,255,255,0.1)",
});
document.body.appendChild(measure);
const height = measure.firstElementChild!.getBoundingClientRect().height;
measure.remove();
return height;
}
function showSourceElement(el: HTMLElement) {
el.style.opacity = "1";
el.style.transform = "";
}
function elementScale(el: HTMLElement) {
const transform = getComputedStyle(el).transform;
if (!transform || transform === "none") return { x: 1, y: 1 };
const match = transform.match(/matrix.*\((.+)\)/);
if (!match) return { x: 1, y: 1 };
const values = match[1].split(", ");
return { x: parseFloat(values[0]), y: parseFloat(values[3]) };
}
export function processNoticeColor(colour: unknown): string | undefined { export function processNoticeColor(colour: unknown): string | undefined {
if (typeof colour !== "string") return undefined; if (typeof colour !== "string") return undefined;
const rgb = GetThresholdOfColor(colour); const rgb = GetThresholdOfColor(colour);
if (rgb < 100 && settingsState.DarkMode) { if (rgb < 100 && settingsState.DarkMode) return undefined;
return undefined;
}
return colour; return colour;
} }
@@ -29,35 +126,13 @@ export function appendNoticeEmptyState(container: HTMLElement, message: string)
} }
function createNoticeElement(notice: any, colour: string | undefined): Node { function createNoticeElement(notice: any, colour: string | undefined): Node {
const textPreview = const htmlContent = noticeCardHtml(notice, colour, noticePreview(notice.contents), {
notice.contents wrapperStyle:
.replace(/<[^>]*>/g, "") "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);",
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "") hideClose: true,
.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;">&times;</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; const element = stringToHTML(htmlContent).firstChild as HTMLElement;
element.addEventListener("click", () => element.addEventListener("click", () => openNoticeModal(notice, colour, element));
openNoticeModal(notice, colour, element),
);
return element; return element;
} }
@@ -66,10 +141,7 @@ export function openNoticeModal(
colour: string | undefined, colour: string | undefined,
sourceElement: HTMLElement, sourceElement: HTMLElement,
) { ) {
const cleanContent = notice.contents const cleanContent = noticeBody(notice.contents);
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " ");
document.getElementById("notice-modal")?.remove(); document.getElementById("notice-modal")?.remove();
const sourceRect = sourceElement.getBoundingClientRect(); const sourceRect = sourceElement.getBoundingClientRect();
@@ -80,84 +152,37 @@ export function openNoticeModal(
let sourceWidth = sourceRect.width; let sourceWidth = sourceRect.width;
let sourceHeight = sourceRect.height; let sourceHeight = sourceRect.height;
const modalHtml = ` const modalHtml = `<div id="notice-modal" class="notice-modal-overlay" style="opacity: 0;">
<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-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-modal-content notice-transitioning">
<div class="notice-unified-content notice-card-state"> ${noticeCardHtml(notice, colour, cleanContent)}
<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">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
</div>
</div> </div>
</div> </div>
</div>`; </div>`;
const modal = stringToHTML(modalHtml).firstChild as HTMLElement; const modal = stringToHTML(modalHtml).firstChild as HTMLElement;
const transitionContainer = modal.querySelector( const transitionContainer = modal.querySelector(".notice-modal-transition") as HTMLElement;
".notice-modal-transition", const unifiedContent = modal.querySelector(".notice-unified-content") as HTMLElement;
) as HTMLElement;
const unifiedContent = modal.querySelector(
".notice-unified-content",
) as HTMLElement;
const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement; const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement;
document.body.appendChild(modal); document.body.appendChild(modal);
sourceElement.setAttribute("data-transitioning", "true"); sourceElement.setAttribute("data-transitioning", "true");
sourceElement.style.opacity = "0"; sourceElement.style.opacity = "0";
sourceElement.style.transform = "scale(0.95)"; sourceElement.style.transform = "scale(0.95)";
const viewportWidth = window.innerWidth; const initialWidth = Math.round(
const viewportHeight = window.innerHeight; Math.min(Math.max(sourceWidth, 800), window.innerWidth - 40),
let targetWidth = Math.round(
Math.min(Math.max(sourceWidth, 800), viewportWidth - 40),
); );
const measuredHeight = measureNoticeHeight(notice, cleanContent, initialWidth);
let { width: targetWidth, height: targetHeight, left: targetLeft, top: targetTop } =
modalTargetSize(sourceWidth, measuredHeight);
const tempMeasureDiv = document.createElement("div"); const applyTargetLayout = () => {
tempMeasureDiv.style.position = "absolute"; transitionContainer.style.left = `${Math.round(targetLeft + scrollX)}px`;
tempMeasureDiv.style.left = "-9999px"; transitionContainer.style.top = `${Math.round(targetTop)}px`;
tempMeasureDiv.style.width = targetWidth + "px"; transitionContainer.style.width = `${Math.round(targetWidth)}px`;
tempMeasureDiv.style.visibility = "hidden"; transitionContainer.style.height = `${Math.round(targetHeight)}px`;
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">&times;</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 = () => { const closeModal = () => {
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
@@ -165,8 +190,7 @@ export function openNoticeModal(
if (!settingsState.animations) { if (!settingsState.animations) {
modal.remove(); modal.remove();
sourceElement.style.opacity = "1"; showSourceElement(sourceElement);
sourceElement.style.transform = "";
sourceElement.removeAttribute("data-transitioning"); sourceElement.removeAttribute("data-transitioning");
return; return;
} }
@@ -179,146 +203,65 @@ export function openNoticeModal(
}, },
{ duration: 0.2 }, { duration: 0.2 },
); );
animate(transitionContainer, { opacity: [1, 0] }, { duration: 0.2, delay: 0.3 });
animate( showSourceElement(sourceElement);
transitionContainer,
{ opacity: [1, 0] },
{ duration: 0.2, delay: 0.3 },
);
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
modal.style.pointerEvents = "none"; modal.style.pointerEvents = "none";
animate(transitionContainer, {
animate( left: [targetLeft + scrollX, sourceLeft + scrollX],
transitionContainer, top: [targetTop, sourceTop + scrollY],
{ width: [targetWidth, sourceWidth],
left: [targetLeft + scrollX, sourceLeft + scrollX], height: [targetHeight, sourceHeight],
top: [targetTop, sourceTop + scrollY], }, SPRING_CLOSE).finished.then(() => {
width: [targetWidth, sourceWidth],
height: [targetHeight, sourceHeight],
scale: [1, 1],
},
{
duration: 0.35,
type: "spring",
stiffness: 400,
damping: 35,
},
).finished.then(async () => {
modal.remove(); modal.remove();
sourceElement.removeAttribute("data-transitioning"); sourceElement.removeAttribute("data-transitioning");
}); });
}; };
closeBtn?.addEventListener("click", closeModal); closeBtn.addEventListener("click", closeModal);
modal?.addEventListener("click", (e) => { modal.addEventListener("click", (e) => {
if (e.target === modal) { if (e.target === modal) closeModal();
closeModal();
}
}); });
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") closeModal();
closeModal();
document.removeEventListener("keydown", handleEscape);
window.removeEventListener("resize", handleResize);
}
}; };
document.addEventListener("keydown", handleEscape); document.addEventListener("keydown", handleEscape);
const handleResize = () => { const handleResize = () => {
const newSourceRect = sourceElement.getBoundingClientRect(); const rect = sourceElement.getBoundingClientRect();
const newScrollY = Math.round(window.scrollY); scrollY = Math.round(window.scrollY);
const newScrollX = Math.round(window.scrollX); scrollX = Math.round(window.scrollX);
const scale = elementScale(sourceElement);
sourceWidth = rect.width / scale.x;
sourceHeight = rect.height / scale.y;
sourceLeft = rect.left - (sourceWidth - rect.width) / 2;
sourceTop = rect.top - (sourceHeight - rect.height) / 2;
const computedStyle = getComputedStyle(sourceElement); const next = modalTargetSize(
const transform = computedStyle.transform; sourceWidth,
let scaleX = 1, unifiedContent.getBoundingClientRect().height,
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; targetLeft = next.left;
const newTargetHeight = Math.round( targetTop = next.top;
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9), targetWidth = next.width;
); targetHeight = next.height;
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2); applyTargetLayout();
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); window.addEventListener("resize", handleResize);
unifiedContent.classList.replace("notice-card-state", "notice-modal-state");
if (settingsState.animations) { if (settingsState.animations) {
animate(modal, { opacity: [0, 1] }, { duration: 0.2 }); animate(modal, { opacity: [0, 1] }, { duration: 0.2 });
animate(transitionContainer, {
animate( left: [sourceLeft + scrollX, targetLeft + scrollX],
transitionContainer, top: [sourceTop + scrollY, targetTop],
{ width: [sourceWidth, targetWidth],
left: [sourceLeft + scrollX, targetLeft + scrollX], height: [sourceHeight, targetHeight],
top: [sourceTop + scrollY, targetTop], }, SPRING_OPEN);
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 { } else {
modal.style.opacity = "1"; modal.style.opacity = "1";
transitionContainer.style.left = Math.round(targetLeft + scrollX) + "px"; applyTargetLayout();
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");
} }
} }
@@ -341,22 +284,19 @@ export function renderNoticesIntoContainer(
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const notice of notices) {
notices.forEach((notice: any) => { if (
const shouldInclude = settingsState.mockNotices ||
settingsState.mockNotices || noticeMatchesLabelFilter(notice, labelTokens); noticeMatchesLabelFilter(notice, labelTokens)
) {
if (shouldInclude) { fragment.appendChild(createNoticeElement(notice, processNoticeColor(notice.colour)));
const colour = processNoticeColor(notice.colour);
fragment.appendChild(createNoticeElement(notice, colour));
} }
}); }
if (fragment.childNodes.length === 0) { if (!fragment.childNodes.length) {
appendNoticeEmptyState(noticeContainer, emptyMessage); appendNoticeEmptyState(noticeContainer, emptyMessage);
return; return;
} }
noticeContainer.appendChild(fragment); noticeContainer.appendChild(fragment);
} }
@@ -372,8 +312,9 @@ export async function fetchNoticesForDate(
container.innerHTML = ""; container.innerHTML = "";
} }
let data: { payload?: unknown };
try { try {
const data = settingsState.mockNotices data = settingsState.mockNotices
? getMockNotices() ? getMockNotices()
: await ( : await (
await fetch(noticesUrl, { await fetch(noticesUrl, {
@@ -383,11 +324,10 @@ export async function fetchNoticesForDate(
body: JSON.stringify({ date }), body: JSON.stringify({ date }),
}) })
).json(); ).json();
renderNoticesIntoContainer(containerId, data, labelTokens);
} catch { } catch {
renderNoticesIntoContainer(containerId, { payload: [] }, labelTokens); data = { payload: [] };
} }
renderNoticesIntoContainer(containerId, data, labelTokens);
} }
export type SetupNoticesSectionOptions = { export type SetupNoticesSectionOptions = {
@@ -405,9 +345,7 @@ export function setupNoticesSection(options: SetupNoticesSectionOptions): () =>
? (document.querySelector(options.dateInput) as HTMLInputElement | null) ? (document.querySelector(options.dateInput) as HTMLInputElement | null)
: options.dateInput; : options.dateInput;
if (dateControl) { if (dateControl) dateControl.value = options.initialDate;
dateControl.value = options.initialDate;
}
const debouncedInputChange = debounce((e: Event) => { const debouncedInputChange = debounce((e: Event) => {
void fetchNoticesForDate( void fetchNoticesForDate(
+16 -28
View File
@@ -1,14 +1,13 @@
type PrefEntry = { name?: string; value?: unknown }; type PrefEntry = { name?: string; value?: unknown };
const NOTICES_FILTER = "notices.filters";
const JSON_HEADERS = { "Content-Type": "application/json; charset=utf-8" };
/** Parse `notices.filters` pref (space-separated label IDs). */ /** Parse `notices.filters` pref (space-separated label IDs). */
export function parseNoticesFilterPref(prefsPayload: unknown): string[] { export function parseNoticesFilterPref(prefsPayload: unknown): string[] {
if (!Array.isArray(prefsPayload)) return []; if (!Array.isArray(prefsPayload)) return [];
const values = (prefsPayload as PrefEntry[]) const raw = (prefsPayload as PrefEntry[]).find((item) => item?.name === NOTICES_FILTER)?.value;
.filter((item) => item?.name === "notices.filters") return typeof raw === "string" ? raw.split(" ").filter(Boolean) : [];
.map((item) => item?.value)
.filter((v): v is string => typeof v === "string");
if (values.length === 0) return [];
return String(values[0]).split(" ").filter(Boolean);
} }
/** Label IDs from `load/notices` with `{ mode: "labels" }`. */ /** Label IDs from `load/notices` with `{ mode: "labels" }`. */
@@ -16,13 +15,12 @@ export async function fetchNoticeLabelIds(noticesUrl: string): Promise<string[]>
try { try {
const res = await fetch(noticesUrl, { const res = await fetch(noticesUrl, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" }, headers: JSON_HEADERS,
credentials: "include", credentials: "include",
body: JSON.stringify({ mode: "labels" }), body: JSON.stringify({ mode: "labels" }),
}); });
if (!res.ok) return []; if (!res.ok) return [];
const json = (await res.json()) as { payload?: Array<{ id?: number }> }; const payload = ((await res.json()) as { payload?: Array<{ id?: number }> })?.payload;
const payload = json?.payload;
if (!Array.isArray(payload)) return []; if (!Array.isArray(payload)) return [];
return payload return payload
.map((entry) => entry?.id) .map((entry) => entry?.id)
@@ -39,26 +37,16 @@ export async function resolveNoticeFilterTokens(
noticesUrl: string, noticesUrl: string,
): Promise<string[]> { ): Promise<string[]> {
const fromPref = parseNoticesFilterPref(prefsPayload); const fromPref = parseNoticesFilterPref(prefsPayload);
if (fromPref.length > 0) return fromPref; return fromPref.length > 0 ? fromPref : await fetchNoticeLabelIds(noticesUrl);
return await fetchNoticeLabelIds(noticesUrl);
} }
export function normalizeNoticeLabelId(label: unknown): string | null { export function normalizeNoticeLabelId(label: unknown): string | null {
if (typeof label === "number" && !Number.isNaN(label)) { if (typeof label === "number" && !Number.isNaN(label)) return String(label);
return String(label); if (typeof label === "string" && label.trim()) return label.trim();
} const id =
if (typeof label === "string" && label.trim()) { label && typeof label === "object" ? (label as { id?: unknown }).id : undefined;
return label.trim(); if (typeof id === "number" && !Number.isNaN(id)) return String(id);
} if (typeof id === "string" && id.trim()) return id.trim();
if (label && typeof label === "object") {
const obj = label as Record<string, unknown>;
if (typeof obj.id === "number" && !Number.isNaN(obj.id)) {
return String(obj.id);
}
if (typeof obj.id === "string" && obj.id.trim()) {
return obj.id.trim();
}
}
return null; return null;
} }
@@ -68,6 +56,6 @@ export function noticeMatchesLabelFilter(
): boolean { ): boolean {
if (filterTokens.length === 0) return true; if (filterTokens.length === 0) return true;
const id = normalizeNoticeLabelId(notice?.label); const id = normalizeNoticeLabelId(notice?.label);
if (id !== null && filterTokens.includes(id)) return true; return (id !== null && filterTokens.includes(id)) ||
return filterTokens.includes(JSON.stringify(notice?.label)); filterTokens.includes(JSON.stringify(notice?.label));
} }
+20 -33
View File
@@ -8,44 +8,36 @@ import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
import { verboseInfo } from "@/utils/verboseLog"; 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";
let colorisRecoveryAttached = false;
let dismissTimer: ReturnType<typeof setTimeout> | null = null;
export function dismissStaleModaliserContainers(): number { function dismissStaleColourDialogs(forceColourChooser = false): {
let removed = 0; slideRemoved: number;
for (const container of document.querySelectorAll(".modaliser-container")) { modalRemoved: number;
const modal = container.querySelector(".modaliser"); } {
if (!modal?.childElementCount || !container.classList.contains("visible")) { let slideRemoved = 0;
container.remove();
removed++;
}
}
return removed;
}
export function dismissStaleColourSlidePanes(
forceColourChooser = false,
): number {
let removed = 0;
for (const pane of document.querySelectorAll(".uiSlidePane")) { for (const pane of document.querySelectorAll(".uiSlidePane")) {
if (pane.querySelector(".pane.colourChooser")) { if (pane.querySelector(".pane.colourChooser")) {
pane.remove(); pane.remove();
removed++; slideRemoved++;
continue; continue;
} }
if (!forceColourChooser && pane.classList.contains("shown")) continue; if (!forceColourChooser && pane.classList.contains("shown")) continue;
if (!pane.classList.contains("shown")) { if (!pane.classList.contains("shown")) {
pane.remove(); pane.remove();
removed++; slideRemoved++;
}
}
let modalRemoved = 0;
for (const container of document.querySelectorAll(".modaliser-container")) {
const modal = container.querySelector(".modaliser");
if (!modal?.childElementCount || !container.classList.contains("visible")) {
container.remove();
modalRemoved++;
} }
} }
return removed;
}
export function dismissStaleColourDialogs(forceColourChooser = false): {
slideRemoved: number;
modalRemoved: number;
} {
const slideRemoved = dismissStaleColourSlidePanes(forceColourChooser);
const modalRemoved = dismissStaleModaliserContainers();
document.body.classList.remove("clr-open"); document.body.classList.remove("clr-open");
document.documentElement.classList.remove("clr-open"); document.documentElement.classList.remove("clr-open");
return { slideRemoved, modalRemoved }; return { slideRemoved, modalRemoved };
@@ -69,8 +61,7 @@ function setClrPickerState(reset: boolean): void {
} }
} }
/** Hide colour-picker / modal layers that intercept clicks after a colour save. */ function dismissTimetableUiBlockers(): {
export function dismissTimetableUiBlockers(): {
slideRemoved: number; slideRemoved: number;
modalRemoved: number; modalRemoved: number;
} { } {
@@ -79,14 +70,10 @@ export function dismissTimetableUiBlockers(): {
return dismissStaleColourDialogs(); return dismissStaleColourDialogs();
} }
/** Clear inline styles that can prevent Coloris from reopening. */ function prepareColorisPickerOpen(): void {
export function prepareColorisPickerOpen(): void {
setClrPickerState(true); setClrPickerState(true);
} }
let colorisRecoveryAttached = false;
let dismissTimer: ReturnType<typeof setTimeout> | null = null;
export function attachTimetableColorisRecovery(): void { export function attachTimetableColorisRecovery(): void {
if (colorisRecoveryAttached) return; if (colorisRecoveryAttached) return;
colorisRecoveryAttached = true; colorisRecoveryAttached = true;
+23 -31
View File
@@ -7,62 +7,54 @@
var MENU_UPDATE_COLOURS = "menu.update.colours"; var MENU_UPDATE_COLOURS = "menu.update.colours";
var SUBJECT_PREFIX = "timetable.subject.colour."; var SUBJECT_PREFIX = "timetable.subject.colour.";
var TUTOR_PREFIX = "timetable.tutor."; var TUTOR_PREFIX = "timetable.tutor.";
var CLEANUP_FOLLOWUP_MS = 300;
var cleanupFollowup = null;
function isTesStyling() { function isTesStyling() {
var el = document.getElementById("logo-style"); var el = document.getElementById("logo-style");
return el && el.textContent.indexOf("tesSeqta") !== -1; return el && el.textContent.indexOf("tesSeqta") !== -1;
} }
function dismissModalisers() { function dismissStaleDialogs(forceColour) {
var n = 0; var slide = 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"); var panes = document.querySelectorAll(".uiSlidePane");
for (var i = 0; i < panes.length; i++) { for (var i = 0; i < panes.length; i++) {
var p = panes[i]; var p = panes[i];
if (p.querySelector(".pane.colourChooser")) { if (p.querySelector(".pane.colourChooser")) {
p.remove(); p.remove();
n++; slide++;
continue; continue;
} }
if (!forceColour && p.classList.contains("shown")) continue; if (!forceColour && p.classList.contains("shown")) continue;
if (!p.classList.contains("shown")) { if (!p.classList.contains("shown")) {
p.remove(); p.remove();
n++; slide++;
}
}
var modal = 0;
var containers = document.querySelectorAll(".modaliser-container");
for (var j = 0; j < containers.length; j++) {
var c = containers[j];
var m = c.querySelector(".modaliser");
if (!m || !m.childElementCount || !c.classList.contains("visible")) {
c.remove();
modal++;
} }
} }
return n;
}
function dismissStaleDialogs(forceColour) {
var slide = dismissSlidePanes(forceColour);
var modal = dismissModalisers();
document.body.classList.remove("clr-open"); document.body.classList.remove("clr-open");
document.documentElement.classList.remove("clr-open"); document.documentElement.classList.remove("clr-open");
return { slideRemoved: slide, modalRemoved: modal }; return { slideRemoved: slide, modalRemoved: modal };
} }
function scheduleCleanup() { function scheduleCleanup() {
var delays = [0, 100, 300, 600]; dismissStaleDialogs(true);
for (var i = 0; i < delays.length; i++) { if (cleanupFollowup) clearTimeout(cleanupFollowup);
(function (d) { cleanupFollowup = setTimeout(function () {
setTimeout(function () { cleanupFollowup = null;
dismissStaleDialogs(true); dismissStaleDialogs(true);
}, d); }, CLEANUP_FOLLOWUP_MS);
})(delays[i]);
}
} }
function applyMenuColours() { function applyMenuColours() {
+2 -8
View File
@@ -14,7 +14,6 @@
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 cssState = { custom: "", preview: "" };
var headObserver = null; var headObserver = null;
function log(event, detail) { function log(event, detail) {
@@ -77,8 +76,6 @@
function clearAll() { function clearAll() {
releaseCachedUrls(); releaseCachedUrls();
cssState.custom = "";
cssState.preview = "";
setStyleText(IMAGES_STYLE_ID, ""); setStyleText(IMAGES_STYLE_ID, "");
document.getElementById(THEME_STYLE_ID)?.remove(); document.getElementById(THEME_STYLE_ID)?.remove();
document.getElementById(PREVIEW_STYLE_ID)?.remove(); document.getElementById(PREVIEW_STYLE_ID)?.remove();
@@ -128,18 +125,15 @@
if (payload.images !== undefined) applyThemeImages(payload.images); if (payload.images !== undefined) applyThemeImages(payload.images);
if (payload.customCss !== undefined) { if (payload.customCss !== undefined) {
cssState.custom = payload.customCss || ""; setStyleText(THEME_STYLE_ID, payload.customCss || "", true);
setStyleText(THEME_STYLE_ID, cssState.custom, true);
log("custom css applied"); log("custom css applied");
} }
if (payload.previewCss !== undefined) { if (payload.previewCss !== undefined) {
cssState.preview = payload.previewCss || ""; setStyleText(PREVIEW_STYLE_ID, payload.previewCss || "");
setStyleText(PREVIEW_STYLE_ID, cssState.preview);
} }
if (payload.clearPreview) { if (payload.clearPreview) {
cssState.preview = "";
setStyleText(PREVIEW_STYLE_ID, ""); setStyleText(PREVIEW_STYLE_ID, "");
} }
} catch (e) { } catch (e) {