From 0ca0c7cf434b404b4b4c350c9a13c3ea647dae88 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Mon, 20 Apr 2026 21:43:05 +0930 Subject: [PATCH 01/32] add handlers for individual Channels --- .gitignore | 3 +- src/seqta/ui/colors/Manager.ts | 37 ++++++++++- .../ui/colors/customThemeAdaptiveBindings.ts | 64 ++++++++++++++----- 3 files changed, 85 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index e24ea199..46e7ae3b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ betterseqtaplus-safari/ .parcel-cache .env .env.submit -dependency-graph.svg \ No newline at end of file +dependency-graph.svg +/src/resources/themes diff --git a/src/seqta/ui/colors/Manager.ts b/src/seqta/ui/colors/Manager.ts index d72c8844..2adccfbf 100644 --- a/src/seqta/ui/colors/Manager.ts +++ b/src/seqta/ui/colors/Manager.ts @@ -5,7 +5,7 @@ import { lightenAndPaleColor } from "./lightenAndPaleColor"; import ColorLuminance from "./ColorLuminance"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour"; -import { getCustomThemeAdaptiveCssVariables } from "@/seqta/ui/colors/customThemeAdaptiveBindings"; +import { getCustomThemeAdaptiveCssVariableBindings } from "@/seqta/ui/colors/customThemeAdaptiveBindings"; import darkLogo from "@/resources/icons/betterseqta-light-full.png"; import lightLogo from "@/resources/icons/betterseqta-dark-full.png"; @@ -84,6 +84,21 @@ function cancelColorTransition() { } } +function getRepresentativeRgbChannels(s: string): { r: number; g: number; b: number } | null { + const parsedHex = parseRepresentativeHex(s); + if (!parsedHex) return null; + try { + const [r, g, b] = Color(parsedHex).rgb().array(); + return { + r: Math.round(r), + g: Math.round(g), + b: Math.round(b), + }; + } catch { + return null; + } +} + function applyColorsWith(selectedColor: string) { if (settingsState.transparencyEffects) { document.documentElement.classList.add("transparencyEffects"); @@ -129,8 +144,24 @@ function applyColorsWith(selectedColor: string) { applyProperties({ ...commonProps, ...modeProps, ...dynamicProps }); if (settingsState.selectedTheme) { - for (const name of getCustomThemeAdaptiveCssVariables()) { - setCSSVar(name, selectedColor); + const channels = getRepresentativeRgbChannels(selectedColor); + for (const binding of getCustomThemeAdaptiveCssVariableBindings()) { + if (!binding.channel) { + setCSSVar(binding.cssVarName, selectedColor); + continue; + } + + if (!channels) { + continue; + } + + if (binding.channel === "r") { + setCSSVar(binding.cssVarName, String(channels.r)); + } else if (binding.channel === "g") { + setCSSVar(binding.cssVarName, String(channels.g)); + } else { + setCSSVar(binding.cssVarName, String(channels.b)); + } } } diff --git a/src/seqta/ui/colors/customThemeAdaptiveBindings.ts b/src/seqta/ui/colors/customThemeAdaptiveBindings.ts index dba35e3f..cdd0c823 100644 --- a/src/seqta/ui/colors/customThemeAdaptiveBindings.ts +++ b/src/seqta/ui/colors/customThemeAdaptiveBindings.ts @@ -1,20 +1,49 @@ /** Tracks which author-declared CSS variables mirror the effective accent; not persisted in settings storage. */ const VALID_CUSTOM_PROP = /^--[a-zA-Z0-9_-]{1,120}$/; +const VALID_CHANNEL = /^(r|g|b)$/; -let boundNames: string[] = []; +export type AdaptiveChannel = "r" | "g" | "b"; -export function normalizeAdaptiveCssVariableNames( +export type AdaptiveCssVariableBinding = { + cssVarName: string; + channel?: AdaptiveChannel; +}; + +let boundBindings: AdaptiveCssVariableBinding[] = []; + +function parseAdaptiveBinding( + rawBinding: string, +): AdaptiveCssVariableBinding | null { + const trimmed = rawBinding.trim(); + if (!trimmed) return null; + + const [rawName, rawChannel] = trimmed.split(":", 2); + const cssVarName = rawName?.trim() ?? ""; + if (!VALID_CUSTOM_PROP.test(cssVarName)) return null; + + if (!rawChannel) return { cssVarName }; + + const channel = rawChannel.trim().toLowerCase(); + if (!VALID_CHANNEL.test(channel)) return null; + + return { cssVarName, channel: channel as AdaptiveChannel }; +} + +export function normalizeAdaptiveCssVariableBindings( names: string[] | undefined, -): string[] { +): AdaptiveCssVariableBinding[] { if (!names?.length) return []; - const out: string[] = []; + const out: AdaptiveCssVariableBinding[] = []; const seen = new Set(); + for (const raw of names) { - const s = raw.trim(); - if (!VALID_CUSTOM_PROP.test(s) || seen.has(s)) continue; - seen.add(s); - out.push(s); + const parsed = parseAdaptiveBinding(raw); + if (!parsed) continue; + const key = `${parsed.cssVarName}:${parsed.channel ?? "full"}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(parsed); } return out; } @@ -22,19 +51,24 @@ export function normalizeAdaptiveCssVariableNames( export function setCustomThemeAdaptiveCssVariables( names: string[] | undefined, ): void { - for (const n of boundNames) { - document.documentElement.style.removeProperty(n); + for (const binding of boundBindings) { + document.documentElement.style.removeProperty(binding.cssVarName); } - boundNames = normalizeAdaptiveCssVariableNames(names); + boundBindings = normalizeAdaptiveCssVariableBindings(names); } +export function getCustomThemeAdaptiveCssVariableBindings(): AdaptiveCssVariableBinding[] { + return boundBindings; +} + +// Backward-compatible helper for existing callsites. export function getCustomThemeAdaptiveCssVariables(): string[] { - return boundNames; + return boundBindings.map((b) => b.cssVarName); } export function clearCustomThemeAdaptiveCssVariables(): void { - for (const n of boundNames) { - document.documentElement.style.removeProperty(n); + for (const binding of boundBindings) { + document.documentElement.style.removeProperty(binding.cssVarName); } - boundNames = []; + boundBindings = []; } From 37be31859f4ab37b8bbaff5c51f95c47192d0f34 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Mon, 20 Apr 2026 21:47:05 +0930 Subject: [PATCH 02/32] add notes --- src/seqta/utils/Openers/OpenWhatsNewPopup.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts index bbe7f386..06e44fa9 100644 --- a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts +++ b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts @@ -34,6 +34,9 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) { const text = stringToHTML(/* html */ `
+

3.6.3 - Adaptive theme channel bindings & Alpine refresh

+
  • Added adaptive CSS variable channel bindings for custom themes: use --my-var:r, --my-var:g, or --my-var:b in adaptiveCssVariables.
  • +
  • Adaptive theme bindings now support both full-colour and single-channel values, enabling more reliable theme math without fragile CSS colour parsing.
  • 3.6.2 - Cloud backup, various fixes & SEQTA Engage support

  • BetterSEQTA Cloud: back up and restore extension settings from your account (General settings).
  • Optional automatic cloud sync if signed in (on by default).
  • From 44116edca50f0d0e48409898bb13596d27df823c Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Mon, 20 Apr 2026 22:50:39 +0930 Subject: [PATCH 03/32] patch fix theme overrides for adaptive colour --- src/css/injected.scss | 5 +++-- src/seqta/ui/colors/Manager.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/css/injected.scss b/src/css/injected.scss index 261552c2..9c5fe51c 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -1,3 +1,4 @@ + @use "sass:meta"; @import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,600,700"); @@ -2609,7 +2610,7 @@ body { [class*="MessageList__unread___"] { position: relative; - background: rgb(228 225 225); + background: var(--background-secondary, rgb(228 225 225)); } .dark [class*="MessageList__unread___"] { @@ -2735,7 +2736,7 @@ body { [class*="MessageList__MessageList___"] > ol > li[class*="MessageList__selected___"] { - background: rgb(228 225 225); + background: var(--background-secondary, rgb(228 225 225)); color: var(--text-primary); box-shadow: none !important; position: relative; diff --git a/src/seqta/ui/colors/Manager.ts b/src/seqta/ui/colors/Manager.ts index 2adccfbf..71740da9 100644 --- a/src/seqta/ui/colors/Manager.ts +++ b/src/seqta/ui/colors/Manager.ts @@ -165,6 +165,17 @@ function applyColorsWith(selectedColor: string) { } } + // Let themes opt-in to overriding only adaptive accent output. + // A theme can define `--adaptive-better-main` from adaptive channel bindings. + if (settingsState.selectedTheme && settingsState.adaptiveThemeColour) { + const adaptiveOverride = getComputedStyle(document.documentElement) + .getPropertyValue("--adaptive-better-main") + .trim(); + if (adaptiveOverride) { + setCSSVar("--better-main", adaptiveOverride); + } + } + let alliframes = document.getElementsByTagName("iframe"); for (let i = 0; i < alliframes.length; i++) { From fa8f36f3d5fcd5bad706ee16b8cfd14247dd6bbf Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Tue, 21 Apr 2026 20:31:06 +0930 Subject: [PATCH 04/32] idk --- .cursor/skills/theme-authoring/SKILL.md | 96 ++++++++++++++++++++ src/plugins/built-in/themes/theme-manager.ts | 4 +- 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 .cursor/skills/theme-authoring/SKILL.md diff --git a/.cursor/skills/theme-authoring/SKILL.md b/.cursor/skills/theme-authoring/SKILL.md new file mode 100644 index 00000000..58de7286 --- /dev/null +++ b/.cursor/skills/theme-authoring/SKILL.md @@ -0,0 +1,96 @@ +--- +name: theme-authoring +description: Creates BetterSEQTA+ custom themes as `.theme.json` files for import or publishing to the BetterSEQTA theme store. Use when the user asks to make a new theme, adjust theme colors/CSS, or prepare a theme JSON for upload. +--- + +# Theme authoring (BetterSEQTA+) + +## Goal +Produce a valid `*.theme.json` file that BetterSEQTA+ can install (via theme import or store download). + +## Output contract +Return: +- The final theme JSON (ready to save as `my-theme.theme.json`) +- A short list of the main palette values used (accent + background + text) + +## Theme JSON schema (practical) +Required keys: +- `id`: string (stable identifier; use kebab-case, e.g. `banana-theme`) +- `name`: string (display name) +- `description`: string +- `defaultColour`: string CSS color (e.g. `rgb(...)` or `#RRGGBB`) +- `CanChangeColour`: boolean +- `CustomCSS`: string (CSS applied as a ` +
    +

    Weighting Override

    +

    + Set the weighting for this assessment manually. + ${statusNote} +

    +
    + + ${autoWeight != null ? `${autoWeight}%` : "none"} +
    +
    + + +
    +
    + +
    + ${!assessmentID ? `

    Assessment not yet indexed — try refreshing.

    ` : ""} +
    + `; + + if (!assessmentID) return; + + const input = sheet.querySelector( + "#betterseqta-weight-override", + ) as HTMLInputElement; + const statusEl = sheet.querySelector( + ".betterseqta-save-status", + ) as HTMLElement; + + const save = () => { + const raw = input.value.trim(); + if (raw === "") { + const { [assessmentID]: _, ...rest } = api.storage.weightingOverrides; + api.storage.weightingOverrides = rest; + } else { + const val = parseFloat(raw); + if (isNaN(val) || val < 0) { + input.style.borderColor = "rgba(255,80,80,0.6)"; + statusEl.textContent = "Invalid. Must be 0 or greater"; + statusEl.style.color = "rgba(255,80,80,0.8)"; + return; + } + input.style.borderColor = "rgba(128,128,128,0.3)"; + api.storage.weightingOverrides = { + ...api.storage.weightingOverrides, + [assessmentID]: String(val), + }; + } + statusEl.textContent = "Saved"; + statusEl.style.color = ""; + setTimeout(() => (statusEl.textContent = ""), 2000); + document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged")); + }; + + input.addEventListener("blur", save); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + input.blur(); + save(); + } + }); + input.addEventListener("input", () => { + input.style.borderColor = "rgba(128,128,128,0.3)"; + if (statusEl.textContent === "Invalid. Must be 0 or greater.") + statusEl.textContent = ""; + }); +} + +export function injectWeightingsTab(api: any) { + const tabList = document.querySelector( + '[class*="TabSet__tabs___"]', + ) as HTMLElement; + const container = document.querySelector( + '[class*="TabSet__tabContainer___"]', + ) as HTMLElement; + if (!tabList || !container) return; + if (tabList.querySelector(".betterseqta-weightings-tab")) return; + + const cls = resolveTabSetClasses(); + + const prefix = (tabList.querySelector("li") as HTMLElement).id.replace( + /-tab-\d+$/, + "", + ); + const newIndex = tabList.querySelectorAll("li").length; + + const newTab = document.createElement("li"); + newTab.id = `${prefix}-tab-${newIndex}`; + newTab.className = ""; + newTab.setAttribute("aria-selected", "false"); + newTab.setAttribute("aria-controls", `${prefix}-tabsheet-${newIndex}`); + newTab.classList.add("betterseqta-weightings-tab"); + newTab.textContent = "Weightings"; + tabList.appendChild(newTab); + + const newSheet = document.createElement("div"); + newSheet.id = `${prefix}-tabsheet-${newIndex}`; + newSheet.setAttribute("aria-labelledby", `${prefix}-tab-${newIndex}`); + newSheet.className = [ + cls["TabSet__tabsheet___"], + cls["TabSet__hidden___"], + cls["TabSet__disappearToRight___"], + ].join(" "); + container.appendChild(newSheet); + + let populated = false; + newTab.addEventListener("click", () => { + if (!populated) { + buildWeightingsTabContent(api, newSheet); + populated = true; + } + }); + + const allTabs = Array.from(tabList.querySelectorAll("li")); + const allSheets = Array.from( + container.querySelectorAll('[class*="tabsheet"]'), + ); + + allTabs.forEach((tab, i) => { + tab.addEventListener("click", () => { + const currentIndex = allTabs.findIndex((t) => + t.className.includes("TabSet__selected___"), + ); + if (i === currentIndex) return; + const goingRight = i > currentIndex; + + allTabs.forEach((t) => { + t.className = ""; + t.setAttribute("aria-selected", "false"); + }); + + allSheets[currentIndex].className = [ + cls["TabSet__tabsheet___"], + cls["TabSet__hidden___"], + goingRight + ? cls["TabSet__disappearToLeft___"] + : cls["TabSet__disappearToRight___"], + ].join(" "); + + allSheets[i].className = [ + cls["TabSet__tabsheet___"], + cls["TabSet__selected___"], + goingRight + ? cls["TabSet__appearFromRight___"] + : cls["TabSet__appearFromLeft___"], + ].join(" "); + + tab.className = cls["TabSet__selected___"]; + tab.setAttribute("aria-selected", "true"); + }); + }); +} \ No newline at end of file From 260afac294b18a2b4886b540d93047e40cf5d533 Mon Sep 17 00:00:00 2001 From: Jaxon Lewis-Wilson Date: Mon, 4 May 2026 18:36:16 +0800 Subject: [PATCH 13/32] assessmentsAverage: Fix display of missing weighting, and minor change to override section. --- src/plugins/built-in/assessmentsAverage/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index c27a5ec4..9522014d 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -88,7 +88,7 @@ function createWeightLabel( if (!statsContainer) return; const displayText = - weighting && weighting !== "processing" + weighting && weighting !== "processing" && weighting !== "N/A" ? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%` : "N/A"; @@ -698,7 +698,7 @@ function buildWeightingsTabContent(api: any, sheet: HTMLElement) {

    Weighting Override

    - Set the weighting for this assessment manually. + Set the weighting for this assessment. ${statusNote}

    From 999f12e9585fec28b010da24308e859fb117f55d Mon Sep 17 00:00:00 2001 From: Jaxon Lewis-Wilson Date: Mon, 4 May 2026 22:39:53 +0800 Subject: [PATCH 14/32] assessmentsAverage: Add changes to changelog --- src/seqta/utils/Openers/OpenWhatsNewPopup.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts index 71c51032..371cc03b 100644 --- a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts +++ b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts @@ -34,6 +34,11 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) { const text = stringToHTML(/* html */ `
    +

    3.6.4 - Assessment weighting override & fixes

    +
  • Added the ability to override/add weightings to assessments (on assessment page).
  • +
  • Fixed the display of weightings that could not automatically be discovered.
  • +
  • Fixed the formatting of the weighting tag that was broken due to a SEQTA update.
  • +

    3.6.3 - Assessment overview fix

  • Fixed assessments overview failing to load.
  • From f35520029f09d3f8b24dc7493284ec2802117aba Mon Sep 17 00:00:00 2001 From: Jaxx7594 Date: Mon, 4 May 2026 22:53:05 +0800 Subject: [PATCH 15/32] assessmentAverage: Remove remnant comment --- src/plugins/built-in/assessmentsAverage/utils.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index 9522014d..ae6cbc19 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -598,7 +598,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { }; } -// Add this above injectWeightingsTab in utils.ts function resolveTabSetClasses(): Record { const patterns = [ "TabSet__tabsheet___", @@ -644,7 +643,7 @@ function resolveTabSetClasses(): Record { } } } catch { - // Cross-origin stylesheet — skip + // Cross-origin stylesheet } } } catch {} @@ -865,4 +864,4 @@ export function injectWeightingsTab(api: any) { tab.setAttribute("aria-selected", "true"); }); }); -} \ No newline at end of file +} From 2aecd63850cb68bd65b0af8c16ae0aa551f0ec02 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Tue, 5 May 2026 17:44:58 +0930 Subject: [PATCH 16/32] feat: dont inject weightings page in assements without results --- src/plugins/built-in/assessmentsAverage/utils.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index ae6cbc19..b282a1b1 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -791,6 +791,19 @@ export function injectWeightingsTab(api: any) { if (!tabList || !container) return; if (tabList.querySelector(".betterseqta-weightings-tab")) return; + const selectedTitle = document + .querySelector( + "[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']", + ) + ?.textContent?.trim(); + const selectedAssessmentID = selectedTitle + ? api.storage.assessments?.[selectedTitle] + : undefined; + + // Only inject for assessments that exist in the marks/task dataset. + // This avoids showing the tab on PENDING/UPCOMING "details-only" assessments. + if (!selectedAssessmentID) return; + const cls = resolveTabSetClasses(); const prefix = (tabList.querySelector("li") as HTMLElement).id.replace( From f721bf6609a9b9da5c367b22306bf20ed4ceaf80 Mon Sep 17 00:00:00 2001 From: Jaxon Lewis-Wilson Date: Tue, 5 May 2026 16:32:12 +0800 Subject: [PATCH 17/32] Revert "feat: dont inject weightings page in assements without results" This reverts commit 2aecd63850cb68bd65b0af8c16ae0aa551f0ec02. Reverting so that I can solve the indexing issue. Only marked assessments are getting indexed, which is incorrect behaviour that slipped testing when the plugin was first made. --- src/plugins/built-in/assessmentsAverage/utils.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index b282a1b1..ae6cbc19 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -791,19 +791,6 @@ export function injectWeightingsTab(api: any) { if (!tabList || !container) return; if (tabList.querySelector(".betterseqta-weightings-tab")) return; - const selectedTitle = document - .querySelector( - "[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']", - ) - ?.textContent?.trim(); - const selectedAssessmentID = selectedTitle - ? api.storage.assessments?.[selectedTitle] - : undefined; - - // Only inject for assessments that exist in the marks/task dataset. - // This avoids showing the tab on PENDING/UPCOMING "details-only" assessments. - if (!selectedAssessmentID) return; - const cls = resolveTabSetClasses(); const prefix = (tabList.querySelector("li") as HTMLElement).id.replace( From b0857054ebf35c26e4be01da512dfcb7c6088e68 Mon Sep 17 00:00:00 2001 From: Jaxon Lewis-Wilson Date: Tue, 5 May 2026 17:56:06 +0800 Subject: [PATCH 18/32] assessmentsAverage: Fix unmarked/upcoming assessment indexing and weight display --- .../built-in/assessmentsAverage/index.ts | 36 ++++---- .../built-in/assessmentsAverage/utils.ts | 83 ++++++++++++------- 2 files changed, 72 insertions(+), 47 deletions(-) diff --git a/src/plugins/built-in/assessmentsAverage/index.ts b/src/plugins/built-in/assessmentsAverage/index.ts index c99ffad1..b3438ea6 100644 --- a/src/plugins/built-in/assessmentsAverage/index.ts +++ b/src/plugins/built-in/assessmentsAverage/index.ts @@ -7,7 +7,6 @@ import { import { type Plugin } from "@/plugins/core/types"; import stringToHTML from "@/seqta/utils/stringToHTML"; import { waitForElm } from "@/seqta/utils/waitForElm"; -import ReactFiber from "@/seqta/utils/ReactFiber.ts"; import { clearStuck, getClassByPattern, @@ -128,6 +127,22 @@ async function renderSubjectAverage(api: any) { sampleAssessmentItem, "AssessmentItem__title___", ); + + const assessmentItems = Array.from( + assessmentsList.querySelectorAll( + `[class*='AssessmentItem__AssessmentItem___']`, + ), + ).filter( + (item) => + !item + .querySelector(`[class*='AssessmentItem__title___']`) + ?.textContent?.includes("Subject Average"), + ); + + const { weightedTotal, totalWeight, hasInaccurateWeighting, count } = + await processAssessments(api, assessmentItems); + if (!count || totalWeight === 0) return; + const thermoscoreElement = document.querySelector( "[class*='Thermoscore__Thermoscore___']", ); @@ -144,24 +159,7 @@ async function renderSubjectAverage(api: any) { thermoscoreElement, "Thermoscore__text___", ); - const state = await ReactFiber.find( - "[class*='AssessmentList__items___']", - ).getState(); - const marks = state["marks"]; - if (!marks || !marks.length) return; - const assessmentItems = Array.from( - assessmentsList.querySelectorAll( - `[class*='AssessmentItem__AssessmentItem___']`, - ), - ).filter( - (item) => - !item - .querySelector(`[class*='AssessmentItem__title___']`) - ?.textContent?.includes("Subject Average"), - ); - const { weightedTotal, totalWeight, hasInaccurateWeighting, count } = - await processAssessments(api, assessmentItems); - if (!count || totalWeight === 0) return; + const avg = weightedTotal / totalWeight; const rounded = Math.ceil(avg / 5) * 5; const numberToLetter = Object.entries(letterToNumber).reduce( diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index ae6cbc19..e841e8a9 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -82,10 +82,24 @@ function createWeightLabel( assessmentItem: Element, weighting: string | undefined, ) { - const statsContainer = assessmentItem.querySelector( + let statsContainer = assessmentItem.querySelector( `[class*='AssessmentItem__stats___']`, - ) as HTMLElement; - if (!statsContainer) return; + ) as HTMLElement | null; + + if (!statsContainer) { + const statsClass = getClassByPattern(document, "AssessmentItem__stats___"); + statsContainer = document.createElement("div"); + statsContainer.className = statsClass; + statsContainer.style.justifyContent = "flex-end"; + const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`); + if (thermoscore) { + thermoscore.insertAdjacentElement("afterend", statsContainer); + } else { + assessmentItem.appendChild(statsContainer); + } + } else { + statsContainer.style.justifyContent = "space-between"; + } const displayText = weighting && weighting !== "processing" && weighting !== "N/A" @@ -104,31 +118,42 @@ function createWeightLabel( return; } - const label = statsContainer.querySelector( + statsContainer.style.display = "flex"; + statsContainer.style.alignItems = "center"; + statsContainer.style.width = "100%"; + + // Try to clone an existing label from the stats container first, + // fall back to building from scratch if none exists + const existingNativeLabel = statsContainer.querySelector( `[class*='Label__Label___']`, - ) as HTMLElement; + ) as HTMLElement | null; - if (!label) return; + const weightLabel = existingNativeLabel + ? (existingNativeLabel.cloneNode(true) as HTMLElement) + : (() => { + const labelClass = getClassByPattern(document, "Label__Label___"); + const innerTextClass = getClassByPattern(document, "Label__innerText___"); + const el = document.createElement("label"); + el.className = labelClass; + el.innerHTML = `
    Weight
    `; + return el; + })(); - const weightLabel = label.cloneNode(true) as HTMLElement; weightLabel.classList.add("betterseqta-weight-label"); + weightLabel.style.flex = "none"; + weightLabel.style.width = "fit-content"; - const innerTextDiv = weightLabel.querySelector( - `[class*='Label__innerText___']`, - ); + const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`); if (innerTextDiv) innerTextDiv.textContent = "Weight"; const textNodes = Array.from(weightLabel.childNodes).filter( (node) => node.nodeType === Node.TEXT_NODE, ); - if (textNodes.length) textNodes[0].textContent = displayText; - statsContainer.style.display = "flex"; - statsContainer.style.alignItems = "center"; - statsContainer.style.justifyContent = "space-between"; - statsContainer.style.width = "100%"; - - weightLabel.style.flex = "none"; - weightLabel.style.width = "fit-content"; + if (textNodes.length) { + textNodes[0].textContent = displayText; + } else { + weightLabel.appendChild(document.createTextNode(displayText)); + } statsContainer.appendChild(weightLabel); } @@ -525,7 +550,11 @@ export async function parseAssessments(api: any) { "[class*='AssessmentList__items___']", ).getState(); - const marks = state["marks"]; + const marks = [ + ...(state["marks"] ?? []), + ...(state["upcoming"] ?? []), + ...(state["pending"] ?? []), + ]; if (!marks) return; await Promise.all(marks.map((mark: any) => handleWeightings(mark, api))); @@ -538,15 +567,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { let count = 0; for (const assessmentItem of assessmentItems) { - const gradeElement = assessmentItem.querySelector( - `[class*='Thermoscore__text___']`, - ); - - if (!gradeElement) continue; - - const grade = parseGrade(gradeElement.textContent || ""); - if (grade <= 0) continue; - const titleEl = assessmentItem.querySelector( `[class*='AssessmentItem__title___']`, ); @@ -566,6 +586,13 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { createWeightLabel(assessmentItem, weighting); + const gradeElement = assessmentItem.querySelector( + `[class*='Thermoscore__text___']`, + ); + if (!gradeElement) continue; + const grade = parseGrade(gradeElement.textContent || ""); + if (grade <= 0) continue; + if ( weighting === null || weighting === undefined || From da5bc7ab112ece82c1455623b01658c841805694 Mon Sep 17 00:00:00 2001 From: Jaxon Lewis-Wilson Date: Tue, 5 May 2026 18:10:13 +0800 Subject: [PATCH 19/32] assessmentsAverage: Fix weight display upon setting override --- src/plugins/built-in/assessmentsAverage/utils.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index e841e8a9..3478d7ba 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -83,24 +83,29 @@ function createWeightLabel( weighting: string | undefined, ) { let statsContainer = assessmentItem.querySelector( - `[class*='AssessmentItem__stats___']`, + `[class*='AssessmentItem__stats___'], .betterseqta-stats-container`, ) as HTMLElement | null; if (!statsContainer) { const statsClass = getClassByPattern(document, "AssessmentItem__stats___"); statsContainer = document.createElement("div"); statsContainer.className = statsClass; - statsContainer.style.justifyContent = "flex-end"; + statsContainer.classList.add("betterseqta-stats-container"); const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`); if (thermoscore) { thermoscore.insertAdjacentElement("afterend", statsContainer); } else { assessmentItem.appendChild(statsContainer); } - } else { - statsContainer.style.justifyContent = "space-between"; } + const hasNativeLabel = !!statsContainer.querySelector( + `[class*='Label__Label___']:not(.betterseqta-weight-label)`, + ); + statsContainer.style.justifyContent = hasNativeLabel + ? "space-between" + : "flex-end"; + const displayText = weighting && weighting !== "processing" && weighting !== "N/A" ? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%` From aa5d193e5519820ca46df661be57f96f4b905979 Mon Sep 17 00:00:00 2001 From: Jaxon Lewis-Wilson Date: Tue, 5 May 2026 18:13:27 +0800 Subject: [PATCH 20/32] assessmentsAverage: Fix inaccurate weight when a weight == N/A N/A weights were automatically set to a weight of 1 for some reason. I removed it from the calculations completely with this commit. --- src/plugins/built-in/assessmentsAverage/utils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index 3478d7ba..bf7514a6 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -605,8 +605,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { weighting === "processing" ) { hasInaccurateWeighting = true; - weightedTotal += grade; - totalWeight += 1; + continue } else { const weight = parseFloat(weighting); From 01e679eab6880580165dfce0260e5a7eaf572efe Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Wed, 6 May 2026 17:31:41 +0930 Subject: [PATCH 21/32] Revert "fix: add some better detection logic for assements widget #429" This reverts commit 01cd5d14283175304b396b53e225ad9d8d4703d8. --- src/plugins/built-in/assessmentsOverview/api.ts | 12 ++---------- src/seqta/utils/Loaders/LoadHomePage.ts | 11 +---------- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/src/plugins/built-in/assessmentsOverview/api.ts b/src/plugins/built-in/assessmentsOverview/api.ts index 3b1d32fd..38df693c 100644 --- a/src/plugins/built-in/assessmentsOverview/api.ts +++ b/src/plugins/built-in/assessmentsOverview/api.ts @@ -28,17 +28,9 @@ async function fetchJSON(url: string, body: any) { async function loadSubjects() { const res = await fetchJSON("/seqta/student/load/subjects?", {}); - const activeGroup = res.payload.find((s: any) => s.active === 1); - const activeYear = activeGroup?.year; - const allSubjects = res.payload - .filter((s: any) => s.year === activeYear) + return res.payload + .filter((s: any) => s.active === 1) .flatMap((s: any) => s.subjects); - const seen = new Set(); - return allSubjects.filter((s: Subject) => { - if (seen.has(s.code)) return false; - seen.add(s.code); - return true; - }); } async function loadPrefs(student: number) { diff --git a/src/seqta/utils/Loaders/LoadHomePage.ts b/src/seqta/utils/Loaders/LoadHomePage.ts index e590e097..dcbd553d 100644 --- a/src/seqta/utils/Loaders/LoadHomePage.ts +++ b/src/seqta/utils/Loaders/LoadHomePage.ts @@ -113,16 +113,7 @@ export async function loadHomePage() { callHomeTimetable(TodayFormatted, true); const activeClass = classes.find((c: any) => c.hasOwnProperty("active")); - const activeYear = activeClass?.year; - const allSubjectsInYear = classes - .filter((c: any) => c.year === activeYear) - .flatMap((c: any) => c.subjects || []); - const seen = new Set(); - const activeSubjects = allSubjectsInYear.filter((s: any) => { - if (seen.has(s.code)) return false; - seen.add(s.code); - return true; - }); + const activeSubjects = activeClass?.subjects || []; const activeSubjectCodes = activeSubjects.map((s: any) => s.code); const currentAssessments = assessments .filter((a: any) => activeSubjectCodes.includes(a.code)) From b4598668d43f46289230c9201bb97be9c1253c9a Mon Sep 17 00:00:00 2001 From: Aden Lindsay Date: Wed, 13 May 2026 13:30:27 +0930 Subject: [PATCH 22/32] feat: re enable message folders with improvments --- src/plugins/built-in/messageFolders/index.ts | 482 +++++++++++++----- .../built-in/messageFolders/styles.css | 258 +++++++++- src/plugins/index.ts | 4 +- 3 files changed, 599 insertions(+), 145 deletions(-) diff --git a/src/plugins/built-in/messageFolders/index.ts b/src/plugins/built-in/messageFolders/index.ts index e8170e86..a3e9161e 100644 --- a/src/plugins/built-in/messageFolders/index.ts +++ b/src/plugins/built-in/messageFolders/index.ts @@ -22,6 +22,7 @@ interface Folder { id: string; name: string; color: string; + emoji: string; } interface MessageFoldersStorage { @@ -34,12 +35,33 @@ const FOLDER_COLORS = [ "#8b5cf6", "#ec4899", "#14b8a6", "#f97316", ]; +const FOLDER_HEROICONS = [ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, +]; + const FOLDER_ICON_SVG = ``; const PLUS_SVG = ``; const CHECK_SVG_WHITE = ``; const CLOSE_SVG = ``; const EDIT_SVG = ``; const TRASH_SVG = ``; +const CHEVRON_SVG = ``; +const DRAG_SVG = ``; function generateId(): string { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); @@ -49,7 +71,7 @@ const messageFoldersPlugin: Plugin void) | null = null; + let foldedSection: HTMLElement | null = null; const unregisters: Array<{ unregister: () => void }> = []; - // ── Storage accessors ── - const getFolders = (): Folder[] => api.storage.folders ?? []; const getAssignments = (): Record => api.storage.messageAssignments ?? {}; @@ -94,6 +115,18 @@ const messageFoldersPlugin: Plugin { + const assignments = getAssignments(); + if (!assignments[folderId]) assignments[folderId] = []; + const idx = assignments[folderId].indexOf(messageId); + if (add && idx < 0) { + assignments[folderId].push(messageId); + } else if (!add && idx >= 0) { + assignments[folderId].splice(idx, 1); + } + saveAssignments(assignments); + }; + const toggleMessageInFolder = (messageId: string, folderId: string) => { const assignments = getAssignments(); if (!assignments[folderId]) assignments[folderId] = []; @@ -129,16 +162,28 @@ const messageFoldersPlugin: Plugin { + const selectedMsg = document.querySelector("[class*='MessageList__selected___']"); + return selectedMsg?.getAttribute("data-message") ?? null; + }; - const showConfirmModal = ( - title: string, - message: string, - onConfirm: () => void, - ) => { + const getMessageIdFromEvent = (target: HTMLElement): string | null => { + const li = target.closest("li[data-message]"); + return li?.getAttribute("data-message") ?? null; + }; + + const getAllVisibleMessageIds = (): string[] => { + const ids: string[] = []; + document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => { + const id = li.getAttribute("data-message"); + if (id) ids.push(id); + }); + return ids; + }; + + const showConfirmModal = (title: string, message: string, onConfirm: () => void) => { const overlay = document.createElement("div"); overlay.className = "bsplus-modal-overlay"; - const modal = document.createElement("div"); modal.className = "bsplus-modal"; modal.innerHTML = ` @@ -150,16 +195,13 @@ const messageFoldersPlugin: Plugin `; overlay.appendChild(modal); - const remove = () => { overlay.remove(); document.removeEventListener("keydown", onKey); }; - const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") remove(); }; - overlay.addEventListener("click", (e) => { if (e.target === overlay) remove(); }); @@ -168,36 +210,42 @@ const messageFoldersPlugin: Plugin { const sidebar = document.querySelector("[class*='Viewer__sidebar___']"); if (!sidebar) return; - const ol = sidebar.querySelector("ol"); if (!ol) return; - let section = ol.querySelector(".bsplus-folders-section"); + let section = ol.querySelector(".bsplus-folders-section") as HTMLElement; if (!section) { section = document.createElement("div"); section.className = "bsplus-folders-section"; ol.appendChild(section); } + foldedSection = section; const folders = getFolders(); - const existingInput = section.querySelector(".bsplus-folder-input"); - const existingColors = section.querySelector(".bsplus-folder-colors"); - section.innerHTML = ""; - // Header const header = document.createElement("div"); header.className = "bsplus-folders-header"; + header.dataset.folded = "false"; + + const collapseBtn = document.createElement("button"); + collapseBtn.className = "bsplus-folders-collapse"; + collapseBtn.innerHTML = CHEVRON_SVG; + collapseBtn.title = "Collapse"; + collapseBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const isFolded = collapseBtn.classList.toggle("bsplus-folded"); + section.classList.toggle("bsplus-section-folded", isFolded); + collapseBtn.title = isFolded ? "Expand" : "Collapse"; + }); + header.appendChild(collapseBtn); const label = document.createElement("span"); label.textContent = "Folders"; @@ -214,9 +262,8 @@ const messageFoldersPlugin: Plugin All Messages @@ -226,20 +273,34 @@ const messageFoldersPlugin: Plugin { + applyFolderFilter(); + applyBadges(); + }, 100); }); section.appendChild(allItem); - // Folder items for (const folder of folders) { const item = document.createElement("div"); item.className = `bsplus-folder-item${activeFolderId === folder.id ? " bsplus-folder-active" : ""}`; item.dataset.folderId = folder.id; + item.draggable = true; + + const dragHandle = document.createElement("div"); + dragHandle.className = "bsplus-folder-drag"; + dragHandle.innerHTML = DRAG_SVG; + item.appendChild(dragHandle); const dot = document.createElement("div"); dot.className = "bsplus-folder-dot"; dot.style.background = folder.color; item.appendChild(dot); + const iconSpan = document.createElement("span"); + iconSpan.className = "bsplus-folder-icon"; + iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0]; + item.appendChild(iconSpan); + const name = document.createElement("span"); name.className = "bsplus-folder-name"; name.textContent = folder.name; @@ -264,21 +325,17 @@ const messageFoldersPlugin: Plugin { e.stopPropagation(); - showConfirmModal( - "Delete folder", - `Remove "${folder.name}"? Messages won't be deleted.`, - () => { - const folders = getFolders().filter((f) => f.id !== folder.id); - saveFolders(folders); - const assignments = getAssignments(); - delete assignments[folder.id]; - saveAssignments(assignments); - if (activeFolderId === folder.id) activeFolderId = null; - applyFolderFilter(); - applyBadges(); - renderSidebarFolders(); - }, - ); + showConfirmModal("Delete folder", `Remove "${folder.name}"? Messages won't be deleted.`, () => { + const folders = getFolders().filter((f) => f.id !== folder.id); + saveFolders(folders); + const assignments = getAssignments(); + delete assignments[folder.id]; + saveAssignments(assignments); + if (activeFolderId === folder.id) activeFolderId = null; + applyFolderFilter(); + applyBadges(); + renderSidebarFolders(); + }); }); actions.appendChild(deleteBtn); @@ -295,15 +352,89 @@ const messageFoldersPlugin: Plugin { + applyFolderFilter(); + applyBadges(); + }, 100); + }); + + item.addEventListener("dragstart", (e) => { + e.dataTransfer?.setData("text/plain", `reorder:${folder.id}`); + item.classList.add("bsplus-dragging"); + }); + item.addEventListener("dragend", () => { + item.classList.remove("bsplus-dragging"); + document.querySelectorAll(".bsplus-folder-item").forEach((el) => el.classList.remove("bsplus-drag-over")); + }); + item.addEventListener("dragover", (e) => { + e.preventDefault(); + const data = e.dataTransfer?.getData("text/plain") || ""; + if (data.startsWith("reorder:") && !data.includes(folder.id)) { + item.classList.add("bsplus-drag-over"); + } + }); + item.addEventListener("dragleave", () => { + item.classList.remove("bsplus-drag-over"); + }); + item.addEventListener("drop", (e) => { + e.preventDefault(); + item.classList.remove("bsplus-drag-over"); + const data = e.dataTransfer?.getData("text/plain") || ""; + if (data.startsWith("reorder:")) { + const draggedId = data.replace("reorder:", ""); + const folders = getFolders(); + const draggedIdx = folders.findIndex((f) => f.id === draggedId); + const targetIdx = folders.findIndex((f) => f.id === folder.id); + if (draggedIdx >= 0 && targetIdx >= 0 && draggedIdx !== targetIdx) { + const [removed] = folders.splice(draggedIdx, 1); + folders.splice(targetIdx, 0, removed); + saveFolders(folders); + renderSidebarFolders(); + } + } }); section.appendChild(item); } - // Restore input if it was open - if (existingInput || existingColors) { - // Don't restore – let user re-trigger - } + section.addEventListener("dragover", (e) => { + e.preventDefault(); + }); + section.addEventListener("drop", (e) => { + e.preventDefault(); + const data = e.dataTransfer?.getData("text/plain") || ""; + if (data.startsWith("msg:")) { + const messageId = data.replace("msg:", ""); + const folderId = (e.target as HTMLElement).closest("[data-folder-id]")?.getAttribute("data-folder-id"); + if (messageId && folderId) { + assignMessageToFolder(messageId, folderId, true); + applyBadges(); + applyFolderFilter(); + renderSidebarFolders(); + } + } + }); + + attachDragListeners(); + }; + + const attachDragListeners = () => { + document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => { + if (li.getAttribute("data-bsplus-drag") === "true") return; + li.setAttribute("data-bsplus-drag", "true"); + li.draggable = true; + li.addEventListener("dragstart", (e) => { + const id = li.getAttribute("data-message"); + if (id) { + e.dataTransfer?.setData("text/plain", `msg:${id}`); + li.classList.add("bsplus-msg-dragging"); + } + }); + li.addEventListener("dragend", () => { + li.classList.remove("bsplus-msg-dragging"); + document.querySelectorAll(".bsplus-folder-item").forEach((el) => el.classList.remove("bsplus-drag-over")); + }); + }); }; const showNewFolderInput = (container: Element, editFolder?: Folder) => { @@ -312,16 +443,34 @@ const messageFoldersPlugin: Plugin { + e.stopPropagation(); + const picker = container.querySelector(".bsplus-icon-picker") as HTMLElement | null; + if (picker) { + picker.remove(); + return; + } + showIconPicker(container, (iconSvg) => { + selectedIcon = iconSvg; + iconBtn.innerHTML = iconSvg; + }); + }); + const confirmBtn = document.createElement("button"); confirmBtn.className = "bsplus-folder-input-confirm"; confirmBtn.innerHTML = CHECK_SVG_WHITE; @@ -330,11 +479,11 @@ const messageFoldersPlugin: Plugin { const name = input.value.trim(); if (!name) return; - if (editFolder) { const folders = getFolders().map((f) => - f.id === editFolder.id ? { ...f, name, color: selectedColor } : f, + f.id === editFolder.id ? { ...f, name, color: selectedColor, emoji: selectedIcon } : f, ); saveFolders(folders); } else { - const folder: Folder = { id: generateId(), name, color: selectedColor }; + const folder: Folder = { id: generateId(), name, color: selectedColor, emoji: selectedIcon }; saveFolders([...getFolders(), folder]); } applyBadges(); @@ -386,23 +534,38 @@ const messageFoldersPlugin: Plugin input.focus()); }; + const showIconPicker = (container: Element, onSelect: (iconSvg: string) => void) => { + const existing = container.querySelector(".bsplus-icon-picker"); + if (existing) existing.remove(); + + const picker = document.createElement("div"); + picker.className = "bsplus-icon-picker"; + for (const icon of FOLDER_HEROICONS) { + const btn = document.createElement("button"); + btn.className = "bsplus-icon-opt"; + btn.innerHTML = icon; + btn.addEventListener("click", (e) => { + e.stopPropagation(); + onSelect(icon); + picker.remove(); + }); + picker.appendChild(btn); + } + container.appendChild(picker); + }; + const showEditFolderInput = (container: Element, folder: Folder) => { showNewFolderInput(container, folder); }; - // ── Intercept native sidebar clicks to clear folder filter ── - const attachNativeSidebarListeners = () => { const sidebar = document.querySelector("[class*='Viewer__sidebar___']"); if (!sidebar) return; - const ol = sidebar.querySelector("ol"); if (!ol) return; - ol.addEventListener("click", (e) => { const target = e.target as HTMLElement; if (target.closest(".bsplus-folders-section")) return; - const li = target.closest("li"); if (li && ol.contains(li)) { if (activeFolderId !== null) { @@ -415,47 +578,22 @@ const messageFoldersPlugin: Plugin { - if (actionsBar.querySelector(".bsplus-folder-btn")) return; - - const wrapper = document.createElement("div"); - wrapper.className = "bsplus-folder-btn"; - wrapper.style.position = "relative"; - wrapper.style.display = "inline-block"; - - const btn = document.createElement("button"); - const btnClasses = actionsBar.querySelector("button")?.className ?? ""; - btn.className = btnClasses; - btn.title = "Add to folder"; - btn.innerHTML = FOLDER_ICON_SVG; - - btn.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - closeDropdown(); - - const selectedMsg = document.querySelector("[class*='MessageList__selected___']"); - const messageId = selectedMsg?.getAttribute("data-message"); - if (!messageId) return; - - showFolderDropdown(wrapper, messageId); - }); - - wrapper.appendChild(btn); - - const moreMenu = actionsBar.querySelector("[class*='MenuButton__Menu___']"); - if (moreMenu) { - actionsBar.insertBefore(wrapper, moreMenu); - } else { - actionsBar.appendChild(wrapper); + const closeDropdown = () => { + if (openDropdown) { + openDropdown.remove(); + openDropdown = null; + } + if (dropdownCloseHandler) { + document.removeEventListener("click", dropdownCloseHandler, true); + dropdownCloseHandler = null; } }; const showFolderDropdown = (anchor: HTMLElement, messageId: string) => { + closeDropdown(); const dropdown = document.createElement("div"); dropdown.className = "bsplus-folder-dropdown"; + dropdown.dataset.msgId = messageId; const folders = getFolders(); const currentFolderIds = getMessageFolderIds(messageId); @@ -470,6 +608,7 @@ const messageFoldersPlugin: Plugin { e.stopPropagation(); + e.preventDefault(); toggleMessageInFolder(messageId, folder.id); - const nowChecked = getMessageFolderIds(messageId).includes(folder.id); item.classList.toggle("bsplus-checked", nowChecked); check.style.borderColor = nowChecked ? folder.color : ""; check.style.background = nowChecked ? folder.color : ""; - applyBadges(); applyFolderFilter(); renderSidebarFolders(); @@ -519,22 +662,105 @@ const messageFoldersPlugin: Plugin { - if (openDropdown) { - openDropdown.remove(); - openDropdown = null; - } - if (dropdownCloseHandler) { - document.removeEventListener("click", dropdownCloseHandler, true); - dropdownCloseHandler = null; + const injectFolderButton = (actionsBar: Element) => { + if (actionsBar.querySelector(".bsplus-folder-btn")) return; + const wrapper = document.createElement("div"); + wrapper.className = "bsplus-folder-btn"; + wrapper.style.position = "relative"; + wrapper.style.display = "inline-block"; + const btn = document.createElement("button"); + const btnClasses = actionsBar.querySelector("button")?.className ?? ""; + btn.className = btnClasses; + btn.title = "Add to folder"; + btn.innerHTML = FOLDER_ICON_SVG; + btn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const selectedMsg = document.querySelector("[class*='MessageList__selected___']"); + const messageId = selectedMsg?.getAttribute("data-message"); + if (!messageId) return; + showFolderDropdown(wrapper, messageId); + }); + wrapper.appendChild(btn); + const moreMenu = actionsBar.querySelector("[class*='MenuButton__Menu___']"); + if (moreMenu) { + actionsBar.insertBefore(wrapper, moreMenu); + } else { + actionsBar.appendChild(wrapper); } }; - // ── Message badges ── + const showContextMenu = (e: MouseEvent, messageId: string) => { + e.preventDefault(); + e.stopPropagation(); + closeDropdown(); + const existing = document.querySelector(".bsplus-context-menu"); + if (existing) existing.remove(); + + const menu = document.createElement("div"); + menu.className = "bsplus-context-menu"; + menu.style.left = `${e.clientX}px`; + menu.style.top = `${e.clientY}px`; + + const title = document.createElement("div"); + title.className = "bsplus-context-title"; + title.textContent = "Add to folder"; + menu.appendChild(title); + + const folders = getFolders(); + const currentFolderIds = getMessageFolderIds(messageId); + + if (folders.length === 0) { + const empty = document.createElement("div"); + empty.className = "bsplus-context-empty"; + empty.textContent = "No folders"; + menu.appendChild(empty); + } else { + for (const folder of folders) { + const isChecked = currentFolderIds.includes(folder.id); + const item = document.createElement("button"); + item.className = `bsplus-context-item${isChecked ? " bsplus-context-checked" : ""}`; + const dot = document.createElement("div"); + dot.className = "bsplus-folder-dot"; + dot.style.background = folder.color; + const iconSpan = document.createElement("span"); + iconSpan.className = "bsplus-folder-icon"; + iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0]; + const name = document.createElement("span"); + name.textContent = folder.name; + item.appendChild(dot); + item.appendChild(iconSpan); + item.appendChild(name); + if (isChecked) { + const check = document.createElement("span"); + check.className = "bsplus-context-checkmark"; + check.textContent = "\u2713"; + item.appendChild(check); + } + item.addEventListener("click", (e) => { + e.stopPropagation(); + toggleMessageInFolder(messageId, folder.id); + applyBadges(); + applyFolderFilter(); + renderSidebarFolders(); + menu.remove(); + }); + menu.appendChild(item); + } + } + + document.body.appendChild(menu); + const closeMenu = (ev: MouseEvent) => { + if (!menu.contains(ev.target as Node)) { + menu.remove(); + document.removeEventListener("click", closeMenu); + } + }; + setTimeout(() => document.addEventListener("click", closeMenu), 0); + }; const applyBadges = () => { const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]"); - if (!shouldShowBadgesInList()) { for (const li of messageItems) { const subject = li.querySelector("[class*='MessageList__subject___']"); @@ -546,26 +772,20 @@ const messageFoldersPlugin: Plugin f.id === fId); @@ -591,7 +810,7 @@ const messageFoldersPlugin: Plugin${folder.emoji}` : ""}${folder.name}`; badge.title = `Filter by "${folder.name}"`; badge.addEventListener("click", (e) => { e.stopPropagation(); @@ -605,12 +824,9 @@ const messageFoldersPlugin: Plugin { const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]"); const moreBtn = document.querySelector("[class*='MessageList__MessageList___'] ol > button"); - if (activeFolderId === null) { if (api.settings.hideFolderedMessagesInAll) { for (const li of messageItems) { @@ -629,9 +845,7 @@ const messageFoldersPlugin: Plugin { const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol"); if (!messageList || messageListObserver) return; - messageListObserver = new MutationObserver(() => { applyBadges(); applyFolderFilter(); + attachDragListeners(); + attachContextMenuListeners(); }); messageListObserver.observe(messageList, { childList: true, subtree: false }); }; + const attachContextMenuListeners = () => { + document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => { + if (li.getAttribute("data-bsplus-ctx") === "true") return; + li.setAttribute("data-bsplus-ctx", "true"); + li.addEventListener("contextmenu", (e) => { + const msgId = li.getAttribute("data-message"); + if (msgId) { + showContextMenu(e, msgId); + } + }); + }); + }; + const setupActionsObserver = () => { if (actionsObserver) return; - const target = document.querySelector("[class*='Viewer__Viewer___']") ?? document.querySelector("div.messages"); if (!target) return; - actionsObserver = new MutationObserver(() => { const actionsBar = document.querySelector("[class*='Message__actions___']"); if (actionsBar && !actionsBar.querySelector(".bsplus-folder-btn")) { @@ -671,28 +895,19 @@ const messageFoldersPlugin: Plugin { await waitForElm("[class*='Viewer__sidebar___'] ol", true, 50, 100); - renderSidebarFolders(); attachNativeSidebarListeners(); - await waitForElm("[class*='MessageList__MessageList___'] ol", true, 50, 100); applyBadges(); applyFolderFilter(); setupMessageListObserver(); - - // The actions bar only exists when a message is selected/open, - // so we observe the whole viewer for it to appear dynamically setupActionsObserver(); - - // If a message is already selected, inject immediately + attachDragListeners(); + attachContextMenuListeners(); const actionsBar = document.querySelector("[class*='Message__actions___']"); if (actionsBar) injectFolderButton(actionsBar); - - // Re-observe the sidebar for SEQTA re-renders const sidebar = document.querySelector("[class*='Viewer__sidebar___']"); if (sidebar && !sidebarObserver) { sidebarObserver = new MutationObserver(() => { @@ -706,11 +921,8 @@ const messageFoldersPlugin: Plugin { applyBadges(); @@ -732,6 +944,7 @@ const messageFoldersPlugin: Plugin el.remove()); document.querySelectorAll(".bsplus-folder-btn").forEach((el) => el.remove()); document.querySelectorAll(".bsplus-msg-badges").forEach((el) => el.remove()); + document.querySelectorAll(".bsplus-context-menu").forEach((el) => el.remove()); document.querySelectorAll("[class*='MessageList__subject___']").forEach((subject) => { if (subject.querySelector(".bsplus-subject-text")) { restoreSubjectPlain(subject); @@ -741,6 +954,7 @@ const messageFoldersPlugin: Plugin el.remove()); + }; }, }; diff --git a/src/plugins/built-in/messageFolders/styles.css b/src/plugins/built-in/messageFolders/styles.css index e239a883..6b99b126 100644 --- a/src/plugins/built-in/messageFolders/styles.css +++ b/src/plugins/built-in/messageFolders/styles.css @@ -3,12 +3,21 @@ border-top: 1px solid var(--background-secondary, rgba(128, 128, 128, 0.2)); margin-top: 4px; padding-top: 4px; + transition: opacity .2s; +} + +.bsplus-folders-section.bsplus-section-folded .bsplus-folder-item, +.bsplus-folders-section.bsplus-section-folded .bsplus-folder-input, +.bsplus-folders-section.bsplus-section-folded .bsplus-folder-colors, +.bsplus-folders-section.bsplus-section-folded .bsplus-emoji-picker, +.bsplus-folders-section.bsplus-section-folded .bsplus-all-msgs { + display: none !important; } .bsplus-folders-header { display: flex; align-items: center; - justify-content: space-between; + gap: 4px; padding: 6px 12px 2px; user-select: none; } @@ -20,6 +29,33 @@ letter-spacing: 0.5px; color: var(--text-primary, #666); opacity: 0.5; + flex: 1; +} + +.bsplus-folders-collapse { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 18px !important; + height: 18px !important; + min-width: 0 !important; + border: none !important; + background: transparent !important; + opacity: 0.4; + cursor: pointer; + border-radius: 4px !important; + padding: 0 !important; + margin: 0 !important; + transition: all .2s; +} + +.bsplus-folders-collapse:hover { + opacity: 0.8; + background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important; +} + +.bsplus-folders-collapse.bsplus-folded svg { + transform: rotate(-90deg); } .bsplus-folders-add-btn { @@ -51,12 +87,21 @@ align-items: center; padding: 6px 12px; cursor: pointer; - transition: background 0.15s ease; + transition: background 0.15s ease, opacity 0.2s; position: relative; - gap: 8px; + gap: 6px; user-select: none; } +.bsplus-folder-item.bsplus-dragging { + opacity: 0.4; +} + +.bsplus-folder-item.bsplus-drag-over { + background: var(--better-main, #007bff22) !important; + border-radius: 4px; +} + .bsplus-folder-item:hover { background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08)); } @@ -76,6 +121,18 @@ border-radius: 0 2px 2px 0; } +.bsplus-folder-drag { + display: flex; + align-items: center; + opacity: 0; + transition: opacity .15s; + margin-right: -4px; +} + +.bsplus-folder-item:hover .bsplus-folder-drag { + opacity: 0.5; +} + .bsplus-folder-dot { width: 8px; height: 8px; @@ -83,6 +140,23 @@ flex-shrink: 0; } +.bsplus-folder-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; + color: var(--text-primary, #333); +} + +.bsplus-folder-icon svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; +} + .bsplus-folder-name { font-size: 13px; color: var(--text-primary, #333); @@ -97,6 +171,8 @@ color: var(--text-primary, #999); opacity: 0.5; flex-shrink: 0; + min-width: 16px; + text-align: right; } .bsplus-folder-actions { @@ -158,6 +234,35 @@ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2); } +.bsplus-folder-icon-btn { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 28px !important; + height: 28px !important; + min-width: 0 !important; + border: 1px solid var(--background-secondary, #ccc) !important; + border-radius: 6px !important; + background: var(--background-secondary, #f5f5f5) !important; + cursor: pointer; + padding: 0 !important; + margin: 0 !important; + transition: all .15s; + color: var(--text-primary, #333); +} + +.bsplus-folder-icon-btn:hover { + transform: scale(1.1); + background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.1)) !important; +} + +.bsplus-folder-icon-btn svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; +} + .bsplus-folder-input-confirm, .bsplus-folder-input-cancel { display: flex !important; @@ -192,6 +297,43 @@ background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important; } +/* ── Icon picker ── */ +.bsplus-icon-picker { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px; + padding: 4px 12px 6px; + max-width: 140px; +} + +.bsplus-icon-opt { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 28px !important; + height: 28px !important; + min-width: 0 !important; + border: none !important; + border-radius: 6px !important; + background: transparent !important; + cursor: pointer; + padding: 0 !important; + transition: all .15s; + color: var(--text-primary, #333); +} + +.bsplus-icon-opt svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; +} + +.bsplus-icon-opt:hover { + transform: scale(1.3); + background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.1)) !important; +} + /* ── Color picker row ── */ .bsplus-folder-colors { display: grid; @@ -322,14 +464,113 @@ opacity: 0.5; } -/* ── Let primary column use available space instead of being clipped ── */ +/* ── Context menu ── */ +.bsplus-context-menu { + position: fixed; + min-width: 160px; + background: var(--background-primary, #fff) !important; + border: 1px solid var(--background-secondary, #e0e0e0) !important; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + z-index: 2147483646; + overflow: hidden; + animation: bsplus-dropdown-in 0.12s ease-out; + padding: 4px 0; +} + +.bsplus-context-title { + padding: 6px 12px 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-primary, #999) !important; + opacity: 0.5; + user-select: none; +} + +.bsplus-context-item:hover { + background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08)) !important; +} + +.bsplus-context-item span { + flex: 1; +} + +.bsplus-context-checkmark { + color: var(--better-main, #007bff) !important; + font-weight: bold; + flex: 0 !important; +} + +.bsplus-context-item { + display: flex !important; + align-items: center !important; + justify-content: flex-start !important; + gap: 8px; + padding: 7px 12px !important; + font-size: 13px; + cursor: pointer; + border: none !important; + background: transparent !important; + width: 100%; + text-align: left !important; + color: var(--text-primary, #333) !important; + transition: background .1s; + font-family: inherit; +} + +.bsplus-context-item .bsplus-folder-icon { + color: var(--text-primary, #333) !important; + width: 16px; + height: 16px; +} + +.bsplus-context-item .bsplus-folder-icon svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; +} + +.bsplus-context-item:hover { + background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08)); +} + +.bsplus-context-item span { + flex: 1; +} + +.bsplus-context-checkmark { + color: var(--better-main, #007bff) !important; + font-weight: bold; + flex: 0 !important; +} + +.bsplus-context-empty { + padding: 12px; + text-align: center; + font-size: 12px; + color: var(--text-primary, #999); + opacity: 0.5; +} + +/* ── Drag feedback ── */ +.bsplus-msg-dragging { + opacity: 0.4; +} + +[class*='MessageList__MessageList___'] ol > li[data-message] { + transition: opacity .15s; +} + +/* ── Layout fixes ── */ [class*='MessageList__primary___'] { flex: 1 1 0% !important; min-width: 0 !important; overflow: hidden !important; } -/* ── Make subject line a flex row so badges sit inline ── */ [class*='MessageList__subject___'] { display: flex !important; align-items: center; @@ -338,7 +579,6 @@ overflow: hidden !important; } -/* ── Subject text truncates to make room for badges ── */ .bsplus-subject-text { overflow: hidden; text-overflow: ellipsis; @@ -347,7 +587,6 @@ flex: 1 1 auto; } -/* ── Shrink the secondary column to its content ── */ [class*='MessageList__secondary___'] { flex: 0 0 auto !important; width: auto !important; @@ -355,7 +594,6 @@ max-width: 200px !important; } -/* ── Constrain the flags/attachment icon column ── */ [class*='MessageList__flags___'] { width: 24px !important; min-width: 0 !important; @@ -391,7 +629,7 @@ transform: scale(1.05); } -/* ── Folder filtering (hide messages not in active folder) ── */ +/* ── Folder filtering ── */ .bsplus-folder-hidden { display: none !important; } @@ -489,3 +727,5 @@ transform: translateY(-1px); box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35); } + + diff --git a/src/plugins/index.ts b/src/plugins/index.ts index aa8779ad..009e9e43 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -10,7 +10,7 @@ import assessmentsAveragePlugin from "./built-in/assessmentsAverage"; import profilePicturePlugin from "./built-in/profilePicture"; import assessmentsOverviewPlugin from "./built-in/assessmentsOverview"; import backgroundMusicPlugin from "./built-in/backgroundMusic"; -//import messageFoldersPlugin from "./built-in/messageFolders"; +import messageFoldersPlugin from "./built-in/messageFolders"; //import testPlugin from './built-in/test'; // Heavy plugins (lazy-loaded only when enabled) @@ -29,7 +29,7 @@ pluginManager.registerPlugin(timetableEditPlugin); pluginManager.registerPlugin(profilePicturePlugin); pluginManager.registerPlugin(assessmentsOverviewPlugin); pluginManager.registerPlugin(backgroundMusicPlugin); -//pluginManager.registerPlugin(messageFoldersPlugin); +pluginManager.registerPlugin(messageFoldersPlugin); //pluginManager.registerPlugin(testPlugin); // Register heavy plugins with lazy loading From c0a8a761052d156ddee6d4ca194cac2cfc96800a Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Tue, 19 May 2026 20:19:50 +0930 Subject: [PATCH 23/32] feat: Theme Of The Month --- src/background.ts | 32 ++- src/css/injected.scss | 53 +++++ src/interface/pages/settings/general.svelte | 67 ++++++ src/interface/pages/store.svelte | 27 +++ src/plugins/built-in/themes/theme-manager.ts | 6 +- src/plugins/monofile.ts | 2 +- src/seqta/utils/DevApiBase.ts | 65 ++++++ .../utils/Openers/OpenThemeOfTheMonthPopup.ts | 210 ++++++++++++++++++ src/seqta/utils/Openers/StartupPopupQueue.ts | 24 +- .../utils/openThemeStoreWithHighlight.ts | 39 ++++ src/types/storage.ts | 2 + 11 files changed, 520 insertions(+), 7 deletions(-) create mode 100644 src/seqta/utils/DevApiBase.ts create mode 100644 src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts create mode 100644 src/seqta/utils/openThemeStoreWithHighlight.ts diff --git a/src/background.ts b/src/background.ts index 32f22083..756a9812 100644 --- a/src/background.ts +++ b/src/background.ts @@ -9,6 +9,21 @@ import { runCloudSettingsPoll, } from "./background/cloudSettingsAutoSync"; +/** + * Session-only dev-mode override of the content API base. + * + * Stored in a module-level variable (not `chrome.storage`) so it is wiped + * automatically when the browser/service-worker process restarts. Content + * scripts re-sync this on every page load via `setDevApiBase` so the value + * survives transient service-worker terminations within the same browser + * session. + */ +const DEFAULT_API_BASE = "https://betterseqta.org"; +let DEV_API_BASE: string | null = null; +function apiBase(): string { + return DEV_API_BASE ?? DEFAULT_API_BASE; +} + function reloadSeqtaPages() { const result = browser.tabs.query({}); function open(tabs: any) { @@ -29,7 +44,7 @@ type MessageSender = { (response?: unknown): void }; function handleFetchThemes(request: any, sendResponse: MessageSender): boolean { const { token } = request; - const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`; + const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`; const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`; const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; @@ -57,7 +72,7 @@ function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boo } const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; - fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers }) + fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers }) .then((r) => r.json()) .then(sendResponse) .catch((err) => { @@ -283,7 +298,7 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean return false; } const isFavorite = action === "favorite"; - fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, { + fetch(`${apiBase()}/api/themes/${themeId}/favorite`, { method: isFavorite ? "POST" : "DELETE", headers: { Authorization: `Bearer ${token}` }, }) @@ -310,8 +325,19 @@ function isSeqtaOrigin(origin: string): boolean { } } +function handleSetDevApiBase(request: any): boolean { + const url = typeof request?.url === "string" ? request.url.trim() : null; + if (url && /^https?:\/\//.test(url)) { + DEV_API_BASE = url.replace(/\/$/, ""); + } else { + DEV_API_BASE = null; + } + return false; +} + const MESSAGE_HANDLERS: Record = { reloadTabs: () => reloadSeqtaPages(), + setDevApiBase: handleSetDevApiBase, extensionPages: (req) => { browser.tabs.query({}).then((tabs) => { for (const tab of tabs) { diff --git a/src/css/injected.scss b/src/css/injected.scss index 9c5fe51c..c01c3590 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -3726,6 +3726,59 @@ div.day-empty { color: var(--text-primary); } +.whatsnewHeader.themeOfTheMonthHeader { + height: auto; + min-height: unset; +} +.whatsnewHeader.themeOfTheMonthHeader h1 { + line-height: 1.2; +} +.themeOfTheMonthSubtitle { + margin: 0.25rem 0 0; + font-size: 0.95rem; + font-weight: 500; + letter-spacing: 0.01em; + text-transform: uppercase; + color: color-mix(in srgb, var(--text-primary) 65%, transparent); +} +.themeOfTheMonthFooter { + display: flex; + justify-content: center; + padding: 1rem 0; +} +.themeOfTheMonthViewButton { + appearance: none; + border: none; + cursor: pointer; + padding: 0.65rem 1.25rem; + border-radius: 9999px; + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.01em; + background: var(--better-pri, #6366f1); + color: white; + transition: transform 0.15s ease, filter 0.15s ease; +} +.themeOfTheMonthViewButton:hover { + filter: brightness(1.1); + transform: scale(1.03); +} +.themeOfTheMonthViewButton:active { + transform: scale(0.98); +} + +.bsplus-theme-highlight { + animation: bsplusThemeHighlightPulse 1.4s ease-in-out 2; +} +@keyframes bsplusThemeHighlightPulse { + 0%, 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--better-pri, #6366f1) 0%, transparent); + } + 50% { + box-shadow: 0 0 0 6px color-mix(in srgb, var(--better-pri, #6366f1) 60%, transparent); + } +} + .popup-media-fullscreenable { cursor: pointer; transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index 2928fffa..3b916dd9 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -15,8 +15,34 @@ import CloudHeader from "@/interface/components/store/CloudHeader.svelte" import { cloudAuth } from "@/seqta/utils/CloudAuth" import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification" + import { showThemeOfTheMonthPopupNow } from "@/seqta/utils/Openers/OpenThemeOfTheMonthPopup" import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup" import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync" + import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase" + + let devApiBaseInput = $state(getStoredOverride() ?? "") + let devApiBaseActive = $state(getStoredOverride()) + + function applyDevApiBase() { + const trimmed = devApiBaseInput.trim() + if (trimmed === "") { + setApiBase(null) + devApiBaseActive = null + return + } + if (!/^https?:\/\//.test(trimmed)) { + alert("Please enter a full URL starting with http:// or https://") + return + } + setApiBase(trimmed) + devApiBaseActive = trimmed.replace(/\/$/, "") + } + + function clearDevApiBase() { + devApiBaseInput = "" + setApiBase(null) + devApiBaseActive = null + } import { getAllPluginSettings } from "@/plugins" import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types" @@ -483,6 +509,22 @@ />
    +
    +
    +

    Show Theme of the Month

    +

    Fetch and show the current month's popup now (ignores dismissed state)

    +
    +
    +
    +

    Export cloud settings JSON

    @@ -492,6 +534,31 @@
    +
    +
    +
    +

    API Base URL (session only)

    +

    Override the content API host for this browser session. Cleared on restart. Affects themes, theme of the month, and other server-driven content.

    + {#if devApiBaseActive} +

    + Override active: {devApiBaseActive} +

    + {/if} +
    +
    +
    + +
    +
    {/if}
    diff --git a/src/interface/pages/store.svelte b/src/interface/pages/store.svelte index 92bcef14..b1e6649c 100644 --- a/src/interface/pages/store.svelte +++ b/src/interface/pages/store.svelte @@ -18,6 +18,7 @@ import Backgrounds from '../components/store/Backgrounds.svelte' import { cloudAuth } from '@/seqta/utils/CloudAuth' import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte' + import { consumePendingHighlightThemeId } from '@/seqta/utils/openThemeStoreWithHighlight' const themeManager = ThemeManager.getInstance(); let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn); @@ -122,13 +123,39 @@ } }; + function focusThemeById(themeId: string) { + const match = themes.find((t) => t.id === themeId) + ?? themes.find((t) => t.flavours?.some((f) => f.id === themeId)); + if (match) { + activeTab = 'themes'; + searchTerm = ''; + displayTheme = match; + } + } + + function onHighlightThemeEvent(e: Event) { + const detail = (e as CustomEvent).detail; + if (detail?.themeId && typeof detail.themeId === 'string') { + focusThemeById(detail.themeId); + } + } + // On mount onMount(async () => { + window.addEventListener('bsplus:highlight-theme', onHighlightThemeEvent); + await fetchThemes(); await fetchCurrentThemes(); darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true'; darkMode = $settingsState.DarkMode; + + const pending = consumePendingHighlightThemeId(); + if (pending) focusThemeById(pending); + + return () => { + window.removeEventListener('bsplus:highlight-theme', onHighlightThemeEvent); + }; }); // Filter themes (list is already featured-first, then newest; filter preserves order) diff --git a/src/plugins/built-in/themes/theme-manager.ts b/src/plugins/built-in/themes/theme-manager.ts index 0faf1449..ccaeddf5 100644 --- a/src/plugins/built-in/themes/theme-manager.ts +++ b/src/plugins/built-in/themes/theme-manager.ts @@ -11,6 +11,7 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import debounce from "@/seqta/utils/debounce"; import { themeUpdates } from "@/interface/hooks/ThemeUpdates"; import { cloudAuth } from "@/seqta/utils/CloudAuth"; +import { getApiBase } from "@/seqta/utils/DevApiBase"; import { updateAllColors } from "@/seqta/ui/colors/Manager"; import { clearCustomThemeAdaptiveCssVariables, @@ -545,7 +546,10 @@ export class ThemeManager { } } - private readonly THEME_API_BASE = 'https://betterseqta.org/api'; + /** Use a getter so dev-mode session-only base URL overrides take effect immediately. */ + private get THEME_API_BASE(): string { + return `${getApiBase()}/api`; + } private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes'; /** diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index 4892c7df..c1a45a6c 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -105,7 +105,7 @@ export async function finishLoad() { console.error("Error during loading cleanup:", err); } - runStartupPopupQueue(); + void runStartupPopupQueue(); } export function GetCSSElement(file: string) { diff --git a/src/seqta/utils/DevApiBase.ts b/src/seqta/utils/DevApiBase.ts new file mode 100644 index 00000000..49958da1 --- /dev/null +++ b/src/seqta/utils/DevApiBase.ts @@ -0,0 +1,65 @@ +import browser from "webextension-polyfill"; + +const DEFAULT_BASE = "https://betterseqta.org"; +const KEY = "bsplus_dev_api_base"; + +/** + * Returns the current content-API base URL. + * + * Reads from `sessionStorage` so a developer can temporarily override the + * server for testing. The value is cleared when the browser session ends, + * leaving production traffic unaffected for normal users. + */ +export function getApiBase(): string { + try { + if (typeof sessionStorage === "undefined") return DEFAULT_BASE; + const v = sessionStorage.getItem(KEY); + if (v && /^https?:\/\//.test(v)) return v.replace(/\/$/, ""); + } catch { + // sessionStorage may throw in some restricted contexts; fall back silently. + } + return DEFAULT_BASE; +} + +/** + * Persist a session-scoped override and broadcast it to the background script + * so its `fetch` calls hit the same host. + * + * Pass `null` to clear the override. + */ +export function setApiBase(url: string | null): void { + try { + if (!url) { + sessionStorage.removeItem(KEY); + } else { + sessionStorage.setItem(KEY, url.replace(/\/$/, "")); + } + } catch { + // ignore + } + void browser.runtime + .sendMessage({ type: "setDevApiBase", url: url || null }) + .catch(() => {}); +} + +/** Returns the override URL if one is currently set in this session. */ +export function getStoredOverride(): string | null { + try { + if (typeof sessionStorage === "undefined") return null; + return sessionStorage.getItem(KEY); + } catch { + return null; + } +} + +/** + * Send the current session override to the background script. + * Call this early in page load so the background context stays in sync after + * service-worker restarts. + */ +export function syncApiBaseToBackground(): void { + const override = getStoredOverride(); + void browser.runtime + .sendMessage({ type: "setDevApiBase", url: override }) + .catch(() => {}); +} diff --git a/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts b/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts new file mode 100644 index 00000000..a9f0d553 --- /dev/null +++ b/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts @@ -0,0 +1,210 @@ +import browser from "webextension-polyfill"; +import stringToHTML from "../stringToHTML"; +import { settingsState } from "../listeners/SettingsState"; +import { openPopup, closePopup } from "./PopupManager"; +import { getApiBase } from "../DevApiBase"; +import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight"; +import { cloudAuth } from "../CloudAuth"; + +/** + * Server response shape from `/api/theme-of-the-month/current`. + * Hero image is resolved client-side via the theme store API when `theme_id` is set. + */ +export interface ThemeOfTheMonthEntry { + id: string; + month: string; + title: string; + description: string; + cover_image: string | null; + theme_id: string | null; + theme: { id: string; name: string; slug: string } | null; + created_at: number; + updated_at: number; +} + +/** + * Fetches the current month's Theme of the Month entry from the API. + * Returns `null` when no entry is configured for this month, or when the + * request fails (we never want a network problem to block other startup + * popups). + */ +export async function fetchThemeOfTheMonth(): Promise { + try { + const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, { + cache: "no-store", + }); + if (!res.ok) return null; + const text = await res.text(); + if (!text) return null; + const data = JSON.parse(text); + if (!data || typeof data !== "object" || !data.id) return null; + return data as ThemeOfTheMonthEntry; + } catch (err) { + console.warn("[ThemeOfTheMonth] Failed to fetch current entry:", err); + return null; + } +} + +/** True when we have a new monthly entry the user hasn't dismissed yet. */ +export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean { + if (!entry) return false; + return settingsState.themeOfTheMonthLastSeenId !== entry.id; +} + +function escapeHTML(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function formatMonthLabel(month: string): string { + const [yyyy, mm] = month.split("-"); + if (!yyyy || !mm) return month; + const date = new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, 1); + return date.toLocaleDateString("en-US", { year: "numeric", month: "long" }); +} + +/** Same priority as the theme store: marquee, then cover/banner. */ +function heroUrlFromStoreTheme(theme: { + marqueeImage?: string | null; + coverImage?: string | null; +}): string | null { + const url = (theme.marqueeImage || theme.coverImage || "").trim(); + return url || null; +} + +/** + * Loads hero image for a store theme via the background script (same path as + * {@link ThemeSelector} / theme store detail fetches). + */ +export async function fetchThemeStoreHeroImage(themeId: string): Promise { + try { + const token = await cloudAuth.getStoredToken(); + const res = (await browser.runtime.sendMessage({ + type: "fetchThemeDetails", + themeId, + token: token ?? undefined, + })) as { success?: boolean; data?: { theme?: { marqueeImage?: string; coverImage?: string } } }; + + if (!res?.success || !res?.data?.theme) return null; + return heroUrlFromStoreTheme(res.data.theme); + } catch (err) { + console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err); + return null; + } +} + +/** Linked theme store image, else optional admin-uploaded cover. */ +async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise { + const themeId = entry.theme_id ?? entry.theme?.id; + if (themeId) { + const fromStore = await fetchThemeStoreHeroImage(themeId); + if (fromStore) return fromStore; + } + const fallback = entry.cover_image?.trim(); + return fallback || null; +} + +function createHeroImageContainer(imageUrl: string, alt: string): HTMLElement { + const container = document.createElement("div"); + container.classList.add("whatsnewImgContainer"); + + const img = document.createElement("img"); + img.src = imageUrl; + img.alt = alt; + img.classList.add("whatsnewImg"); + container.appendChild(img); + + return container; +} + +/** + * Renders the Theme of the Month announcement popup. + */ +export async function OpenThemeOfTheMonthPopup( + entry: ThemeOfTheMonthEntry, + onDismissed?: () => void, +) { + if (document.getElementById("whatsnewbk")) { + onDismissed?.(); + return; + } + + const monthLabel = formatMonthLabel(entry.month); + + const header = stringToHTML( + /* html */ ` +
    +

    ${escapeHTML(entry.title)}

    +

    Theme of the Month · ${escapeHTML(monthLabel)}

    +
    `, + ).firstChild as HTMLElement; + + const heroUrl = await resolvePopupHeroImageUrl(entry); + const imageContainer = heroUrl ? createHeroImageContainer(heroUrl, entry.title) : null; + + const descriptionHTML = escapeHTML(entry.description).replace(/\n/g, "
    "); + const text = stringToHTML(/* html */ ` +
    +

    ${descriptionHTML}

    +
    + `).firstChild as HTMLElement; + + let footer: HTMLElement | null = null; + const linkedThemeId = entry.theme_id ?? entry.theme?.id; + const linkedThemeName = entry.theme?.name; + if (linkedThemeId && linkedThemeName) { + footer = document.createElement("div"); + footer.classList.add("whatsnewFooter", "themeOfTheMonthFooter"); + + const viewBtn = document.createElement("button"); + viewBtn.type = "button"; + viewBtn.classList.add("themeOfTheMonthViewButton"); + viewBtn.textContent = `View "${linkedThemeName}" in the Theme Store`; + viewBtn.addEventListener("click", () => { + void closePopup(); + openThemeStoreWithHighlight(linkedThemeId); + }); + + footer.appendChild(viewBtn); + } + + settingsState.themeOfTheMonthLastSeenId = entry.id; + + const content: (Node | null)[] = []; + if (imageContainer) content.push(imageContainer); + content.push(text); + if (footer) content.push(footer); + + openPopup({ + header, + content, + afterClose: onDismissed, + }); +} + +/** + * Dev helper: fetch the current month's entry and show the popup immediately, + * even if the user has already dismissed it this month. + */ +export async function showThemeOfTheMonthPopupNow(): Promise { + const entry = await fetchThemeOfTheMonth(); + if (!entry) { + alert( + "No Theme of the Month entry for the current month (UTC). Create one in the website admin, or check your dev API base URL.", + ); + return; + } + + settingsState.themeOfTheMonthLastSeenId = undefined; + + if (document.getElementById("whatsnewbk")) { + await closePopup(); + await new Promise((resolve) => setTimeout(resolve, 150)); + } + + await OpenThemeOfTheMonthPopup(entry); +} diff --git a/src/seqta/utils/Openers/StartupPopupQueue.ts b/src/seqta/utils/Openers/StartupPopupQueue.ts index 7bfa4e8a..391c4a68 100644 --- a/src/seqta/utils/Openers/StartupPopupQueue.ts +++ b/src/seqta/utils/Openers/StartupPopupQueue.ts @@ -4,20 +4,40 @@ import { shouldShowEngageParentsAnnouncement, showEngageParentsToast, } from "./OpenEngageParentsAnnouncement"; +import { + fetchThemeOfTheMonth, + OpenThemeOfTheMonthPopup, + shouldShowThemeOfTheMonth, +} from "./OpenThemeOfTheMonthPopup"; +import { syncApiBaseToBackground } from "../DevApiBase"; type QueueStep = (goNext: () => void) => void; /** * Runs startup modals in order: What's New (if the extension just updated), - * then shows the SEQTA Engage toast (once, non-blocking). + * Theme of the Month (when a new monthly entry hasn't been seen), then shows + * the SEQTA Engage toast (once, non-blocking). */ -export function runStartupPopupQueue() { +export async function runStartupPopupQueue() { + // Make sure the background script knows about any dev-mode API override + // before we start firing requests. + syncApiBaseToBackground(); + const steps: QueueStep[] = []; if (settingsState.justupdated) { steps.push((goNext) => OpenWhatsNewPopup(goNext)); } + // Fetch the Theme of the Month before queueing so we don't show an empty + // popup if the network or server is unavailable. + const themeOfTheMonth = await fetchThemeOfTheMonth(); + if (shouldShowThemeOfTheMonth(themeOfTheMonth)) { + steps.push((goNext) => { + void OpenThemeOfTheMonthPopup(themeOfTheMonth!, goNext); + }); + } + function runNext() { const step = steps.shift(); if (step) step(runNext); diff --git a/src/seqta/utils/openThemeStoreWithHighlight.ts b/src/seqta/utils/openThemeStoreWithHighlight.ts new file mode 100644 index 00000000..f4eca878 --- /dev/null +++ b/src/seqta/utils/openThemeStoreWithHighlight.ts @@ -0,0 +1,39 @@ +import { OpenStorePage } from "@/seqta/ui/renderStore"; + +/** + * Module-level handoff for "open the theme store and highlight this theme". + * + * The store page is mounted lazily inside a Shadow DOM the first time it + * opens, so a `CustomEvent` listener would have to be wired up before mount + * (causing a race). Using a shared cell keeps the producer (popup button) and + * consumer (store `onMount`) decoupled without that timing constraint. + * + * The store reads & clears this on mount via {@link consumePendingHighlightThemeId}. + */ +let pendingHighlightThemeId: string | null = null; + +/** Read and clear the pending theme id (called by the store on mount). */ +export function consumePendingHighlightThemeId(): string | null { + const id = pendingHighlightThemeId; + pendingHighlightThemeId = null; + return id; +} + +/** + * Opens the theme store and asks it to focus / highlight the given theme. + * If the store is already mounted we dispatch a DOM event so it can react + * without remounting; otherwise the store consumes the pending id on mount. + */ +export function openThemeStoreWithHighlight(themeId: string): void { + pendingHighlightThemeId = themeId; + + const existing = document.getElementById("store"); + if (existing) { + window.dispatchEvent( + new CustomEvent("bsplus:highlight-theme", { detail: { themeId } }), + ); + return; + } + + OpenStorePage(); +} diff --git a/src/types/storage.ts b/src/types/storage.ts index adbaf8c9..29224a07 100644 --- a/src/types/storage.ts +++ b/src/types/storage.ts @@ -36,6 +36,8 @@ export interface SettingsState { engageParentsAnnouncementShown?: boolean; /** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */ bsCloudAutoSyncAnnouncementShown?: boolean; + /** ID of the last Theme of the Month entry shown to the user (shows once per new entry). */ + themeOfTheMonthLastSeenId?: string; timeFormat?: string; animations: boolean; defaultPage: string; From 6c79fe3588f411e2ea3b09be26d02a0322e7daef Mon Sep 17 00:00:00 2001 From: codefactor-io Date: Tue, 19 May 2026 10:53:27 +0000 Subject: [PATCH 24/32] [CodeFactor] Apply fixes --- src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts b/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts index a9f0d553..e8f1f0fa 100644 --- a/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts +++ b/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts @@ -1,7 +1,7 @@ import browser from "webextension-polyfill"; import stringToHTML from "../stringToHTML"; import { settingsState } from "../listeners/SettingsState"; -import { openPopup, closePopup } from "./PopupManager"; +import { closePopup, openPopup } from "./PopupManager"; import { getApiBase } from "../DevApiBase"; import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight"; import { cloudAuth } from "../CloudAuth"; From 68173a8b750b77a89f1a848bfb6266d4177fc969 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sat, 23 May 2026 08:58:21 +0930 Subject: [PATCH 25/32] fix: fix custom teacher names not applying to popup --- src/plugins/built-in/timetableEdit/index.ts | 111 ++++++++++++++++---- 1 file changed, 92 insertions(+), 19 deletions(-) diff --git a/src/plugins/built-in/timetableEdit/index.ts b/src/plugins/built-in/timetableEdit/index.ts index 02ca8911..1d13121f 100644 --- a/src/plugins/built-in/timetableEdit/index.ts +++ b/src/plugins/built-in/timetableEdit/index.ts @@ -145,8 +145,10 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = { let observer: MutationObserver | null = null; let quickbarObserver: MutationObserver | null = null; + let quickbarSyncTimer: ReturnType | null = null; let lastClickedCi: number | null = null; let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null; + let lastSyncedQuickbarCi: number | null = null; const getOverrides = (): TimetableOverrides => api.storage.timetableOverrides ?? {}; @@ -186,9 +188,11 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = { if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff; } - const captureClick = (e: MouseEvent) => { + const captureClick = () => { lastClickedCi = ci; lastClickedEntry = { roomEl, teacherEl, item }; + lastSyncedQuickbarCi = null; + scheduleQuickbarSync(); }; entry.addEventListener("click", captureClick, true); }; @@ -199,6 +203,76 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = { }); }; + const getVisibleClassQuickbar = (): HTMLElement | null => { + const quickbar = document.querySelector( + ".timetablepage .quickbar.below.visible, .timetablepage .quickbar.above.visible, .timetablepage .quickbar.visible", + ); + if (!quickbar || quickbar.getAttribute("data-type") !== "class") return null; + return quickbar as HTMLElement; + }; + + const applyOverridesToQuickbar = (quickbar: HTMLElement): void => { + if (lastClickedCi === null) return; + if (lastSyncedQuickbarCi === lastClickedCi) return; + + const description = + quickbar.querySelector(".title")?.textContent?.trim() ?? + lastClickedEntry?.item.description ?? + ""; + const override = getEffectiveOverride(lastClickedCi, description); + if (!override) { + lastSyncedQuickbarCi = lastClickedCi; + return; + } + + const roomEl = quickbar.querySelector(".meta .room"); + const teacherEl = quickbar.querySelector(".meta .teacher"); + if (override.room !== undefined && !roomEl) return; + if (override.staff !== undefined && !teacherEl) return; + + if (override.room !== undefined && roomEl && roomEl.textContent !== override.room) { + roomEl.textContent = override.room; + } + if (override.staff !== undefined && teacherEl && teacherEl.textContent !== override.staff) { + teacherEl.textContent = override.staff; + } + + lastSyncedQuickbarCi = lastClickedCi; + }; + + const updateVisibleQuickbar = (room: string, staff: string): void => { + const quickbar = getVisibleClassQuickbar(); + if (!quickbar) return; + const roomEl = quickbar.querySelector(".meta .room"); + const teacherEl = quickbar.querySelector(".meta .teacher"); + if (roomEl && roomEl.textContent !== room) roomEl.textContent = room; + if (teacherEl && teacherEl.textContent !== staff) teacherEl.textContent = staff; + if (lastClickedCi !== null) lastSyncedQuickbarCi = lastClickedCi; + }; + + const syncClassQuickbar = (quickbar: HTMLElement): void => { + applyOverridesToQuickbar(quickbar); + addEditButtonToQuickbar(quickbar); + }; + + const scheduleQuickbarSync = (): void => { + if (quickbarSyncTimer !== null) clearTimeout(quickbarSyncTimer); + + let attempts = 0; + const trySync = (): void => { + const quickbar = getVisibleClassQuickbar(); + if (quickbar && lastClickedCi !== null) { + syncClassQuickbar(quickbar); + return; + } + if (++attempts < 6) { + quickbarSyncTimer = setTimeout(trySync, 50); + } + }; + + requestAnimationFrame(trySync); + }; + const addEditButtonToQuickbar = (quickbar: HTMLElement) => { if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return; @@ -251,6 +325,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = { } if (entryData.roomEl) entryData.roomEl.textContent = room; if (entryData.teacherEl) entryData.teacherEl.textContent = staff; + updateVisibleQuickbar(room, staff); processAllEntries(); }, (ci) => { @@ -262,6 +337,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = { api.storage.timetableOverridesBySubject = bySubject; if (entryData.roomEl) entryData.roomEl.textContent = item.room; if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff; + updateVisibleQuickbar(item.room, item.staff); processAllEntries(); }, ); @@ -271,34 +347,30 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = { }; const syncQuickbarFromDOM = () => { - const quickbar = document.querySelector( - ".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible", - ); - if (quickbar && quickbar.getAttribute("data-type") === "class") { - const titleEl = quickbar.querySelector(".title"); - const roomEl = quickbar.querySelector(".meta .room"); - const teacherEl = quickbar.querySelector(".meta .teacher"); - if (titleEl && roomEl && teacherEl && lastClickedCi !== null && lastClickedEntry) { - addEditButtonToQuickbar(quickbar as HTMLElement); - } - } + const quickbar = getVisibleClassQuickbar(); + if (!quickbar || lastClickedCi === null || !lastClickedEntry) return; + syncClassQuickbar(quickbar); }; const setupQuickbarObserver = () => { const timetablePage = document.querySelector(".timetablepage"); if (!timetablePage || quickbarObserver) return; - quickbarObserver = new MutationObserver(() => { - const quickbar = document.querySelector( - ".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible", + quickbarObserver = new MutationObserver((mutations) => { + const quickbarBecameVisible = mutations.some( + (mutation) => + mutation.type === "attributes" && + mutation.attributeName === "class" && + (mutation.target as HTMLElement).classList.contains("quickbar") && + (mutation.target as HTMLElement).classList.contains("visible"), ); - if (quickbar?.getAttribute("data-type") === "class") { - addEditButtonToQuickbar(quickbar as HTMLElement); - } + if (!quickbarBecameVisible || lastClickedCi === null) return; + + const quickbar = getVisibleClassQuickbar(); + if (quickbar) syncClassQuickbar(quickbar); }); quickbarObserver.observe(timetablePage, { - childList: true, subtree: true, attributes: true, attributeFilter: ["class"], @@ -336,6 +408,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = { unregister(); observer?.disconnect(); quickbarObserver?.disconnect(); + if (quickbarSyncTimer !== null) clearTimeout(quickbarSyncTimer); styleEl.remove(); document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => { el.removeAttribute("data-timetable-edit-processed"); From 0bc6beb0f15e92f884f4d8afda8f3f5ccc0cc935 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sat, 23 May 2026 09:08:31 +0930 Subject: [PATCH 26/32] chore: bump ver & release notes --- package.json | 2 +- src/seqta/utils/Openers/OpenWhatsNewPopup.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 27826d94..6b486eb5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betterseqtaplus", - "version": "3.6.4", + "version": "3.6.5", "type": "module", "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!", "browserslist": "> 0.5%, last 2 versions, not dead", diff --git a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts index 6160f6e5..b368b90c 100644 --- a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts +++ b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts @@ -33,9 +33,12 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) { const text = stringToHTML(/* html */ `
    - -

    3.6.5 - Assessment weighting override & fixes

    +

    3.6.5 - Theme of the Month, custom message folders & assessment weighting overrides

    +
  • Added Theme of the Month — a monthly featured theme popup with a link to view it in the theme store.
  • +
  • Added custom message folders for organising direct DM's with drag to reorder.
  • Added the ability to override/add weightings to assessments (on assessment page).
  • +
  • Fixed custom room and teacher names not showing in the timetable popup.
  • +
  • Fixed assessment averages treating N/A weightings incorrectly in subject average calculations.
  • Fixed the display of weightings that could not automatically be discovered.
  • Fixed the formatting of the weighting tag that was broken due to a SEQTA update.
  • From 304ce2e1280c8f32914ae39c75774183c68463a7 Mon Sep 17 00:00:00 2001 From: SethBurkart123 Date: Sat, 23 May 2026 22:53:06 +1000 Subject: [PATCH 27/32] feat: refine startup announcement cards --- src/background.ts | 1 + src/css/injected.scss | 214 ++++++++++++++---- .../Openers/OpenEngageParentsAnnouncement.ts | 5 +- .../utils/Openers/OpenThemeOfTheMonthPopup.ts | 124 +++++----- src/types/storage.ts | 2 + 5 files changed, 237 insertions(+), 109 deletions(-) diff --git a/src/background.ts b/src/background.ts index 756a9812..775778f7 100644 --- a/src/background.ts +++ b/src/background.ts @@ -495,6 +495,7 @@ function getDefaultValues(): SettingsState { adaptiveThemeColour: false, adaptiveThemeGradient: false, adaptiveThemeColourTransition: true, + themeOfTheMonthDisabled: false, autoCloudSettingsSync: true, }; } diff --git a/src/css/injected.scss b/src/css/injected.scss index c01c3590..57f7fc64 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -3726,45 +3726,136 @@ div.day-empty { color: var(--text-primary); } -.whatsnewHeader.themeOfTheMonthHeader { - height: auto; - min-height: unset; +.themeOfTheMonthCard { + position: fixed; + right: max(18px, env(safe-area-inset-right)); + bottom: max(18px, env(safe-area-inset-bottom)); + z-index: 48; + width: min(360px, calc(100vw - 36px)); + overflow: visible; + border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent); + border-radius: 20px; + background: var(--background-primary); + color: var(--text-primary); + box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35); + animation: themeOfTheMonthCardIn 0.24s ease-out; } -.whatsnewHeader.themeOfTheMonthHeader h1 { +.themeOfTheMonthCard::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + overflow: hidden; + border-radius: inherit; + background: inherit; +} +.themeOfTheMonthCardClosing { + pointer-events: none; + animation: themeOfTheMonthCardOut 0.18s ease-in forwards; +} +.themeOfTheMonthCardClose { + position: absolute !important; + top: 4px !important; + right: 4px !important; + z-index: 2; + width: 32px; + height: 32px; + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 16px !important; + background: rgba(0, 0, 0, 0.42); + color: white; + cursor: pointer; + font-size: 1.35rem; + line-height: 1; +} +.themeOfTheMonthCardImage { + display: block; + width: 100%; + height: 150px; + margin: 0; + border-radius: 20px 20px 0 0; + object-fit: cover; +} +.themeOfTheMonthCardBody { + padding: 14px 16px 16px; +} +.themeOfTheMonthCardEyebrow { + margin: 0 0 6px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: color-mix(in srgb, var(--better-pri, #6366f1) 82%, var(--text-primary) 18%); +} +.themeOfTheMonthCard h2 { + margin: 0; + font-size: 1.2rem; line-height: 1.2; } -.themeOfTheMonthSubtitle { - margin: 0.25rem 0 0; - font-size: 0.95rem; - font-weight: 500; - letter-spacing: 0.01em; - text-transform: uppercase; - color: color-mix(in srgb, var(--text-primary) 65%, transparent); +.themeOfTheMonthCardDescription { + display: -webkit-box; + margin: 8px 0 14px; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + font-size: 0.92rem; + line-height: 1.45; + color: color-mix(in srgb, var(--text-primary) 78%, transparent); } -.themeOfTheMonthFooter { +.themeOfTheMonthCardActions { display: flex; - justify-content: center; - padding: 1rem 0; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; } -.themeOfTheMonthViewButton { +.themeOfTheMonthCardPrimary, +.themeOfTheMonthCardSecondary { appearance: none; border: none; cursor: pointer; - padding: 0.65rem 1.25rem; border-radius: 9999px; - font-size: 1rem; - font-weight: 600; - letter-spacing: 0.01em; + padding: 0.58rem 0.9rem; + font-size: 0.86rem; + font-weight: 700; + transition: transform 0.15s ease, filter 0.15s ease, background 0.15s ease; +} +.themeOfTheMonthCardPrimary { background: var(--better-pri, #6366f1); color: white; - transition: transform 0.15s ease, filter 0.15s ease; } -.themeOfTheMonthViewButton:hover { - filter: brightness(1.1); - transform: scale(1.03); +.themeOfTheMonthCardSecondary { + background: color-mix(in srgb, var(--text-primary) 10%, transparent); + color: var(--text-primary); } -.themeOfTheMonthViewButton:active { - transform: scale(0.98); +.themeOfTheMonthCardPrimary:hover, +.themeOfTheMonthCardSecondary:hover { + filter: brightness(1.08); + transform: translateY(-1px); +} +.themeOfTheMonthCardPrimary:active, +.themeOfTheMonthCardSecondary:active { + transform: translateY(0); +} +@keyframes themeOfTheMonthCardIn { + from { + opacity: 0; + transform: translateY(18px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} +@keyframes themeOfTheMonthCardOut { + to { + opacity: 0; + transform: translateY(12px) scale(0.98); + } +} +@media (max-width: 900px) { + .themeOfTheMonthCard { + z-index: 2147483645; + } } .bsplus-theme-highlight { @@ -4428,38 +4519,63 @@ h2.home-subtitle { .bsplus-toast { position: fixed; - bottom: 24px; - right: 24px; + right: max(18px, env(safe-area-inset-right)); + bottom: max(18px, env(safe-area-inset-bottom)); z-index: 10000; - display: flex; - align-items: flex-start; - gap: 12px; - max-width: 380px; - padding: 16px 18px; - border-radius: 12px; - background: var(--background-secondary, #fff); + width: min(360px, calc(100vw - 36px)); + padding: 14px 16px 16px; + border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent); + border-radius: 20px; + background: var(--background-primary, #fff); color: var(--text-primary, #1a1a1a); - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18); + box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35); font-size: 0.9rem; - line-height: 1.5; + line-height: 1.45; } -.bsplus-toast-content p { - margin: 6px 0 0; - opacity: 0.8; - font-size: 0.85rem; +.bsplus-toast-eyebrow { + margin: 0 0 6px !important; + font-size: 0.72rem !important; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: color-mix(in srgb, #ea580c 82%, var(--text-primary) 18%); + opacity: 1 !important; +} +.dark .bsplus-toast-eyebrow { + color: color-mix(in srgb, #fb923c 82%, var(--text-primary) 18%); +} +.bsplus-toast-content strong { + display: block; + padding-right: 34px; + font-size: 1.2rem; + line-height: 1.2; +} +.bsplus-toast-content p:not(.bsplus-toast-eyebrow) { + display: -webkit-box; + margin: 8px 0 0; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + color: color-mix(in srgb, var(--text-primary) 78%, transparent); + font-size: 0.92rem; + line-height: 1.45; } .bsplus-toast-close { - flex-shrink: 0; - background: none; - border: none; - color: var(--text-primary, #1a1a1a); - font-size: 1.3rem; + position: absolute !important; + top: 4px !important; + right: 4px !important; + z-index: 2; + width: 32px; + height: 32px; + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 16px !important; + background: rgba(0, 0, 0, 0.42); + color: white; cursor: pointer; - padding: 0 2px; + font-size: 1.35rem; line-height: 1; - opacity: 0.5; - transition: opacity 0.15s; + transition: filter 0.15s ease; } .bsplus-toast-close:hover { - opacity: 1; + filter: brightness(1.08); } diff --git a/src/seqta/utils/Openers/OpenEngageParentsAnnouncement.ts b/src/seqta/utils/Openers/OpenEngageParentsAnnouncement.ts index 1cd72939..126f9e51 100644 --- a/src/seqta/utils/Openers/OpenEngageParentsAnnouncement.ts +++ b/src/seqta/utils/Openers/OpenEngageParentsAnnouncement.ts @@ -14,13 +14,14 @@ export function showEngageParentsToast() { settingsState.engageParentsAnnouncementShown = true; const toast = document.createElement("div"); - toast.className = "bsplus-toast"; + toast.className = "bsplus-toast engageParentsToast"; toast.innerHTML = /* html */ ` +
    +

    SEQTA Engage support

    BetterSEQTA+ now supports SEQTA Engage

    Buy your mum a BetterSEQTA Plus! Parents now get themes, a cleaner home page, and all the Plus polish on SEQTA Engage.

    - `; toast.style.opacity = "0"; diff --git a/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts b/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts index e8f1f0fa..aaf6348a 100644 --- a/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts +++ b/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts @@ -1,7 +1,7 @@ import browser from "webextension-polyfill"; import stringToHTML from "../stringToHTML"; import { settingsState } from "../listeners/SettingsState"; -import { closePopup, openPopup } from "./PopupManager"; +import { closePopup } from "./PopupManager"; import { getApiBase } from "../DevApiBase"; import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight"; import { cloudAuth } from "../CloudAuth"; @@ -47,7 +47,7 @@ export async function fetchThemeOfTheMonth(): Promise void, + markSeen = true, +) { + if (card.classList.contains("themeOfTheMonthCardClosing")) return; - const img = document.createElement("img"); - img.src = imageUrl; - img.alt = alt; - img.classList.add("whatsnewImg"); - container.appendChild(img); + if (markSeen) { + const entryId = card.dataset.entryId; + if (entryId) settingsState.themeOfTheMonthLastSeenId = entryId; + } - return container; + card.classList.add("themeOfTheMonthCardClosing"); + window.setTimeout(() => { + card.remove(); + onDismissed?.(); + }, 180); } /** - * Renders the Theme of the Month announcement popup. + * Renders the Theme of the Month announcement card. */ export async function OpenThemeOfTheMonthPopup( entry: ThemeOfTheMonthEntry, onDismissed?: () => void, ) { - if (document.getElementById("whatsnewbk")) { - onDismissed?.(); - return; - } + document.getElementById("theme-of-the-month-card")?.remove(); const monthLabel = formatMonthLabel(entry.month); - - const header = stringToHTML( - /* html */ ` -
    -

    ${escapeHTML(entry.title)}

    -

    Theme of the Month · ${escapeHTML(monthLabel)}

    -
    `, - ).firstChild as HTMLElement; - const heroUrl = await resolvePopupHeroImageUrl(entry); - const imageContainer = heroUrl ? createHeroImageContainer(heroUrl, entry.title) : null; + const description = escapeHTML(entry.description).replace(/\n/g, " "); + const linkedThemeId = entry.theme_id ?? entry.theme?.id; - const descriptionHTML = escapeHTML(entry.description).replace(/\n/g, "
    "); - const text = stringToHTML(/* html */ ` -
    -

    ${descriptionHTML}

    -
    + const card = stringToHTML(/* html */ ` + `).firstChild as HTMLElement; - let footer: HTMLElement | null = null; - const linkedThemeId = entry.theme_id ?? entry.theme?.id; - const linkedThemeName = entry.theme?.name; - if (linkedThemeId && linkedThemeName) { - footer = document.createElement("div"); - footer.classList.add("whatsnewFooter", "themeOfTheMonthFooter"); + card.dataset.entryId = entry.id; + const autoCloseTimeout = window.setTimeout(() => { + closeThemeOfTheMonthCard(card, onDismissed); + }, 12000); - const viewBtn = document.createElement("button"); - viewBtn.type = "button"; - viewBtn.classList.add("themeOfTheMonthViewButton"); - viewBtn.textContent = `View "${linkedThemeName}" in the Theme Store`; - viewBtn.addEventListener("click", () => { - void closePopup(); - openThemeStoreWithHighlight(linkedThemeId); - }); + const dismiss = (markSeen = true) => { + window.clearTimeout(autoCloseTimeout); + closeThemeOfTheMonthCard(card, onDismissed, markSeen); + }; - footer.appendChild(viewBtn); - } + card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true }); - settingsState.themeOfTheMonthLastSeenId = entry.id; - - const content: (Node | null)[] = []; - if (imageContainer) content.push(imageContainer); - content.push(text); - if (footer) content.push(footer); - - openPopup({ - header, - content, - afterClose: onDismissed, + card.querySelector(".themeOfTheMonthCardClose")?.addEventListener("click", () => { + dismiss(); }); + + card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => { + dismiss(); + openThemeStoreWithHighlight(linkedThemeId!); + }); + + card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => { + settingsState.themeOfTheMonthDisabled = true; + dismiss(); + }); + + document.body.appendChild(card); } /** diff --git a/src/types/storage.ts b/src/types/storage.ts index 29224a07..d859d5f5 100644 --- a/src/types/storage.ts +++ b/src/types/storage.ts @@ -38,6 +38,8 @@ export interface SettingsState { bsCloudAutoSyncAnnouncementShown?: boolean; /** ID of the last Theme of the Month entry shown to the user (shows once per new entry). */ themeOfTheMonthLastSeenId?: string; + /** Permanently disables Theme of the Month startup prompts. */ + themeOfTheMonthDisabled?: boolean; timeFormat?: string; animations: boolean; defaultPage: string; From 475b86500059e4c7327bb9d111741183ca488f26 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sun, 24 May 2026 17:11:47 +0930 Subject: [PATCH 28/32] feat: apply our exisitng icons to engage sidebar --- src/plugins/monofile.ts | 77 +----------- src/seqta/ui/AddBetterSEQTAElements.ts | 39 +----- src/seqta/utils/sidebarMenuIcons.ts | 153 +++++++++++++++++++++++ src/seqta/utils/waitForEngageMenuList.ts | 39 ++++++ 4 files changed, 195 insertions(+), 113 deletions(-) create mode 100644 src/seqta/utils/sidebarMenuIcons.ts create mode 100644 src/seqta/utils/waitForEngageMenuList.ts diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index c1a45a6c..c74d39ea 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -3,10 +3,6 @@ import browser from "webextension-polyfill"; import { animate, stagger } from "motion"; // Internal utilities and functions -import { - ChangeMenuItemPositions, - MenuOptionsOpen, -} from "@/seqta/utils/Openers/OpenMenuOptions"; import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour"; import { waitForElm } from "@/seqta/utils/waitForElm"; import { delay } from "@/seqta/utils/delay"; @@ -34,7 +30,7 @@ import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue"; import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes"; // JSON content -import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json"; +import { observeMenuItemPosition } from "@/seqta/utils/sidebarMenuIcons"; // Icons and fonts import IconFamily from "@/resources/fonts/IconFamily.woff"; @@ -612,75 +608,6 @@ export function tryLoad() { ); } -function ReplaceMenuSVG(element: HTMLElement, svg: string) { - let item = element.firstChild as HTMLElement; - item!.firstChild!.remove(); - - item.innerHTML = `${item.innerHTML}`; - - let newsvg = stringToHTML(svg).firstChild; - item.insertBefore(newsvg as Node, item.firstChild); -} - -const processedSymbol = Symbol("processed"); - -export async function ObserveMenuItemPosition() { - if (isSeqtaEngageExperience()) return; - await waitForElm("#menu > ul > li"); - - eventManager.register( - "menuList", - { - parentElement: document.querySelector("#menu")!.firstChild as Element, - }, - (element: Element) => { - const node = element as HTMLElement; - - // Only process top-level menu items and skip everything else - if ( - !node.classList.contains("item") || - node.nodeName !== "LI" || - node.parentElement?.parentElement?.id !== "menu" - ) { - return; - } - - // Early exit if already processed - if ((element as any)[processedSymbol]) { - return; - } - - if (!MenuOptionsOpen) { - const key = - MenuitemSVGKey[node?.dataset?.key! as keyof typeof MenuitemSVGKey]; - if (key) { - ReplaceMenuSVG( - node, - MenuitemSVGKey[node.dataset.key as keyof typeof MenuitemSVGKey], - ); - } else if (node?.firstChild?.nodeName === "LABEL") { - const label = node.firstChild as HTMLElement; - let textNode = label.lastChild as HTMLElement; - - if ( - textNode.nodeType === 3 && - textNode.parentNode && - textNode.parentNode.nodeName !== "SPAN" - ) { - const span = document.createElement("span"); - span.textContent = textNode.nodeValue; - - label.replaceChild(span, textNode); - } - } - ChangeMenuItemPositions(settingsState.menuorder); - - (element as any)[processedSymbol] = true; - } - }, - ); -} - export function showConflictPopup() { if (document.getElementById("conflict-popup")) return; document.body.classList.remove("hidden"); @@ -760,7 +687,7 @@ export function init() { } document.querySelector(".legacy-root")?.classList.add("hidden"); - ObserveMenuItemPosition(); + void observeMenuItemPosition(); new StorageChangeHandler(); new MessageHandler(); diff --git a/src/seqta/ui/AddBetterSEQTAElements.ts b/src/seqta/ui/AddBetterSEQTAElements.ts index dd512c99..644f33c1 100644 --- a/src/seqta/ui/AddBetterSEQTAElements.ts +++ b/src/seqta/ui/AddBetterSEQTAElements.ts @@ -1,3 +1,4 @@ +import { waitForEngageMenuList } from "@/seqta/utils/waitForEngageMenuList"; import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings"; import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage"; @@ -287,44 +288,6 @@ 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; diff --git a/src/seqta/utils/sidebarMenuIcons.ts b/src/seqta/utils/sidebarMenuIcons.ts new file mode 100644 index 00000000..48b49875 --- /dev/null +++ b/src/seqta/utils/sidebarMenuIcons.ts @@ -0,0 +1,153 @@ +import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json"; +import { + ChangeMenuItemPositions, + MenuOptionsOpen, +} from "@/seqta/utils/Openers/OpenMenuOptions"; +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; +import stringToHTML from "@/seqta/utils/stringToHTML"; +import { waitForEngageMenuList } from "@/seqta/utils/waitForEngageMenuList"; +import { waitForElm } from "@/seqta/utils/waitForElm"; +import { eventManager } from "@/seqta/utils/listeners/EventManager"; +import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; + +const BETTERSEQTA_ICON_ATTR = "data-betterseqta-icon"; + +function getMenuLabel(element: HTMLElement): HTMLElement | null { + const label = element.querySelector(":scope > label"); + return label instanceof HTMLElement ? label : null; +} + +function getTopLevelMenuList(menu = document.getElementById("menu")): HTMLElement | null { + if (!menu) return null; + return ( + (menu.querySelector(":scope > ul") as HTMLElement | null) ?? + (menu.querySelector("ul") as HTMLElement | null) + ); +} + +export function isTopLevelSidebarItem(node: HTMLElement): boolean { + if (!node.classList.contains("item")) return false; + if (node.nodeName !== "LI" && node.nodeName !== "SECTION") return false; + + const topList = getTopLevelMenuList(); + return !!topList && node.parentElement === topList; +} + +function wrapMenuLabelText(label: HTMLElement) { + const textNode = label.lastChild; + if ( + textNode?.nodeType === 3 && + textNode.parentNode && + textNode.parentNode.nodeName !== "SPAN" + ) { + const span = document.createElement("span"); + span.textContent = textNode.nodeValue; + label.replaceChild(span, textNode); + } +} + +export function replaceMenuSVG(element: HTMLElement, svg: string) { + const label = getMenuLabel(element); + if (!label?.firstChild) return; + + if (label.firstElementChild?.getAttribute(BETTERSEQTA_ICON_ATTR) === "true") { + return; + } + + label.firstChild.remove(); + label.innerHTML = `${label.innerHTML}`; + + const newSvg = stringToHTML(svg).firstChild; + if (!(newSvg instanceof Element)) return; + + newSvg.setAttribute(BETTERSEQTA_ICON_ATTR, "true"); + label.insertBefore(newSvg, label.firstChild); +} + +export function processMenuItemNode(node: HTMLElement) { + if (!isTopLevelSidebarItem(node) || MenuOptionsOpen) return; + + const key = node.dataset.key as keyof typeof MenuitemSVGKey | undefined; + if (key && MenuitemSVGKey[key]) { + replaceMenuSVG(node, MenuitemSVGKey[key]); + } else { + const label = getMenuLabel(node); + if (label) wrapMenuLabelText(label); + } +} + +function processTopLevelMenuItems(reorder = !isSeqtaEngageExperience()) { + if (MenuOptionsOpen) return; + + const topList = getTopLevelMenuList(); + if (!topList) return; + + for (const child of topList.children) { + if (child instanceof HTMLElement) { + processMenuItemNode(child); + } + } + + if (reorder) { + ChangeMenuItemPositions(settingsState.menuorder); + } +} + +let engageMenuIconObserver: MutationObserver | null = null; +let engageMenuIconFrame: number | null = null; + +function scheduleEngageMenuIconPass() { + if (engageMenuIconFrame !== null) return; + + engageMenuIconFrame = window.requestAnimationFrame(() => { + engageMenuIconFrame = null; + processTopLevelMenuItems(false); + }); +} + +async function observeEngageMenuIcons() { + const menuList = await waitForEngageMenuList(); + const menu = document.getElementById("menu"); + if (!menu || !menuList) return; + + processTopLevelMenuItems(false); + + engageMenuIconObserver?.disconnect(); + engageMenuIconObserver = new MutationObserver(() => { + scheduleEngageMenuIconPass(); + }); + engageMenuIconObserver.observe(menu, { + childList: true, + subtree: true, + }); +} + +const processedSymbol = Symbol("processed"); + +export async function observeMenuItemPosition() { + if (isSeqtaEngageExperience()) { + await observeEngageMenuIcons(); + return; + } + + await waitForElm("#menu > ul > li"); + + eventManager.register( + "menuList", + { + parentElement: document.querySelector("#menu")!.firstChild as Element, + }, + (element: Element) => { + const node = element as HTMLElement; + + if (!isTopLevelSidebarItem(node)) return; + if ((element as any)[processedSymbol]) return; + + if (!MenuOptionsOpen) { + processMenuItemNode(node); + ChangeMenuItemPositions(settingsState.menuorder); + (element as any)[processedSymbol] = true; + } + }, + ); +} diff --git a/src/seqta/utils/waitForEngageMenuList.ts b/src/seqta/utils/waitForEngageMenuList.ts new file mode 100644 index 00000000..0c9e6951 --- /dev/null +++ b/src/seqta/utils/waitForEngageMenuList.ts @@ -0,0 +1,39 @@ +import { waitForElm } from "@/seqta/utils/waitForElm"; + +/** Engage mounts the sidebar inside batched React trees; polling `waitForElm` matches the real DOM reliably. */ +export 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; +} From fee79e8623d0cdc0b9eb6756964df4e0afd28160 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sun, 24 May 2026 17:14:06 +0930 Subject: [PATCH 29/32] temp: disable global search on engage --- src/plugins/built-in/globalSearch/lazy.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/plugins/built-in/globalSearch/lazy.ts b/src/plugins/built-in/globalSearch/lazy.ts index a8ae19eb..f6ac76f3 100644 --- a/src/plugins/built-in/globalSearch/lazy.ts +++ b/src/plugins/built-in/globalSearch/lazy.ts @@ -5,6 +5,7 @@ import { defineSettings, hotkeySetting, } from "../../core/settingsHelpers"; +import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; import styles from "./src/core/styles.css?inline"; // Platform-aware default hotkey @@ -112,7 +113,7 @@ const settings = defineSettings({ }); // Create the lazy plugin definition - this loads immediately but doesn't import heavy dependencies -export default defineLazyPlugin({ +const globalSearchPlugin = defineLazyPlugin({ id: "global-search", name: "Global Search", description: "Quick search for everything in SEQTA", @@ -125,3 +126,15 @@ export default defineLazyPlugin({ // Lazy loader - only imports the heavy plugin when actually needed loader: () => import("./src/core/index") }); + +const runGlobalSearch = globalSearchPlugin.run!; + +globalSearchPlugin.run = async (api) => { + if (isSeqtaEngageExperience()) { + return () => {}; + } + + return runGlobalSearch(api); +}; + +export default globalSearchPlugin; From 4f6916d8b35a046ff24bdb1d4084888bd1d9114a Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sun, 24 May 2026 17:21:21 +0930 Subject: [PATCH 30/32] feat: bring assement weighting to engage --- .../built-in/assessmentsAverage/engage.ts | 55 ++++++++++++++ .../built-in/assessmentsAverage/utils.ts | 76 ++++++++++++------- 2 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 src/plugins/built-in/assessmentsAverage/engage.ts diff --git a/src/plugins/built-in/assessmentsAverage/engage.ts b/src/plugins/built-in/assessmentsAverage/engage.ts new file mode 100644 index 00000000..07b7c1e0 --- /dev/null +++ b/src/plugins/built-in/assessmentsAverage/engage.ts @@ -0,0 +1,55 @@ +const ENGAGE_STUDENT_STORAGE_KEY = () => + `bsplus.engageTimetable.student.${location.origin}`; + +/** Engage assessments URLs: /#?page=/assessments/{studentId}/{programme}:{metaclass}:{studentId} */ +export function getEngageAssessmentStudentId(): string | null { + const hashMatch = window.location.hash.match(/\/assessments\/(\d+)/); + if (hashMatch?.[1]) return hashMatch[1]; + + return localStorage.getItem(ENGAGE_STUDENT_STORAGE_KEY()); +} + +function randomEngagePdfFileName(): string { + const token = Math.random().toString(36).slice(2, 10); + return `${token}.pdf`; +} + +export async function requestEngageAssessmentPdf(params: { + assessmentID: string | number; + metaclassID: string | number; + studentID: string | number; +}): Promise { + const fileName = randomEngagePdfFileName(); + const cacheBuster = Math.random().toString(36).slice(2, 10); + + const response = await fetch( + `${location.origin}/seqta/parent/print/assessment?${cacheBuster}`, + { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + credentials: "include", + body: JSON.stringify({ + id: params.assessmentID, + metaclass: params.metaclassID, + student: Number(params.studentID), + fileName, + }), + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to generate PDF: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + payload?: { file?: string }; + }; + + return data.payload?.file ?? fileName; +} + +export function getEngageAssessmentReportUrl(fileName: string): string { + return `${location.origin}/seqta/parent/report/get?file=${encodeURIComponent(fileName)}`; +} diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index bf7514a6..f720cd19 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -1,5 +1,11 @@ import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts"; import ReactFiber from "@/seqta/utils/ReactFiber.ts"; +import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; +import { + getEngageAssessmentReportUrl, + getEngageAssessmentStudentId, + requestEngageAssessmentPdf, +} from "./engage.ts"; import { ensurePdfjsWorker, getPdfjsPageContextUrls, @@ -464,8 +470,6 @@ export async function extractPDFText(url: string): Promise { async function handleWeightings(mark: any, api: any) { const assessmentID = mark.id; const metaclassID = mark.metaclassID; - const userInfo = await getUserInfo(); - const userID = userInfo.id; const title = mark.title; if ( @@ -486,35 +490,55 @@ async function handleWeightings(mark: any, api: any) { }; try { - const filename = - "BetterSEQTA-" + - String(Math.floor(Math.random() * 1e15)).padStart(15, "0"); + let pdfUrl: string; - const printResponse = await fetch( - `${location.origin}/seqta/student/print/assessment`, - { - method: "POST", - headers: { "Content-Type": "application/json; charset=utf-8" }, - credentials: "include", - body: JSON.stringify({ - fileName: filename, - id: assessmentID, - metaclass: metaclassID, - student: userID, - }), - }, - ); + if (isSeqtaEngageExperience()) { + const studentID = getEngageAssessmentStudentId(); + if (!studentID) { + throw new Error("Could not resolve Engage student ID from URL or storage"); + } - if (!printResponse.ok) { - throw new Error( - `Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`, + const reportFile = await requestEngageAssessmentPdf({ + assessmentID, + metaclassID, + studentID, + }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + pdfUrl = getEngageAssessmentReportUrl(reportFile); + } else { + const userInfo = await getUserInfo(); + const userID = userInfo.id; + + const filename = + "BetterSEQTA-" + + String(Math.floor(Math.random() * 1e15)).padStart(15, "0"); + + const printResponse = await fetch( + `${location.origin}/seqta/student/print/assessment`, + { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + credentials: "include", + body: JSON.stringify({ + fileName: filename, + id: assessmentID, + metaclass: metaclassID, + student: userID, + }), + }, ); + + if (!printResponse.ok) { + throw new Error( + `Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`, + ); + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`; } - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`; - if (pdfUrl.startsWith("blob:")) { throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`); } From f0358bec071877539c4452940b0ef6b9ae0ea9a0 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sun, 24 May 2026 17:28:20 +0930 Subject: [PATCH 31/32] feat: make assement overview for SEQTA Engage --- src/interface/pages/settings/general.svelte | 5 +- .../built-in/assessmentsAverage/engage.ts | 11 +- .../built-in/assessmentsAverage/utils.ts | 2 +- .../AssessmentsOverview.svelte | 44 +++- .../built-in/assessmentsOverview/api.ts | 58 +++-- .../built-in/assessmentsOverview/engageApi.ts | 239 ++++++++++++++++++ .../built-in/assessmentsOverview/index.ts | 80 +++++- .../built-in/assessmentsOverview/styles.css | 9 + src/seqta/utils/engageAssessmentStudent.ts | 30 +++ 9 files changed, 436 insertions(+), 42 deletions(-) create mode 100644 src/plugins/built-in/assessmentsOverview/engageApi.ts create mode 100644 src/seqta/utils/engageAssessmentStudent.ts diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index 3b916dd9..7bc7ecc9 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -45,6 +45,7 @@ } import { getAllPluginSettings } from "@/plugins" + import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage" import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types" // Union type representing all possible settings @@ -79,7 +80,9 @@ settings: Record; } - const pluginSettings = getAllPluginSettings() as Plugin[]; + const pluginSettings = getAllPluginSettings().filter( + (plugin) => !(isSeqtaEngageExperience() && plugin.pluginId === "global-search"), + ) as Plugin[]; const pluginSettingsValues = $state>>({}); let cloudState = $state(cloudAuth.state); diff --git a/src/plugins/built-in/assessmentsAverage/engage.ts b/src/plugins/built-in/assessmentsAverage/engage.ts index 07b7c1e0..1010e74a 100644 --- a/src/plugins/built-in/assessmentsAverage/engage.ts +++ b/src/plugins/built-in/assessmentsAverage/engage.ts @@ -1,13 +1,4 @@ -const ENGAGE_STUDENT_STORAGE_KEY = () => - `bsplus.engageTimetable.student.${location.origin}`; - -/** Engage assessments URLs: /#?page=/assessments/{studentId}/{programme}:{metaclass}:{studentId} */ -export function getEngageAssessmentStudentId(): string | null { - const hashMatch = window.location.hash.match(/\/assessments\/(\d+)/); - if (hashMatch?.[1]) return hashMatch[1]; - - return localStorage.getItem(ENGAGE_STUDENT_STORAGE_KEY()); -} +import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent"; function randomEngagePdfFileName(): string { const token = Math.random().toString(36).slice(2, 10); diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index f720cd19..4bdc044c 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -1,9 +1,9 @@ import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts"; import ReactFiber from "@/seqta/utils/ReactFiber.ts"; import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; +import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent"; import { getEngageAssessmentReportUrl, - getEngageAssessmentStudentId, requestEngageAssessmentPdf, } from "./engage.ts"; import { diff --git a/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte b/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte index 821f4b2e..c30c1835 100644 --- a/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte +++ b/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte @@ -1,12 +1,15 @@ @@ -352,6 +383,14 @@

    Assessments

    + {#if showStudentFilter} + + {/if}