From 39d0b6002426fab4ea9063fe25719f0ed608ad4f Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Wed, 8 Apr 2026 11:27:38 +0930 Subject: [PATCH] feat: engage homepag --- src/css/injected.scss | 57 ++ src/interface/pages/settings/general.svelte | 23 +- src/plugins/monofile.ts | 40 +- src/seqta/ui/AddBetterSEQTAElements.ts | 66 ++ src/seqta/utils/Loaders/LoadEngageHomePage.ts | 816 ++++++++++++++++++ .../utils/Loaders/engageParentTimetable.ts | 87 ++ src/seqta/utils/engageRoute.ts | 17 + 7 files changed, 1093 insertions(+), 13 deletions(-) create mode 100644 src/seqta/utils/Loaders/LoadEngageHomePage.ts create mode 100644 src/seqta/utils/Loaders/engageParentTimetable.ts create mode 100644 src/seqta/utils/engageRoute.ts diff --git a/src/css/injected.scss b/src/css/injected.scss index cd40a524..7130e166 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -3325,6 +3325,63 @@ div.day-empty { right: 250px; top: 0; } + +/* Engage parent home: same timetable DOM as Learn; title+student replace the lone h2 — give the cluster Learn’s h2 margin/inset. */ +.timetable-container .home-subtitle > .engage-timetable-title-cluster { + align-items: center; + box-sizing: border-box; + display: flex; + flex: 1; + flex-wrap: wrap; + gap: 0.75rem 1rem; + margin: 20px; + min-width: 0; +} + +.timetable-container .engage-timetable-title-cluster > h2 { + font-size: 20px; + font-weight: 400; + margin: 0 !important; +} + +#engage-home-root.home-root { + box-sizing: border-box; + min-height: 100%; +} + +.engage-child-select { + background: var(--background-primary); + border: 1px solid var(--border-primary, rgba(128, 128, 128, 0.35)); + border-radius: 0.5rem; + color: var(--text-primary); + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25; + max-width: 16rem; + min-width: 10rem; + padding: 0.35rem 0.6rem; + transition: border-color 0.2s ease-in-out, color 0.2s ease-in-out; +} + +.engage-child-select:focus { + outline: none; + box-shadow: 0 0 0 2px var(--background-primary), 0 0 0 4px rgba(59, 130, 246, 0.45); +} + +#engage-day-container:has(> .day-empty) { + align-content: center; + display: flex; + grid-auto-columns: unset; + grid-auto-flow: unset; + justify-content: center; + min-height: 12rem; + padding: 1.5rem; +} + +#engage-day-container .day-empty { + text-align: center; +} + #engage-logouttooltip { width: 50px !important; margin-left: -28px !important; diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index 6092b238..5e7236fd 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -196,22 +196,23 @@ }, { title: "Default Page", - description: "The page to load when SEQTA Learn is opened", + description: + "The page to load when SEQTA Learn or SEQTA Engage opens (uses the same #?page=/… URL as SEQTA). BetterSEQTA home on Engage only applies when Home is selected.", id: 10, Component: Select, props: { state: $settingsState.defaultPage, - onChange: (value: string) => settingsState.defaultPage = value, + onChange: (value: string) => (settingsState.defaultPage = value), options: [ - { value: 'home', label: 'Home' }, - { value: 'dashboard', label: 'Dashboard' }, - { value: 'timetable', label: 'Timetable' }, - { value: 'welcome', label: 'Welcome' }, - { value: 'messages', label: 'Messages' }, - { value: 'documents', label: 'Documents' }, - { value: 'reports', label: 'Reports' }, - ] - } + { value: "home", label: "Home" }, + { value: "dashboard", label: "Dashboard" }, + { value: "timetable", label: "Timetable" }, + { value: "welcome", label: "Welcome" }, + { value: "messages", label: "Messages" }, + { value: "documents", label: "Documents" }, + { value: "reports", label: "Reports" }, + ], + }, }, { title: "News Feed Source", diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index 95f62acc..353386e3 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -23,6 +23,11 @@ import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements"; import { updateAllColors } from "@/seqta/ui/colors/Manager"; import loading from "@/seqta/ui/Loading"; import { SendNewsPage } from "@/seqta/utils/SendNewsPage"; +import { getEngageRoutePage } from "@/seqta/utils/engageRoute"; +import { + loadEngageHomePage, + updateEngageHomeMenuActive, +} from "@/seqta/utils/Loaders/LoadEngageHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup"; import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"; @@ -84,6 +89,7 @@ export function hideSideBar() { } let betterSeqtaFinishLoadDone = false; +let engageHashListenerAttached = false; export async function finishLoad() { if (betterSeqtaFinishLoadDone) return; @@ -208,7 +214,20 @@ function SortMessagePageItems(messagesParentElement: any) { async function LoadPageElements(): Promise { await AddBetterSEQTAElements(); - const sublink: string | undefined = window.location.href.split("/")[4]; + const sublink: string | undefined = isSeqtaEngageExperience() + ? getEngageRoutePage() + : window.location.href.split("/")[4]; + + if (isSeqtaEngageExperience() && !engageHashListenerAttached) { + engageHashListenerAttached = true; + window.addEventListener("hashchange", () => { + if (getEngageRoutePage() === "home") { + void loadEngageHomePage(); + } else { + updateEngageHomeMenuActive(false); + } + }); + } eventManager.register( "messagesAdded", @@ -303,7 +322,24 @@ async function handleNotices(node: Element): Promise { async function handleSublink(sublink: string | undefined): Promise { if (isSeqtaEngageExperience()) { - finishLoad(); + switch (sublink) { + case undefined: + window.location.replace( + `${location.origin}/#?page=/${settingsState.defaultPage}`, + ); + if (settingsState.defaultPage === "home") void loadEngageHomePage(); + finishLoad(); + break; + case "home": + window.location.replace(`${location.origin}/#?page=/home`); + console.info("[BetterSEQTA+] Started Init (SEQTA Engage home)"); + if (settingsState.onoff) void loadEngageHomePage(); + finishLoad(); + break; + default: + finishLoad(); + break; + } return; } diff --git a/src/seqta/ui/AddBetterSEQTAElements.ts b/src/seqta/ui/AddBetterSEQTAElements.ts index 05b87b55..8a7729a3 100644 --- a/src/seqta/ui/AddBetterSEQTAElements.ts +++ b/src/seqta/ui/AddBetterSEQTAElements.ts @@ -1,5 +1,6 @@ import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings"; import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; +import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { SendNewsPage } from "@/seqta/utils/SendNewsPage"; import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton"; @@ -47,6 +48,9 @@ export async function AddBetterSEQTAElements() { if (isSeqtaEngageExperience()) { await waitForElm("#content"); addExtensionSettings(); + if (settingsState.onoff) { + await injectEngageHomeButton(); + } void setupEngageSettingsButton(); void addEngageUserInfo(); return; @@ -281,6 +285,68 @@ async function createSettingsButton(parent?: Element) { ); } +/** Engage mounts the sidebar inside batched React trees; EventManager-based waitForElm can miss `#menu`. Polling `waitForElm` matches the real DOM reliably. */ +async function waitForEngageMenuList(): Promise { + const poll = true as const; + const interval = 100; + const trySelectors: { selector: string; maxIterations: number }[] = [ + { selector: "#menu > ul > li", maxIterations: 500 }, + { selector: "#menu ul", maxIterations: 350 }, + { selector: "#menu", maxIterations: 350 }, + ]; + + for (const { selector, maxIterations } of trySelectors) { + try { + await waitForElm(selector, poll, interval, maxIterations); + } catch { + continue; + } + + if (selector === "#menu > ul > li") { + const ul = document.querySelector("#menu > ul") as HTMLElement | null; + if (ul) return ul; + } else if (selector === "#menu ul") { + const ul = document.querySelector("#menu ul") as HTMLElement | null; + if (ul) return ul; + } else { + const menu = document.getElementById("menu"); + const ul = + (menu?.querySelector("ul") as HTMLElement | null) ?? + (menu?.firstElementChild as HTMLElement | null); + if (ul) return ul; + } + } + + console.warn( + "[BetterSEQTA+] Engage: could not find a menu list to inject the home button", + ); + return null; +} + +async function injectEngageHomeButton() { + if (document.getElementById("homebutton")) return; + + const menuList = await waitForEngageMenuList(); + if (!menuList || document.getElementById("homebutton")) return; + + const li = stringToHTML( + /* html */ `
  • `, + ).firstChild as HTMLElement; + menuList.insertBefore(li, menuList.firstElementChild); + + document.getElementById("homebutton")?.addEventListener("click", () => { + const btn = document.getElementById("homebutton") as HTMLElement; + if ( + btn.classList.contains("draggable") || + btn.classList.contains("active") + ) { + return; + } + window.location.replace(`${location.origin}/#?page=/home`); + void loadEngageHomePage(); + }); +} + async function getEngageUserInfo() { const response = await fetch(`${location.origin}/seqta/parent/login`, { method: "POST", diff --git a/src/seqta/utils/Loaders/LoadEngageHomePage.ts b/src/seqta/utils/Loaders/LoadEngageHomePage.ts new file mode 100644 index 00000000..9c0c77af --- /dev/null +++ b/src/seqta/utils/Loaders/LoadEngageHomePage.ts @@ -0,0 +1,816 @@ +import { animate } from "motion"; +import browser from "webextension-polyfill"; +import LogoLight from "@/resources/icons/betterseqta-light-icon.png"; +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 { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent"; +import { + type EngageParentChild, + type EngageParentTimetableItem, + fetchEngageParentChildren, + fetchEngageParentTimetableWeek, + isDateInCachedWeek, + toISODate, + weekRangeContaining, +} from "@/seqta/utils/Loaders/engageParentTimetable"; + +export function updateEngageHomeMenuActive(isHome: boolean): void { + const home = document.getElementById("homebutton"); + if (!home) return; + if (isHome) { + for (const el of document.querySelectorAll("#menu li.active")) { + if (el !== home) el.classList.remove("active"); + } + home.classList.add("active"); + } else { + home.classList.remove("active"); + } +} + +const STORAGE_KEY_STUDENT = () => + `bsplus.engageTimetable.student.${location.origin}`; + +let engageViewDate = new Date(); +let engageWeekFrom = ""; +let engageWeekUntil = ""; +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); + } +} + +function makeEngageLessonDiv( + lesson: EngageParentTimetableItem, + index: number, +): HTMLElement { + let from = lesson.from?.substring(0, 5) ?? ""; + let until = lesson.until?.substring(0, 5) ?? ""; + if (settingsState.timeFormat === "12") { + from = convertTo12HourFormat(from); + until = convertTo12HourFormat(until); + } + + const title = + lesson.type === "class" + ? lesson.description + : lesson.type || "Lesson"; + + const div = document.createElement("div"); + div.className = "day"; + div.id = `engage-lesson-${lesson.code}-${index}`; + div.style.cssText = "--item-colour: #8e8e8e;"; + + const h2 = document.createElement("h2"); + h2.textContent = title; + + const hStaff = document.createElement("h3"); + hStaff.textContent = lesson.staff?.trim() || "—"; + + const hRoom = document.createElement("h3"); + hRoom.textContent = lesson.room?.trim() || "—"; + + const hTime = document.createElement("h4"); + hTime.textContent = `${from} – ${until}`; + + const hPeriod = document.createElement("h5"); + hPeriod.textContent = lesson.period?.trim() || ""; + + div.append(h2, hStaff, hRoom, hTime, hPeriod); + return div; +} + +function renderEngageDayLessons(): void { + const dayContainer = document.getElementById("engage-day-container"); + if (!dayContainer) return; + + const dayStr = toISODate(engageViewDate); + const lessons = engageWeekItems + .filter((item) => item.date === dayStr) + .sort((a, b) => a.from.localeCompare(b.from)); + + dayContainer.innerHTML = ""; + + if (lessons.length === 0) { + dayContainer.innerHTML = ` +
    + +

    No lessons for this day.

    +
    `; + } else { + lessons.forEach((lesson, i) => { + dayContainer.appendChild(makeEngageLessonDiv(lesson, i)); + }); + } + + dayContainer.classList.remove("loading"); + setEngageTimetableSubtitle(); +} + +async function fetchWeekAndRender(): Promise { + const dayContainer = document.getElementById("engage-day-container"); + if (!dayContainer || !engageSelectedStudentId) return; + + dayContainer.classList.add("loading"); + dayContainer.innerHTML = ""; + + const { from, until } = weekRangeContaining(engageViewDate); + try { + engageWeekItems = await fetchEngageParentTimetableWeek( + from, + until, + engageSelectedStudentId, + ); + engageWeekFrom = from; + engageWeekUntil = until; + } catch (e) { + console.error("[BetterSEQTA+] Engage parent timetable failed:", e); + engageWeekItems = []; + engageWeekFrom = from; + engageWeekUntil = until; + } + + renderEngageDayLessons(); +} + +function shiftEngageDay(delta: number): void { + const next = new Date(engageViewDate); + next.setDate(next.getDate() + delta); + engageViewDate = next; + + const dayContainer = document.getElementById("engage-day-container"); + dayContainer?.classList.add("loading"); + dayContainer && (dayContainer.innerHTML = ""); + + if ( + engageWeekFrom && + engageWeekUntil && + isDateInCachedWeek(engageViewDate, engageWeekFrom, engageWeekUntil) + ) { + renderEngageDayLessons(); + } else { + void fetchWeekAndRender(); + } +} + +function populateChildSelector( + select: HTMLSelectElement, + children: EngageParentChild[], +): void { + select.innerHTML = ""; + for (const c of children) { + const opt = document.createElement("option"); + opt.value = c.id; + opt.textContent = c.name || `Student ${c.id}`; + select.appendChild(opt); + } + + const stored = localStorage.getItem(STORAGE_KEY_STUDENT()); + const validStored = stored && children.some((c) => c.id === stored); + engageSelectedStudentId = validStored + ? stored! + : children[0]?.id ?? null; + + if (engageSelectedStudentId) { + select.value = engageSelectedStudentId; + localStorage.setItem(STORAGE_KEY_STUDENT(), engageSelectedStudentId); + } +} + +function bindEngageTimetableUi(): void { + engageListenersCleanup?.(); + const cleanups: Array<() => void> = []; + + const back = document.getElementById("engage-home-timetable-back"); + const forward = document.getElementById("engage-home-timetable-forward"); + const select = document.getElementById( + "engage-child-selector", + ) as HTMLSelectElement | null; + + const onBack = () => shiftEngageDay(-1); + const onForward = () => shiftEngageDay(1); + + back?.addEventListener("click", onBack); + forward?.addEventListener("click", onForward); + cleanups.push( + () => back?.removeEventListener("click", onBack), + () => forward?.removeEventListener("click", onForward), + ); + + const onSelectChange = () => { + if (!select) return; + engageSelectedStudentId = select.value; + localStorage.setItem(STORAGE_KEY_STUDENT(), engageSelectedStudentId); + void fetchWeekAndRender(); + }; + select?.addEventListener("change", onSelectChange); + cleanups.push(() => + select?.removeEventListener("change", onSelectChange), + ); + + engageListenersCleanup = () => { + cleanups.forEach((fn) => fn()); + engageListenersCleanup = null; + }; +} + +/* ——— 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.innerHTML = ""; + + const notices = response?.payload; + if (!Array.isArray(notices)) { + const emptyState = document.createElement("div"); + emptyState.classList.add("day-empty"); + const img = document.createElement("img"); + img.src = browser.runtime.getURL(LogoLight); + const text = document.createElement("p"); + text.innerText = "No notices for today."; + emptyState.append(img, text); + noticeContainer.append(emptyState); + return; + } + + if (!notices.length) { + const emptyState = document.createElement("div"); + emptyState.classList.add("day-empty"); + const img = document.createElement("img"); + img.src = browser.runtime.getURL(LogoLight); + const text = document.createElement("p"); + text.innerText = "No notices for today."; + emptyState.append(img, text); + noticeContainer.append(emptyState); + return; + } + + const fragment = document.createDocumentFragment(); + + notices.forEach((notice: any) => { + const shouldInclude = + settingsState.mockNotices || + labelArray.length === 0 || + labelArray.includes(JSON.stringify(notice.label)); + + if (shouldInclude) { + const colour = processEngageNoticeColor(notice.colour); + const noticeElement = createEngageNoticeElement(notice, colour); + fragment.appendChild(noticeElement); + } + }); + + noticeContainer.appendChild(fragment); +} + +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 = ` +
    +
    +
    + + ${notice.label_title || "General"} + + ${notice.staff} +
    + +
    +

    ${notice.title}

    +
    ${textPreview}
    +
    `; + + 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 = ` +
    +
    +
    +
    +
    +
    + + ${notice.label_title || "General"} + + ${notice.staff} +
    + +
    +

    ${notice.title}

    +
    ${cleanContent}
    +
    +
    +
    +
    `; + + 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 = ` +
    +
    +
    + ${notice.label_title || "General"} + ${notice.staff} +
    + +
    +

    ${notice.title}

    +
    ${cleanContent}
    +
    + `; + 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 { + 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 { + const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID); + if (!noticeContainer) return; + + let labelFilterValues: string[] = []; + try { + const prefsRes = await fetch(`${location.origin}/seqta/parent/load/prefs?`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + credentials: "include", + body: JSON.stringify({ asArray: true, request: "userPrefs" }), + }); + const prefs = await prefsRes.json(); + const payload = prefs?.payload; + if (Array.isArray(payload)) { + labelFilterValues = payload + .filter((item: { name?: string }) => item.name === "notices.filters") + .map((item: { value?: string }) => item.value) + .filter((v): v is string => typeof v === "string"); + } + } catch { + labelFilterValues = []; + } + + const labelTokens = + labelFilterValues.length > 0 + ? String(labelFilterValues[0]).split(" ").filter(Boolean) + : []; + + const dateControl = document.getElementById(ENGAGE_NOTICES_DATE_ID); + if (dateControl) { + (dateControl as HTMLInputElement).value = todayFormatted; + } + + await fetchEngageNoticesFromApi(todayFormatted, labelTokens); + + const cleanup = bindEngageNoticesDateInput(labelTokens, todayFormatted); + engageMergeNoticeCleanup(cleanup); + + noticeContainer.classList.remove("loading"); +} + +function engageMergeNoticeCleanup(noticeCleanup: () => void): void { + const prev = engageListenersCleanup; + engageListenersCleanup = () => { + prev?.(); + noticeCleanup(); + }; +} + +function showEngageTimetableError(message: string): void { + const dayContainer = document.getElementById("engage-day-container"); + if (!dayContainer) return; + dayContainer.classList.remove("loading"); + dayContainer.innerHTML = ` +
    + +

    ${message}

    +
    `; +} + +/** SEQTA Engage parent home: child timetable (today view) using parent APIs. */ +export async function loadEngageHomePage(allowRetry = true): Promise { + updateEngageHomeMenuActive(true); + document.title = "Home ― SEQTA Engage"; + + const main = document.getElementById("main"); + if (!main) { + if (allowRetry) { + await new Promise((r) => requestAnimationFrame(() => r())); + return loadEngageHomePage(false); + } + return; + } + + engageListenersCleanup?.(); + engageViewDate = new Date(); + + main.innerHTML = ""; + /* `stringToHTML` returns `document.body`; use firstElementChild so we don't append a whitespace text node (which would drop #engage-home-container and break queries). */ + const engageHomeBody = stringToHTML(/* html */ ` +
    +
    +
    +
    +
    +

    Today's Lessons

    + +
    +
    + + + + + + +
    +
    +
    +
    +
    +
    +

    Notices

    + +
    +
    +
    +
    +
    + `); + const engageHomeRoot = engageHomeBody.firstElementChild as HTMLElement | null; + if (engageHomeRoot) { + main.appendChild(engageHomeRoot); + } else { + console.error( + "[BetterSEQTA+] Engage home: parsed markup had no root element (check DOMPurify / stringToHTML).", + ); + } + + bindEngageTimetableUi(); + setEngageTimetableSubtitle(); + + const select = document.getElementById( + "engage-child-selector", + ) as HTMLSelectElement | null; + + const todayFormatted = toISODate(new Date()); + + let children: EngageParentChild[]; + try { + try { + children = await fetchEngageParentChildren(); + } catch (e) { + console.error("[BetterSEQTA+] Engage parent child list failed:", e); + showEngageTimetableError("Could not load students for this account."); + return; + } + + if (!select) return; + + if (children.length === 0) { + select.disabled = true; + showEngageTimetableError("No linked students found."); + return; + } + + populateChildSelector(select, children); + + if (!engageSelectedStudentId) { + showEngageTimetableError("No student selected."); + return; + } + + await fetchWeekAndRender(); + } finally { + await initEngageNoticesUi(todayFormatted); + } +} diff --git a/src/seqta/utils/Loaders/engageParentTimetable.ts b/src/seqta/utils/Loaders/engageParentTimetable.ts new file mode 100644 index 00000000..4439e4d0 --- /dev/null +++ b/src/seqta/utils/Loaders/engageParentTimetable.ts @@ -0,0 +1,87 @@ +const TIMETABLE_URL = "/seqta/parent/load/timetable"; + +export interface EngageParentChild { + name: string; + id: string; +} + +export interface EngageParentTimetableItem { + date: string; + period: string; + code: string; + description: string; + staff: string; + type: string; + room: string; + from: string; + until: string; + programmeID?: number; + metaID?: number; + assessments?: unknown[]; +} + +export function toISODate(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +/** Monday–Sunday range (inclusive) containing `date`, as YYYY-MM-DD. */ +export function weekRangeContaining(date: Date): { from: string; until: string } { + const local = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const dow = local.getDay(); + const diff = dow === 0 ? -6 : 1 - dow; + local.setDate(local.getDate() + diff); + const monday = local; + const sunday = new Date(monday); + sunday.setDate(sunday.getDate() + 6); + return { from: toISODate(monday), until: toISODate(sunday) }; +} + +function isInWeekRange( + isoDay: string, + weekFrom: string, + weekUntil: string, +): boolean { + return isoDay >= weekFrom && isoDay <= weekUntil; +} + +export function isDateInCachedWeek( + date: Date, + weekFrom: string, + weekUntil: string, +): boolean { + return isInWeekRange(toISODate(date), weekFrom, weekUntil); +} + +async function postParentTimetable(body: object): Promise { + const res = await fetch(`${location.origin}${TIMETABLE_URL}`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + credentials: "include", + body: JSON.stringify(body), + }); + return res.json(); +} + +export async function fetchEngageParentChildren(): Promise { + const data = await postParentTimetable({ list: true }); + const raw = data?.payload; + if (!Array.isArray(raw)) return []; + return raw.map((row: { name?: string; id?: string | number }) => ({ + name: String(row?.name ?? ""), + id: String(row?.id ?? ""), + })); +} + +export async function fetchEngageParentTimetableWeek( + from: string, + until: string, + studentId: string, +): Promise { + const student = /^\d+$/.test(studentId) ? Number(studentId) : studentId; + const data = await postParentTimetable({ from, until, student }); + const items = data?.payload?.items; + return Array.isArray(items) ? items : []; +} diff --git a/src/seqta/utils/engageRoute.ts b/src/seqta/utils/engageRoute.ts new file mode 100644 index 00000000..38fb1cc0 --- /dev/null +++ b/src/seqta/utils/engageRoute.ts @@ -0,0 +1,17 @@ +/** + * Learn-style hash routes on Engage: `#?page=/home` → `"home"`. + * Falls back to the legacy path segment used by classic Learn routing. + */ +export function getEngageRoutePage(): string | undefined { + const hash = window.location.hash.replace(/^#/, ""); + if (hash) { + const qs = hash.startsWith("?") ? hash : `?${hash}`; + const params = new URLSearchParams(qs); + const page = params.get("page"); + if (page?.startsWith("/")) { + const segment = page.replace(/^\//, "").split("/")[0]; + return segment || undefined; + } + } + return window.location.href.split("/")[4]; +}