mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
refactor: trim PR debloat and fix transformers build
Extract shared helpers for home notices, timetable subtitles, and theme images; dedupe global search, Select, and build scripts while preserving behaviour. Add @huggingface/transformers as a direct dependency and resolve ORT WASM paths via require.resolve so pnpm postinstall and Vite can bundle vector search. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,15 +1,11 @@
|
||||
import { animate } from "motion";
|
||||
import browser from "webextension-polyfill";
|
||||
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
||||
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
|
||||
import debounce from "@/seqta/utils/debounce";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
||||
import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
|
||||
import { lessonsSubtitleForViewDate } from "@/seqta/utils/Loaders/timetableSubtitle";
|
||||
import {
|
||||
type EngageParentChild,
|
||||
type EngageParentTimetableItem,
|
||||
@@ -19,10 +15,8 @@ import {
|
||||
toISODate,
|
||||
weekRangeContaining,
|
||||
} from "@/seqta/utils/Loaders/engageParentTimetable";
|
||||
import {
|
||||
noticeMatchesLabelFilter,
|
||||
resolveNoticeFilterTokens,
|
||||
} from "@/seqta/utils/notices/noticeLabelFilters";
|
||||
import { resolveNoticeFilterTokens } from "@/seqta/utils/notices/noticeLabelFilters";
|
||||
import { setupNoticesSection } from "@/seqta/utils/notices/noticeHomeUi";
|
||||
|
||||
export function updateEngageHomeMenuActive(isHome: boolean): void {
|
||||
const home = document.getElementById("homebutton");
|
||||
@@ -47,37 +41,10 @@ let engageWeekItems: EngageParentTimetableItem[] = [];
|
||||
let engageSelectedStudentId: string | null = null;
|
||||
let engageListenersCleanup: (() => void) | null = null;
|
||||
|
||||
function formatDateString(date: Date): string {
|
||||
return `${date.toLocaleString("en-us", { weekday: "short" })} ${date.toLocaleDateString("en-au")}`;
|
||||
}
|
||||
|
||||
function setEngageTimetableSubtitle(): void {
|
||||
const el = document.getElementById("engage-home-lesson-subtitle");
|
||||
if (!el) return;
|
||||
|
||||
const today = new Date();
|
||||
const isSameMonth =
|
||||
today.getFullYear() === engageViewDate.getFullYear() &&
|
||||
today.getMonth() === engageViewDate.getMonth();
|
||||
|
||||
if (isSameMonth) {
|
||||
const dayDiff = today.getDate() - engageViewDate.getDate();
|
||||
switch (dayDiff) {
|
||||
case 0:
|
||||
el.textContent = "Today's Lessons";
|
||||
break;
|
||||
case 1:
|
||||
el.textContent = "Yesterday's Lessons";
|
||||
break;
|
||||
case -1:
|
||||
el.textContent = "Tomorrow's Lessons";
|
||||
break;
|
||||
default:
|
||||
el.textContent = formatDateString(engageViewDate);
|
||||
}
|
||||
} else {
|
||||
el.textContent = formatDateString(engageViewDate);
|
||||
}
|
||||
el.textContent = lessonsSubtitleForViewDate(engageViewDate);
|
||||
}
|
||||
|
||||
function makeEngageLessonDiv(
|
||||
@@ -254,422 +221,9 @@ function bindEngageTimetableUi(): void {
|
||||
};
|
||||
}
|
||||
|
||||
/* ——— Notices (duplicated from Learn `LoadHomePage`; fetch uses `/seqta/parent/load/notices`.) ——— */
|
||||
|
||||
const ENGAGE_NOTICE_CONTAINER_ID = "engage-notice-container";
|
||||
const ENGAGE_NOTICES_DATE_ID = "engage-notices-date";
|
||||
|
||||
function processEngageNoticeColor(colour: unknown): string | undefined {
|
||||
if (typeof colour !== "string") return undefined;
|
||||
const rgb = GetThresholdOfColor(colour);
|
||||
if (rgb < 100 && settingsState.DarkMode) {
|
||||
return undefined;
|
||||
}
|
||||
return colour;
|
||||
}
|
||||
|
||||
function processEngageNotices(response: any, labelArray: string[]): void {
|
||||
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
|
||||
if (!noticeContainer) return;
|
||||
|
||||
noticeContainer.classList.remove("loading");
|
||||
noticeContainer.innerHTML = "";
|
||||
|
||||
const notices = response?.payload;
|
||||
if (!Array.isArray(notices)) {
|
||||
appendEngageNoticeEmptyState(noticeContainer, "No notices for today.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!notices.length) {
|
||||
appendEngageNoticeEmptyState(noticeContainer, "No notices for today.");
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
notices.forEach((notice: any) => {
|
||||
const shouldInclude =
|
||||
settingsState.mockNotices || noticeMatchesLabelFilter(notice, labelArray);
|
||||
|
||||
if (shouldInclude) {
|
||||
const colour = processEngageNoticeColor(notice.colour);
|
||||
const noticeElement = createEngageNoticeElement(notice, colour);
|
||||
fragment.appendChild(noticeElement);
|
||||
}
|
||||
});
|
||||
|
||||
if (fragment.childNodes.length === 0) {
|
||||
appendEngageNoticeEmptyState(noticeContainer, "No notices for today.");
|
||||
return;
|
||||
}
|
||||
|
||||
noticeContainer.appendChild(fragment);
|
||||
}
|
||||
|
||||
function appendEngageNoticeEmptyState(container: HTMLElement, message: string) {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = resolveExtensionAssetUrl(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = message;
|
||||
emptyState.append(img, text);
|
||||
container.append(emptyState);
|
||||
}
|
||||
|
||||
function createEngageNoticeElement(
|
||||
notice: any,
|
||||
colour: string | undefined,
|
||||
): Node {
|
||||
const textPreview =
|
||||
notice.contents
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.substring(0, 150) + (notice.contents.length > 150 ? "..." : "");
|
||||
|
||||
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const htmlContent = `
|
||||
<div class="notice-unified-content notice-card-state" data-notice-id="${noticeId}" style="--colour: ${colour || "#8e8e8e"}; position: relative; background: var(--background-primary); cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);">
|
||||
<div class="notice-header">
|
||||
<div class="notice-badge-row">
|
||||
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
|
||||
${notice.label_title || "General"}
|
||||
</span>
|
||||
<span class="notice-staff">${notice.staff}</span>
|
||||
</div>
|
||||
<button class="notice-close-btn" style="opacity: 0; pointer-events: none;">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${textPreview}</div>
|
||||
</div>`;
|
||||
|
||||
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
|
||||
element.addEventListener("click", () =>
|
||||
openEngageNoticeModal(notice, colour, element),
|
||||
);
|
||||
return element;
|
||||
}
|
||||
|
||||
function openEngageNoticeModal(
|
||||
notice: any,
|
||||
colour: string | undefined,
|
||||
sourceElement: HTMLElement,
|
||||
) {
|
||||
const cleanContent = notice.contents
|
||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||
.replace(/ +/, " ");
|
||||
|
||||
document.getElementById("notice-modal")?.remove();
|
||||
|
||||
const sourceRect = sourceElement.getBoundingClientRect();
|
||||
let scrollY = Math.round(window.scrollY);
|
||||
let scrollX = Math.round(window.scrollX);
|
||||
let sourceLeft = sourceRect.left;
|
||||
let sourceTop = sourceRect.top;
|
||||
let sourceWidth = sourceRect.width;
|
||||
let sourceHeight = sourceRect.height;
|
||||
|
||||
const modalHtml = `
|
||||
<div id="notice-modal" class="notice-modal-overlay" style="opacity: 0;">
|
||||
<div class="notice-modal-transition" style="
|
||||
position: fixed;
|
||||
left: ${sourceLeft + scrollX}px;
|
||||
top: ${sourceTop + scrollY}px;
|
||||
width: ${sourceWidth}px;
|
||||
height: ${sourceHeight}px;
|
||||
transform-origin: center;
|
||||
z-index: 10001;
|
||||
">
|
||||
<div class="notice-modal-content notice-transitioning">
|
||||
<div class="notice-unified-content notice-card-state">
|
||||
<div class="notice-header">
|
||||
<div class="notice-badge-row">
|
||||
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
|
||||
${notice.label_title || "General"}
|
||||
</span>
|
||||
<span class="notice-staff">${notice.staff}</span>
|
||||
</div>
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const modal = stringToHTML(modalHtml).firstChild as HTMLElement;
|
||||
const transitionContainer = modal.querySelector(
|
||||
".notice-modal-transition",
|
||||
) as HTMLElement;
|
||||
const unifiedContent = modal.querySelector(
|
||||
".notice-unified-content",
|
||||
) as HTMLElement;
|
||||
const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
sourceElement.setAttribute("data-transitioning", "true");
|
||||
sourceElement.style.opacity = "0";
|
||||
sourceElement.style.transform = "scale(0.95)";
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
let targetWidth = Math.round(
|
||||
Math.min(Math.max(sourceWidth, 800), viewportWidth - 40),
|
||||
);
|
||||
|
||||
const tempMeasureDiv = document.createElement("div");
|
||||
tempMeasureDiv.style.position = "absolute";
|
||||
tempMeasureDiv.style.left = "-9999px";
|
||||
tempMeasureDiv.style.width = targetWidth + "px";
|
||||
tempMeasureDiv.style.visibility = "hidden";
|
||||
tempMeasureDiv.innerHTML = `
|
||||
<div class="notice-unified-content notice-modal-state" style="position: relative; width: 100%; padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1);">
|
||||
<div class="notice-header">
|
||||
<div class="notice-badge-row">
|
||||
<span class="notice-badge">${notice.label_title || "General"}</span>
|
||||
<span class="notice-staff">${notice.staff}</span>
|
||||
</div>
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(tempMeasureDiv);
|
||||
const measuredHeight =
|
||||
tempMeasureDiv.firstElementChild!.getBoundingClientRect().height;
|
||||
document.body.removeChild(tempMeasureDiv);
|
||||
|
||||
let targetHeight = Math.round(
|
||||
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
|
||||
);
|
||||
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
|
||||
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
|
||||
|
||||
const closeModal = () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
|
||||
if (!settingsState.animations) {
|
||||
modal.remove();
|
||||
sourceElement.style.opacity = "1";
|
||||
sourceElement.style.transform = "";
|
||||
sourceElement.removeAttribute("data-transitioning");
|
||||
return;
|
||||
}
|
||||
|
||||
animate(
|
||||
modal,
|
||||
{
|
||||
backgroundColor: ["rgba(0, 0, 0, 0.5)", "rgba(0, 0, 0, 0)"],
|
||||
backdropFilter: ["blur(4px)", "blur(0px)"],
|
||||
},
|
||||
{ duration: 0.2 },
|
||||
);
|
||||
|
||||
animate(
|
||||
transitionContainer,
|
||||
{ opacity: [1, 0] },
|
||||
{ duration: 0.2, delay: 0.3 },
|
||||
);
|
||||
|
||||
sourceElement.style.opacity = "1";
|
||||
sourceElement.style.transform = "";
|
||||
|
||||
modal.style.pointerEvents = "none";
|
||||
|
||||
animate(
|
||||
transitionContainer,
|
||||
{
|
||||
left: [targetLeft + scrollX, sourceLeft + scrollX],
|
||||
top: [targetTop, sourceTop + scrollY],
|
||||
width: [targetWidth, sourceWidth],
|
||||
height: [targetHeight, sourceHeight],
|
||||
scale: [1, 1],
|
||||
},
|
||||
{
|
||||
duration: 0.35,
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 35,
|
||||
},
|
||||
).finished.then(async () => {
|
||||
modal.remove();
|
||||
sourceElement.removeAttribute("data-transitioning");
|
||||
});
|
||||
};
|
||||
|
||||
closeBtn?.addEventListener("click", closeModal);
|
||||
modal?.addEventListener("click", (e) => {
|
||||
if (e.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
closeModal();
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
|
||||
const handleResize = () => {
|
||||
const newSourceRect = sourceElement.getBoundingClientRect();
|
||||
const newScrollY = Math.round(window.scrollY);
|
||||
const newScrollX = Math.round(window.scrollX);
|
||||
|
||||
const computedStyle = getComputedStyle(sourceElement);
|
||||
const transform = computedStyle.transform;
|
||||
let scaleX = 1,
|
||||
scaleY = 1;
|
||||
|
||||
if (transform && transform !== "none") {
|
||||
const matrix = transform.match(/matrix.*\((.+)\)/);
|
||||
if (matrix) {
|
||||
const values = matrix[1].split(", ");
|
||||
scaleX = parseFloat(values[0]);
|
||||
scaleY = parseFloat(values[3]);
|
||||
}
|
||||
}
|
||||
|
||||
const newSourceWidth = newSourceRect.width / scaleX;
|
||||
const newSourceHeight = newSourceRect.height / scaleY;
|
||||
|
||||
const deltaX = (newSourceWidth - newSourceRect.width) / 2;
|
||||
const deltaY = (newSourceHeight - newSourceRect.height) / 2;
|
||||
|
||||
const newSourceLeft = newSourceRect.left - deltaX;
|
||||
const newSourceTop = newSourceRect.top - deltaY;
|
||||
|
||||
const newViewportWidth = window.innerWidth;
|
||||
const newViewportHeight = window.innerHeight;
|
||||
const newTargetWidth = Math.round(
|
||||
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
|
||||
);
|
||||
const currentHeight = unifiedContent.getBoundingClientRect().height;
|
||||
const newTargetHeight = Math.round(
|
||||
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
|
||||
);
|
||||
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
|
||||
const newTargetTop =
|
||||
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
|
||||
|
||||
transitionContainer.style.left =
|
||||
Math.round(newTargetLeft + newScrollX) + "px";
|
||||
transitionContainer.style.top = Math.round(newTargetTop) + "px";
|
||||
transitionContainer.style.width = Math.round(newTargetWidth) + "px";
|
||||
transitionContainer.style.height = Math.round(newTargetHeight) + "px";
|
||||
|
||||
sourceLeft = newSourceLeft;
|
||||
sourceTop = newSourceTop;
|
||||
sourceWidth = newSourceWidth;
|
||||
sourceHeight = newSourceHeight;
|
||||
targetLeft = newTargetLeft;
|
||||
targetTop = newTargetTop;
|
||||
targetWidth = newTargetWidth;
|
||||
targetHeight = newTargetHeight;
|
||||
scrollY = newScrollY;
|
||||
scrollX = newScrollX;
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
if (settingsState.animations) {
|
||||
animate(modal, { opacity: [0, 1] }, { duration: 0.2 });
|
||||
|
||||
animate(
|
||||
transitionContainer,
|
||||
{
|
||||
left: [sourceLeft + scrollX, targetLeft + scrollX],
|
||||
top: [sourceTop + scrollY, targetTop],
|
||||
width: [sourceWidth, targetWidth],
|
||||
height: [sourceHeight, targetHeight],
|
||||
scale: [1, 1],
|
||||
},
|
||||
{
|
||||
duration: 0.5,
|
||||
type: "spring",
|
||||
stiffness: 280,
|
||||
damping: 24,
|
||||
},
|
||||
);
|
||||
|
||||
unifiedContent.classList.remove("notice-card-state");
|
||||
unifiedContent.classList.add("notice-modal-state");
|
||||
} else {
|
||||
modal.style.opacity = "1";
|
||||
transitionContainer.style.left = Math.round(targetLeft + scrollX) + "px";
|
||||
transitionContainer.style.top = Math.round(targetTop) + "px";
|
||||
transitionContainer.style.width = Math.round(targetWidth) + "px";
|
||||
transitionContainer.style.height = Math.round(targetHeight) + "px";
|
||||
unifiedContent.classList.remove("notice-card-state");
|
||||
unifiedContent.classList.add("notice-modal-state");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEngageNoticesFromApi(
|
||||
date: string,
|
||||
labelTokens: string[],
|
||||
): Promise<void> {
|
||||
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
|
||||
if (noticeContainer) {
|
||||
noticeContainer.classList.add("loading");
|
||||
noticeContainer.innerHTML = "";
|
||||
}
|
||||
|
||||
try {
|
||||
const data = settingsState.mockNotices
|
||||
? getMockNotices()
|
||||
: await (
|
||||
await fetch(`${location.origin}/seqta/parent/load/notices`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ date }),
|
||||
})
|
||||
).json();
|
||||
|
||||
processEngageNotices(data, labelTokens);
|
||||
} catch (e) {
|
||||
console.warn("[BetterSEQTA+] Engage notices request failed:", e);
|
||||
processEngageNotices({ payload: [] }, labelTokens);
|
||||
}
|
||||
}
|
||||
|
||||
function bindEngageNoticesDateInput(
|
||||
labelTokens: string[],
|
||||
initialDate: string,
|
||||
): () => void {
|
||||
const dateControl = document.getElementById(
|
||||
ENGAGE_NOTICES_DATE_ID,
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
if (!dateControl) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
dateControl.value = initialDate;
|
||||
|
||||
const debouncedInputChange = debounce((e: Event) => {
|
||||
void fetchEngageNoticesFromApi(
|
||||
(e.target as HTMLInputElement).value,
|
||||
labelTokens,
|
||||
);
|
||||
}, 250);
|
||||
|
||||
dateControl.addEventListener("input", debouncedInputChange);
|
||||
|
||||
return () => dateControl.removeEventListener("input", debouncedInputChange);
|
||||
}
|
||||
|
||||
async function initEngageNoticesUi(todayFormatted: string): Promise<void> {
|
||||
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
|
||||
if (!noticeContainer) return;
|
||||
@@ -693,14 +247,13 @@ async function initEngageNoticesUi(todayFormatted: string): Promise<void> {
|
||||
`${location.origin}/seqta/parent/load/notices`,
|
||||
);
|
||||
|
||||
const dateControl = document.getElementById(ENGAGE_NOTICES_DATE_ID);
|
||||
if (dateControl) {
|
||||
(dateControl as HTMLInputElement).value = todayFormatted;
|
||||
}
|
||||
|
||||
await fetchEngageNoticesFromApi(todayFormatted, labelTokens);
|
||||
|
||||
const cleanup = bindEngageNoticesDateInput(labelTokens, todayFormatted);
|
||||
const cleanup = setupNoticesSection({
|
||||
containerId: ENGAGE_NOTICE_CONTAINER_ID,
|
||||
dateInput: `#${ENGAGE_NOTICES_DATE_ID}`,
|
||||
noticesUrl: `${location.origin}/seqta/parent/load/notices`,
|
||||
labelTokens,
|
||||
initialDate: todayFormatted,
|
||||
});
|
||||
engageMergeNoticeCleanup(cleanup);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { animate, stagger } from "motion";
|
||||
import browser from "webextension-polyfill";
|
||||
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
||||
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
||||
import assessmentsicon from "@/seqta/icons/assessmentsIcon";
|
||||
@@ -12,7 +11,6 @@ import stringToHTML from "../stringToHTML";
|
||||
import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
|
||||
import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
|
||||
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
||||
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
||||
import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip";
|
||||
import { verboseInfo } from "@/utils/verboseLog";
|
||||
import {
|
||||
@@ -21,10 +19,9 @@ import {
|
||||
filterAssessmentsForActiveSubjects,
|
||||
subjectsWithUpcomingAssessments,
|
||||
} from "@/plugins/built-in/assessmentsOverview/utils";
|
||||
import {
|
||||
noticeMatchesLabelFilter,
|
||||
resolveNoticeFilterTokens,
|
||||
} from "@/seqta/utils/notices/noticeLabelFilters";
|
||||
import { resolveNoticeFilterTokens } from "@/seqta/utils/notices/noticeLabelFilters";
|
||||
import { setupNoticesSection } from "@/seqta/utils/notices/noticeHomeUi";
|
||||
import { lessonsSubtitleForViewDate } from "@/seqta/utils/Loaders/timetableSubtitle";
|
||||
|
||||
let LessonInterval: any;
|
||||
let currentSelectedDate = new Date();
|
||||
@@ -141,15 +138,14 @@ export async function loadHomePage() {
|
||||
`${location.origin}/seqta/student/load/notices?`,
|
||||
);
|
||||
|
||||
const noticeContainer = document.getElementById("notice-container");
|
||||
if (noticeContainer) {
|
||||
const dateControl = document.querySelector(
|
||||
'input[type="date"]',
|
||||
) as HTMLInputElement;
|
||||
if (dateControl) {
|
||||
dateControl.value = TodayFormatted;
|
||||
}
|
||||
setupNotices(labelTokens, TodayFormatted);
|
||||
if (document.getElementById("notice-container")) {
|
||||
setupNoticesSection({
|
||||
containerId: "notice-container",
|
||||
dateInput: 'input[type="date"]',
|
||||
noticesUrl: `${location.origin}/seqta/student/load/notices?`,
|
||||
labelTokens,
|
||||
initialDate: TodayFormatted,
|
||||
});
|
||||
}
|
||||
|
||||
return cleanup;
|
||||
@@ -283,57 +279,6 @@ async function GetActiveClasses() {
|
||||
}
|
||||
}
|
||||
|
||||
function setupNotices(labelArray: string[], date: string) {
|
||||
const dateControl = document.querySelector(
|
||||
'input[type="date"]',
|
||||
) as HTMLInputElement;
|
||||
|
||||
const fetchNotices = async (date: string) => {
|
||||
const container = document.getElementById("notice-container");
|
||||
if (container) {
|
||||
container.classList.add("loading");
|
||||
container.innerHTML = "";
|
||||
}
|
||||
|
||||
try {
|
||||
const data = settingsState.mockNotices
|
||||
? getMockNotices()
|
||||
: await (
|
||||
await fetch(`${location.origin}/seqta/student/load/notices?`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ date }),
|
||||
})
|
||||
).json();
|
||||
|
||||
processNotices(data, labelArray);
|
||||
} catch {
|
||||
processNotices({ payload: [] }, labelArray);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedInputChange = debounce((e: Event) => {
|
||||
fetchNotices((e.target as HTMLInputElement).value);
|
||||
}, 250);
|
||||
|
||||
dateControl?.addEventListener("input", debouncedInputChange);
|
||||
fetchNotices(date);
|
||||
|
||||
return () => dateControl?.removeEventListener("input", debouncedInputChange);
|
||||
}
|
||||
|
||||
function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: any;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
function comparedate(obj1: any, obj2: any) {
|
||||
const d1 = new Date(obj1.due || obj1.date || 0).getTime();
|
||||
const d2 = new Date(obj2.due || obj2.date || 0).getTime();
|
||||
@@ -343,362 +288,6 @@ function comparedate(obj1: any, obj2: any) {
|
||||
function startOfDay(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
function processNotices(response: any, labelArray: string[]) {
|
||||
const NoticeContainer = document.getElementById("notice-container");
|
||||
if (!NoticeContainer) return;
|
||||
|
||||
NoticeContainer.classList.remove("loading");
|
||||
NoticeContainer.innerHTML = "";
|
||||
|
||||
const notices = response?.payload;
|
||||
if (!Array.isArray(notices)) {
|
||||
appendNoticeEmptyState(NoticeContainer, "No notices for today.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!notices.length) {
|
||||
appendNoticeEmptyState(NoticeContainer, "No notices for today.");
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
notices.forEach((notice: any) => {
|
||||
const shouldInclude =
|
||||
settingsState.mockNotices || noticeMatchesLabelFilter(notice, labelArray);
|
||||
|
||||
if (shouldInclude) {
|
||||
const colour = processNoticeColor(notice.colour);
|
||||
const noticeElement = createNoticeElement(notice, colour);
|
||||
fragment.appendChild(noticeElement);
|
||||
}
|
||||
});
|
||||
|
||||
if (fragment.childNodes.length === 0) {
|
||||
appendNoticeEmptyState(NoticeContainer, "No notices for today.");
|
||||
return;
|
||||
}
|
||||
|
||||
NoticeContainer.appendChild(fragment);
|
||||
}
|
||||
|
||||
function appendNoticeEmptyState(container: HTMLElement, message: string) {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = resolveExtensionAssetUrl(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = message;
|
||||
emptyState.append(img, text);
|
||||
container.append(emptyState);
|
||||
}
|
||||
|
||||
function processNoticeColor(colour: string): string | undefined {
|
||||
if (typeof colour === "string") {
|
||||
const rgb = GetThresholdOfColor(colour);
|
||||
if (rgb < 100 && settingsState.DarkMode) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return colour;
|
||||
}
|
||||
|
||||
function createNoticeElement(notice: any, colour: string | undefined): Node {
|
||||
const textPreview =
|
||||
notice.contents
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.substring(0, 150) + (notice.contents.length > 150 ? "..." : "");
|
||||
|
||||
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const htmlContent = `
|
||||
<div class="notice-unified-content notice-card-state" data-notice-id="${noticeId}" style="--colour: ${colour || "#8e8e8e"}; position: relative; background: var(--background-primary); cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);">
|
||||
<div class="notice-header">
|
||||
<div class="notice-badge-row">
|
||||
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
|
||||
${notice.label_title || "General"}
|
||||
</span>
|
||||
<span class="notice-staff">${notice.staff}</span>
|
||||
</div>
|
||||
<button class="notice-close-btn" style="opacity: 0; pointer-events: none;">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${textPreview}</div>
|
||||
</div>`;
|
||||
|
||||
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
|
||||
element.addEventListener("click", () =>
|
||||
openNoticeModal(notice, colour, element),
|
||||
);
|
||||
return element;
|
||||
}
|
||||
|
||||
function openNoticeModal(
|
||||
notice: any,
|
||||
colour: string | undefined,
|
||||
sourceElement: HTMLElement,
|
||||
) {
|
||||
const cleanContent = notice.contents
|
||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||
.replace(/ +/, " ");
|
||||
|
||||
document.getElementById("notice-modal")?.remove();
|
||||
|
||||
const sourceRect = sourceElement.getBoundingClientRect();
|
||||
let scrollY = Math.round(window.scrollY);
|
||||
let scrollX = Math.round(window.scrollX);
|
||||
let sourceLeft = sourceRect.left;
|
||||
let sourceTop = sourceRect.top;
|
||||
let sourceWidth = sourceRect.width;
|
||||
let sourceHeight = sourceRect.height;
|
||||
|
||||
const modalHtml = `
|
||||
<div id="notice-modal" class="notice-modal-overlay" style="opacity: 0;">
|
||||
<div class="notice-modal-transition" style="
|
||||
position: fixed;
|
||||
left: ${sourceLeft + scrollX}px;
|
||||
top: ${sourceTop + scrollY}px;
|
||||
width: ${sourceWidth}px;
|
||||
height: ${sourceHeight}px;
|
||||
transform-origin: center;
|
||||
z-index: 10001;
|
||||
">
|
||||
<div class="notice-modal-content notice-transitioning">
|
||||
<div class="notice-unified-content notice-card-state">
|
||||
<div class="notice-header">
|
||||
<div class="notice-badge-row">
|
||||
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
|
||||
${notice.label_title || "General"}
|
||||
</span>
|
||||
<span class="notice-staff">${notice.staff}</span>
|
||||
</div>
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const modal = stringToHTML(modalHtml).firstChild as HTMLElement;
|
||||
const transitionContainer = modal.querySelector(
|
||||
".notice-modal-transition",
|
||||
) as HTMLElement;
|
||||
const unifiedContent = modal.querySelector(
|
||||
".notice-unified-content",
|
||||
) as HTMLElement;
|
||||
const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
sourceElement.setAttribute("data-transitioning", "true");
|
||||
sourceElement.style.opacity = "0";
|
||||
sourceElement.style.transform = "scale(0.95)";
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
let targetWidth = Math.round(
|
||||
Math.min(Math.max(sourceWidth, 800), viewportWidth - 40),
|
||||
);
|
||||
|
||||
const tempMeasureDiv = document.createElement("div");
|
||||
tempMeasureDiv.style.position = "absolute";
|
||||
tempMeasureDiv.style.left = "-9999px";
|
||||
tempMeasureDiv.style.width = targetWidth + "px";
|
||||
tempMeasureDiv.style.visibility = "hidden";
|
||||
tempMeasureDiv.innerHTML = `
|
||||
<div class="notice-unified-content notice-modal-state" style="position: relative; width: 100%; padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1);">
|
||||
<div class="notice-header">
|
||||
<div class="notice-badge-row">
|
||||
<span class="notice-badge">${notice.label_title || "General"}</span>
|
||||
<span class="notice-staff">${notice.staff}</span>
|
||||
</div>
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(tempMeasureDiv);
|
||||
const measuredHeight =
|
||||
tempMeasureDiv.firstElementChild!.getBoundingClientRect().height;
|
||||
document.body.removeChild(tempMeasureDiv);
|
||||
|
||||
let targetHeight = Math.round(
|
||||
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
|
||||
);
|
||||
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
|
||||
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
|
||||
|
||||
const closeModal = () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
|
||||
if (!settingsState.animations) {
|
||||
modal.remove();
|
||||
sourceElement.style.opacity = "1";
|
||||
sourceElement.style.transform = "";
|
||||
sourceElement.removeAttribute("data-transitioning");
|
||||
return;
|
||||
}
|
||||
|
||||
animate(
|
||||
modal,
|
||||
{
|
||||
backgroundColor: ["rgba(0, 0, 0, 0.5)", "rgba(0, 0, 0, 0)"],
|
||||
backdropFilter: ["blur(4px)", "blur(0px)"],
|
||||
},
|
||||
{ duration: 0.2 },
|
||||
);
|
||||
|
||||
animate(
|
||||
transitionContainer,
|
||||
{ opacity: [1, 0] },
|
||||
{ duration: 0.2, delay: 0.3 },
|
||||
);
|
||||
|
||||
sourceElement.style.opacity = "1";
|
||||
sourceElement.style.transform = "";
|
||||
|
||||
modal.style.pointerEvents = "none";
|
||||
|
||||
animate(
|
||||
transitionContainer,
|
||||
{
|
||||
left: [targetLeft + scrollX, sourceLeft + scrollX],
|
||||
top: [targetTop, sourceTop + scrollY],
|
||||
width: [targetWidth, sourceWidth],
|
||||
height: [targetHeight, sourceHeight],
|
||||
scale: [1, 1],
|
||||
},
|
||||
{
|
||||
duration: 0.35,
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 35,
|
||||
},
|
||||
).finished.then(async () => {
|
||||
modal.remove();
|
||||
sourceElement.removeAttribute("data-transitioning");
|
||||
});
|
||||
};
|
||||
|
||||
closeBtn?.addEventListener("click", closeModal);
|
||||
modal?.addEventListener("click", (e) => {
|
||||
if (e.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
closeModal();
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
|
||||
const handleResize = () => {
|
||||
const newSourceRect = sourceElement.getBoundingClientRect();
|
||||
const newScrollY = Math.round(window.scrollY);
|
||||
const newScrollX = Math.round(window.scrollX);
|
||||
|
||||
// Get the current scale applied to the source element and compensate for it
|
||||
const computedStyle = getComputedStyle(sourceElement);
|
||||
const transform = computedStyle.transform;
|
||||
let scaleX = 1,
|
||||
scaleY = 1;
|
||||
|
||||
if (transform && transform !== "none") {
|
||||
const matrix = transform.match(/matrix.*\((.+)\)/);
|
||||
if (matrix) {
|
||||
const values = matrix[1].split(", ");
|
||||
scaleX = parseFloat(values[0]);
|
||||
scaleY = parseFloat(values[3]);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply inverse scale to get true original dimensions and positions
|
||||
const newSourceWidth = newSourceRect.width / scaleX;
|
||||
const newSourceHeight = newSourceRect.height / scaleY;
|
||||
|
||||
// Calculate position shift due to center-based scaling
|
||||
const deltaX = (newSourceWidth - newSourceRect.width) / 2;
|
||||
const deltaY = (newSourceHeight - newSourceRect.height) / 2;
|
||||
|
||||
const newSourceLeft = newSourceRect.left - deltaX;
|
||||
const newSourceTop = newSourceRect.top - deltaY;
|
||||
|
||||
const newViewportWidth = window.innerWidth;
|
||||
const newViewportHeight = window.innerHeight;
|
||||
const newTargetWidth = Math.round(
|
||||
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
|
||||
);
|
||||
const currentHeight = unifiedContent.getBoundingClientRect().height;
|
||||
const newTargetHeight = Math.round(
|
||||
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
|
||||
);
|
||||
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
|
||||
const newTargetTop =
|
||||
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
|
||||
|
||||
transitionContainer.style.left =
|
||||
Math.round(newTargetLeft + newScrollX) + "px";
|
||||
transitionContainer.style.top = Math.round(newTargetTop) + "px";
|
||||
transitionContainer.style.width = Math.round(newTargetWidth) + "px";
|
||||
transitionContainer.style.height = Math.round(newTargetHeight) + "px";
|
||||
|
||||
sourceLeft = newSourceLeft;
|
||||
sourceTop = newSourceTop;
|
||||
sourceWidth = newSourceWidth;
|
||||
sourceHeight = newSourceHeight;
|
||||
targetLeft = newTargetLeft;
|
||||
targetTop = newTargetTop;
|
||||
targetWidth = newTargetWidth;
|
||||
targetHeight = newTargetHeight;
|
||||
scrollY = newScrollY;
|
||||
scrollX = newScrollX;
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
if (settingsState.animations) {
|
||||
animate(modal, { opacity: [0, 1] }, { duration: 0.2 });
|
||||
|
||||
animate(
|
||||
transitionContainer,
|
||||
{
|
||||
left: [sourceLeft + scrollX, targetLeft + scrollX],
|
||||
top: [sourceTop + scrollY, targetTop],
|
||||
width: [sourceWidth, targetWidth],
|
||||
height: [sourceHeight, targetHeight],
|
||||
scale: [1, 1],
|
||||
},
|
||||
{
|
||||
duration: 0.5,
|
||||
type: "spring",
|
||||
stiffness: 280,
|
||||
damping: 24,
|
||||
},
|
||||
);
|
||||
|
||||
unifiedContent.classList.remove("notice-card-state");
|
||||
unifiedContent.classList.add("notice-modal-state");
|
||||
} else {
|
||||
modal.style.opacity = "1";
|
||||
transitionContainer.style.left = Math.round(targetLeft + scrollX) + "px";
|
||||
transitionContainer.style.top = Math.round(targetTop) + "px";
|
||||
transitionContainer.style.width = Math.round(targetWidth) + "px";
|
||||
transitionContainer.style.height = Math.round(targetHeight) + "px";
|
||||
unifiedContent.classList.remove("notice-card-state");
|
||||
unifiedContent.classList.add("notice-modal-state");
|
||||
}
|
||||
}
|
||||
|
||||
function callHomeTimetable(date: string, change?: any) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
@@ -1287,32 +876,5 @@ function CreateSubjectFilter(
|
||||
function SetTimetableSubtitle() {
|
||||
const homelessonsubtitle = document.getElementById("home-lesson-subtitle");
|
||||
if (!homelessonsubtitle) return;
|
||||
|
||||
const date = new Date();
|
||||
const isSameMonth =
|
||||
date.getFullYear() === currentSelectedDate.getFullYear() &&
|
||||
date.getMonth() === currentSelectedDate.getMonth();
|
||||
|
||||
if (isSameMonth) {
|
||||
const dayDiff = date.getDate() - currentSelectedDate.getDate();
|
||||
switch (dayDiff) {
|
||||
case 0:
|
||||
homelessonsubtitle.innerText = "Today's Lessons";
|
||||
break;
|
||||
case 1:
|
||||
homelessonsubtitle.innerText = "Yesterday's Lessons";
|
||||
break;
|
||||
case -1:
|
||||
homelessonsubtitle.innerText = "Tomorrow's Lessons";
|
||||
break;
|
||||
default:
|
||||
homelessonsubtitle.innerText = formatDateString(currentSelectedDate);
|
||||
}
|
||||
} else {
|
||||
homelessonsubtitle.innerText = formatDateString(currentSelectedDate);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateString(date: Date): string {
|
||||
return `${date.toLocaleString("en-us", { weekday: "short" })} ${date.toLocaleDateString("en-au")}`;
|
||||
homelessonsubtitle.innerText = lessonsSubtitleForViewDate(currentSelectedDate);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Shared "Today's Lessons" / relative day labels for Learn and Engage home timetables. */
|
||||
export function formatTimetableDayLabel(date: Date): string {
|
||||
return `${date.toLocaleString("en-us", { weekday: "short" })} ${date.toLocaleDateString("en-au")}`;
|
||||
}
|
||||
|
||||
export function lessonsSubtitleForViewDate(viewDate: Date): string {
|
||||
const today = new Date();
|
||||
const isSameMonth =
|
||||
today.getFullYear() === viewDate.getFullYear() &&
|
||||
today.getMonth() === viewDate.getMonth();
|
||||
|
||||
if (isSameMonth) {
|
||||
const dayDiff = today.getDate() - viewDate.getDate();
|
||||
switch (dayDiff) {
|
||||
case 0:
|
||||
return "Today's Lessons";
|
||||
case 1:
|
||||
return "Yesterday's Lessons";
|
||||
case -1:
|
||||
return "Tomorrow's Lessons";
|
||||
default:
|
||||
return formatTimetableDayLabel(viewDate);
|
||||
}
|
||||
}
|
||||
|
||||
return formatTimetableDayLabel(viewDate);
|
||||
}
|
||||
@@ -3,32 +3,21 @@ import { verboseInfo } from "@/utils/verboseLog";
|
||||
|
||||
const STYLE_ID = "bsplus-menuitem-visibility";
|
||||
|
||||
function isEditSidebarOpen(): boolean {
|
||||
return document.querySelector(".editmenuoption-container") != null;
|
||||
}
|
||||
|
||||
function hideRule(menuItem: string): string {
|
||||
return `li[data-key=${menuItem}],section[data-key=${menuItem}]{display:var(--menuHidden) !important;transition:1s;}`;
|
||||
}
|
||||
|
||||
/** Whether a sidebar key is hidden via Edit Sidebar toggles. */
|
||||
export function isMenuItemHidden(key: string): boolean {
|
||||
const items = settingsState.menuitems as Record<string, { toggle?: boolean }>;
|
||||
const entry = items?.[key];
|
||||
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 (isEditSidebarOpen()) return;
|
||||
if (document.querySelector(".editmenuoption-container")) return;
|
||||
|
||||
try {
|
||||
let css = "";
|
||||
for (const [menuItem, config] of Object.entries(
|
||||
settingsState.menuitems ?? {},
|
||||
)) {
|
||||
for (const [menuItem, config] of Object.entries(settingsState.menuitems ?? {})) {
|
||||
if (config && !config.toggle) {
|
||||
css += hideRule(menuItem);
|
||||
css += `li[data-key=${menuItem}],section[data-key=${menuItem}]{display:var(--menuHidden) !important;transition:1s;}`;
|
||||
verboseInfo(`[BetterSEQTA+] Hiding ${menuItem} menu item`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import { animate } from "motion";
|
||||
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
||||
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
||||
import debounce from "@/seqta/utils/debounce";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { noticeMatchesLabelFilter } from "@/seqta/utils/notices/noticeLabelFilters";
|
||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||
|
||||
export function processNoticeColor(colour: unknown): string | undefined {
|
||||
if (typeof colour !== "string") return undefined;
|
||||
const rgb = GetThresholdOfColor(colour);
|
||||
if (rgb < 100 && settingsState.DarkMode) {
|
||||
return undefined;
|
||||
}
|
||||
return colour;
|
||||
}
|
||||
|
||||
export function appendNoticeEmptyState(container: HTMLElement, message: string) {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = resolveExtensionAssetUrl(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = message;
|
||||
emptyState.append(img, text);
|
||||
container.append(emptyState);
|
||||
}
|
||||
|
||||
function createNoticeElement(notice: any, colour: string | undefined): Node {
|
||||
const textPreview =
|
||||
notice.contents
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.substring(0, 150) + (notice.contents.length > 150 ? "..." : "");
|
||||
|
||||
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const htmlContent = `
|
||||
<div class="notice-unified-content notice-card-state" data-notice-id="${noticeId}" style="--colour: ${colour || "#8e8e8e"}; position: relative; background: var(--background-primary); cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);">
|
||||
<div class="notice-header">
|
||||
<div class="notice-badge-row">
|
||||
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
|
||||
${notice.label_title || "General"}
|
||||
</span>
|
||||
<span class="notice-staff">${notice.staff}</span>
|
||||
</div>
|
||||
<button class="notice-close-btn" style="opacity: 0; pointer-events: none;">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${textPreview}</div>
|
||||
</div>`;
|
||||
|
||||
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
|
||||
element.addEventListener("click", () =>
|
||||
openNoticeModal(notice, colour, element),
|
||||
);
|
||||
return element;
|
||||
}
|
||||
|
||||
export function openNoticeModal(
|
||||
notice: any,
|
||||
colour: string | undefined,
|
||||
sourceElement: HTMLElement,
|
||||
) {
|
||||
const cleanContent = notice.contents
|
||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||
.replace(/ +/, " ");
|
||||
|
||||
document.getElementById("notice-modal")?.remove();
|
||||
|
||||
const sourceRect = sourceElement.getBoundingClientRect();
|
||||
let scrollY = Math.round(window.scrollY);
|
||||
let scrollX = Math.round(window.scrollX);
|
||||
let sourceLeft = sourceRect.left;
|
||||
let sourceTop = sourceRect.top;
|
||||
let sourceWidth = sourceRect.width;
|
||||
let sourceHeight = sourceRect.height;
|
||||
|
||||
const modalHtml = `
|
||||
<div id="notice-modal" class="notice-modal-overlay" style="opacity: 0;">
|
||||
<div class="notice-modal-transition" style="
|
||||
position: fixed;
|
||||
left: ${sourceLeft + scrollX}px;
|
||||
top: ${sourceTop + scrollY}px;
|
||||
width: ${sourceWidth}px;
|
||||
height: ${sourceHeight}px;
|
||||
transform-origin: center;
|
||||
z-index: 10001;
|
||||
">
|
||||
<div class="notice-modal-content notice-transitioning">
|
||||
<div class="notice-unified-content notice-card-state">
|
||||
<div class="notice-header">
|
||||
<div class="notice-badge-row">
|
||||
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
|
||||
${notice.label_title || "General"}
|
||||
</span>
|
||||
<span class="notice-staff">${notice.staff}</span>
|
||||
</div>
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const modal = stringToHTML(modalHtml).firstChild as HTMLElement;
|
||||
const transitionContainer = modal.querySelector(
|
||||
".notice-modal-transition",
|
||||
) as HTMLElement;
|
||||
const unifiedContent = modal.querySelector(
|
||||
".notice-unified-content",
|
||||
) as HTMLElement;
|
||||
const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
sourceElement.setAttribute("data-transitioning", "true");
|
||||
sourceElement.style.opacity = "0";
|
||||
sourceElement.style.transform = "scale(0.95)";
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
let targetWidth = Math.round(
|
||||
Math.min(Math.max(sourceWidth, 800), viewportWidth - 40),
|
||||
);
|
||||
|
||||
const tempMeasureDiv = document.createElement("div");
|
||||
tempMeasureDiv.style.position = "absolute";
|
||||
tempMeasureDiv.style.left = "-9999px";
|
||||
tempMeasureDiv.style.width = targetWidth + "px";
|
||||
tempMeasureDiv.style.visibility = "hidden";
|
||||
tempMeasureDiv.innerHTML = `
|
||||
<div class="notice-unified-content notice-modal-state" style="position: relative; width: 100%; padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1);">
|
||||
<div class="notice-header">
|
||||
<div class="notice-badge-row">
|
||||
<span class="notice-badge">${notice.label_title || "General"}</span>
|
||||
<span class="notice-staff">${notice.staff}</span>
|
||||
</div>
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(tempMeasureDiv);
|
||||
const measuredHeight =
|
||||
tempMeasureDiv.firstElementChild!.getBoundingClientRect().height;
|
||||
document.body.removeChild(tempMeasureDiv);
|
||||
|
||||
let targetHeight = Math.round(
|
||||
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
|
||||
);
|
||||
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
|
||||
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
|
||||
|
||||
const closeModal = () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
|
||||
if (!settingsState.animations) {
|
||||
modal.remove();
|
||||
sourceElement.style.opacity = "1";
|
||||
sourceElement.style.transform = "";
|
||||
sourceElement.removeAttribute("data-transitioning");
|
||||
return;
|
||||
}
|
||||
|
||||
animate(
|
||||
modal,
|
||||
{
|
||||
backgroundColor: ["rgba(0, 0, 0, 0.5)", "rgba(0, 0, 0, 0)"],
|
||||
backdropFilter: ["blur(4px)", "blur(0px)"],
|
||||
},
|
||||
{ duration: 0.2 },
|
||||
);
|
||||
|
||||
animate(
|
||||
transitionContainer,
|
||||
{ opacity: [1, 0] },
|
||||
{ duration: 0.2, delay: 0.3 },
|
||||
);
|
||||
|
||||
sourceElement.style.opacity = "1";
|
||||
sourceElement.style.transform = "";
|
||||
|
||||
modal.style.pointerEvents = "none";
|
||||
|
||||
animate(
|
||||
transitionContainer,
|
||||
{
|
||||
left: [targetLeft + scrollX, sourceLeft + scrollX],
|
||||
top: [targetTop, sourceTop + scrollY],
|
||||
width: [targetWidth, sourceWidth],
|
||||
height: [targetHeight, sourceHeight],
|
||||
scale: [1, 1],
|
||||
},
|
||||
{
|
||||
duration: 0.35,
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 35,
|
||||
},
|
||||
).finished.then(async () => {
|
||||
modal.remove();
|
||||
sourceElement.removeAttribute("data-transitioning");
|
||||
});
|
||||
};
|
||||
|
||||
closeBtn?.addEventListener("click", closeModal);
|
||||
modal?.addEventListener("click", (e) => {
|
||||
if (e.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
closeModal();
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
|
||||
const handleResize = () => {
|
||||
const newSourceRect = sourceElement.getBoundingClientRect();
|
||||
const newScrollY = Math.round(window.scrollY);
|
||||
const newScrollX = Math.round(window.scrollX);
|
||||
|
||||
const computedStyle = getComputedStyle(sourceElement);
|
||||
const transform = computedStyle.transform;
|
||||
let scaleX = 1,
|
||||
scaleY = 1;
|
||||
|
||||
if (transform && transform !== "none") {
|
||||
const matrix = transform.match(/matrix.*\((.+)\)/);
|
||||
if (matrix) {
|
||||
const values = matrix[1].split(", ");
|
||||
scaleX = parseFloat(values[0]);
|
||||
scaleY = parseFloat(values[3]);
|
||||
}
|
||||
}
|
||||
|
||||
const newSourceWidth = newSourceRect.width / scaleX;
|
||||
const newSourceHeight = newSourceRect.height / scaleY;
|
||||
|
||||
const deltaX = (newSourceWidth - newSourceRect.width) / 2;
|
||||
const deltaY = (newSourceHeight - newSourceRect.height) / 2;
|
||||
|
||||
const newSourceLeft = newSourceRect.left - deltaX;
|
||||
const newSourceTop = newSourceRect.top - deltaY;
|
||||
|
||||
const newViewportWidth = window.innerWidth;
|
||||
const newViewportHeight = window.innerHeight;
|
||||
const newTargetWidth = Math.round(
|
||||
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
|
||||
);
|
||||
const currentHeight = unifiedContent.getBoundingClientRect().height;
|
||||
const newTargetHeight = Math.round(
|
||||
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
|
||||
);
|
||||
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
|
||||
const newTargetTop =
|
||||
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
|
||||
|
||||
transitionContainer.style.left =
|
||||
Math.round(newTargetLeft + newScrollX) + "px";
|
||||
transitionContainer.style.top = Math.round(newTargetTop) + "px";
|
||||
transitionContainer.style.width = Math.round(newTargetWidth) + "px";
|
||||
transitionContainer.style.height = Math.round(newTargetHeight) + "px";
|
||||
|
||||
sourceLeft = newSourceLeft;
|
||||
sourceTop = newSourceTop;
|
||||
sourceWidth = newSourceWidth;
|
||||
sourceHeight = newSourceHeight;
|
||||
targetLeft = newTargetLeft;
|
||||
targetTop = newTargetTop;
|
||||
targetWidth = newTargetWidth;
|
||||
targetHeight = newTargetHeight;
|
||||
scrollY = newScrollY;
|
||||
scrollX = newScrollX;
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
if (settingsState.animations) {
|
||||
animate(modal, { opacity: [0, 1] }, { duration: 0.2 });
|
||||
|
||||
animate(
|
||||
transitionContainer,
|
||||
{
|
||||
left: [sourceLeft + scrollX, targetLeft + scrollX],
|
||||
top: [sourceTop + scrollY, targetTop],
|
||||
width: [sourceWidth, targetWidth],
|
||||
height: [sourceHeight, targetHeight],
|
||||
scale: [1, 1],
|
||||
},
|
||||
{
|
||||
duration: 0.5,
|
||||
type: "spring",
|
||||
stiffness: 280,
|
||||
damping: 24,
|
||||
},
|
||||
);
|
||||
|
||||
unifiedContent.classList.remove("notice-card-state");
|
||||
unifiedContent.classList.add("notice-modal-state");
|
||||
} else {
|
||||
modal.style.opacity = "1";
|
||||
transitionContainer.style.left = Math.round(targetLeft + scrollX) + "px";
|
||||
transitionContainer.style.top = Math.round(targetTop) + "px";
|
||||
transitionContainer.style.width = Math.round(targetWidth) + "px";
|
||||
transitionContainer.style.height = Math.round(targetHeight) + "px";
|
||||
unifiedContent.classList.remove("notice-card-state");
|
||||
unifiedContent.classList.add("notice-modal-state");
|
||||
}
|
||||
}
|
||||
|
||||
export function renderNoticesIntoContainer(
|
||||
containerId: string,
|
||||
response: { payload?: unknown },
|
||||
labelTokens: string[],
|
||||
emptyMessage = "No notices for today.",
|
||||
): void {
|
||||
const noticeContainer = document.getElementById(containerId);
|
||||
if (!noticeContainer) return;
|
||||
|
||||
noticeContainer.classList.remove("loading");
|
||||
noticeContainer.innerHTML = "";
|
||||
|
||||
const notices = response?.payload;
|
||||
if (!Array.isArray(notices) || !notices.length) {
|
||||
appendNoticeEmptyState(noticeContainer, emptyMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
notices.forEach((notice: any) => {
|
||||
const shouldInclude =
|
||||
settingsState.mockNotices || noticeMatchesLabelFilter(notice, labelTokens);
|
||||
|
||||
if (shouldInclude) {
|
||||
const colour = processNoticeColor(notice.colour);
|
||||
fragment.appendChild(createNoticeElement(notice, colour));
|
||||
}
|
||||
});
|
||||
|
||||
if (fragment.childNodes.length === 0) {
|
||||
appendNoticeEmptyState(noticeContainer, emptyMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
noticeContainer.appendChild(fragment);
|
||||
}
|
||||
|
||||
export async function fetchNoticesForDate(
|
||||
containerId: string,
|
||||
date: string,
|
||||
noticesUrl: string,
|
||||
labelTokens: string[],
|
||||
): Promise<void> {
|
||||
const container = document.getElementById(containerId);
|
||||
if (container) {
|
||||
container.classList.add("loading");
|
||||
container.innerHTML = "";
|
||||
}
|
||||
|
||||
try {
|
||||
const data = settingsState.mockNotices
|
||||
? getMockNotices()
|
||||
: await (
|
||||
await fetch(noticesUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ date }),
|
||||
})
|
||||
).json();
|
||||
|
||||
renderNoticesIntoContainer(containerId, data, labelTokens);
|
||||
} catch {
|
||||
renderNoticesIntoContainer(containerId, { payload: [] }, labelTokens);
|
||||
}
|
||||
}
|
||||
|
||||
export type SetupNoticesSectionOptions = {
|
||||
containerId: string;
|
||||
dateInput: HTMLInputElement | string;
|
||||
noticesUrl: string;
|
||||
labelTokens: string[];
|
||||
initialDate: string;
|
||||
};
|
||||
|
||||
/** Wire date picker + initial fetch for a home-page notices block. Returns cleanup. */
|
||||
export function setupNoticesSection(options: SetupNoticesSectionOptions): () => void {
|
||||
const dateControl =
|
||||
typeof options.dateInput === "string"
|
||||
? (document.querySelector(options.dateInput) as HTMLInputElement | null)
|
||||
: options.dateInput;
|
||||
|
||||
if (dateControl) {
|
||||
dateControl.value = options.initialDate;
|
||||
}
|
||||
|
||||
const debouncedInputChange = debounce((e: Event) => {
|
||||
void fetchNoticesForDate(
|
||||
options.containerId,
|
||||
(e.target as HTMLInputElement).value,
|
||||
options.noticesUrl,
|
||||
options.labelTokens,
|
||||
);
|
||||
}, 250);
|
||||
|
||||
dateControl?.addEventListener("input", debouncedInputChange);
|
||||
void fetchNoticesForDate(
|
||||
options.containerId,
|
||||
options.initialDate,
|
||||
options.noticesUrl,
|
||||
options.labelTokens,
|
||||
);
|
||||
|
||||
return () => dateControl?.removeEventListener("input", debouncedInputChange);
|
||||
}
|
||||
@@ -1,29 +1,19 @@
|
||||
/**
|
||||
* SEQTA Learn bug (vanilla too): MainMenu.updateColours uses
|
||||
* `.each(function (item) { this.options... }).bind(this)` — the bind is on
|
||||
* `.each()`'s return value, not the callback. Saving a timetable subject colour
|
||||
* sends `menu.update.colours` and throws.
|
||||
*
|
||||
* Also: ColourChooser (SlidePane + Modaliser) can leave a full-screen
|
||||
* uiSlidePane / empty modaliser-container that blocks timetable clicks.
|
||||
*
|
||||
* Must run in the PAGE JavaScript context — inject via web_accessible script URL.
|
||||
* Timetable colour save recovery (#221): broken menu.update.colours in PAGE context
|
||||
* (injected script) plus Coloris / overlay cleanup in the content script.
|
||||
*/
|
||||
|
||||
import browser from "webextension-polyfill";
|
||||
import patchScript from "@/seqta/utils/seqtaMenuColourPatch.js?url";
|
||||
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
||||
import { verboseInfo } from "@/utils/verboseLog";
|
||||
|
||||
const PAGE_PATCH_LOADER_ID = "bsplus-seqta-menu-colour-patch-loader";
|
||||
|
||||
/** Remove empty or hidden modaliser shells left after colour dialog teardown. */
|
||||
export function dismissStaleModaliserContainers(): number {
|
||||
let removed = 0;
|
||||
for (const container of document.querySelectorAll(".modaliser-container")) {
|
||||
const modal = container.querySelector(".modaliser");
|
||||
const empty = !modal || modal.childElementCount === 0;
|
||||
const hidden = !container.classList.contains("visible");
|
||||
if (empty || hidden) {
|
||||
if (!modal?.childElementCount || !container.classList.contains("visible")) {
|
||||
container.remove();
|
||||
removed++;
|
||||
}
|
||||
@@ -31,14 +21,12 @@ export function dismissStaleModaliserContainers(): number {
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Remove stuck SEQTA colour chooser slide panes that intercept timetable clicks. */
|
||||
export function dismissStaleColourSlidePanes(
|
||||
forceColourChooser = false,
|
||||
): number {
|
||||
let removed = 0;
|
||||
for (const pane of document.querySelectorAll(".uiSlidePane")) {
|
||||
const isColourChooser = pane.querySelector(".pane.colourChooser");
|
||||
if (isColourChooser) {
|
||||
if (pane.querySelector(".pane.colourChooser")) {
|
||||
pane.remove();
|
||||
removed++;
|
||||
continue;
|
||||
@@ -63,6 +51,91 @@ export function dismissStaleColourDialogs(forceColourChooser = false): {
|
||||
return { slideRemoved, modalRemoved };
|
||||
}
|
||||
|
||||
function setClrPickerState(reset: boolean): void {
|
||||
document.body.classList.remove("clr-open");
|
||||
document.documentElement.classList.remove("clr-open");
|
||||
for (const picker of document.querySelectorAll(".clr-picker")) {
|
||||
picker.classList.remove("clr-open");
|
||||
if (!(picker instanceof HTMLElement)) continue;
|
||||
if (reset) {
|
||||
picker.style.removeProperty("display");
|
||||
picker.style.removeProperty("pointer-events");
|
||||
picker.style.removeProperty("visibility");
|
||||
} else {
|
||||
picker.style.display = "none";
|
||||
picker.style.pointerEvents = "none";
|
||||
picker.style.visibility = "hidden";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Hide colour-picker / modal layers that intercept clicks after a colour save. */
|
||||
export function dismissTimetableUiBlockers(): {
|
||||
slideRemoved: number;
|
||||
modalRemoved: number;
|
||||
} {
|
||||
document.body.style.removeProperty("overflow");
|
||||
setClrPickerState(false);
|
||||
return dismissStaleColourDialogs();
|
||||
}
|
||||
|
||||
/** Clear inline styles that can prevent Coloris from reopening. */
|
||||
export function prepareColorisPickerOpen(): void {
|
||||
setClrPickerState(true);
|
||||
}
|
||||
|
||||
let colorisRecoveryAttached = false;
|
||||
let dismissTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function attachTimetableColorisRecovery(): void {
|
||||
if (colorisRecoveryAttached) return;
|
||||
colorisRecoveryAttached = true;
|
||||
|
||||
const scheduleDismiss = () => {
|
||||
if (dismissTimer !== null) clearTimeout(dismissTimer);
|
||||
dismissTimer = setTimeout(() => {
|
||||
dismissTimer = null;
|
||||
dismissTimetableUiBlockers();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
document.addEventListener("coloris:close", scheduleDismiss);
|
||||
document.addEventListener("coloris:pick", scheduleDismiss);
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest(".timetablepage")) return;
|
||||
|
||||
if (target.closest("[title='Choose a colour']")) {
|
||||
if (dismissTimer !== null) {
|
||||
clearTimeout(dismissTimer);
|
||||
dismissTimer = null;
|
||||
}
|
||||
prepareColorisPickerOpen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.closest(".entry")) return;
|
||||
|
||||
const pickerOpen =
|
||||
document.body.classList.contains("clr-open") &&
|
||||
document.querySelector(".clr-picker.clr-open");
|
||||
if (!pickerOpen) {
|
||||
const result = dismissTimetableUiBlockers();
|
||||
if (result.slideRemoved > 0 || result.modalRemoved > 0) {
|
||||
verboseInfo(
|
||||
"[BetterSEQTA+] timetable colour: content-script cleanup",
|
||||
result,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export function installSeqtaMenuColourPatch(): void {
|
||||
if (document.getElementById(PAGE_PATCH_LOADER_ID)) return;
|
||||
|
||||
|
||||
@@ -46,41 +46,25 @@ function ensureBridgeElements(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function bumpBridgeRevision(): void {
|
||||
const bridge = document.getElementById(BRIDGE_ID);
|
||||
if (!bridge) return;
|
||||
const rev = Number(bridge.getAttribute("data-rev") || "0") + 1;
|
||||
bridge.setAttribute("data-rev", String(rev));
|
||||
}
|
||||
|
||||
function sendPayload(payload: Record<string, unknown>): void {
|
||||
installThemeImagePagePatch();
|
||||
ensureBridgeElements();
|
||||
const payloadEl = document.getElementById(PAYLOAD_ID) as HTMLTextAreaElement;
|
||||
payloadEl.value = JSON.stringify(payload);
|
||||
bumpBridgeRevision();
|
||||
const bridge = document.getElementById(BRIDGE_ID)!;
|
||||
bridge.setAttribute("data-rev", String(Number(bridge.getAttribute("data-rev") || "0") + 1));
|
||||
}
|
||||
|
||||
export async function syncThemeToPage(input: ThemePageSyncInput): Promise<void> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
if (input.clear) {
|
||||
sendPayload({ clear: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.clearPreview) {
|
||||
payload.clearPreview = true;
|
||||
}
|
||||
|
||||
if (input.customCss !== undefined) {
|
||||
payload.customCss = input.customCss;
|
||||
}
|
||||
|
||||
if (input.previewCss !== undefined) {
|
||||
payload.previewCss = input.previewCss;
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (input.clearPreview) payload.clearPreview = true;
|
||||
if (input.customCss !== undefined) payload.customCss = input.customCss;
|
||||
if (input.previewCss !== undefined) payload.previewCss = input.previewCss;
|
||||
if (input.images !== undefined) {
|
||||
payload.images = await Promise.all(
|
||||
input.images.map(async (image) => ({
|
||||
@@ -98,15 +82,3 @@ export async function syncThemeToPage(input: ThemePageSyncInput): Promise<void>
|
||||
export function clearThemeInPage(): void {
|
||||
sendPayload({ clear: true });
|
||||
}
|
||||
|
||||
/** @deprecated Use clearThemeInPage */
|
||||
export function clearThemeImagesInPage(): void {
|
||||
clearThemeInPage();
|
||||
}
|
||||
|
||||
/** @deprecated Use syncThemeToPage */
|
||||
export async function syncThemeImagesToPage(
|
||||
images: Array<{ variableName: string; blob: Blob }>,
|
||||
): Promise<void> {
|
||||
await syncThemeToPage({ images });
|
||||
}
|
||||
|
||||
@@ -1,121 +1,95 @@
|
||||
/**
|
||||
* PAGE context only — patches SEQTA menu.update.colours and removes stuck colour-dialog
|
||||
* layers (uiSlidePane + modaliser) that block timetable entry clicks after a colour save.
|
||||
* PAGE context — patches broken menu.update.colours and removes stuck colour-dialog layers.
|
||||
*/
|
||||
(function () {
|
||||
if (window.__bsplusMenuColoursPatched) return;
|
||||
|
||||
var LOG = "[BetterSEQTA+] timetable colour:";
|
||||
var MENU_UPDATE_COLOURS = "menu.update.colours";
|
||||
var SUBJECT_COLOUR_PREF_PREFIX = "timetable.subject.colour.";
|
||||
var TUTOR_COLOUR_PREF_PREFIX = "timetable.tutor.";
|
||||
var SUBJECT_PREFIX = "timetable.subject.colour.";
|
||||
var TUTOR_PREFIX = "timetable.tutor.";
|
||||
|
||||
function log(event, detail) {
|
||||
if (!document.documentElement.hasAttribute("data-bsplus-verbose-log")) return;
|
||||
if (detail !== undefined) {
|
||||
console.info(LOG, event, detail);
|
||||
} else {
|
||||
console.info(LOG, event);
|
||||
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;
|
||||
var panes = document.querySelectorAll(".uiSlidePane");
|
||||
for (var i = 0; i < panes.length; i++) {
|
||||
var p = panes[i];
|
||||
if (p.querySelector(".pane.colourChooser")) {
|
||||
p.remove();
|
||||
n++;
|
||||
continue;
|
||||
}
|
||||
if (!forceColour && p.classList.contains("shown")) continue;
|
||||
if (!p.classList.contains("shown")) {
|
||||
p.remove();
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function dismissStaleDialogs(forceColour) {
|
||||
var slide = dismissSlidePanes(forceColour);
|
||||
var modal = dismissModalisers();
|
||||
document.body.classList.remove("clr-open");
|
||||
document.documentElement.classList.remove("clr-open");
|
||||
return { slideRemoved: slide, modalRemoved: modal };
|
||||
}
|
||||
|
||||
function scheduleCleanup() {
|
||||
var delays = [0, 100, 300, 600];
|
||||
for (var i = 0; i < delays.length; i++) {
|
||||
(function (d) {
|
||||
setTimeout(function () {
|
||||
dismissStaleDialogs(true);
|
||||
}, d);
|
||||
})(delays[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function isTesStylingEnabled() {
|
||||
var logoStyle = document.getElementById("logo-style");
|
||||
return logoStyle && logoStyle.textContent.indexOf("tesSeqta") !== -1;
|
||||
}
|
||||
|
||||
function countOverlayState() {
|
||||
return {
|
||||
slidePanes: document.querySelectorAll(".uiSlidePane").length,
|
||||
slidePanesShown: document.querySelectorAll(".uiSlidePane.shown").length,
|
||||
colourChoosers: document.querySelectorAll(
|
||||
".uiSlidePane .pane.colourChooser",
|
||||
).length,
|
||||
modalisers: document.querySelectorAll(".modaliser-container").length,
|
||||
modalisersVisible: document.querySelectorAll(
|
||||
".modaliser-container.visible",
|
||||
).length,
|
||||
quickbarsVisible: document.querySelectorAll(
|
||||
".timetablepage .quickbar.visible",
|
||||
).length,
|
||||
};
|
||||
}
|
||||
|
||||
function applyMenuSubjectColours() {
|
||||
function applyMenuColours() {
|
||||
if (!window.user) return;
|
||||
var defaultColour = isTesStylingEnabled() ? "#2b3547" : "#dddddd";
|
||||
var def = isTesStyling() ? "#2b3547" : "#dddddd";
|
||||
var items = document.querySelectorAll("#menu li[data-colour]");
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
var prefName = item.getAttribute("data-colour");
|
||||
if (!prefName) continue;
|
||||
var pref = window.user.getPreference(prefName);
|
||||
var colour = (pref && pref.value) || defaultColour;
|
||||
item.style.setProperty("--item-colour", colour);
|
||||
item.style.setProperty("--item-colour", (pref && pref.value) || def);
|
||||
}
|
||||
}
|
||||
|
||||
function dismissStaleModaliserContainers() {
|
||||
var removed = 0;
|
||||
var containers = document.querySelectorAll(".modaliser-container");
|
||||
for (var i = 0; i < containers.length; i++) {
|
||||
var container = containers[i];
|
||||
var modal = container.querySelector(".modaliser");
|
||||
var empty = !modal || modal.childElementCount === 0;
|
||||
var hidden = !container.classList.contains("visible");
|
||||
if (empty || hidden) {
|
||||
container.remove();
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function dismissStaleColourSlidePanes(forceColourChooser) {
|
||||
var removed = 0;
|
||||
var panes = document.querySelectorAll(".uiSlidePane");
|
||||
for (var i = 0; i < panes.length; i++) {
|
||||
var pane = panes[i];
|
||||
var isColourChooser = pane.querySelector(".pane.colourChooser");
|
||||
if (isColourChooser) {
|
||||
pane.remove();
|
||||
removed++;
|
||||
continue;
|
||||
}
|
||||
if (!forceColourChooser && pane.classList.contains("shown")) continue;
|
||||
if (!pane.classList.contains("shown")) {
|
||||
pane.remove();
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function dismissStaleColourDialogs(forceColourChooser) {
|
||||
var slideRemoved = dismissStaleColourSlidePanes(forceColourChooser);
|
||||
var modalRemoved = dismissStaleModaliserContainers();
|
||||
document.body.classList.remove("clr-open");
|
||||
document.documentElement.classList.remove("clr-open");
|
||||
return {
|
||||
slideRemoved: slideRemoved,
|
||||
modalRemoved: modalRemoved,
|
||||
overlays: countOverlayState(),
|
||||
};
|
||||
}
|
||||
|
||||
function reconcileStuckQuickbars(reason) {
|
||||
function reconcileQuickbars() {
|
||||
var fixed = 0;
|
||||
var quickbars = document.querySelectorAll(".timetablepage .quickbar.visible");
|
||||
for (var i = 0; i < quickbars.length; i++) {
|
||||
var qb = quickbars[i];
|
||||
var wrapper = qb.querySelector(".wrapper");
|
||||
if (!wrapper || !wrapper.childElementCount) {
|
||||
var w = qb.querySelector(".wrapper");
|
||||
if (!w || !w.childElementCount) {
|
||||
qb.classList.remove("visible");
|
||||
fixed++;
|
||||
}
|
||||
}
|
||||
if (fixed > 0) {
|
||||
log("cleared stuck quickbar shell (" + reason + ")", { fixed: fixed });
|
||||
if (fixed) {
|
||||
try {
|
||||
window.msg.send("calendar.quickbar.hide");
|
||||
} catch (err) {
|
||||
@@ -125,30 +99,29 @@
|
||||
return fixed;
|
||||
}
|
||||
|
||||
function findEntryElement(calendarId, code) {
|
||||
function findEntry(calendarId, code) {
|
||||
if (calendarId) {
|
||||
var byCalendar = document.querySelector(
|
||||
var byId = document.querySelector(
|
||||
".timetablepage .entry[data-calendarid=\"" + calendarId + "\"]",
|
||||
);
|
||||
if (byCalendar) return byCalendar;
|
||||
if (byId) return byId;
|
||||
}
|
||||
if (code) {
|
||||
var entries = document.querySelectorAll(".timetablepage .entry.class");
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var entry = entries[i];
|
||||
var titleEl = entry.querySelector(".title");
|
||||
var title =
|
||||
titleEl && titleEl.textContent ? titleEl.textContent.trim() : "";
|
||||
if (title && title.indexOf(code) !== -1) return entry;
|
||||
}
|
||||
if (!code) return null;
|
||||
var entries = document.querySelectorAll(".timetablepage .entry.class");
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var entry = entries[i];
|
||||
var titleEl = entry.querySelector(".title");
|
||||
var title =
|
||||
titleEl && titleEl.textContent ? titleEl.textContent.trim() : "";
|
||||
if (title && title.indexOf(code) !== -1) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeQuickbarOpenContext(contents) {
|
||||
function normalizeQuickbarContext(contents) {
|
||||
if (!contents) return contents;
|
||||
|
||||
reconcileStuckQuickbars("before-open");
|
||||
reconcileQuickbars();
|
||||
|
||||
var element = contents.element;
|
||||
var calendarId =
|
||||
@@ -162,190 +135,74 @@
|
||||
document.contains(element);
|
||||
|
||||
if (!connected) {
|
||||
var replacement = findEntryElement(calendarId, code);
|
||||
if (replacement) {
|
||||
contents.element = replacement;
|
||||
log("replaced detached quickbar entry element", {
|
||||
calendarId: calendarId,
|
||||
code: code,
|
||||
});
|
||||
} else {
|
||||
log("quickbar entry element detached, no replacement found", {
|
||||
calendarId: calendarId,
|
||||
code: code,
|
||||
});
|
||||
}
|
||||
var replacement = findEntry(calendarId, code);
|
||||
if (replacement) contents.element = replacement;
|
||||
}
|
||||
|
||||
return contents;
|
||||
}
|
||||
|
||||
function logQuickbarOpenResult(phase) {
|
||||
var qb = document.querySelector(".timetablepage .quickbar.visible");
|
||||
var wrapper = qb && qb.querySelector(".wrapper");
|
||||
log("quickbar open result (" + phase + ")", {
|
||||
visible: !!qb,
|
||||
hasWrapper: !!wrapper,
|
||||
wrapperChildren: wrapper ? wrapper.childElementCount : 0,
|
||||
overlays: countOverlayState(),
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleColourDialogCleanup(reason) {
|
||||
var delays = [0, 100, 300, 600];
|
||||
for (var i = 0; i < delays.length; i++) {
|
||||
(function (delay) {
|
||||
setTimeout(function () {
|
||||
var result = dismissStaleColourDialogs(true);
|
||||
if (
|
||||
result.slideRemoved > 0 ||
|
||||
result.modalRemoved > 0 ||
|
||||
delay === 0
|
||||
) {
|
||||
log("cleanup (" + reason + ", +" + delay + "ms)", result);
|
||||
}
|
||||
}, delay);
|
||||
})(delays[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function isSubjectOrTutorColourPref(handle) {
|
||||
function isColourPref(handle) {
|
||||
return (
|
||||
typeof handle === "string" &&
|
||||
(handle.indexOf(SUBJECT_COLOUR_PREF_PREFIX) === 0 ||
|
||||
handle.indexOf(TUTOR_COLOUR_PREF_PREFIX) === 0)
|
||||
(handle.indexOf(SUBJECT_PREFIX) === 0 || handle.indexOf(TUTOR_PREFIX) === 0)
|
||||
);
|
||||
}
|
||||
|
||||
function runMenuColourUpdate() {
|
||||
log("menu.update.colours intercepted", countOverlayState());
|
||||
function onMenuColourUpdate() {
|
||||
try {
|
||||
applyMenuSubjectColours();
|
||||
applyMenuColours();
|
||||
} catch (err) {
|
||||
console.error("[BetterSEQTA+] menu.update.colours failed:", err);
|
||||
}
|
||||
scheduleColourDialogCleanup("menu.update.colours");
|
||||
reconcileStuckQuickbars("after-colour-save");
|
||||
scheduleCleanup();
|
||||
reconcileQuickbars();
|
||||
}
|
||||
|
||||
function fixedMenuColourHandler() {
|
||||
runMenuColourUpdate();
|
||||
}
|
||||
|
||||
function neutralizeBrokenMenuColourListeners(msg) {
|
||||
function neutralizeBrokenListeners(msg) {
|
||||
var listeners = msg.listeners && msg.listeners[MENU_UPDATE_COLOURS];
|
||||
if (!listeners) return;
|
||||
for (var i = 0; i < listeners.length; i++) {
|
||||
if (listeners[i]) {
|
||||
listeners[i].fn = fixedMenuColourHandler;
|
||||
}
|
||||
if (listeners[i]) listeners[i].fn = onMenuColourUpdate;
|
||||
}
|
||||
}
|
||||
|
||||
function patchMsg(msg) {
|
||||
if (!msg || msg.__bsplusPatched) return;
|
||||
|
||||
var originalSend = msg.send.bind(msg);
|
||||
var send = msg.send.bind(msg);
|
||||
var register = msg.register.bind(msg);
|
||||
|
||||
msg.send = function (handle, contents, suppressLogs, noRecord) {
|
||||
if (handle === MENU_UPDATE_COLOURS) {
|
||||
runMenuColourUpdate();
|
||||
onMenuColourUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSubjectOrTutorColourPref(handle)) {
|
||||
log("colour pref save detected", {
|
||||
pref: handle,
|
||||
colour: contents,
|
||||
overlays: countOverlayState(),
|
||||
});
|
||||
var prefResult = originalSend(
|
||||
if (isColourPref(handle)) {
|
||||
var result = send(handle, contents, suppressLogs, noRecord);
|
||||
scheduleCleanup();
|
||||
reconcileQuickbars();
|
||||
return result;
|
||||
}
|
||||
if (handle === "calendar.quickbar.class") {
|
||||
return send(
|
||||
handle,
|
||||
contents,
|
||||
normalizeQuickbarContext(contents),
|
||||
suppressLogs,
|
||||
noRecord,
|
||||
);
|
||||
scheduleColourDialogCleanup("pref:" + handle);
|
||||
reconcileStuckQuickbars("after-colour-save");
|
||||
return prefResult;
|
||||
}
|
||||
|
||||
if (handle === "calendar.quickbar.class") {
|
||||
var openContext = normalizeQuickbarOpenContext(contents);
|
||||
var label =
|
||||
openContext &&
|
||||
openContext.data &&
|
||||
(openContext.data.description || openContext.data.code);
|
||||
log("quickbar open msg.send", {
|
||||
subject: label,
|
||||
elementConnected:
|
||||
openContext &&
|
||||
openContext.element &&
|
||||
openContext.element.isConnected,
|
||||
overlays: countOverlayState(),
|
||||
});
|
||||
var openResult;
|
||||
try {
|
||||
openResult = originalSend(
|
||||
handle,
|
||||
openContext,
|
||||
suppressLogs,
|
||||
noRecord,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[BetterSEQTA+] quickbar open failed:", err);
|
||||
throw err;
|
||||
}
|
||||
setTimeout(function () {
|
||||
logQuickbarOpenResult("+50ms");
|
||||
}, 50);
|
||||
setTimeout(function () {
|
||||
logQuickbarOpenResult("+200ms");
|
||||
}, 200);
|
||||
return openResult;
|
||||
}
|
||||
|
||||
if (handle === "calendar.quickbar.hide") {
|
||||
log("quickbar hide msg.send", countOverlayState());
|
||||
return originalSend(handle, contents, suppressLogs, noRecord);
|
||||
}
|
||||
|
||||
return originalSend(handle, contents, suppressLogs, noRecord);
|
||||
return send(handle, contents, suppressLogs, noRecord);
|
||||
};
|
||||
|
||||
var originalRegister = msg.register.bind(msg);
|
||||
msg.register = function (handle, callback, clear, ignoreHistory) {
|
||||
if (handle === MENU_UPDATE_COLOURS) {
|
||||
return originalRegister(
|
||||
handle,
|
||||
fixedMenuColourHandler,
|
||||
clear,
|
||||
ignoreHistory,
|
||||
);
|
||||
return register(handle, onMenuColourUpdate, clear, ignoreHistory);
|
||||
}
|
||||
if (handle === "calendar.quickbar.class") {
|
||||
return originalRegister(
|
||||
handle,
|
||||
function (context) {
|
||||
log("quickbar class handler invoked", {
|
||||
elementConnected:
|
||||
context &&
|
||||
context.element &&
|
||||
context.element.isConnected,
|
||||
subject:
|
||||
context &&
|
||||
context.data &&
|
||||
(context.data.description || context.data.code),
|
||||
});
|
||||
return callback(context);
|
||||
},
|
||||
clear,
|
||||
ignoreHistory,
|
||||
);
|
||||
}
|
||||
return originalRegister(handle, callback, clear, ignoreHistory);
|
||||
return register(handle, callback, clear, ignoreHistory);
|
||||
};
|
||||
|
||||
neutralizeBrokenMenuColourListeners(msg);
|
||||
neutralizeBrokenListeners(msg);
|
||||
msg.__bsplusPatched = true;
|
||||
}
|
||||
|
||||
@@ -354,41 +211,15 @@
|
||||
if (!window.msg || !window.msg.send) return false;
|
||||
patchMsg(window.msg);
|
||||
window.__bsplusMenuColoursPatched = true;
|
||||
log("patch active");
|
||||
return true;
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.closest) return;
|
||||
var entry = target.closest(".timetablepage .entry");
|
||||
if (!entry) return;
|
||||
|
||||
var before = countOverlayState();
|
||||
var cleanup = dismissStaleColourDialogs(false);
|
||||
var calendarId = entry.getAttribute("data-calendarid");
|
||||
var instance = entry.getAttribute("data-instance");
|
||||
var titleEl = entry.querySelector(".title");
|
||||
var title = titleEl && titleEl.textContent ? titleEl.textContent.trim() : "";
|
||||
log("entry click (capture)", {
|
||||
calendarId: calendarId,
|
||||
instance: instance,
|
||||
title: title,
|
||||
before: before,
|
||||
cleanup: cleanup,
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
if (!tryPatch()) {
|
||||
var interval = setInterval(function () {
|
||||
if (tryPatch()) {
|
||||
clearInterval(interval);
|
||||
} else if (window.msg) {
|
||||
neutralizeBrokenMenuColourListeners(window.msg);
|
||||
neutralizeBrokenListeners(window.msg);
|
||||
}
|
||||
}, 25);
|
||||
setTimeout(function () {
|
||||
|
||||
@@ -45,22 +45,12 @@ export function insertKeyAfterInOrder(
|
||||
|
||||
/** Default Analytics immediately below Courses in saved menu order. */
|
||||
export function ensureAnalyticsMenuOrder(): void {
|
||||
if (!settingsState.defaultmenuorder.includes("analytics")) {
|
||||
settingsState.defaultmenuorder = insertKeyAfterInOrder(
|
||||
settingsState.defaultmenuorder,
|
||||
"analytics",
|
||||
"courses",
|
||||
);
|
||||
}
|
||||
if (
|
||||
settingsState.menuorder.length > 0 &&
|
||||
!settingsState.menuorder.includes("analytics")
|
||||
) {
|
||||
settingsState.menuorder = insertKeyAfterInOrder(
|
||||
settingsState.menuorder,
|
||||
"analytics",
|
||||
"courses",
|
||||
);
|
||||
for (const key of ["defaultmenuorder", "menuorder"] as const) {
|
||||
const order = settingsState[key];
|
||||
if (key === "menuorder" && order.length === 0) continue;
|
||||
if (!order.includes("analytics")) {
|
||||
settingsState[key] = insertKeyAfterInOrder(order, "analytics", "courses");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,139 +14,110 @@
|
||||
var THEME_STYLE_ID = "custom-theme";
|
||||
var PREVIEW_STYLE_ID = "custom-theme-preview";
|
||||
var urlCache = {};
|
||||
var state = {
|
||||
customCss: "",
|
||||
previewCss: "",
|
||||
};
|
||||
var cssState = { custom: "", preview: "" };
|
||||
var headObserver = null;
|
||||
|
||||
function log(event, detail) {
|
||||
if (!document.documentElement.hasAttribute("data-bsplus-verbose-log")) return;
|
||||
if (detail !== undefined) {
|
||||
console.info(LOG, event, detail);
|
||||
} else {
|
||||
console.info(LOG, event);
|
||||
}
|
||||
console.info(LOG, event, detail);
|
||||
}
|
||||
|
||||
function base64ToBlob(base64, mime) {
|
||||
var byteString = atob(base64);
|
||||
var ab = new ArrayBuffer(byteString.length);
|
||||
var ia = new Uint8Array(ab);
|
||||
for (var i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
var bytes = atob(base64);
|
||||
var ab = new ArrayBuffer(bytes.length);
|
||||
var view = new Uint8Array(ab);
|
||||
for (var i = 0; i < bytes.length; i++) view[i] = bytes.charCodeAt(i);
|
||||
return new Blob([ab], { type: mime || "image/png" });
|
||||
}
|
||||
|
||||
function releaseCachedUrls() {
|
||||
for (var key in urlCache) {
|
||||
if (!urlCache.hasOwnProperty(key)) continue;
|
||||
if (!Object.prototype.hasOwnProperty.call(urlCache, key)) continue;
|
||||
try {
|
||||
URL.revokeObjectURL(urlCache[key]);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
urlCache = {};
|
||||
}
|
||||
|
||||
function ensureStyleElement(id) {
|
||||
var style = document.getElementById(id);
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = id;
|
||||
document.head.appendChild(style);
|
||||
function styleEl(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
el.id = id;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
return style;
|
||||
return el;
|
||||
}
|
||||
|
||||
function ensureThemeStyleLast() {
|
||||
var style = document.getElementById(THEME_STYLE_ID);
|
||||
if (!style || !document.head.contains(style)) return;
|
||||
if (document.head.lastElementChild === style) return;
|
||||
document.head.appendChild(style);
|
||||
function keepThemeStyleLast() {
|
||||
var themeStyle = document.getElementById(THEME_STYLE_ID);
|
||||
if (themeStyle && document.head.contains(themeStyle) && document.head.lastElementChild !== themeStyle) {
|
||||
document.head.appendChild(themeStyle);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureHeadObserver() {
|
||||
function watchHead() {
|
||||
if (headObserver) return;
|
||||
headObserver = new MutationObserver(function () {
|
||||
ensureThemeStyleLast();
|
||||
});
|
||||
headObserver = new MutationObserver(keepThemeStyleLast);
|
||||
headObserver.observe(document.head, { childList: true });
|
||||
}
|
||||
|
||||
function setStyleText(id, text, watchThemeOrder) {
|
||||
if (!text) {
|
||||
document.getElementById(id)?.remove();
|
||||
return;
|
||||
}
|
||||
styleEl(id).textContent = text;
|
||||
if (watchThemeOrder) {
|
||||
watchHead();
|
||||
keepThemeStyleLast();
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
releaseCachedUrls();
|
||||
state.customCss = "";
|
||||
state.previewCss = "";
|
||||
var imagesStyle = document.getElementById(IMAGES_STYLE_ID);
|
||||
if (imagesStyle) imagesStyle.textContent = "";
|
||||
var themeStyle = document.getElementById(THEME_STYLE_ID);
|
||||
if (themeStyle) themeStyle.remove();
|
||||
var previewStyle = document.getElementById(PREVIEW_STYLE_ID);
|
||||
if (previewStyle) previewStyle.remove();
|
||||
if (headObserver) {
|
||||
headObserver.disconnect();
|
||||
headObserver = null;
|
||||
}
|
||||
cssState.custom = "";
|
||||
cssState.preview = "";
|
||||
setStyleText(IMAGES_STYLE_ID, "");
|
||||
document.getElementById(THEME_STYLE_ID)?.remove();
|
||||
document.getElementById(PREVIEW_STYLE_ID)?.remove();
|
||||
headObserver?.disconnect();
|
||||
headObserver = null;
|
||||
log("cleared");
|
||||
}
|
||||
|
||||
function applyThemeImages(images) {
|
||||
releaseCachedUrls();
|
||||
if (!images || !images.length) {
|
||||
var emptyStyle = document.getElementById(IMAGES_STYLE_ID);
|
||||
if (emptyStyle) emptyStyle.textContent = "";
|
||||
if (!images?.length) {
|
||||
setStyleText(IMAGES_STYLE_ID, "");
|
||||
return;
|
||||
}
|
||||
|
||||
var lines = [":root {"];
|
||||
for (var i = 0; i < images.length; i++) {
|
||||
var img = images[i];
|
||||
if (!img || !img.variableName || !img.data) continue;
|
||||
if (!img?.variableName || !img.data) continue;
|
||||
try {
|
||||
var blob = base64ToBlob(img.data, img.mime);
|
||||
var url = URL.createObjectURL(blob);
|
||||
var url = URL.createObjectURL(base64ToBlob(img.data, img.mime));
|
||||
urlCache[img.variableName] = url;
|
||||
lines.push(" --" + img.variableName + ": url(\"" + url + "\");");
|
||||
lines.push(' --' + img.variableName + ': url("' + url + '");');
|
||||
} catch (e) {
|
||||
console.warn(LOG, "skip image", img.variableName, e);
|
||||
}
|
||||
}
|
||||
lines.push("}");
|
||||
ensureStyleElement(IMAGES_STYLE_ID).textContent = lines.join("\n");
|
||||
styleEl(IMAGES_STYLE_ID).textContent = lines.join("\n");
|
||||
log("images applied", { count: images.length });
|
||||
}
|
||||
|
||||
function applyCustomCss(css) {
|
||||
if (!css) {
|
||||
var existing = document.getElementById(THEME_STYLE_ID);
|
||||
if (existing) existing.remove();
|
||||
return;
|
||||
}
|
||||
ensureStyleElement(THEME_STYLE_ID).textContent = css;
|
||||
ensureHeadObserver();
|
||||
ensureThemeStyleLast();
|
||||
}
|
||||
|
||||
function applyPreviewCss(css) {
|
||||
if (!css) {
|
||||
var existing = document.getElementById(PREVIEW_STYLE_ID);
|
||||
if (existing) existing.remove();
|
||||
return;
|
||||
}
|
||||
ensureStyleElement(PREVIEW_STYLE_ID).textContent = css;
|
||||
}
|
||||
|
||||
function processPayload() {
|
||||
var payloadEl = document.getElementById(PAYLOAD_ID);
|
||||
if (!payloadEl) return;
|
||||
var raw = payloadEl.value;
|
||||
var raw = document.getElementById(PAYLOAD_ID)?.value;
|
||||
if (!raw) {
|
||||
clearAll();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var payload = JSON.parse(raw);
|
||||
if (!payload || payload.clear) {
|
||||
@@ -154,24 +125,22 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.images !== undefined) {
|
||||
applyThemeImages(payload.images);
|
||||
}
|
||||
if (payload.images !== undefined) applyThemeImages(payload.images);
|
||||
|
||||
if (payload.customCss !== undefined) {
|
||||
state.customCss = payload.customCss || "";
|
||||
applyCustomCss(state.customCss);
|
||||
cssState.custom = payload.customCss || "";
|
||||
setStyleText(THEME_STYLE_ID, cssState.custom, true);
|
||||
log("custom css applied");
|
||||
}
|
||||
|
||||
if (payload.previewCss !== undefined) {
|
||||
state.previewCss = payload.previewCss || "";
|
||||
applyPreviewCss(state.previewCss);
|
||||
cssState.preview = payload.previewCss || "";
|
||||
setStyleText(PREVIEW_STYLE_ID, cssState.preview);
|
||||
}
|
||||
|
||||
if (payload.clearPreview) {
|
||||
state.previewCss = "";
|
||||
applyPreviewCss("");
|
||||
cssState.preview = "";
|
||||
setStyleText(PREVIEW_STYLE_ID, "");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(LOG, "invalid payload", e);
|
||||
@@ -197,10 +166,7 @@
|
||||
}
|
||||
|
||||
ensureBridge();
|
||||
var bridge = document.getElementById(BRIDGE_ID);
|
||||
new MutationObserver(function () {
|
||||
processPayload();
|
||||
}).observe(bridge, {
|
||||
new MutationObserver(processPayload).observe(document.getElementById(BRIDGE_ID), {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-rev"],
|
||||
});
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* SEQTA timetable colour picker (Coloris) recovery and click-blocker cleanup.
|
||||
* Subject colour saves trigger SEQTA's broken menu.update.colours handler — see
|
||||
* patchSeqtaMenuUpdateColours.ts.
|
||||
*/
|
||||
|
||||
import { dismissStaleColourDialogs } from "@/seqta/utils/patchSeqtaMenuUpdateColours";
|
||||
import { verboseInfo } from "@/utils/verboseLog";
|
||||
|
||||
let attached = false;
|
||||
let dismissTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const DISMISS_DELAY_MS = 100;
|
||||
|
||||
function scheduleDismiss(): void {
|
||||
if (dismissTimer !== null) clearTimeout(dismissTimer);
|
||||
dismissTimer = setTimeout(() => {
|
||||
dismissTimer = null;
|
||||
dismissTimetableUiBlockers();
|
||||
}, DISMISS_DELAY_MS);
|
||||
}
|
||||
|
||||
/** Hide colour-picker / modal layers that intercept clicks after a colour save. */
|
||||
export function dismissTimetableUiBlockers(): {
|
||||
slideRemoved: number;
|
||||
modalRemoved: number;
|
||||
} {
|
||||
document.body.style.removeProperty("overflow");
|
||||
|
||||
for (const picker of document.querySelectorAll(".clr-picker")) {
|
||||
picker.classList.remove("clr-open");
|
||||
if (picker instanceof HTMLElement) {
|
||||
picker.style.display = "none";
|
||||
picker.style.pointerEvents = "none";
|
||||
picker.style.visibility = "hidden";
|
||||
}
|
||||
}
|
||||
|
||||
return dismissStaleColourDialogs();
|
||||
}
|
||||
|
||||
/** Clear inline styles that can prevent Coloris from reopening. */
|
||||
export function prepareColorisPickerOpen(): void {
|
||||
document.body.classList.remove("clr-open");
|
||||
document.documentElement.classList.remove("clr-open");
|
||||
|
||||
for (const picker of document.querySelectorAll(".clr-picker")) {
|
||||
picker.classList.remove("clr-open");
|
||||
if (picker instanceof HTMLElement) {
|
||||
picker.style.removeProperty("display");
|
||||
picker.style.removeProperty("pointer-events");
|
||||
picker.style.removeProperty("visibility");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function attachTimetableColorisRecovery(): void {
|
||||
if (attached) return;
|
||||
attached = true;
|
||||
|
||||
document.addEventListener("coloris:close", scheduleDismiss);
|
||||
document.addEventListener("coloris:pick", scheduleDismiss);
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest(".timetablepage")) return;
|
||||
|
||||
if (target.closest("[title='Choose a colour']")) {
|
||||
if (dismissTimer !== null) {
|
||||
clearTimeout(dismissTimer);
|
||||
dismissTimer = null;
|
||||
}
|
||||
prepareColorisPickerOpen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.closest(".entry")) return;
|
||||
|
||||
const pickerOpen =
|
||||
document.body.classList.contains("clr-open") &&
|
||||
document.querySelector(".clr-picker.clr-open");
|
||||
if (!pickerOpen) {
|
||||
const result = dismissTimetableUiBlockers();
|
||||
if (result.slideRemoved > 0 || result.modalRemoved > 0) {
|
||||
verboseInfo(
|
||||
"[BetterSEQTA+] timetable colour: content-script cleanup",
|
||||
result,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user