mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
feat: engage homepag
This commit is contained in:
@@ -3325,6 +3325,63 @@ div.day-empty {
|
|||||||
right: 250px;
|
right: 250px;
|
||||||
top: 0;
|
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 {
|
#engage-logouttooltip {
|
||||||
width: 50px !important;
|
width: 50px !important;
|
||||||
margin-left: -28px !important;
|
margin-left: -28px !important;
|
||||||
|
|||||||
@@ -196,22 +196,23 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Default Page",
|
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,
|
id: 10,
|
||||||
Component: Select,
|
Component: Select,
|
||||||
props: {
|
props: {
|
||||||
state: $settingsState.defaultPage,
|
state: $settingsState.defaultPage,
|
||||||
onChange: (value: string) => settingsState.defaultPage = value,
|
onChange: (value: string) => (settingsState.defaultPage = value),
|
||||||
options: [
|
options: [
|
||||||
{ value: 'home', label: 'Home' },
|
{ value: "home", label: "Home" },
|
||||||
{ value: 'dashboard', label: 'Dashboard' },
|
{ value: "dashboard", label: "Dashboard" },
|
||||||
{ value: 'timetable', label: 'Timetable' },
|
{ value: "timetable", label: "Timetable" },
|
||||||
{ value: 'welcome', label: 'Welcome' },
|
{ value: "welcome", label: "Welcome" },
|
||||||
{ value: 'messages', label: 'Messages' },
|
{ value: "messages", label: "Messages" },
|
||||||
{ value: 'documents', label: 'Documents' },
|
{ value: "documents", label: "Documents" },
|
||||||
{ value: 'reports', label: 'Reports' },
|
{ value: "reports", label: "Reports" },
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "News Feed Source",
|
title: "News Feed Source",
|
||||||
|
|||||||
+38
-2
@@ -23,6 +23,11 @@ import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements";
|
|||||||
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
||||||
import loading from "@/seqta/ui/Loading";
|
import loading from "@/seqta/ui/Loading";
|
||||||
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
|
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 { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||||
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
|
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
|
||||||
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification";
|
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification";
|
||||||
@@ -84,6 +89,7 @@ export function hideSideBar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let betterSeqtaFinishLoadDone = false;
|
let betterSeqtaFinishLoadDone = false;
|
||||||
|
let engageHashListenerAttached = false;
|
||||||
|
|
||||||
export async function finishLoad() {
|
export async function finishLoad() {
|
||||||
if (betterSeqtaFinishLoadDone) return;
|
if (betterSeqtaFinishLoadDone) return;
|
||||||
@@ -208,7 +214,20 @@ function SortMessagePageItems(messagesParentElement: any) {
|
|||||||
|
|
||||||
async function LoadPageElements(): Promise<void> {
|
async function LoadPageElements(): Promise<void> {
|
||||||
await AddBetterSEQTAElements();
|
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(
|
eventManager.register(
|
||||||
"messagesAdded",
|
"messagesAdded",
|
||||||
@@ -303,7 +322,24 @@ async function handleNotices(node: Element): Promise<void> {
|
|||||||
|
|
||||||
async function handleSublink(sublink: string | undefined): Promise<void> {
|
async function handleSublink(sublink: string | undefined): Promise<void> {
|
||||||
if (isSeqtaEngageExperience()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings";
|
import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings";
|
||||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
|
import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
||||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||||
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
|
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
|
||||||
import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton";
|
import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton";
|
||||||
@@ -47,6 +48,9 @@ export async function AddBetterSEQTAElements() {
|
|||||||
if (isSeqtaEngageExperience()) {
|
if (isSeqtaEngageExperience()) {
|
||||||
await waitForElm("#content");
|
await waitForElm("#content");
|
||||||
addExtensionSettings();
|
addExtensionSettings();
|
||||||
|
if (settingsState.onoff) {
|
||||||
|
await injectEngageHomeButton();
|
||||||
|
}
|
||||||
void setupEngageSettingsButton();
|
void setupEngageSettingsButton();
|
||||||
void addEngageUserInfo();
|
void addEngageUserInfo();
|
||||||
return;
|
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<HTMLElement | null> {
|
||||||
|
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 */ `<li class="item" data-key="home" id="homebutton" data-path="/home" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" /></svg><span>Home</span></label></li>`,
|
||||||
|
).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() {
|
async function getEngageUserInfo() {
|
||||||
const response = await fetch(`${location.origin}/seqta/parent/login`, {
|
const response = await fetch(`${location.origin}/seqta/parent/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<div class="day-empty">
|
||||||
|
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
|
||||||
|
<p>No lessons for this day.</p>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
lessons.forEach((lesson, i) => {
|
||||||
|
dayContainer.appendChild(makeEngageLessonDiv(lesson, i));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dayContainer.classList.remove("loading");
|
||||||
|
setEngageTimetableSubtitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWeekAndRender(): Promise<void> {
|
||||||
|
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 = `
|
||||||
|
<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> {
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="day-empty">
|
||||||
|
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
|
||||||
|
<p>${message}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SEQTA Engage parent home: child timetable (today view) using parent APIs. */
|
||||||
|
export async function loadEngageHomePage(allowRetry = true): Promise<void> {
|
||||||
|
updateEngageHomeMenuActive(true);
|
||||||
|
document.title = "Home ― SEQTA Engage";
|
||||||
|
|
||||||
|
const main = document.getElementById("main");
|
||||||
|
if (!main) {
|
||||||
|
if (allowRetry) {
|
||||||
|
await new Promise<void>((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 */ `
|
||||||
|
<div class="home-root" id="engage-home-root">
|
||||||
|
<div class="home-container" id="engage-home-container">
|
||||||
|
<div class="border timetable-container">
|
||||||
|
<div class="home-subtitle">
|
||||||
|
<div class="engage-timetable-title-cluster">
|
||||||
|
<h2 id="engage-home-lesson-subtitle">Today's Lessons</h2>
|
||||||
|
<select id="engage-child-selector" class="engage-child-select" aria-label="Student"></select>
|
||||||
|
</div>
|
||||||
|
<div class="timetable-arrows">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" style="transform: scale(-1,1)" id="engage-home-timetable-back">
|
||||||
|
<g style="fill: currentcolor;"><path d="M8.578 16.359l4.594-4.594-4.594-4.594 1.406-1.406 6 6-6 6z"></path></g>
|
||||||
|
</svg>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" id="engage-home-timetable-forward">
|
||||||
|
<g style="fill: currentcolor;"><path d="M8.578 16.359l4.594-4.594-4.594-4.594 1.406-1.406 6 6-6 6z"></path></g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="day-container loading" id="engage-day-container"></div>
|
||||||
|
</div>
|
||||||
|
<div class="border notices-container">
|
||||||
|
<div style="display: flex; justify-content: space-between">
|
||||||
|
<h2 class="home-subtitle">Notices</h2>
|
||||||
|
<input type="date" id="engage-notices-date" />
|
||||||
|
</div>
|
||||||
|
<div class="notice-container upcoming-items loading" id="engage-notice-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<any> {
|
||||||
|
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<EngageParentChild[]> {
|
||||||
|
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<EngageParentTimetableItem[]> {
|
||||||
|
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 : [];
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user