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 activeIndex = $state(0);
let root: HTMLDivElement | undefined = $state();
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}`;
}
let trigger = $state<HTMLButtonElement>();
function openMenu(preferredIndex?: number) {
isOpen = true;
@@ -41,62 +27,59 @@
closeMenu();
}
function moveActive(delta: number) {
if (!options.length) return;
activeIndex = (activeIndex + delta + options.length) % options.length;
}
function onKeydown(event: KeyboardEvent, inListbox = false) {
const { key } = event;
function handleKeydown(event: KeyboardEvent, inListbox = false) {
switch (event.key) {
case "ArrowDown":
case "ArrowUp": {
event.preventDefault();
const delta = event.key === "ArrowDown" ? 1 : -1;
if (isOpen || inListbox) moveActive(delta);
else openMenu();
break;
if (key === "ArrowDown" || key === "ArrowUp") {
event.preventDefault();
const count = options.length;
if (!count) return;
if (isOpen || inListbox) {
activeIndex = (activeIndex + (key === "ArrowDown" ? 1 : -1) + count) % count;
} else {
openMenu();
}
case "Enter":
case " ":
event.preventDefault();
if (isOpen) {
const option = options[activeIndex];
if (option) selectValue(option.value);
} else {
openMenu();
}
break;
case "Escape":
if (isOpen) {
event.preventDefault();
closeMenu();
}
break;
case "Home":
if (inListbox) {
event.preventDefault();
activeIndex = 0;
}
break;
case "End":
if (inListbox) {
event.preventDefault();
activeIndex = Math.max(0, options.length - 1);
}
break;
case "Tab":
if (inListbox) closeMenu(false);
break;
return;
}
if (key === "Enter" || key === " ") {
event.preventDefault();
if (isOpen) {
const option = options[activeIndex];
if (option) selectValue(option.value);
} else {
openMenu();
}
return;
}
if (key === "Escape" && isOpen) {
event.preventDefault();
closeMenu();
return;
}
if (!inListbox) return;
if (key === "Home") {
event.preventDefault();
activeIndex = 0;
} else if (key === "End") {
event.preventDefault();
activeIndex = Math.max(0, options.length - 1);
} else if (key === "Tab") {
closeMenu(false);
}
}
$effect(() => {
if (!isOpen) return;
queueMicrotask(() => listbox?.focus());
queueMicrotask(() => document.getElementById(listboxId)?.focus());
const wrapper = trigger?.parentElement;
const onPointerDown = (event: PointerEvent) => {
if (root && event.composedPath().includes(root)) return;
if (wrapper && event.composedPath().includes(wrapper)) return;
closeMenu(false);
};
@@ -105,46 +88,47 @@
});
</script>
<div class="select-wrapper" bind:this={root}>
<div class="select relative w-full">
<button
bind:this={trigger}
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-expanded={isOpen}
aria-controls={listboxId}
onclick={() => (isOpen ? closeMenu() : openMenu())}
onkeydown={(event) => handleKeydown(event)}
onkeydown={onKeydown}
>
<span class="select-label">{selectedLabel}</span>
<span class="select-icon" aria-hidden="true">
<span class="truncate">
{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">
<path
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"
></path>
/>
</svg>
</span>
</button>
{#if isOpen}
<div
bind:this={listbox}
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"
tabindex="-1"
aria-activedescendant={activeDescendantId}
onkeydown={(event) => handleKeydown(event, true)}
aria-activedescendant={options[activeIndex] ? `${listboxId}-opt-${activeIndex}` : undefined}
onkeydown={(event) => onKeydown(event, true)}
>
{#each options as option, index (option.value)}
<button
type="button"
id={optionId(option.value)}
id={`${listboxId}-opt-${index}`}
role="option"
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-active={index === activeIndex}
tabindex="-1"
@@ -159,99 +143,54 @@
</div>
<style>
.select-wrapper {
position: relative;
width: 100%;
.select {
--sel-border: var(--theme-offset-bg, var(--theme-secondary, #e5e7eb));
--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 {
display: flex;
align-items: center;
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);
border-color: var(--sel-border);
background: var(--sel-bg);
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:focus-visible {
outline: none;
background: var(--theme-secondary, #e5e7eb);
border-color: var(--theme-offset-bg, var(--theme-secondary, #d4d4d8));
background: var(--sel-surface);
border-color: var(--sel-border);
}
.select-trigger:focus-visible {
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);
}
.select-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-color: var(--sel-focus-border);
box-shadow: var(--sel-ring);
}
.select-icon {
flex-shrink: 0;
color: color-mix(in srgb, var(--text-primary) 60%, transparent);
}
.select-menu {
position: absolute;
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: 1px solid var(--sel-border);
border-radius: 14px;
background: var(--theme-primary, #ffffff);
box-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;
background: var(--sel-bg);
box-shadow: var(--sel-menu-shadow);
}
.select-menu:focus-visible {
outline: none;
box-shadow:
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);
box-shadow: var(--sel-menu-shadow), var(--sel-ring);
}
.select-option {
display: block;
width: 100%;
border: none;
border-radius: 10px;
background: transparent;
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,
@@ -259,6 +198,6 @@
.select-option.is-active,
.select-option.is-selected {
outline: none;
background: var(--theme-secondary, #e5e7eb);
background: var(--sel-surface);
}
</style>
@@ -9,12 +9,14 @@ const LAYER_CLASSES = [
["bg", "bg3", ANIMATED_BG_MARKER],
] 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) {
document.querySelectorAll(`.bg.${ANIMATED_BG_MARKER}`).forEach((element, index) => {
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`;
document.querySelectorAll(bgSel).forEach((element, index) => {
const base = BASE_SPEEDS[index] ?? BASE_SPEEDS[2];
(element as HTMLElement).style.animationDuration = `${base / speed}s`;
});
}
@@ -23,12 +25,12 @@ export function ensureAnimatedBackgroundLayers(
menu: HTMLElement,
speed: number,
): void {
if (container.querySelectorAll(layerSelector).length >= 3) {
if (container.querySelectorAll(scopeSel).length >= 3) {
updateAnimationSpeed(speed);
return;
}
container.querySelectorAll(layerSelector).forEach((el) => el.remove());
container.querySelectorAll(scopeSel).forEach((el) => el.remove());
for (const classes of LAYER_CLASSES) {
const bk = document.createElement("div");
@@ -40,7 +42,7 @@ export function ensureAnimatedBackgroundLayers(
}
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(
@@ -29,7 +29,6 @@ class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
}
const instance = new AnimatedBackgroundPluginClass();
const resync = (api: PluginAPI<typeof settings>) => () => void syncAnimatedBackground(api);
const animatedBackgroundPlugin: Plugin<typeof settings> = {
id: "animated-background",
@@ -42,22 +41,20 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
run: async (api) => {
await syncAnimatedBackground(api);
const resync = () => void syncAnimatedBackground(api);
const speedUnregister = api.settings.onChange("speed", updateAnimationSpeed);
const pageChangeUnregister = api.seqta.onPageChange(resync(api));
const pageshowHandler = (event: PageTransitionEvent) => {
if (event.persisted) void syncAnimatedBackground(api);
};
window.addEventListener("pageshow", pageshowHandler);
const pageChangeUnregister = api.seqta.onPageChange(resync);
window.addEventListener("pageshow", resync);
const containerObserver = new MutationObserver(resync(api));
const containerObserver = new MutationObserver(resync);
const container = document.getElementById("container");
if (container) containerObserver.observe(container, { childList: true });
return () => {
speedUnregister.unregister();
pageChangeUnregister.unregister();
window.removeEventListener("pageshow", pageshowHandler);
window.removeEventListener("pageshow", resync);
containerObserver.disconnect();
removeAnimatedBackgroundLayers();
};
+34 -70
View File
@@ -44,15 +44,9 @@ let objectUrl: string | null = null;
let gestureCleanup: (() => void) | null = null;
let resumeTimer: ReturnType<typeof setTimeout> | null = null;
let hintEl: HTMLElement | null = null;
let playing = false;
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 {
hintEl?.remove();
hintEl = null;
@@ -63,40 +57,18 @@ function disarmGesture(): void {
gestureCleanup = null;
}
function onPlayStarted(): void {
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");
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. */
async function prepareAudio(vol: number): Promise<boolean> {
const blob = await loadBlob();
if (!blob) {
const blob = await store.getItem<Blob>("audio-blob");
if (!(blob instanceof Blob)) {
stopAudio();
clearHint();
return false;
@@ -114,25 +86,16 @@ async function prepareAudio(vol: number): Promise<boolean> {
return true;
}
/** Call synchronously from a user-gesture handler (no await before this). */
function playPrepared(vol: number): void {
if (!audio) return;
function attemptPlay(vol: number): Promise<boolean> {
if (!audio) return Promise.resolve(false);
audio.volume = clamp(vol);
void audio.play().then(onPlayStarted).catch(() => {
playing = false;
});
}
async function tryAutoplay(vol: number): Promise<boolean> {
if (!(await prepareAudio(vol)) || !audio) return false;
try {
await audio.play();
onPlayStarted();
return true;
} catch {
playing = false;
return false;
}
return audio
.play()
.then(() => {
disarmGesture();
return true;
})
.catch(() => false);
}
function armGesture(onGesture: () => void): void {
@@ -153,14 +116,19 @@ function armGesture(onGesture: () => void): void {
}
clearHint();
};
showHint(onGesture);
}
function clearResumeTimer(): void {
if (resumeTimer !== null) {
clearTimeout(resumeTimer);
resumeTimer = null;
}
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();
onGesture();
});
document.body.append(hint);
hintEl = hint;
}
const backgroundMusicPlugin: Plugin<typeof settings> = {
@@ -177,22 +145,20 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
await api.storage.loaded;
type BgSettings = { volume?: number; pauseOnHidden?: boolean };
const s = () => api.settings as BgSettings;
const vol = () => s().volume ?? 0.5;
const pauseOnHidden = () => s().pauseOnHidden ?? true;
const vol = () => (api.settings as BgSettings).volume ?? 0.5;
const pauseOnHidden = () => (api.settings as BgSettings).pauseOnHidden ?? true;
const gestureStart = () => {
if (audio) playPrepared(vol());
const gesturePlay = () => {
void attemptPlay(vol());
};
const ensurePlayback = async () => {
if (!(await prepareAudio(vol()))) return;
if (playing && audio && !audio.paused) {
clearHint();
if (audio && !audio.paused) {
disarmGesture();
return;
}
if (!(await tryAutoplay(vol()))) armGesture(gestureStart);
if (!(await attemptPlay(vol()))) armGesture(gesturePlay);
};
api.settings.onChange("volume" as never, (value: unknown) => {
@@ -214,9 +180,9 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
const onVisibility = () => {
if (document.visibilityState === "hidden") {
if (!pauseOnHidden() || !audio) return;
clearResumeTimer();
if (resumeTimer) clearTimeout(resumeTimer);
resumeTimer = null;
audio.pause();
playing = false;
return;
}
if (!audio) {
@@ -224,10 +190,10 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
return;
}
if (!pauseOnHidden()) return;
clearResumeTimer();
if (resumeTimer) clearTimeout(resumeTimer);
resumeTimer = setTimeout(() => {
resumeTimer = null;
void tryAutoplay(vol());
void attemptPlay(vol());
}, 200);
};
@@ -235,16 +201,14 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
const onStop = () => {
disarmGesture();
stopAudio();
clearHint();
};
const teardown = () => {
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pageshow", onUpdated);
window.removeEventListener("betterseqta-background-music-updated", onUpdated);
window.removeEventListener("betterseqta-background-music-stop", onStop);
clearResumeTimer();
if (resumeTimer) clearTimeout(resumeTimer);
disarmGesture();
clearHint();
stopAudio();
};
@@ -11,14 +11,4 @@
font: 600 0.8125rem/1.25 system-ui, sans-serif;
cursor: pointer;
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;
try {
const db = await openDB();
let db = await openDB();
if (!db.objectStoreNames.contains(store)) {
await upgradeDB(store);
db = await openDB();
}
await runStoreDiffTransaction(await openDB(), store, puts, removeKeys);
await runStoreDiffTransaction(db, store, puts, removeKeys);
} catch (error) {
console.error(`Error in applyStoreDiff for store ${store}:`, error);
throw error;
@@ -1,6 +1,6 @@
import { applyStoreDiff, get, getAll, put, remove } from "./db";
import { jobs } from "./jobs";
import { decorateIndexItems, publishDynamicItemsUpdate } from "./renderComponents";
import { decorateIndexItems } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types";
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
import { loadDynamicItems } from "../utils/dynamicItems";
@@ -260,54 +260,25 @@ function dispatchVectorProgress(
completedJobs: number,
totalSteps: number,
): number {
let detailMessage = progress.message || "";
const { status, total, processed, message = "" } = progress;
let detail = 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);
if (status === "processing" && total != null && processed != null) {
detail = `Vectorizing: ${processed} / ${total}`;
} else if (status === "started") {
detail = `Vectorization started for ${total} items`;
} else if (status === "complete") {
dispatchProgress(++completed, totalSteps, false, "Indexing finished", "Vectorization complete");
return completed;
} else if (progress.status === "error") {
dispatchProgress(
completed,
totalSteps,
false,
"Vectorization failed",
`Vectorization error: ${progress.message}`,
);
} else if (status === "error") {
dispatchProgress(completed, totalSteps, false, "Vectorization failed", `Vectorization error: ${message}`);
return completed;
} else if (progress.status === "cancelled") {
dispatchProgress(
completed,
totalSteps,
false,
"Vectorization cancelled",
`Vectorization cancelled: ${progress.message}`,
);
} else if (status === "cancelled") {
dispatchProgress(completed, totalSteps, false, "Vectorization cancelled", `Vectorization cancelled: ${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,
);
} else {
dispatchProgress(completed, totalSteps, true, "Vectorization in progress", detail);
}
return completed;
@@ -322,10 +293,7 @@ export async function runIndexing(): Promise<void> {
}
await ensureSchemaCurrent();
if (isIndexingPaused()) {
return;
}
if (isIndexingPaused()) return;
if (!(await acquireLock())) {
verboseDebug(
@@ -1,5 +1,3 @@
import { verboseDebug } from '@/utils/verboseLog';
const EMBEDDIA_DB = "embeddiaDB";
const EMBEDDIA_STORE = "embeddiaObjectStore";
@@ -13,13 +11,9 @@ function openEmbeddiaDb(): Promise<IDBDatabase | null> {
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) return new Set();
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
verboseDebug("embeddiaObjectStore not found, assuming no items are vectorized");
db.close();
return new Set();
}
@@ -39,7 +33,6 @@ export async function getVectorizedItemIds(): Promise<Set<string>> {
if (typeof key === "string") vectorizedIds.add(key);
}
verboseDebug(`Found ${vectorizedIds.size} already vectorized items in embeddia DB`);
db.close();
return vectorizedIds;
} catch (error) {
@@ -28,15 +28,23 @@ function shouldDedupeAsSameCourseSPA(item: IndexItem): boolean {
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 {
if (!shouldDedupeAsSameCourseSPA(item)) return undefined;
const md = item.metadata ?? {};
const programme = toFiniteNumber(
md.programme ?? md.programmeId ?? md.programmeID,
);
const metaclass = toFiniteNumber(
md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId,
);
const { programme, metaclass } = programmeMetaclassIds(item);
if (programme === undefined || metaclass === undefined) return undefined;
return `course:${programme}:${metaclass}`;
}
@@ -74,13 +82,7 @@ function isPassiveLike(item: IndexItem): boolean {
}
function hasProgrammeMetaclass(item: IndexItem): boolean {
const md = item.metadata ?? {};
const programme = toFiniteNumber(
md.programme ?? md.programmeId ?? md.programmeID,
);
const metaclass = toFiniteNumber(
md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId,
);
const { programme, metaclass } = programmeMetaclassIds(item);
return programme !== undefined && metaclass !== undefined;
}
@@ -166,44 +168,29 @@ function dynamicSearchKey(row: CombinedResult): string | undefined {
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(
results: CombinedResult[],
): CombinedResult[] {
const best = new Map<string, CombinedResult>();
for (const r of results) {
const key = dynamicSearchKey(r);
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;
return dedupeByCanonicalKey(
results,
dynamicSearchKey,
mergeCombinedDuplicates,
);
}
@@ -63,7 +63,7 @@ const gradeAnalyticsPlugin: Plugin<{}> = {
});
menuObserver.observe(menuList, { childList: true });
const onClick = (e: Event) => {
analyticsItem.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (
MenuOptionsOpen ||
@@ -75,12 +75,10 @@ const gradeAnalyticsPlugin: Plugin<{}> = {
e.preventDefault();
window.history.pushState({}, "", "/#?page=/analytics");
void loadAnalyticsPage();
};
analyticsItem.addEventListener("click", onClick);
});
return () => {
menuObserver.disconnect();
analyticsItem.removeEventListener("click", onClick);
analyticsItem.remove();
};
},
@@ -1,27 +1,17 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
type RawNotification = Record<string, unknown>;
export interface ArchivedNotification {
notificationID: number;
type: string;
timestamp: string;
title: string;
subtitle: string;
messageID?: number;
assessmentID?: number;
programmeID?: number;
metaclassID?: number;
subjectCode?: string;
firstSavedAt: string;
lastSeenAt: string;
raw: RawNotification;
}
export type ArchiveMap = Record<string, ArchivedNotification>;
export type ArchivesByUser = Record<string, ArchiveMap>;
type RawNotification = Record<string, unknown>;
export async function resolveNotificationUserKey(): Promise<string | null> {
try {
const info = await getUserInfo();
@@ -45,165 +35,58 @@ export async function fetchAllNotifications(): Promise<RawNotification[]> {
hash: "#?page=/notifications",
}),
});
if (!res.ok) return [];
const json = (await res.json()) as {
notifications?: RawNotification[];
payload?: { notifications?: RawNotification[] };
};
const list = json.notifications ?? json.payload?.notifications;
return Array.isArray(list) ? list : [];
}
function readString(value: unknown): string {
if (value == null) return "";
return String(value).trim();
}
export function normalizeArchivedNotification(
raw: RawNotification,
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 },
};
function archiveTimestamp(
item: ArchivedNotification & { timestamp?: string },
): number {
const ms = new Date(
String(item.raw?.timestamp ?? item.timestamp ?? 0),
).getTime();
return Number.isNaN(ms) ? 0 : ms;
}
export function mergeNotificationsIntoArchive(
existing: ArchiveMap,
notifications: RawNotification[],
): ArchiveMap {
): { archive: ArchiveMap; changed: boolean } {
const now = new Date().toISOString();
const merged: ArchiveMap = { ...existing };
let changed = false;
const archive = { ...existing };
for (const raw of notifications) {
const normalized = normalizeArchivedNotification(raw, now);
if (!normalized) continue;
const notificationID = Number(raw.notificationID);
if (!notificationID || Number.isNaN(notificationID)) continue;
const key = String(normalized.notificationID);
const prev = merged[key];
if (prev) {
merged[key] = {
...prev,
...normalized,
timestamp: normalized.timestamp || prev.timestamp,
firstSavedAt: prev.firstSavedAt,
lastSeenAt: now,
raw: { ...prev.raw, ...raw },
};
} else {
merged[key] = normalized;
}
const key = String(notificationID);
const prev = archive[key];
archive[key] = prev
? { ...prev, lastSeenAt: now, raw: { ...prev.raw, ...raw } }
: { notificationID, firstSavedAt: now, lastSeenAt: now, raw: { ...raw } };
changed = true;
}
return merged;
return { archive, changed };
}
export function listArchivedNotifications(archive: ArchiveMap): ArchivedNotification[] {
export function listArchivedNotifications(
archive: ArchiveMap,
): ArchivedNotification[] {
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(
item: ArchivedNotification,
): RawNotification {
if (item.raw && typeof item.raw === "object") {
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,
};
return { ...item.raw, notificationID: item.notificationID };
}
@@ -14,6 +14,9 @@ import {
} from "./injectArchivedNotifications";
import styles from "./styles.css?inline";
const BUBBLE_SELECTOR = "[class*='notifications__bubble___']";
const LIST_SELECTOR = '[class*="notifications__list___"]';
const notificationCollectorSettings = {
saveLocally: booleanSetting({
default: true,
@@ -60,15 +63,9 @@ const notificationCollectorPlugin: Plugin<
const baseInterval = 30000;
const maxInterval = 300000;
if (!api.storage.lastNotificationCount) {
api.storage.lastNotificationCount = 0;
}
if (!api.storage.consecutiveErrors) {
api.storage.consecutiveErrors = 0;
}
if (!api.storage.archivesByUser) {
api.storage.archivesByUser = {};
}
api.storage.lastNotificationCount ||= 0;
api.storage.consecutiveErrors ||= 0;
api.storage.archivesByUser ||= {};
const syncArchive = async () => {
if (!api.settings.saveLocally || archiveSyncInFlight) return;
@@ -81,12 +78,15 @@ const notificationCollectorPlugin: Plugin<
const notifications = await fetchAllNotifications();
const archivesByUser = { ...(api.storage.archivesByUser ?? {}) };
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;
api.storage.archivesByUser = archivesByUser;
} else if (document.querySelector('[class*="notifications__list___"]')) {
} else if (document.querySelector(LIST_SELECTOR)) {
await injectArchivedForUser(merged);
}
} catch (error) {
@@ -100,9 +100,7 @@ const notificationCollectorPlugin: Plugin<
if (!isVisible) return;
try {
const alertDiv = document.querySelector(
"[class*='notifications__bubble___']",
) as HTMLElement;
const alertDiv = document.querySelector(BUBBLE_SELECTOR) as HTMLElement;
if (alertDiv && api.storage.lastNotificationCount !== 0) {
alertDiv.textContent = api.storage.lastNotificationCount.toString();
@@ -159,9 +157,7 @@ const notificationCollectorPlugin: Plugin<
if (pollInterval) {
window.clearTimeout(pollInterval);
pollInterval = null;
const alertDiv = document.querySelector(
"[class*='notifications__bubble___']",
) as HTMLElement;
const alertDiv = document.querySelector(BUBBLE_SELECTOR) as HTMLElement;
if (alertDiv) {
if (api.storage.lastNotificationCount > 9) {
alertDiv.textContent = "9+";
@@ -175,10 +171,7 @@ const notificationCollectorPlugin: Plugin<
const handleVisibilityChange = () => {
isVisible = !document.hidden;
if (isVisible && !pollInterval) {
const alertDiv = document.querySelector(
"[class*='notifications__bubble___']",
);
if (alertDiv) startPolling();
if (document.querySelector(BUBBLE_SELECTOR)) startPolling();
}
};
@@ -195,18 +188,16 @@ const notificationCollectorPlugin: Plugin<
resolveNotificationUserKey,
);
api.seqta.onMount("[class*='notifications__bubble___']", () => {
const onBubbleMount = () => {
startPolling();
if (api.settings.saveLocally) {
void syncArchive();
}
});
if (api.settings.saveLocally) void syncArchive();
};
const onListMount = () => {
if (api.settings.saveLocally) void syncArchive();
};
api.seqta.onMount("[class*='notifications__list___']", () => {
if (api.settings.saveLocally) {
void syncArchive();
}
});
api.seqta.onMount(BUBBLE_SELECTOR, onBubbleMount);
api.seqta.onMount(LIST_SELECTOR, onListMount);
return () => {
stopPolling();
@@ -14,9 +14,6 @@ const ITEM_SELECTOR = '[class*="notifications__item___"]';
const BACKED_UP_CLASS = "bsplus-notification-backed-up";
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 {
const ms = new Date(String(item.timestamp ?? 0)).getTime();
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> {
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 merged = mergeLiveWithArchived(liveItems, archive);
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 });
return true;
}
async function injectWithRetries(archive: ArchiveMap, attempts = 10) {
for (let i = 0; i < attempts; i++) {
const done = await tryInjectArchived(archive);
if (done) break;
async function injectWithRetries(archive: ArchiveMap) {
for (let attempt = 0; attempt < 10; attempt++) {
if (await tryInjectArchived(archive)) break;
await delay(120);
}
applyBackupBadges(archive);
@@ -86,7 +78,7 @@ export function applyBackupBadges(archive: ArchiveMap) {
const badge = document.createElement("span");
badge.className = BACKUP_BADGE_CLASS;
badge.title = "Saved locally";
badge.innerHTML = BACKUP_CHECK_SVG;
badge.textContent = "✓";
itemEl.appendChild(badge);
}
} else {
@@ -119,30 +111,23 @@ export function mountArchivedNotificationInjection(
const watchItemsContainer = () => {
const itemsEl = document.querySelector(ITEMS_SELECTOR);
if (!itemsEl) return;
if (observer) observer.disconnect();
observer = new MutationObserver(() => scheduleInject());
observer?.disconnect();
observer = new MutationObserver(scheduleInject);
observer.observe(itemsEl, { childList: true });
};
api.seqta.onMount(LIST_SELECTOR, () => {
const onNotificationsMount = () => {
scheduleInject();
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(
archive: ArchiveMap,
): Promise<void> {
export async function injectArchivedForUser(archive: ArchiveMap): Promise<void> {
await injectWithRetries(archive);
}
@@ -10,16 +10,11 @@
height: 14px;
border-radius: 50%;
background: var(--better-main, #22c55e);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 9px;
line-height: 14px;
text-align: center;
pointer-events: none;
z-index: 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 { settingsState } from "../listeners/SettingsState";
import { applyMenuItemVisibility } from "../menuItemVisibility";
import { insertKeyAfterInOrder } from "@/seqta/utils/sidebarMenuIcons";
import { ensureAnalyticsMenuOrder } from "@/seqta/utils/sidebarMenuIcons";
import stringToHTML from "../stringToHTML";
import Sortable from "sortablejs";
@@ -30,20 +30,10 @@ function syncDefaultMenuOrder(menu: HTMLElement) {
for (let i = 0; i < childnodes.length; i++) {
const key = (childnodes[i] as HTMLElement).dataset.key;
if (key && settingsState.defaultmenuorder.indexOf(key) === -1) {
if (key === "analytics") {
settingsState.defaultmenuorder = insertKeyAfterInOrder(
settingsState.defaultmenuorder,
key,
"courses",
);
} else {
settingsState.defaultmenuorder = [
...settingsState.defaultmenuorder,
key,
];
}
settingsState.defaultmenuorder = [...settingsState.defaultmenuorder, key];
}
}
ensureAnalyticsMenuOrder();
}
function mergeMenuItemsFromDom(
@@ -242,7 +232,6 @@ export function OpenMenuOptions() {
if (sortable) {
saveNewOrder(sortable);
}
applyMenuItemVisibility();
closeAll();
};
-8
View File
@@ -1,14 +1,7 @@
import { settingsState } from "./listeners/SettingsState";
import { verboseInfo } from "@/utils/verboseLog";
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). */
export function applyMenuItemVisibility(): void {
if (document.querySelector(".editmenuoption-container")) return;
@@ -18,7 +11,6 @@ export function applyMenuItemVisibility(): void {
for (const [menuItem, config] of Object.entries(settingsState.menuitems ?? {})) {
if (config && !config.toggle) {
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 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 {
if (typeof colour !== "string") return undefined;
const rgb = GetThresholdOfColor(colour);
if (rgb < 100 && settingsState.DarkMode) {
return undefined;
}
if (rgb < 100 && settingsState.DarkMode) return undefined;
return colour;
}
@@ -29,35 +126,13 @@ export function appendNoticeEmptyState(container: HTMLElement, message: string)
}
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;">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${textPreview}</div>
</div>`;
const htmlContent = noticeCardHtml(notice, colour, noticePreview(notice.contents), {
wrapperStyle:
"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);",
hideClose: true,
});
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
element.addEventListener("click", () =>
openNoticeModal(notice, colour, element),
);
element.addEventListener("click", () => openNoticeModal(notice, colour, element));
return element;
}
@@ -66,10 +141,7 @@ export function openNoticeModal(
colour: string | undefined,
sourceElement: HTMLElement,
) {
const cleanContent = notice.contents
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " ");
const cleanContent = noticeBody(notice.contents);
document.getElementById("notice-modal")?.remove();
const sourceRect = sourceElement.getBoundingClientRect();
@@ -80,84 +152,37 @@ export function openNoticeModal(
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;
">
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">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
</div>
${noticeCardHtml(notice, colour, cleanContent)}
</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 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 initialWidth = Math.round(
Math.min(Math.max(sourceWidth, 800), window.innerWidth - 40),
);
const measuredHeight = measureNoticeHeight(notice, cleanContent, initialWidth);
let { width: targetWidth, height: targetHeight, left: targetLeft, top: targetTop } =
modalTargetSize(sourceWidth, measuredHeight);
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">&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 applyTargetLayout = () => {
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`;
};
const closeModal = () => {
window.removeEventListener("resize", handleResize);
@@ -165,8 +190,7 @@ export function openNoticeModal(
if (!settingsState.animations) {
modal.remove();
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
showSourceElement(sourceElement);
sourceElement.removeAttribute("data-transitioning");
return;
}
@@ -179,146 +203,65 @@ export function openNoticeModal(
},
{ duration: 0.2 },
);
animate(
transitionContainer,
{ opacity: [1, 0] },
{ duration: 0.2, delay: 0.3 },
);
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
animate(transitionContainer, { opacity: [1, 0] }, { duration: 0.2, delay: 0.3 });
showSourceElement(sourceElement);
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 () => {
animate(transitionContainer, {
left: [targetLeft + scrollX, sourceLeft + scrollX],
top: [targetTop, sourceTop + scrollY],
width: [targetWidth, sourceWidth],
height: [targetHeight, sourceHeight],
}, SPRING_CLOSE).finished.then(() => {
modal.remove();
sourceElement.removeAttribute("data-transitioning");
});
};
closeBtn?.addEventListener("click", closeModal);
modal?.addEventListener("click", (e) => {
if (e.target === modal) {
closeModal();
}
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);
}
if (e.key === "Escape") closeModal();
};
document.addEventListener("keydown", handleEscape);
const handleResize = () => {
const newSourceRect = sourceElement.getBoundingClientRect();
const newScrollY = Math.round(window.scrollY);
const newScrollX = Math.round(window.scrollX);
const rect = sourceElement.getBoundingClientRect();
scrollY = Math.round(window.scrollY);
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 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 next = modalTargetSize(
sourceWidth,
unifiedContent.getBoundingClientRect().height,
);
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;
targetLeft = next.left;
targetTop = next.top;
targetWidth = next.width;
targetHeight = next.height;
applyTargetLayout();
};
window.addEventListener("resize", handleResize);
unifiedContent.classList.replace("notice-card-state", "notice-modal-state");
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");
animate(transitionContainer, {
left: [sourceLeft + scrollX, targetLeft + scrollX],
top: [sourceTop + scrollY, targetTop],
width: [sourceWidth, targetWidth],
height: [sourceHeight, targetHeight],
}, SPRING_OPEN);
} 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");
applyTargetLayout();
}
}
@@ -341,22 +284,19 @@ export function renderNoticesIntoContainer(
}
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));
for (const notice of notices) {
if (
settingsState.mockNotices ||
noticeMatchesLabelFilter(notice, labelTokens)
) {
fragment.appendChild(createNoticeElement(notice, processNoticeColor(notice.colour)));
}
});
}
if (fragment.childNodes.length === 0) {
if (!fragment.childNodes.length) {
appendNoticeEmptyState(noticeContainer, emptyMessage);
return;
}
noticeContainer.appendChild(fragment);
}
@@ -372,8 +312,9 @@ export async function fetchNoticesForDate(
container.innerHTML = "";
}
let data: { payload?: unknown };
try {
const data = settingsState.mockNotices
data = settingsState.mockNotices
? getMockNotices()
: await (
await fetch(noticesUrl, {
@@ -383,11 +324,10 @@ export async function fetchNoticesForDate(
body: JSON.stringify({ date }),
})
).json();
renderNoticesIntoContainer(containerId, data, labelTokens);
} catch {
renderNoticesIntoContainer(containerId, { payload: [] }, labelTokens);
data = { payload: [] };
}
renderNoticesIntoContainer(containerId, data, labelTokens);
}
export type SetupNoticesSectionOptions = {
@@ -405,9 +345,7 @@ export function setupNoticesSection(options: SetupNoticesSectionOptions): () =>
? (document.querySelector(options.dateInput) as HTMLInputElement | null)
: options.dateInput;
if (dateControl) {
dateControl.value = options.initialDate;
}
if (dateControl) dateControl.value = options.initialDate;
const debouncedInputChange = debounce((e: Event) => {
void fetchNoticesForDate(
+16 -28
View File
@@ -1,14 +1,13 @@
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). */
export function parseNoticesFilterPref(prefsPayload: unknown): string[] {
if (!Array.isArray(prefsPayload)) return [];
const values = (prefsPayload as PrefEntry[])
.filter((item) => item?.name === "notices.filters")
.map((item) => item?.value)
.filter((v): v is string => typeof v === "string");
if (values.length === 0) return [];
return String(values[0]).split(" ").filter(Boolean);
const raw = (prefsPayload as PrefEntry[]).find((item) => item?.name === NOTICES_FILTER)?.value;
return typeof raw === "string" ? raw.split(" ").filter(Boolean) : [];
}
/** Label IDs from `load/notices` with `{ mode: "labels" }`. */
@@ -16,13 +15,12 @@ export async function fetchNoticeLabelIds(noticesUrl: string): Promise<string[]>
try {
const res = await fetch(noticesUrl, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
headers: JSON_HEADERS,
credentials: "include",
body: JSON.stringify({ mode: "labels" }),
});
if (!res.ok) return [];
const json = (await res.json()) as { payload?: Array<{ id?: number }> };
const payload = json?.payload;
const payload = ((await res.json()) as { payload?: Array<{ id?: number }> })?.payload;
if (!Array.isArray(payload)) return [];
return payload
.map((entry) => entry?.id)
@@ -39,26 +37,16 @@ export async function resolveNoticeFilterTokens(
noticesUrl: string,
): Promise<string[]> {
const fromPref = parseNoticesFilterPref(prefsPayload);
if (fromPref.length > 0) return fromPref;
return await fetchNoticeLabelIds(noticesUrl);
return fromPref.length > 0 ? fromPref : await fetchNoticeLabelIds(noticesUrl);
}
export function normalizeNoticeLabelId(label: unknown): string | null {
if (typeof label === "number" && !Number.isNaN(label)) {
return String(label);
}
if (typeof label === "string" && label.trim()) {
return label.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();
}
}
if (typeof label === "number" && !Number.isNaN(label)) return String(label);
if (typeof label === "string" && label.trim()) return label.trim();
const id =
label && typeof label === "object" ? (label as { id?: unknown }).id : undefined;
if (typeof id === "number" && !Number.isNaN(id)) return String(id);
if (typeof id === "string" && id.trim()) return id.trim();
return null;
}
@@ -68,6 +56,6 @@ export function noticeMatchesLabelFilter(
): boolean {
if (filterTokens.length === 0) return true;
const id = normalizeNoticeLabelId(notice?.label);
if (id !== null && filterTokens.includes(id)) return true;
return filterTokens.includes(JSON.stringify(notice?.label));
return (id !== null && filterTokens.includes(id)) ||
filterTokens.includes(JSON.stringify(notice?.label));
}
+20 -33
View File
@@ -8,44 +8,36 @@ import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
import { verboseInfo } from "@/utils/verboseLog";
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 {
let removed = 0;
for (const container of document.querySelectorAll(".modaliser-container")) {
const modal = container.querySelector(".modaliser");
if (!modal?.childElementCount || !container.classList.contains("visible")) {
container.remove();
removed++;
}
}
return removed;
}
export function dismissStaleColourSlidePanes(
forceColourChooser = false,
): number {
let removed = 0;
function dismissStaleColourDialogs(forceColourChooser = false): {
slideRemoved: number;
modalRemoved: number;
} {
let slideRemoved = 0;
for (const pane of document.querySelectorAll(".uiSlidePane")) {
if (pane.querySelector(".pane.colourChooser")) {
pane.remove();
removed++;
slideRemoved++;
continue;
}
if (!forceColourChooser && pane.classList.contains("shown")) continue;
if (!pane.classList.contains("shown")) {
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.documentElement.classList.remove("clr-open");
return { slideRemoved, modalRemoved };
@@ -69,8 +61,7 @@ function setClrPickerState(reset: boolean): void {
}
}
/** Hide colour-picker / modal layers that intercept clicks after a colour save. */
export function dismissTimetableUiBlockers(): {
function dismissTimetableUiBlockers(): {
slideRemoved: number;
modalRemoved: number;
} {
@@ -79,14 +70,10 @@ export function dismissTimetableUiBlockers(): {
return dismissStaleColourDialogs();
}
/** Clear inline styles that can prevent Coloris from reopening. */
export function prepareColorisPickerOpen(): void {
function prepareColorisPickerOpen(): void {
setClrPickerState(true);
}
let colorisRecoveryAttached = false;
let dismissTimer: ReturnType<typeof setTimeout> | null = null;
export function attachTimetableColorisRecovery(): void {
if (colorisRecoveryAttached) return;
colorisRecoveryAttached = true;
+23 -31
View File
@@ -7,62 +7,54 @@
var MENU_UPDATE_COLOURS = "menu.update.colours";
var SUBJECT_PREFIX = "timetable.subject.colour.";
var TUTOR_PREFIX = "timetable.tutor.";
var CLEANUP_FOLLOWUP_MS = 300;
var cleanupFollowup = null;
function isTesStyling() {
var el = document.getElementById("logo-style");
return el && el.textContent.indexOf("tesSeqta") !== -1;
}
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;
function dismissStaleDialogs(forceColour) {
var slide = 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++;
slide++;
continue;
}
if (!forceColour && p.classList.contains("shown")) continue;
if (!p.classList.contains("shown")) {
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.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]);
}
dismissStaleDialogs(true);
if (cleanupFollowup) clearTimeout(cleanupFollowup);
cleanupFollowup = setTimeout(function () {
cleanupFollowup = null;
dismissStaleDialogs(true);
}, CLEANUP_FOLLOWUP_MS);
}
function applyMenuColours() {
+2 -8
View File
@@ -14,7 +14,6 @@
var THEME_STYLE_ID = "custom-theme";
var PREVIEW_STYLE_ID = "custom-theme-preview";
var urlCache = {};
var cssState = { custom: "", preview: "" };
var headObserver = null;
function log(event, detail) {
@@ -77,8 +76,6 @@
function clearAll() {
releaseCachedUrls();
cssState.custom = "";
cssState.preview = "";
setStyleText(IMAGES_STYLE_ID, "");
document.getElementById(THEME_STYLE_ID)?.remove();
document.getElementById(PREVIEW_STYLE_ID)?.remove();
@@ -128,18 +125,15 @@
if (payload.images !== undefined) applyThemeImages(payload.images);
if (payload.customCss !== undefined) {
cssState.custom = payload.customCss || "";
setStyleText(THEME_STYLE_ID, cssState.custom, true);
setStyleText(THEME_STYLE_ID, payload.customCss || "", true);
log("custom css applied");
}
if (payload.previewCss !== undefined) {
cssState.preview = payload.previewCss || "";
setStyleText(PREVIEW_STYLE_ID, cssState.preview);
setStyleText(PREVIEW_STYLE_ID, payload.previewCss || "");
}
if (payload.clearPreview) {
cssState.preview = "";
setStyleText(PREVIEW_STYLE_ID, "");
}
} catch (e) {