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);
|
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 {
|
.themeOfTheMonthCard {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: max(18px, env(safe-area-inset-right));
|
top: 0;
|
||||||
bottom: max(18px, env(safe-area-inset-bottom));
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
bottom: auto;
|
||||||
z-index: 48;
|
z-index: 48;
|
||||||
|
margin: 0;
|
||||||
width: min(360px, calc(100vw - 36px));
|
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: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: var(--background-primary);
|
background: var(--background-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
|
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 {
|
.themeOfTheMonthCard::before {
|
||||||
content: "";
|
content: "";
|
||||||
@@ -3907,16 +4194,25 @@ div.day-empty {
|
|||||||
.themeOfTheMonthCardConfirmAccept:active {
|
.themeOfTheMonthCardConfirmAccept:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
.themeOfTheMonthCardImage {
|
#theme-of-the-month-card .themeOfTheMonthCardImage {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100% !important;
|
||||||
height: 150px;
|
min-width: 100%;
|
||||||
|
height: 150px !important;
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 20px 20px 0 0;
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
object-position: center center;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardExpanded .themeOfTheMonthCardGallerySlide img {
|
||||||
|
border-radius: 22px 22px 0 0;
|
||||||
}
|
}
|
||||||
.themeOfTheMonthCardBody {
|
.themeOfTheMonthCardBody {
|
||||||
padding: 14px 16px 16px;
|
padding: 14px 16px 12px;
|
||||||
}
|
}
|
||||||
.themeOfTheMonthCardEyebrow {
|
.themeOfTheMonthCardEyebrow {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
@@ -3932,14 +4228,26 @@ div.day-empty {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
.themeOfTheMonthCardDescription {
|
.themeOfTheMonthCardDescription {
|
||||||
display: -webkit-box;
|
margin: 8px 0 10px;
|
||||||
margin: 8px 0 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
|
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 {
|
.themeOfTheMonthCardActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4027,21 +4335,22 @@ div.day-empty {
|
|||||||
@keyframes themeOfTheMonthCardIn {
|
@keyframes themeOfTheMonthCardIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(18px) scale(0.98);
|
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@keyframes themeOfTheMonthCardOut {
|
@keyframes themeOfTheMonthCardOut {
|
||||||
to {
|
to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(12px) scale(0.98);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.themeOfTheMonthCardExpanded.themeOfTheMonthCardClosing {
|
||||||
|
animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
|
||||||
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.themeOfTheMonthCard {
|
.themeOfTheMonthCard,
|
||||||
|
.themeOfTheMonthBackdrop {
|
||||||
z-index: 2147483645;
|
z-index: 2147483645;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { closePopup } from "./PopupManager";
|
|||||||
import { getApiBase } from "../DevApiBase";
|
import { getApiBase } from "../DevApiBase";
|
||||||
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
|
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
|
||||||
import { cloudAuth } from "../CloudAuth";
|
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 {
|
export interface ThemeOfTheMonthEntry {
|
||||||
id: string;
|
id: string;
|
||||||
month: string;
|
month: string;
|
||||||
@@ -22,12 +24,6 @@ export interface ThemeOfTheMonthEntry {
|
|||||||
updated_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> {
|
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
|
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 {
|
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
|
||||||
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
|
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
|
||||||
return settingsState.themeOfTheMonthDismissedMonth !== entry.month;
|
return settingsState.themeOfTheMonthDismissedMonth !== entry.month;
|
||||||
@@ -67,7 +62,6 @@ function formatMonthLabel(month: string): string {
|
|||||||
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
|
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same priority as the theme store: marquee, then cover/banner. */
|
|
||||||
function heroUrlFromStoreTheme(theme: {
|
function heroUrlFromStoreTheme(theme: {
|
||||||
marqueeImage?: string | null;
|
marqueeImage?: string | null;
|
||||||
coverImage?: string | null;
|
coverImage?: string | null;
|
||||||
@@ -76,41 +70,484 @@ function heroUrlFromStoreTheme(theme: {
|
|||||||
return url || null;
|
return url || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function fetchThemeStoreTheme(themeId: string): Promise<Theme | 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 {
|
try {
|
||||||
const token = await cloudAuth.getStoredToken();
|
const token = await cloudAuth.getStoredToken();
|
||||||
const res = (await browser.runtime.sendMessage({
|
const res = (await browser.runtime.sendMessage({
|
||||||
type: "fetchThemeDetails",
|
type: "fetchThemeDetails",
|
||||||
themeId,
|
themeId,
|
||||||
token: token ?? undefined,
|
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;
|
if (!res?.success || !res?.data?.theme) return null;
|
||||||
return heroUrlFromStoreTheme(res.data.theme);
|
return normalizeStoreTheme(res.data.theme);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err);
|
console.warn("[ThemeOfTheMonth] Failed to fetch theme store details:", err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Linked theme store image, else optional admin-uploaded cover. */
|
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
|
||||||
async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise<string | null> {
|
const theme = await fetchThemeStoreTheme(themeId);
|
||||||
const themeId = entry.theme_id ?? entry.theme?.id;
|
return theme ? heroUrlFromStoreTheme(theme) : null;
|
||||||
if (themeId) {
|
|
||||||
const fromStore = await fetchThemeStoreHeroImage(themeId);
|
|
||||||
if (fromStore) return fromStore;
|
|
||||||
}
|
|
||||||
const fallback = entry.cover_image?.trim();
|
|
||||||
return fallback || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
|
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;
|
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
|
||||||
|
|
||||||
card.classList.add("themeOfTheMonthCardClosing");
|
card.classList.add("themeOfTheMonthCardClosing");
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
card.remove();
|
card.remove();
|
||||||
@@ -118,38 +555,127 @@ function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
|
|||||||
}, 180);
|
}, 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(
|
export async function OpenThemeOfTheMonthPopup(
|
||||||
entry: ThemeOfTheMonthEntry,
|
entry: ThemeOfTheMonthEntry,
|
||||||
onDismissed?: () => void,
|
onDismissed?: () => void,
|
||||||
) {
|
): Promise<void> {
|
||||||
document.getElementById("theme-of-the-month-card")?.remove();
|
document.getElementById("theme-of-the-month-card")?.remove();
|
||||||
|
document.getElementById("theme-of-the-month-backdrop")?.remove();
|
||||||
|
|
||||||
const monthLabel = formatMonthLabel(entry.month);
|
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 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 */ `
|
const card = stringToHTML(/* html */ `
|
||||||
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
|
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard${settingsState.animations ? "" : " themeOfTheMonthCardReducedMotion"}" role="dialog" aria-label="Theme of the Month">
|
||||||
${
|
<div class="themeOfTheMonthCardMedia">
|
||||||
heroUrl
|
<button type="button" class="themeOfTheMonthCardPopout" aria-label="Expand" title="Expand"${hasExpandableContent ? "" : " hidden"}>
|
||||||
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
|
<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">
|
<div class="themeOfTheMonthCardBody">
|
||||||
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
|
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
|
||||||
<h2>${escapeHTML(entry.title)}</h2>
|
<h2>${escapeHTML(entry.title)}</h2>
|
||||||
<p class="themeOfTheMonthCardDescription">${description}</p>
|
<p class="themeOfTheMonthCardDescription themeOfTheMonthCardDescriptionClipped">${descriptionHtml}</p>
|
||||||
<div class="themeOfTheMonthCardActions">
|
<div class="themeOfTheMonthCardActions">
|
||||||
<div class="themeOfTheMonthCardActionsStart">
|
<div class="themeOfTheMonthCardActionsStart">
|
||||||
${
|
${linkedThemeId ? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>` : ""}
|
||||||
linkedThemeId
|
|
||||||
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="themeOfTheMonthCardActionsEnd">
|
<div class="themeOfTheMonthCardActionsEnd">
|
||||||
<button type="button" class="themeOfTheMonthCardSecondary">Close</button>
|
<button type="button" class="themeOfTheMonthCardSecondary">Close</button>
|
||||||
@@ -170,32 +696,97 @@ export async function OpenThemeOfTheMonthPopup(
|
|||||||
</aside>
|
</aside>
|
||||||
`).firstChild as HTMLElement;
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
const autoCloseTimeout = window.setTimeout(() => {
|
let isExpanded = false;
|
||||||
closeThemeOfTheMonthCard(card, onDismissed);
|
let expandAnimating = false;
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
const dismiss = () => {
|
const applyExpandedState = async (expanded: boolean): Promise<void> => {
|
||||||
window.clearTimeout(autoCloseTimeout);
|
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);
|
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 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", () => {
|
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
|
||||||
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
||||||
dismiss();
|
dismissWithCleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
|
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
|
||||||
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
||||||
dismiss();
|
dismissWithCleanup();
|
||||||
openThemeStoreWithHighlight(linkedThemeId!);
|
openThemeStoreWithHighlight(linkedThemeId!);
|
||||||
});
|
});
|
||||||
|
|
||||||
const openDontShowConfirm = () => {
|
const openDontShowConfirm = () => {
|
||||||
window.clearTimeout(autoCloseTimeout);
|
pauseAutoClose();
|
||||||
if (!confirmEl) return;
|
if (!confirmEl) return;
|
||||||
confirmEl.hidden = false;
|
confirmEl.hidden = false;
|
||||||
requestAnimationFrame(() => confirmEl.classList.add("themeOfTheMonthCardConfirmVisible"));
|
requestAnimationFrame(() => confirmEl.classList.add("themeOfTheMonthCardConfirmVisible"));
|
||||||
@@ -206,23 +797,37 @@ export async function OpenThemeOfTheMonthPopup(
|
|||||||
card.querySelector(".themeOfTheMonthCardConfirmCancel")?.addEventListener("click", () => {
|
card.querySelector(".themeOfTheMonthCardConfirmCancel")?.addEventListener("click", () => {
|
||||||
if (!confirmEl) return;
|
if (!confirmEl) return;
|
||||||
confirmEl.classList.remove("themeOfTheMonthCardConfirmVisible");
|
confirmEl.classList.remove("themeOfTheMonthCardConfirmVisible");
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => { confirmEl.hidden = true; }, 160);
|
||||||
confirmEl.hidden = true;
|
|
||||||
}, 160);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
card.querySelector(".themeOfTheMonthCardConfirmAccept")?.addEventListener("click", () => {
|
card.querySelector(".themeOfTheMonthCardConfirmAccept")?.addEventListener("click", () => {
|
||||||
settingsState.themeOfTheMonthDisabled = true;
|
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> {
|
export async function showThemeOfTheMonthPopupNow(): Promise<void> {
|
||||||
const entry = await fetchThemeOfTheMonth();
|
const entry = await fetchThemeOfTheMonth();
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
|
|||||||
Reference in New Issue
Block a user