mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
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:
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;">×</button>'
|
||||
: '<button class="notice-close-btn">×</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;">×</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">×</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">×</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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user