From c0a8a761052d156ddee6d4ca194cac2cfc96800a Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Tue, 19 May 2026 20:19:50 +0930 Subject: [PATCH] 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;