mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
+29
-3
@@ -9,6 +9,21 @@ import {
|
|||||||
runCloudSettingsPoll,
|
runCloudSettingsPoll,
|
||||||
} from "./background/cloudSettingsAutoSync";
|
} 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() {
|
function reloadSeqtaPages() {
|
||||||
const result = browser.tabs.query({});
|
const result = browser.tabs.query({});
|
||||||
function open(tabs: any) {
|
function open(tabs: any) {
|
||||||
@@ -29,7 +44,7 @@ type MessageSender = { (response?: unknown): void };
|
|||||||
|
|
||||||
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
|
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
|
||||||
const { token } = request;
|
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 githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
@@ -57,7 +72,7 @@ function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boo
|
|||||||
}
|
}
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
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((r) => r.json())
|
||||||
.then(sendResponse)
|
.then(sendResponse)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -283,7 +298,7 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const isFavorite = action === "favorite";
|
const isFavorite = action === "favorite";
|
||||||
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, {
|
fetch(`${apiBase()}/api/themes/${themeId}/favorite`, {
|
||||||
method: isFavorite ? "POST" : "DELETE",
|
method: isFavorite ? "POST" : "DELETE",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
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> = {
|
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
||||||
reloadTabs: () => reloadSeqtaPages(),
|
reloadTabs: () => reloadSeqtaPages(),
|
||||||
|
setDevApiBase: handleSetDevApiBase,
|
||||||
extensionPages: (req) => {
|
extensionPages: (req) => {
|
||||||
browser.tabs.query({}).then((tabs) => {
|
browser.tabs.query({}).then((tabs) => {
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
|
|||||||
@@ -3726,6 +3726,59 @@ div.day-empty {
|
|||||||
color: var(--text-primary);
|
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 {
|
.popup-media-fullscreenable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
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 CloudHeader from "@/interface/components/store/CloudHeader.svelte"
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth"
|
import { cloudAuth } from "@/seqta/utils/CloudAuth"
|
||||||
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
|
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
|
||||||
|
import { showThemeOfTheMonthPopupNow } from "@/seqta/utils/Openers/OpenThemeOfTheMonthPopup"
|
||||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
||||||
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
|
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 { getAllPluginSettings } from "@/plugins"
|
||||||
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
|
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
|
||||||
@@ -483,6 +509,22 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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="flex justify-between items-center px-4 py-3">
|
||||||
<div class="pr-4">
|
<div class="pr-4">
|
||||||
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
|
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
|
||||||
@@ -492,6 +534,31 @@
|
|||||||
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
|
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import Backgrounds from '../components/store/Backgrounds.svelte'
|
import Backgrounds from '../components/store/Backgrounds.svelte'
|
||||||
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
||||||
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
|
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
|
||||||
|
import { consumePendingHighlightThemeId } from '@/seqta/utils/openThemeStoreWithHighlight'
|
||||||
|
|
||||||
const themeManager = ThemeManager.getInstance();
|
const themeManager = ThemeManager.getInstance();
|
||||||
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
|
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
|
// On mount
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
window.addEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
|
||||||
|
|
||||||
await fetchThemes();
|
await fetchThemes();
|
||||||
await fetchCurrentThemes();
|
await fetchCurrentThemes();
|
||||||
|
|
||||||
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
|
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
|
||||||
darkMode = $settingsState.DarkMode;
|
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)
|
// 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 debounce from "@/seqta/utils/debounce";
|
||||||
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
|
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
import { getApiBase } from "@/seqta/utils/DevApiBase";
|
||||||
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
||||||
import {
|
import {
|
||||||
clearCustomThemeAdaptiveCssVariables,
|
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';
|
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);
|
console.error("Error during loading cleanup:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
runStartupPopupQueue();
|
void runStartupPopupQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetCSSElement(file: string) {
|
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 { closePopup, openPopup } 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,
|
shouldShowEngageParentsAnnouncement,
|
||||||
showEngageParentsToast,
|
showEngageParentsToast,
|
||||||
} from "./OpenEngageParentsAnnouncement";
|
} from "./OpenEngageParentsAnnouncement";
|
||||||
|
import {
|
||||||
|
fetchThemeOfTheMonth,
|
||||||
|
OpenThemeOfTheMonthPopup,
|
||||||
|
shouldShowThemeOfTheMonth,
|
||||||
|
} from "./OpenThemeOfTheMonthPopup";
|
||||||
|
import { syncApiBaseToBackground } from "../DevApiBase";
|
||||||
|
|
||||||
type QueueStep = (goNext: () => void) => void;
|
type QueueStep = (goNext: () => void) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs startup modals in order: What's New (if the extension just updated),
|
* 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[] = [];
|
const steps: QueueStep[] = [];
|
||||||
|
|
||||||
if (settingsState.justupdated) {
|
if (settingsState.justupdated) {
|
||||||
steps.push((goNext) => OpenWhatsNewPopup(goNext));
|
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() {
|
function runNext() {
|
||||||
const step = steps.shift();
|
const step = steps.shift();
|
||||||
if (step) step(runNext);
|
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;
|
engageParentsAnnouncementShown?: boolean;
|
||||||
/** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */
|
/** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */
|
||||||
bsCloudAutoSyncAnnouncementShown?: boolean;
|
bsCloudAutoSyncAnnouncementShown?: boolean;
|
||||||
|
/** ID of the last Theme of the Month entry shown to the user (shows once per new entry). */
|
||||||
|
themeOfTheMonthLastSeenId?: string;
|
||||||
timeFormat?: string;
|
timeFormat?: string;
|
||||||
animations: boolean;
|
animations: boolean;
|
||||||
defaultPage: string;
|
defaultPage: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user