mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-13 15:14:40 +00:00
super clean popout
This commit is contained in:
+327
-18
@@ -3808,19 +3808,306 @@ div.day-empty {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.themeOfTheMonthBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 47;
|
||||
background: color-mix(in srgb, #000 52%, transparent);
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.55s cubic-bezier(0.76, 0, 0.24, 1);
|
||||
}
|
||||
.themeOfTheMonthBackdropVisible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.themeOfTheMonthCard {
|
||||
position: fixed;
|
||||
right: max(18px, env(safe-area-inset-right));
|
||||
bottom: max(18px, env(safe-area-inset-bottom));
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
z-index: 48;
|
||||
margin: 0;
|
||||
width: min(360px, calc(100vw - 36px));
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||
border-radius: 20px;
|
||||
background: var(--background-primary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
|
||||
animation: themeOfTheMonthCardIn 0.24s ease-out;
|
||||
transform-origin: bottom right;
|
||||
transition: none;
|
||||
animation: themeOfTheMonthCardIn 0.28s ease-out;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded {
|
||||
transform-origin: center center;
|
||||
}
|
||||
/* translate(x,y) is set inline; transition enabled after mount */
|
||||
.themeOfTheMonthCardMorphReady:not(.themeOfTheMonthCardReducedMotion) {
|
||||
transition:
|
||||
transform 0.55s cubic-bezier(0.76, 0, 0.24, 1),
|
||||
width 0.55s cubic-bezier(0.76, 0, 0.24, 1),
|
||||
height 0.55s cubic-bezier(0.76, 0, 0.24, 1),
|
||||
max-height 0.55s cubic-bezier(0.76, 0, 0.24, 1),
|
||||
border-radius 0.55s cubic-bezier(0.76, 0, 0.24, 1);
|
||||
}
|
||||
.themeOfTheMonthCardAnchoredBottom,
|
||||
.themeOfTheMonthCardCollapsing {
|
||||
transform-origin: 100% 100% !important;
|
||||
}
|
||||
/* Expanded: fixed shell; copy scrolls; actions pinned to the bottom. */
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardBody {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardDescription {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
margin: 8px 0 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardActions {
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
padding-top: 14px;
|
||||
}
|
||||
.themeOfTheMonthCardReducedMotion {
|
||||
transition: none !important;
|
||||
}
|
||||
.themeOfTheMonthCardMedia {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#theme-of-the-month-card .themeOfTheMonthCardPopout {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 6;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 50% !important;
|
||||
aspect-ratio: 1;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--background-primary) 80%, transparent);
|
||||
box-shadow:
|
||||
0 2px 10px rgba(0, 0, 0, 0.24),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--text-primary) 14%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.themeOfTheMonthCardPopoutIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.themeOfTheMonthCardPopout:hover {
|
||||
background: color-mix(in srgb, var(--background-primary) 95%, transparent);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.themeOfTheMonthCardPopout:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.themeOfTheMonthCardPopout[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.themeOfTheMonthCardCompactMedia {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: 20px 20px 0 0;
|
||||
line-height: 0;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery .themeOfTheMonthCardCompactMedia {
|
||||
display: none;
|
||||
}
|
||||
.themeOfTheMonthCardExpandedPanel {
|
||||
display: none;
|
||||
}
|
||||
.themeOfTheMonthCardExpandedPanel[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery
|
||||
.themeOfTheMonthCardExpandedPanel:not([hidden]) {
|
||||
display: block;
|
||||
}
|
||||
.themeOfTheMonthCardGallery {
|
||||
position: relative;
|
||||
}
|
||||
.themeOfTheMonthCardHeroEmboss {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
pointer-events: none;
|
||||
}
|
||||
.themeOfTheMonthCardHeroEmbossScrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, #000 8%, transparent) 40%,
|
||||
color-mix(in srgb, #000 55%, transparent) 72%,
|
||||
color-mix(in srgb, #000 82%, transparent) 100%
|
||||
);
|
||||
}
|
||||
.themeOfTheMonthCardHeroEmbossContent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmboss {
|
||||
display: flex;
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossTitle {
|
||||
margin: 0;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
color: #fff;
|
||||
text-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.55),
|
||||
0 2px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossAuthor {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: color-mix(in srgb, #fff 88%, transparent);
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossDescription {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.4;
|
||||
color: color-mix(in srgb, #fff 92%, transparent);
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossVariants {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: color-mix(in srgb, #fff 72%, transparent);
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery
|
||||
.themeOfTheMonthCardGallerySlide
|
||||
figcaption {
|
||||
display: none;
|
||||
}
|
||||
.themeOfTheMonthCardGalleryTrack {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.themeOfTheMonthCardGalleryTrack::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.themeOfTheMonthCardGallerySlide {
|
||||
flex: 0 0 100%;
|
||||
margin: 0;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.themeOfTheMonthCardGallerySlide img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: min(42vh, 280px);
|
||||
object-fit: cover;
|
||||
}
|
||||
.themeOfTheMonthCardGallerySlide figcaption {
|
||||
padding: 8px 14px 0;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.3;
|
||||
color: color-mix(in srgb, var(--text-primary) 68%, transparent);
|
||||
}
|
||||
.themeOfTheMonthCardGalleryPrev,
|
||||
.themeOfTheMonthCardGalleryNext {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin-top: -17px;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.themeOfTheMonthCardGalleryPrev {
|
||||
left: 10px;
|
||||
}
|
||||
.themeOfTheMonthCardGalleryNext {
|
||||
right: 10px;
|
||||
}
|
||||
.themeOfTheMonthCardGalleryDots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px 0;
|
||||
}
|
||||
.themeOfTheMonthCardGalleryDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
background: color-mix(in srgb, var(--text-primary) 28%, transparent);
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.themeOfTheMonthCardGalleryDotActive {
|
||||
background: var(--better-pri, #6366f1);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.themeOfTheMonthCardDescriptionTyping {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: unset;
|
||||
}
|
||||
.themeOfTheMonthCard::before {
|
||||
content: "";
|
||||
@@ -3907,16 +4194,25 @@ div.day-empty {
|
||||
.themeOfTheMonthCardConfirmAccept:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.themeOfTheMonthCardImage {
|
||||
#theme-of-the-month-card .themeOfTheMonthCardImage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
height: 150px !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
margin: 0;
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
object-fit: cover;
|
||||
object-position: center center;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded .themeOfTheMonthCardGallerySlide img {
|
||||
border-radius: 22px 22px 0 0;
|
||||
}
|
||||
.themeOfTheMonthCardBody {
|
||||
padding: 14px 16px 16px;
|
||||
padding: 14px 16px 12px;
|
||||
}
|
||||
.themeOfTheMonthCardEyebrow {
|
||||
margin: 0 0 6px;
|
||||
@@ -3932,14 +4228,26 @@ div.day-empty {
|
||||
line-height: 1.2;
|
||||
}
|
||||
.themeOfTheMonthCardDescription {
|
||||
display: -webkit-box;
|
||||
margin: 8px 0 14px;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
margin: 8px 0 10px;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
|
||||
overflow-wrap: anywhere;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.themeOfTheMonthCardDescriptionClipped {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
.themeOfTheMonthCardDescriptionExpanded {
|
||||
display: block;
|
||||
-webkit-line-clamp: unset;
|
||||
}
|
||||
.themeOfTheMonthCardExpanding:not(.themeOfTheMonthCardExpandedShell)
|
||||
.themeOfTheMonthCardDescriptionExpanded {
|
||||
overflow: hidden;
|
||||
}
|
||||
.themeOfTheMonthCardActions {
|
||||
display: flex;
|
||||
@@ -4027,21 +4335,22 @@ div.day-empty {
|
||||
@keyframes themeOfTheMonthCardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes themeOfTheMonthCardOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
}
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardClosing {
|
||||
animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.themeOfTheMonthCard {
|
||||
.themeOfTheMonthCard,
|
||||
.themeOfTheMonthBackdrop {
|
||||
z-index: 2147483645;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user