mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-13 07:04:39 +00:00
Merge branch 'main' into improved-global-search
This commit is contained in:
@@ -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<HTMLElement | null> {
|
||||
const poll = true as const;
|
||||
const interval = 100;
|
||||
const trySelectors: { selector: string; maxIterations: number }[] = [
|
||||
{ selector: "#menu > ul > li", maxIterations: 500 },
|
||||
{ selector: "#menu ul", maxIterations: 350 },
|
||||
{ selector: "#menu", maxIterations: 350 },
|
||||
];
|
||||
|
||||
for (const { selector, maxIterations } of trySelectors) {
|
||||
try {
|
||||
await waitForElm(selector, poll, interval, maxIterations);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (selector === "#menu > ul > li") {
|
||||
const ul = document.querySelector("#menu > ul") as HTMLElement | null;
|
||||
if (ul) return ul;
|
||||
} else if (selector === "#menu ul") {
|
||||
const ul = document.querySelector("#menu ul") as HTMLElement | null;
|
||||
if (ul) return ul;
|
||||
} else {
|
||||
const menu = document.getElementById("menu");
|
||||
const ul =
|
||||
(menu?.querySelector("ul") as HTMLElement | null) ??
|
||||
(menu?.firstElementChild as HTMLElement | null);
|
||||
if (ul) return ul;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"[BetterSEQTA+] Engage: could not find a menu list to inject the home button",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function injectEngageHomeButton() {
|
||||
if (document.getElementById("homebutton")) return;
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
||||
import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
|
||||
import {
|
||||
type EngageParentChild,
|
||||
type EngageParentTimetableItem,
|
||||
@@ -753,6 +754,9 @@ export async function loadEngageHomePage(): Promise<void> {
|
||||
const engageHomeBody = stringToHTML(/* html */ `
|
||||
<div class="home-root" id="engage-home-root">
|
||||
<div class="home-container" id="engage-home-container">
|
||||
<div class="border shortcut-container">
|
||||
<div class="border shortcuts" id="shortcuts"></div>
|
||||
</div>
|
||||
<div class="border timetable-container">
|
||||
<div class="home-subtitle">
|
||||
<div class="engage-timetable-title-cluster">
|
||||
@@ -792,6 +796,7 @@ export async function loadEngageHomePage(): Promise<void> {
|
||||
|
||||
bindEngageTimetableUi();
|
||||
setEngageTimetableSubtitle();
|
||||
renderShortcuts();
|
||||
|
||||
const select = document.getElementById(
|
||||
"engage-child-selector",
|
||||
|
||||
@@ -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<string>();
|
||||
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))
|
||||
|
||||
@@ -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 */ `
|
||||
<button class="bsplus-toast-close" aria-label="Dismiss">×</button>
|
||||
<div class="bsplus-toast-content">
|
||||
<p class="bsplus-toast-eyebrow">SEQTA Engage support</p>
|
||||
<strong>BetterSEQTA+ now supports <span class="seqtaEngageAccent">SEQTA Engage</span></strong>
|
||||
<p>Buy your mum a BetterSEQTA Plus! Parents now get themes, a cleaner home page, and all the Plus polish on SEQTA Engage.</p>
|
||||
</div>
|
||||
<button class="bsplus-toast-close" aria-label="Dismiss">×</button>
|
||||
`;
|
||||
|
||||
toast.style.opacity = "0";
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import stringToHTML from "../stringToHTML";
|
||||
import { settingsState } from "../listeners/SettingsState";
|
||||
import { 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<ThemeOfTheMonthEntry | null> {
|
||||
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 || settingsState.themeOfTheMonthDisabled) return false;
|
||||
return settingsState.themeOfTheMonthLastSeenId !== entry.id;
|
||||
}
|
||||
|
||||
function escapeHTML(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.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<string | null> {
|
||||
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<string | null> {
|
||||
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 closeThemeOfTheMonthCard(
|
||||
card: HTMLElement,
|
||||
onDismissed?: () => void,
|
||||
markSeen = true,
|
||||
) {
|
||||
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
|
||||
|
||||
if (markSeen) {
|
||||
const entryId = card.dataset.entryId;
|
||||
if (entryId) settingsState.themeOfTheMonthLastSeenId = entryId;
|
||||
}
|
||||
|
||||
card.classList.add("themeOfTheMonthCardClosing");
|
||||
window.setTimeout(() => {
|
||||
card.remove();
|
||||
onDismissed?.();
|
||||
}, 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Theme of the Month announcement card.
|
||||
*/
|
||||
export async function OpenThemeOfTheMonthPopup(
|
||||
entry: ThemeOfTheMonthEntry,
|
||||
onDismissed?: () => void,
|
||||
) {
|
||||
document.getElementById("theme-of-the-month-card")?.remove();
|
||||
|
||||
const monthLabel = formatMonthLabel(entry.month);
|
||||
const heroUrl = await resolvePopupHeroImageUrl(entry);
|
||||
const description = escapeHTML(entry.description).replace(/\n/g, " ");
|
||||
const linkedThemeId = entry.theme_id ?? entry.theme?.id;
|
||||
|
||||
const card = stringToHTML(/* html */ `
|
||||
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
|
||||
<button type="button" class="themeOfTheMonthCardClose" aria-label="Close Theme of the Month">×</button>
|
||||
${
|
||||
heroUrl
|
||||
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
|
||||
: ""
|
||||
}
|
||||
<div class="themeOfTheMonthCardBody">
|
||||
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
|
||||
<h2>${escapeHTML(entry.title)}</h2>
|
||||
<p class="themeOfTheMonthCardDescription">${description}</p>
|
||||
<div class="themeOfTheMonthCardActions">
|
||||
${
|
||||
linkedThemeId
|
||||
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
|
||||
: ""
|
||||
}
|
||||
<button type="button" class="themeOfTheMonthCardSecondary">Don't show again</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
`).firstChild as HTMLElement;
|
||||
|
||||
card.dataset.entryId = entry.id;
|
||||
const autoCloseTimeout = window.setTimeout(() => {
|
||||
closeThemeOfTheMonthCard(card, onDismissed);
|
||||
}, 12000);
|
||||
|
||||
const dismiss = (markSeen = true) => {
|
||||
window.clearTimeout(autoCloseTimeout);
|
||||
closeThemeOfTheMonthCard(card, onDismissed, markSeen);
|
||||
};
|
||||
|
||||
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true });
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -34,7 +34,6 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
||||
const text = stringToHTML(/* html */ `
|
||||
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
||||
|
||||
|
||||
<h1>3.6.6 – Global Search improvements!</h1>
|
||||
<li>Tuned hybrid search and indexing reliability.</li>
|
||||
<li>Clearer progress UI and green “Done!” when a pass finishes.</li>
|
||||
@@ -50,13 +49,12 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
||||
<li>Fixed assessment averages treating N/A weightings incorrectly in subject average calculations.</li>
|
||||
<li>Fixed the display of weightings that could not automatically be discovered.</li>
|
||||
<li>Fixed the formatting of the weighting tag that was broken due to a SEQTA update.</li>
|
||||
|
||||
<h1>3.6.4 - DM Folders, Theme flavours and fixes, Upcoming Assements improvement</h1>
|
||||
|
||||
<h1>3.6.4 - Theme flavours and fixes, Upcoming Assements improvement</h1>
|
||||
<li>Added advanced colour adjustments variables for theme customisation.</li>
|
||||
<li>Improved logic for upcoming assements dashlet to improve compatibility.</li>
|
||||
<li>BS Cloud can now automatically download themes from other devices.</li>
|
||||
<li>Added theme flavours for multiple colour variations of the same theme.</li>
|
||||
<li>Added custom message folder, customizable in settings.</li>
|
||||
|
||||
<h1>3.6.3 - Assessment overview fix</h1>
|
||||
<li>Fixed assessments overview failing to load.</li>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export 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());
|
||||
}
|
||||
|
||||
export function buildEngageAssessmentPagePath(
|
||||
studentId: string | number,
|
||||
programmeId: string | number,
|
||||
metaclassId: string | number,
|
||||
assessmentId?: string | number,
|
||||
): string {
|
||||
const base = `#?page=/assessments/${studentId}/${programmeId}:${metaclassId}:${studentId}`;
|
||||
return assessmentId != null ? `${base}&item=${assessmentId}` : base;
|
||||
}
|
||||
|
||||
export function buildEngageAssessmentOverviewPath(
|
||||
studentId: string | number,
|
||||
): string {
|
||||
return `#?page=/assessments/${studentId}/overview`;
|
||||
}
|
||||
|
||||
export function isEngageAssessmentOverviewRoute(hash = window.location.hash): boolean {
|
||||
return /\/assessments\/\d+\/overview/.test(hash);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 = `<span>${label.innerHTML}</span>`;
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLElement | null> {
|
||||
const poll = true as const;
|
||||
const interval = 100;
|
||||
const trySelectors: { selector: string; maxIterations: number }[] = [
|
||||
{ selector: "#menu > ul > li", maxIterations: 500 },
|
||||
{ selector: "#menu ul", maxIterations: 350 },
|
||||
{ selector: "#menu", maxIterations: 350 },
|
||||
];
|
||||
|
||||
for (const { selector, maxIterations } of trySelectors) {
|
||||
try {
|
||||
await waitForElm(selector, poll, interval, maxIterations);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (selector === "#menu > ul > li") {
|
||||
const ul = document.querySelector("#menu > ul") as HTMLElement | null;
|
||||
if (ul) return ul;
|
||||
} else if (selector === "#menu ul") {
|
||||
const ul = document.querySelector("#menu ul") as HTMLElement | null;
|
||||
if (ul) return ul;
|
||||
} else {
|
||||
const menu = document.getElementById("menu");
|
||||
const ul =
|
||||
(menu?.querySelector("ul") as HTMLElement | null) ??
|
||||
(menu?.firstElementChild as HTMLElement | null);
|
||||
if (ul) return ul;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"[BetterSEQTA+] Engage: could not find a menu list to inject the home button",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user