super clean popout

This commit is contained in:
2026-06-04 14:33:52 +09:30
parent ce18412405
commit 824812ea9e
2 changed files with 1000 additions and 86 deletions
@@ -5,11 +5,13 @@ import { closePopup } from "./PopupManager";
import { getApiBase } from "../DevApiBase";
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
import { cloudAuth } from "../CloudAuth";
import type { Theme } from "@/interface/types/Theme";
import {
buildModalHeroSlides,
normalizeStoreTheme,
} from "@/interface/utils/themeStoreFlavours";
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
/**
* 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;
@@ -22,12 +24,6 @@ export interface ThemeOfTheMonthEntry {
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`, {
@@ -45,7 +41,6 @@ export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | nul
}
}
/** True when the current month's entry should appear in the startup queue. */
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
return settingsState.themeOfTheMonthDismissedMonth !== entry.month;
@@ -67,7 +62,6 @@ function formatMonthLabel(month: string): string {
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;
@@ -76,41 +70,484 @@ function heroUrlFromStoreTheme(theme: {
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> {
export async function fetchThemeStoreTheme(themeId: string): Promise<Theme | 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 } } };
})) as { success?: boolean; data?: { theme?: Record<string, unknown> } };
if (!res?.success || !res?.data?.theme) return null;
return heroUrlFromStoreTheme(res.data.theme);
return normalizeStoreTheme(res.data.theme);
} catch (err) {
console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err);
console.warn("[ThemeOfTheMonth] Failed to fetch theme store details:", 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;
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
const theme = await fetchThemeStoreTheme(themeId);
return theme ? heroUrlFromStoreTheme(theme) : null;
}
function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
type PopupGallerySlide = { imageUrl: string; caption: string };
function buildPopupGallerySlides(
entry: ThemeOfTheMonthEntry,
storeTheme: Theme | null,
heroUrl: string | null,
): PopupGallerySlide[] {
if (storeTheme) {
return buildModalHeroSlides(storeTheme).filter((s) => s.imageUrl.trim());
}
if (heroUrl) {
return [{ imageUrl: heroUrl, caption: entry.title }];
}
return [];
}
/** Store theme identity on the hero — not the TOTM notice copy in the body. */
function renderHeroEmbossHtml(storeTheme: Theme, entry: ThemeOfTheMonthEntry): string {
const name = (storeTheme.name || entry.title).trim();
const author = storeTheme.author?.trim() ?? "";
const storeDescription = storeTheme.description?.trim() ?? "";
const entryDesc = entry.description.trim();
const showDescription =
storeDescription.length > 0 && storeDescription !== entryDesc;
const flavourCount = storeTheme.flavours?.length ?? 0;
const flavourLine =
flavourCount > 0
? `${flavourCount} colour variant${flavourCount === 1 ? "" : "s"}`
: "";
if (!name && !author && !showDescription && !flavourLine) return "";
return `
<div class="themeOfTheMonthCardHeroEmboss">
<div class="themeOfTheMonthCardHeroEmbossScrim" aria-hidden="true"></div>
<div class="themeOfTheMonthCardHeroEmbossContent">
<h3 class="themeOfTheMonthCardHeroEmbossTitle">${escapeHTML(name)}</h3>
${author ? `<p class="themeOfTheMonthCardHeroEmbossAuthor">By ${escapeHTML(author)}</p>` : ""}
${
showDescription
? `<p class="themeOfTheMonthCardHeroEmbossDescription">${escapeHTML(storeDescription).replace(/\n/g, "<br />")}</p>`
: ""
}
${flavourLine ? `<p class="themeOfTheMonthCardHeroEmbossVariants">${escapeHTML(flavourLine)}</p>` : ""}
</div>
</div>
`;
}
function renderGallerySlidesHtml(slides: PopupGallerySlide[]): string {
if (slides.length === 0) return "";
const slidesHtml = slides
.map(
(s, i) => `
<figure class="themeOfTheMonthCardGallerySlide" data-slide="${i}">
<img src="${escapeHTML(s.imageUrl)}" alt="${escapeHTML(s.caption)}" loading="lazy" />
<figcaption>${escapeHTML(s.caption)}</figcaption>
</figure>
`,
)
.join("");
const nav =
slides.length > 1
? `
<button type="button" class="themeOfTheMonthCardGalleryPrev" aria-label="Previous image"></button>
<button type="button" class="themeOfTheMonthCardGalleryNext" aria-label="Next image"></button>
<div class="themeOfTheMonthCardGalleryDots" role="tablist" aria-label="Theme previews">
${slides
.map(
(_, i) =>
`<button type="button" class="themeOfTheMonthCardGalleryDot${i === 0 ? " themeOfTheMonthCardGalleryDotActive" : ""}" data-slide="${i}" role="tab" aria-label="Image ${i + 1} of ${slides.length}" aria-selected="${i === 0 ? "true" : "false"}"></button>`,
)
.join("")}
</div>
`
: "";
return `
<div class="themeOfTheMonthCardGallery">
<div class="themeOfTheMonthCardGalleryTrack">${slidesHtml}</div>
${nav}
</div>
`;
}
const POPOUT_EXPAND_SVG = /* svg */ `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>`;
const POPOUT_COLLAPSE_SVG = /* svg */ `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 14h6v6"/><path d="M20 10h-6V4"/><path d="M14 10l7-7"/><path d="M3 21l7-7"/></svg>`;
const TOTM_MARGIN_PX = 18;
const TOTM_EXPANDED_SHELL_MAX_PX = 560;
const TOTM_EASE = "cubic-bezier(0.76, 0, 0.24, 1)";
const TOTM_MORPH_MS = 550;
const TOTM_LAYOUT_SWAP_MS = TOTM_MORPH_MS / 2;
let themeOfTheMonthAnimGen = 0;
// ---------------------------------------------------------------------------
// Dimension helpers
// ---------------------------------------------------------------------------
function themeOfTheMonthCollapsedWidth(): number {
return Math.min(360, window.innerWidth - TOTM_MARGIN_PX * 2);
}
function themeOfTheMonthExpandedWidth(): number {
return Math.min(520, window.innerWidth - 32);
}
function themeOfTheMonthMaxCardHeight(): number {
return window.innerHeight - TOTM_MARGIN_PX * 2;
}
/** Fixed expanded card height — stable morph target; footer pinned inside via CSS. */
function themeOfTheMonthExpandedShellHeight(): number {
return Math.min(TOTM_EXPANDED_SHELL_MAX_PX, themeOfTheMonthMaxCardHeight());
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
// ---------------------------------------------------------------------------
// Pure-transform positioning
//
// The card sits at position: fixed; top: 0; left: 0 at all times.
// All movement is expressed as translate(x, y) so CSS transitions drive
// the full path — no top/left changes mid-animation that would cause snapping.
// ---------------------------------------------------------------------------
/**
* Compute the translate values that place the card at the correct position.
* Collapsed → bottom-right corner. Expanded → viewport centre.
* Both states are expressed purely as transform offsets from (0, 0).
*/
function computeCardTranslate(
cardWidth: number,
cardHeight: number,
expanded: boolean,
): { x: number; y: number } {
if (expanded) {
const x = Math.round(
Math.max(
TOTM_MARGIN_PX,
Math.min(
(window.innerWidth - cardWidth) / 2,
window.innerWidth - TOTM_MARGIN_PX - cardWidth,
),
),
);
const y = Math.round(
Math.max(
TOTM_MARGIN_PX,
Math.min(
(window.innerHeight - cardHeight) / 2,
window.innerHeight - TOTM_MARGIN_PX - cardHeight,
),
),
);
return { x, y };
} else {
const x = Math.round(
Math.max(
TOTM_MARGIN_PX,
Math.min(
window.innerWidth - cardWidth - TOTM_MARGIN_PX,
window.innerWidth - TOTM_MARGIN_PX - cardWidth,
),
),
);
const y = Math.round(
Math.max(
TOTM_MARGIN_PX,
window.innerHeight - cardHeight - TOTM_MARGIN_PX,
),
);
return { x, y };
}
}
/**
* Apply card dimensions + border-radius, then set transform so the card
* lands at the right position.
*
* `targetHeight` must be passed explicitly — never read scrollHeight here,
* because content may be hidden/shown mid-animation and scrollHeight would
* give the wrong value, causing the snap-to-full-height bug.
*/
function applyThemeOfTheMonthCardPosition(
card: HTMLElement,
expanded: boolean,
animate: boolean,
targetHeight?: number,
): void {
const width = expanded
? themeOfTheMonthExpandedWidth()
: themeOfTheMonthCollapsedWidth();
card.style.width = `${width}px`;
card.style.maxHeight = expanded ? `${themeOfTheMonthMaxCardHeight()}px` : "";
card.style.borderRadius = expanded ? "22px" : "20px";
const h = targetHeight ?? card.offsetHeight;
const { x, y } = computeCardTranslate(width, h, expanded);
const canAnimate =
animate &&
settingsState.animations &&
card.classList.contains("themeOfTheMonthCardMorphReady");
if (canAnimate) {
card.style.transition = [
`transform ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
`width ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
`height ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
`border-radius ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
].join(", ");
} else {
card.style.transition = "none";
}
// Force a reflow so the browser registers the pre-transition state.
void card.offsetHeight;
if (targetHeight !== undefined) card.style.height = `${targetHeight}px`;
card.style.transform = `translate(${x}px, ${y}px)`;
if (!canAnimate) {
requestAnimationFrame(() => {
if (card.isConnected) card.style.transition = "";
});
}
}
// ---------------------------------------------------------------------------
// Height helpers — keep card height explicit during animation so transforms
// can be calculated correctly.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Expand / collapse animations
// ---------------------------------------------------------------------------
function applyExpandedCardShell(card: HTMLElement): number {
const h = themeOfTheMonthExpandedShellHeight();
card.classList.add("themeOfTheMonthCardExpandedShell");
card.style.height = `${h}px`;
card.style.maxHeight = `${themeOfTheMonthMaxCardHeight()}px`;
card.style.overflow = "hidden";
return h;
}
function clearExpandedCardShell(card: HTMLElement): void {
card.classList.remove("themeOfTheMonthCardExpandedShell");
card.style.height = "";
card.style.maxHeight = "";
card.style.overflow = "";
}
function applyExpandedLayout(card: HTMLElement, descriptionHtml: string): void {
const desc = card.querySelector<HTMLElement>(".themeOfTheMonthCardDescription");
const expandedPanel = card.querySelector<HTMLElement>(".themeOfTheMonthCardExpandedPanel");
card.classList.add("themeOfTheMonthCardExpanded", "themeOfTheMonthCardShowGallery");
expandedPanel?.removeAttribute("hidden");
if (expandedPanel) {
expandedPanel.style.opacity = "";
expandedPanel.style.transition = "";
}
if (desc) {
desc.innerHTML = descriptionHtml;
desc.classList.add("themeOfTheMonthCardDescriptionExpanded");
desc.classList.remove("themeOfTheMonthCardDescriptionClipped");
}
}
function applyCollapsedLayout(card: HTMLElement, descriptionHtml: string): void {
const desc = card.querySelector<HTMLElement>(".themeOfTheMonthCardDescription");
const expandedPanel = card.querySelector<HTMLElement>(".themeOfTheMonthCardExpandedPanel");
const body = card.querySelector<HTMLElement>(".themeOfTheMonthCardBody");
card.classList.remove(
"themeOfTheMonthCardExpanded",
"themeOfTheMonthCardShowGallery",
"themeOfTheMonthCardExpandedShell",
);
clearExpandedCardShell(card);
expandedPanel?.setAttribute("hidden", "");
if (expandedPanel) {
expandedPanel.style.opacity = "";
expandedPanel.style.transition = "";
}
if (body) {
body.style.opacity = "";
body.style.transition = "";
}
if (desc) {
desc.innerHTML = descriptionHtml;
desc.classList.remove("themeOfTheMonthCardDescriptionExpanded");
desc.classList.add("themeOfTheMonthCardDescriptionClipped");
}
}
function clearCardInlineSizeForMeasure(card: HTMLElement): void {
card.style.height = "";
card.style.maxHeight = "";
card.style.overflow = "";
}
function measureCollapsedTargetHeight(card: HTMLElement, descriptionHtml: string): number {
applyCollapsedLayout(card, descriptionHtml);
card.style.width = `${themeOfTheMonthCollapsedWidth()}px`;
clearCardInlineSizeForMeasure(card);
void card.offsetHeight;
return Math.min(card.scrollHeight, themeOfTheMonthMaxCardHeight());
}
async function runThemeOfTheMonthExpand(
card: HTMLElement,
backdrop: HTMLElement | null,
descriptionHtml: string,
): Promise<void> {
const gen = ++themeOfTheMonthAnimGen;
const fromH = card.offsetHeight;
const toH = themeOfTheMonthExpandedShellHeight();
// Morph starts in mini layout; swap to expanded layout halfway through the move.
applyCollapsedLayout(card, descriptionHtml);
card.style.width = `${themeOfTheMonthCollapsedWidth()}px`;
card.classList.add("themeOfTheMonthCardExpanding");
card.style.height = `${fromH}px`;
card.style.overflow = "hidden";
if (backdrop) {
backdrop.hidden = false;
backdrop.setAttribute("aria-hidden", "false");
requestAnimationFrame(() => backdrop.classList.add("themeOfTheMonthBackdropVisible"));
}
applyThemeOfTheMonthCardPosition(card, true, true, toH);
await sleep(TOTM_LAYOUT_SWAP_MS);
if (gen !== themeOfTheMonthAnimGen) return;
applyExpandedLayout(card, descriptionHtml);
applyExpandedCardShell(card);
await sleep(TOTM_LAYOUT_SWAP_MS);
if (gen !== themeOfTheMonthAnimGen) return;
card.classList.remove("themeOfTheMonthCardExpanding");
card.style.transition = "";
const finalH = themeOfTheMonthExpandedShellHeight();
card.style.height = `${finalH}px`;
applyThemeOfTheMonthCardPosition(card, true, false, finalH);
}
async function runThemeOfTheMonthCollapse(
card: HTMLElement,
backdrop: HTMLElement | null,
descriptionHtml: string,
): Promise<void> {
const gen = ++themeOfTheMonthAnimGen;
const fromH = card.offsetHeight;
const toH = measureCollapsedTargetHeight(card, descriptionHtml);
// Restore expanded visuals, then run one morph (size + position + height together).
applyExpandedLayout(card, descriptionHtml);
card.style.width = `${themeOfTheMonthExpandedWidth()}px`;
card.classList.add("themeOfTheMonthCardExpanding", "themeOfTheMonthCardCollapsing");
card.style.height = `${fromH}px`;
card.style.overflow = "hidden";
if (backdrop) {
backdrop.classList.remove("themeOfTheMonthBackdropVisible");
backdrop.setAttribute("aria-hidden", "true");
}
applyThemeOfTheMonthCardPosition(card, false, true, toH);
await sleep(TOTM_LAYOUT_SWAP_MS);
if (gen !== themeOfTheMonthAnimGen) return;
applyCollapsedLayout(card, descriptionHtml);
await sleep(TOTM_LAYOUT_SWAP_MS);
if (gen !== themeOfTheMonthAnimGen) return;
card.classList.remove(
"themeOfTheMonthCardExpanding",
"themeOfTheMonthCardCollapsing",
);
card.style.height = `${toH}px`;
card.style.overflow = "";
card.style.transition = "";
if (backdrop) backdrop.hidden = true;
}
// ---------------------------------------------------------------------------
// Instant (reduced-motion) state setter
// ---------------------------------------------------------------------------
function setThemeOfTheMonthExpandedInstant(
card: HTMLElement,
backdrop: HTMLElement | null,
expanded: boolean,
descriptionHtml: string,
): void {
themeOfTheMonthAnimGen++;
card.classList.toggle("themeOfTheMonthCardExpanded", expanded);
updateThemeOfTheMonthPopoutUi(card, expanded);
if (expanded) {
applyExpandedLayout(card, descriptionHtml);
if (backdrop) {
backdrop.hidden = false;
backdrop.setAttribute("aria-hidden", "false");
backdrop.classList.add("themeOfTheMonthBackdropVisible");
}
applyExpandedCardShell(card);
} else {
applyCollapsedLayout(card, descriptionHtml);
if (backdrop) {
backdrop.classList.remove("themeOfTheMonthBackdropVisible");
backdrop.setAttribute("aria-hidden", "true");
backdrop.hidden = true;
}
}
applyThemeOfTheMonthCardPosition(
card,
expanded,
false,
expanded ? themeOfTheMonthExpandedShellHeight() : undefined,
);
}
// ---------------------------------------------------------------------------
// UI helpers
// ---------------------------------------------------------------------------
function updateThemeOfTheMonthPopoutUi(card: HTMLElement, expanded: boolean): void {
const popout = card.querySelector<HTMLButtonElement>(".themeOfTheMonthCardPopout");
const popoutIcon = popout?.querySelector(".themeOfTheMonthCardPopoutIcon");
if (popoutIcon) {
popoutIcon.innerHTML = expanded ? POPOUT_COLLAPSE_SVG : POPOUT_EXPAND_SVG;
}
if (popout) {
popout.setAttribute("aria-label", expanded ? "Collapse" : "Expand");
popout.title = expanded ? "Collapse" : "Expand";
}
}
function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void): void {
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
card.classList.add("themeOfTheMonthCardClosing");
window.setTimeout(() => {
card.remove();
@@ -118,38 +555,127 @@ function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
}, 180);
}
/**
* Renders the Theme of the Month announcement card.
*/
// ---------------------------------------------------------------------------
// Gallery
// ---------------------------------------------------------------------------
function initThemeOfTheMonthGallery(card: HTMLElement): void {
const track = card.querySelector<HTMLElement>(".themeOfTheMonthCardGalleryTrack");
if (!track) return;
const slides = [...track.querySelectorAll<HTMLElement>(".themeOfTheMonthCardGallerySlide")];
if (slides.length <= 1) return;
const dots = [...card.querySelectorAll<HTMLButtonElement>(".themeOfTheMonthCardGalleryDot")];
let activeIndex = 0;
const scrollToIndex = (index: number) => {
const clamped = ((index % slides.length) + slides.length) % slides.length;
activeIndex = clamped;
const slide = slides[clamped];
track.scrollTo({ left: slide.offsetLeft, behavior: "smooth" });
for (const dot of dots) {
const isActive = Number(dot.dataset.slide) === clamped;
dot.classList.toggle("themeOfTheMonthCardGalleryDotActive", isActive);
dot.setAttribute("aria-selected", isActive ? "true" : "false");
}
};
card.querySelector(".themeOfTheMonthCardGalleryPrev")?.addEventListener("click", (e) => {
e.stopPropagation();
scrollToIndex(activeIndex - 1);
});
card.querySelector(".themeOfTheMonthCardGalleryNext")?.addEventListener("click", (e) => {
e.stopPropagation();
scrollToIndex(activeIndex + 1);
});
for (const dot of dots) {
dot.addEventListener("click", (e) => {
e.stopPropagation();
scrollToIndex(Number((e.currentTarget as HTMLButtonElement).dataset.slide));
});
}
const syncDotsFromScroll = () => {
const mid = track.scrollLeft + track.clientWidth / 2;
let nearest = 0;
let nearestDist = Infinity;
slides.forEach((slide, i) => {
const center = slide.offsetLeft + slide.offsetWidth / 2;
const dist = Math.abs(center - mid);
if (dist < nearestDist) {
nearestDist = dist;
nearest = i;
}
});
if (nearest === activeIndex) return;
activeIndex = nearest;
for (const dot of dots) {
const isActive = Number(dot.dataset.slide) === nearest;
dot.classList.toggle("themeOfTheMonthCardGalleryDotActive", isActive);
dot.setAttribute("aria-selected", isActive ? "true" : "false");
}
};
track.addEventListener("scroll", syncDotsFromScroll, { passive: true });
}
function attachPopupImages(root: ParentNode): void {
for (const img of root.querySelectorAll<HTMLImageElement>("img")) {
attachPopupMediaFullscreen(img);
}
}
// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------
export async function OpenThemeOfTheMonthPopup(
entry: ThemeOfTheMonthEntry,
onDismissed?: () => void,
) {
): Promise<void> {
document.getElementById("theme-of-the-month-card")?.remove();
document.getElementById("theme-of-the-month-backdrop")?.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 storeTheme = linkedThemeId ? await fetchThemeStoreTheme(linkedThemeId) : null;
const heroUrl =
(storeTheme ? heroUrlFromStoreTheme(storeTheme) : null) ??
entry.cover_image?.trim() ??
null;
const gallerySlides = buildPopupGallerySlides(entry, storeTheme, heroUrl);
const hasExpandableContent = gallerySlides.length > 0 || entry.description.trim().length > 0;
const descriptionHtml = escapeHTML(entry.description).replace(/\n/g, "<br />");
const heroEmbossHtml =
heroUrl && storeTheme ? renderHeroEmbossHtml(storeTheme, entry) : "";
const backdrop = stringToHTML(/* html */ `
<div id="theme-of-the-month-backdrop" class="themeOfTheMonthBackdrop" hidden aria-hidden="true"></div>
`).firstChild as HTMLElement;
const card = stringToHTML(/* html */ `
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
${
heroUrl
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
: ""
}
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard${settingsState.animations ? "" : " themeOfTheMonthCardReducedMotion"}" role="dialog" aria-label="Theme of the Month">
<div class="themeOfTheMonthCardMedia">
<button type="button" class="themeOfTheMonthCardPopout" aria-label="Expand" title="Expand"${hasExpandableContent ? "" : " hidden"}>
<span class="themeOfTheMonthCardPopoutIcon">${POPOUT_EXPAND_SVG}</span>
</button>
<div class="themeOfTheMonthCardCompactMedia"${heroUrl ? "" : " hidden"}>
${heroUrl ? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />` : ""}
</div>
<div class="themeOfTheMonthCardExpandedPanel" hidden>
${renderGallerySlidesHtml(gallerySlides)}
</div>
${heroEmbossHtml}
</div>
<div class="themeOfTheMonthCardBody">
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
<h2>${escapeHTML(entry.title)}</h2>
<p class="themeOfTheMonthCardDescription">${description}</p>
<p class="themeOfTheMonthCardDescription themeOfTheMonthCardDescriptionClipped">${descriptionHtml}</p>
<div class="themeOfTheMonthCardActions">
<div class="themeOfTheMonthCardActionsStart">
${
linkedThemeId
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
: ""
}
${linkedThemeId ? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>` : ""}
</div>
<div class="themeOfTheMonthCardActionsEnd">
<button type="button" class="themeOfTheMonthCardSecondary">Close</button>
@@ -170,32 +696,97 @@ export async function OpenThemeOfTheMonthPopup(
</aside>
`).firstChild as HTMLElement;
const autoCloseTimeout = window.setTimeout(() => {
closeThemeOfTheMonthCard(card, onDismissed);
}, 30_000);
let isExpanded = false;
let expandAnimating = false;
const dismiss = () => {
window.clearTimeout(autoCloseTimeout);
const applyExpandedState = async (expanded: boolean): Promise<void> => {
updateThemeOfTheMonthPopoutUi(card, expanded);
if (!settingsState.animations) {
setThemeOfTheMonthExpandedInstant(card, backdrop, expanded, descriptionHtml);
return;
}
expandAnimating = true;
try {
if (expanded) {
await runThemeOfTheMonthExpand(card, backdrop, descriptionHtml);
} else {
await runThemeOfTheMonthCollapse(card, backdrop, descriptionHtml);
}
} finally {
expandAnimating = false;
updateThemeOfTheMonthPopoutUi(card, expanded);
}
};
const onDocKey = (ev: KeyboardEvent) => {
if (ev.key !== "Escape") return;
if (!isExpanded || expandAnimating) return;
ev.stopPropagation();
isExpanded = false;
void applyExpandedState(false);
};
let autoCloseTimeout = 0;
const pauseAutoClose = () => window.clearTimeout(autoCloseTimeout);
const onResize = () => {
if (isExpanded) applyExpandedCardShell(card);
applyThemeOfTheMonthCardPosition(
card,
isExpanded,
false,
isExpanded ? themeOfTheMonthExpandedShellHeight() : undefined,
);
};
const dismissWithCleanup = () => {
pauseAutoClose();
window.removeEventListener("resize", onResize);
backdrop.remove();
document.removeEventListener("keydown", onDocKey, true);
closeThemeOfTheMonthCard(card, onDismissed);
};
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true });
autoCloseTimeout = window.setTimeout(dismissWithCleanup, 30_000);
card.addEventListener("mouseenter", pauseAutoClose, { once: true });
initThemeOfTheMonthGallery(card);
attachPopupImages(card);
const confirmEl = card.querySelector<HTMLElement>(".themeOfTheMonthCardConfirm");
const toggleExpanded = () => {
if (expandAnimating) return;
isExpanded = !isExpanded;
pauseAutoClose();
void applyExpandedState(isExpanded);
};
card.querySelector(".themeOfTheMonthCardPopout")?.addEventListener("click", (e) => {
e.stopPropagation();
toggleExpanded();
});
backdrop.addEventListener("click", () => {
if (!isExpanded || expandAnimating) return;
isExpanded = false;
void applyExpandedState(false);
});
document.addEventListener("keydown", onDocKey, true);
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDismissedMonth = entry.month;
dismiss();
dismissWithCleanup();
});
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDismissedMonth = entry.month;
dismiss();
dismissWithCleanup();
openThemeStoreWithHighlight(linkedThemeId!);
});
const openDontShowConfirm = () => {
window.clearTimeout(autoCloseTimeout);
pauseAutoClose();
if (!confirmEl) return;
confirmEl.hidden = false;
requestAnimationFrame(() => confirmEl.classList.add("themeOfTheMonthCardConfirmVisible"));
@@ -206,23 +797,37 @@ export async function OpenThemeOfTheMonthPopup(
card.querySelector(".themeOfTheMonthCardConfirmCancel")?.addEventListener("click", () => {
if (!confirmEl) return;
confirmEl.classList.remove("themeOfTheMonthCardConfirmVisible");
window.setTimeout(() => {
confirmEl.hidden = true;
}, 160);
window.setTimeout(() => { confirmEl.hidden = true; }, 160);
});
card.querySelector(".themeOfTheMonthCardConfirmAccept")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDisabled = true;
dismiss();
dismissWithCleanup();
});
document.body.appendChild(card);
// Mount — card at top:0; left:0, all positioning via transform.
card.style.position = "fixed";
card.style.top = "0";
card.style.left = "0";
document.body.append(backdrop, card);
// Set initial collapsed position instantly (no transition).
applyThemeOfTheMonthCardPosition(card, false, false);
window.addEventListener("resize", onResize);
// Enable morph-ready class after two frames so the initial snap doesn't
// accidentally play a transition.
if (settingsState.animations) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
card.classList.add("themeOfTheMonthCardMorphReady");
});
});
}
}
/**
* Dev helper: fetch the current month's entry and show the popup immediately,
* even if the user dismissed it for this calendar month.
*/
export async function showThemeOfTheMonthPopupNow(): Promise<void> {
const entry = await fetchThemeOfTheMonth();
if (!entry) {
@@ -240,4 +845,4 @@ export async function showThemeOfTheMonthPopupNow(): Promise<void> {
}
await OpenThemeOfTheMonthPopup(entry);
}
}