mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
feat: Theme Of The Month
This commit is contained in:
+29
-3
@@ -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<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
@@ -57,7 +72,7 @@ function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boo
|
||||
}
|
||||
const headers: Record<string, string> = {};
|
||||
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<string, MessageHandler> = {
|
||||
reloadTabs: () => reloadSeqtaPages(),
|
||||
setDevApiBase: handleSetDevApiBase,
|
||||
extensionPages: (req) => {
|
||||
browser.tabs.query({}).then((tabs) => {
|
||||
for (const tab of tabs) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>(getStoredOverride() ?? "")
|
||||
let devApiBaseActive = $state<string | null>(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 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-4 py-3">
|
||||
<div class="pr-4">
|
||||
<h2 class="text-sm font-bold">Show Theme of the Month</h2>
|
||||
<p class="text-xs">Fetch and show the current month's popup now (ignores dismissed state)</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
closeExtensionPopup();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await showThemeOfTheMonthPopupNow();
|
||||
}}
|
||||
text="Show Now"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-4 py-3">
|
||||
<div class="pr-4">
|
||||
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
|
||||
@@ -492,6 +534,31 @@
|
||||
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3">
|
||||
<div class="flex justify-between items-start gap-3">
|
||||
<div class="pr-4">
|
||||
<h2 class="text-sm font-bold">API Base URL (session only)</h2>
|
||||
<p class="text-xs">Override the content API host for this browser session. Cleared on restart. Affects themes, theme of the month, and other server-driven content.</p>
|
||||
{#if devApiBaseActive}
|
||||
<p class="text-xs mt-1 text-amber-600 dark:text-amber-400">
|
||||
Override active: <span class="font-mono">{devApiBaseActive}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://betterseqta.org"
|
||||
bind:value={devApiBaseInput}
|
||||
class="flex-1 px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
|
||||
/>
|
||||
<Button onClick={applyDevApiBase} text="Apply" />
|
||||
{#if devApiBaseActive}
|
||||
<Button onClick={clearDevApiBase} text="Clear" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -105,7 +105,7 @@ export async function finishLoad() {
|
||||
console.error("Error during loading cleanup:", err);
|
||||
}
|
||||
|
||||
runStartupPopupQueue();
|
||||
void runStartupPopupQueue();
|
||||
}
|
||||
|
||||
export function GetCSSElement(file: string) {
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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<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) 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 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 */ `
|
||||
<div class="whatsnewHeader themeOfTheMonthHeader">
|
||||
<h1>${escapeHTML(entry.title)}</h1>
|
||||
<p class="themeOfTheMonthSubtitle">Theme of the Month · ${escapeHTML(monthLabel)}</p>
|
||||
</div>`,
|
||||
).firstChild as HTMLElement;
|
||||
|
||||
const heroUrl = await resolvePopupHeroImageUrl(entry);
|
||||
const imageContainer = heroUrl ? createHeroImageContainer(heroUrl, entry.title) : null;
|
||||
|
||||
const descriptionHTML = escapeHTML(entry.description).replace(/\n/g, "<br />");
|
||||
const text = stringToHTML(/* html */ `
|
||||
<div class="whatsnewTextContainer themeOfTheMonthDescription" style="height: 50%; overflow-y: auto; font-size: 1.2rem; line-height: 1.6;">
|
||||
<p>${descriptionHTML}</p>
|
||||
</div>
|
||||
`).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<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);
|
||||
}
|
||||
@@ -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,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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user