Merge pull request #447 from StroepWafel/Popout-TOTM

super clean popout for TOTM + PFP caching
This commit is contained in:
Seth Burkart
2026-06-08 17:33:21 +10:00
committed by GitHub
10 changed files with 1228 additions and 115 deletions
+327 -18
View File
@@ -3809,19 +3809,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: "";
@@ -3908,16 +4195,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;
@@ -3933,14 +4229,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;
@@ -4028,21 +4336,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 -4
View File
@@ -3,6 +3,7 @@
import { animate } from "motion"; import { animate } from "motion";
import { delay } from "@/seqta/utils/delay.ts"; import { delay } from "@/seqta/utils/delay.ts";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
const { hidePanel } = $props<{ const { hidePanel } = $props<{
hidePanel: () => void; hidePanel: () => void;
@@ -105,12 +106,12 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl} {#if cloudState.user?.pfpUrl}
<img <CloudPfpAvatar
src={cloudState.user.pfpUrl} user={cloudState.user}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600" class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/> />
{:else} {/if}
{#if !cloudState.user?.pfpUrl}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base"> <div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()} {getInitials()}
</div> </div>
@@ -0,0 +1,44 @@
<script lang="ts">
import { resolveCloudPfp } from "@/seqta/utils/cloudPfpCache";
import type { CloudUser } from "@/seqta/utils/CloudAuth";
const { user, class: className = "" } = $props<{
user: CloudUser | null | undefined;
class?: string;
}>();
let avatarSrc = $state<string | undefined>(undefined);
let revokeUrl: string | undefined;
$effect(() => {
const u = user;
if (revokeUrl) {
URL.revokeObjectURL(revokeUrl);
revokeUrl = undefined;
}
avatarSrc = undefined;
if (!u?.pfpUrl || !u.id) return;
let cancelled = false;
void resolveCloudPfp(u.id, u.pfpUrl).then((resolved) => {
if (cancelled || !resolved) return;
if (resolved.fromCache) {
revokeUrl = resolved.src;
}
avatarSrc = resolved.src;
});
return () => {
cancelled = true;
if (revokeUrl) {
URL.revokeObjectURL(revokeUrl);
revokeUrl = undefined;
}
};
});
</script>
{#if avatarSrc}
<img src={avatarSrc} alt="" class={className} />
{/if}
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
let { alwaysShowUserName = false, onClick = undefined } = $props<{ let { alwaysShowUserName = false, onClick = undefined } = $props<{
alwaysShowUserName?: boolean; alwaysShowUserName?: boolean;
@@ -72,12 +73,12 @@
> >
{#if cloudState.isLoggedIn} {#if cloudState.isLoggedIn}
{#if cloudState.user?.pfpUrl} {#if cloudState.user?.pfpUrl}
<img <CloudPfpAvatar
src={cloudState.user.pfpUrl} user={cloudState.user}
alt=""
class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600" class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
/> />
{:else} {/if}
{#if !cloudState.user?.pfpUrl}
<div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]"> <div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
{getInitials()} {getInitials()}
</div> </div>
@@ -111,12 +112,12 @@
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl} {#if cloudState.user?.pfpUrl}
<img <CloudPfpAvatar
src={cloudState.user.pfpUrl} user={cloudState.user}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600" class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/> />
{:else} {/if}
{#if !cloudState.user?.pfpUrl}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base"> <div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()} {getInitials()}
</div> </div>
+7 -3
View File
@@ -8,6 +8,7 @@ import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { resolveCloudPfp } from "@/seqta/utils/cloudPfpCache";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import localforage from "localforage"; import localforage from "localforage";
@@ -65,15 +66,18 @@ const profilePicturePlugin: Plugin<typeof settings> = {
const useCloud = api.settings.useCloudPfp; const useCloud = api.settings.useCloudPfp;
const pfpUrl = cloudAuth.state.user?.pfpUrl; const pfpUrl = cloudAuth.state.user?.pfpUrl;
if (useCloud && pfpUrl) { if (useCloud && pfpUrl && cloudAuth.state.user?.id) {
const resolved = await resolveCloudPfp(cloudAuth.state.user.id, pfpUrl);
if (resolved) {
currentBlobUrl = resolved.src;
img = document.createElement("img"); img = document.createElement("img");
img.className = "userInfoImg"; img.className = "userInfoImg";
const base = pfpUrl.split("?")[0]!; img.src = resolved.src;
img.src = `${base}?v=${Date.now()}`;
if (svg) svg.style.display = "none"; if (svg) svg.style.display = "none";
container.appendChild(img); container.appendChild(img);
return; return;
} }
}
const blob = await store.getItem<Blob>("profile-picture"); const blob = await store.getItem<Blob>("profile-picture");
if (blob && blob instanceof Blob) { if (blob && blob instanceof Blob) {
+4
View File
@@ -1,4 +1,5 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback"; const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
@@ -16,6 +17,7 @@ export type CloudUser = {
username?: string; username?: string;
displayName?: string; displayName?: string;
pfpUrl?: string; pfpUrl?: string;
pfpHash?: string | null;
admin_level?: number; admin_level?: number;
}; };
@@ -201,6 +203,8 @@ class CloudAuthService {
} }
public async logout(): Promise<void> { public async logout(): Promise<void> {
const userId = this._state.user?.id;
if (userId) await clearCloudPfpCache(userId);
await browser.storage.local.remove([ await browser.storage.local.remove([
STORAGE_KEYS.accessToken, STORAGE_KEYS.accessToken,
STORAGE_KEYS.refreshToken, STORAGE_KEYS.refreshToken,
@@ -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) {
+112
View File
@@ -0,0 +1,112 @@
import localforage from "localforage";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
const store = localforage.createInstance({
name: "cloud-pfp-store",
storeName: "cloudPfp",
});
function hashKey(userId: string) {
return `hash:${userId}`;
}
function blobKey(userId: string) {
return `blob:${userId}`;
}
export function isAccountsHostedPfpUrl(url: string): boolean {
if (!url.includes("/api/user/pfp/")) return false;
if (url.includes("/hist/")) return false;
return /\/api\/user\/pfp\/[^/?#]+/.test(url.split("?")[0]!);
}
export function pfpUrlWithHash(url: string, hash: string | null | undefined): string {
if (!url || !hash || !isAccountsHostedPfpUrl(url)) return url;
const base = url.split("?")[0]!;
return `${base}?v=${hash}`;
}
async function fetchServerHash(userId: string): Promise<string | null> {
const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp/${userId}/meta`);
if (!res.ok) return null;
const data = (await res.json()) as { pfpHash?: string | null };
return data.pfpHash ?? null;
}
async function clearLocal(userId: string): Promise<void> {
await store.removeItem(hashKey(userId));
await store.removeItem(blobKey(userId));
}
export async function clearCloudPfpCache(userId?: string): Promise<void> {
const id = userId ?? cloudAuth.state.user?.id;
if (!id) return;
await clearLocal(id);
}
export type ResolveCloudPfpResult = {
src: string;
fromCache: boolean;
};
/**
* Returns an object URL or direct URL for the cloud profile picture.
* Order: session hash match → local blob; else meta → download → store blob then hash.
*/
export async function resolveCloudPfp(
userId: string,
pfpUrl: string,
): Promise<ResolveCloudPfpResult | null> {
if (!isAccountsHostedPfpUrl(pfpUrl)) {
return { src: pfpUrl, fromCache: false };
}
const sessionHash = cloudAuth.state.user?.pfpHash ?? null;
const localHash = await store.getItem<string>(hashKey(userId));
const localBlob = await store.getItem<Blob>(blobKey(userId));
let serverHash = sessionHash;
const localMatches =
!!serverHash && serverHash === localHash && localBlob instanceof Blob;
if (localMatches) {
return { src: URL.createObjectURL(localBlob), fromCache: true };
}
if (!serverHash || serverHash !== localHash) {
serverHash = await fetchServerHash(userId);
}
if (!serverHash) {
await clearLocal(userId);
return null;
}
if (serverHash === localHash && localBlob instanceof Blob) {
return { src: URL.createObjectURL(localBlob), fromCache: true };
}
await clearLocal(userId);
const imageUrl = pfpUrlWithHash(pfpUrl, serverHash);
const headers: HeadersInit = {};
if (localHash) {
headers["If-None-Match"] = `"${localHash}"`;
}
const res = await fetch(imageUrl, { headers });
if (res.status === 304 && localBlob instanceof Blob) {
await store.setItem(hashKey(userId), serverHash);
return { src: URL.createObjectURL(localBlob), fromCache: true };
}
if (!res.ok) return null;
const blob = await res.blob();
await store.setItem(blobKey(userId), blob);
await store.setItem(hashKey(userId), serverHash);
return { src: URL.createObjectURL(blob), fromCache: false };
}
+41 -8
View File
@@ -1,6 +1,7 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import localforage from "localforage"; import localforage from "localforage";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { clearCloudPfpCache, pfpUrlWithHash } from "@/seqta/utils/cloudPfpCache";
const ACCOUNTS_BASE = "https://accounts.betterseqta.org"; const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
const PLUGIN_SETTINGS_KEY = "plugin.profile-picture.settings"; const PLUGIN_SETTINGS_KEY = "plugin.profile-picture.settings";
@@ -10,9 +11,32 @@ const profileStore = localforage.createInstance({
storeName: "profilePicture", storeName: "profilePicture",
}); });
function cacheBustPfpUrl(url: string): string { /** Downscale before upload to reduce ingress (server still normalizes). */
const base = url.split("?")[0]!; async function downscaleForUpload(blob: Blob, maxEdge = 512): Promise<Blob> {
return `${base}?v=${Date.now()}`; if (!blob.type.startsWith("image/")) return blob;
const bitmap = await createImageBitmap(blob);
const maxSide = Math.max(bitmap.width, bitmap.height);
if (maxSide <= maxEdge) {
bitmap.close();
return blob;
}
const scale = maxEdge / maxSide;
const w = Math.max(1, Math.round(bitmap.width * scale));
const h = Math.max(1, Math.round(bitmap.height * scale));
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext("2d");
if (!ctx) {
bitmap.close();
return blob;
}
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
const out = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.85 });
return out;
} }
export async function isUseCloudPfpEnabled(): Promise<boolean> { export async function isUseCloudPfpEnabled(): Promise<boolean> {
@@ -41,6 +65,9 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
const token = await cloudAuth.getStoredToken(); const token = await cloudAuth.getStoredToken();
if (!token) return { success: false, error: "Not logged in" }; if (!token) return { success: false, error: "Not logged in" };
const user = cloudAuth.state.user;
const userId = user?.id;
const blob = await profileStore.getItem<Blob>("profile-picture"); const blob = await profileStore.getItem<Blob>("profile-picture");
try { try {
@@ -57,10 +84,10 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
if (!res.ok) { if (!res.ok) {
return { success: false, error: (data.error as string) ?? `Clear failed (${res.status})` }; return { success: false, error: (data.error as string) ?? `Clear failed (${res.status})` };
} }
const user = cloudAuth.state.user;
if (user) { if (user) {
await cloudAuth.setUser({ ...user, pfpUrl: undefined }); await cloudAuth.setUser({ ...user, pfpUrl: undefined, pfpHash: null });
} }
if (userId) await clearCloudPfpCache(userId);
return { success: true }; return { success: true };
} }
@@ -71,8 +98,9 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
return { success: false, error: "File too large (max 5MB)" }; return { success: false, error: "File too large (max 5MB)" };
} }
const uploadBlob = await downscaleForUpload(blob);
const formData = new FormData(); const formData = new FormData();
formData.append("file", blob, "profile-picture"); formData.append("file", uploadBlob, "profile-picture.jpg");
const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp`, { const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp`, {
method: "POST", method: "POST",
@@ -85,10 +113,15 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
} }
const pfpUrl = data.pfpUrl as string | undefined; const pfpUrl = data.pfpUrl as string | undefined;
const user = cloudAuth.state.user; const pfpHash = (data.pfpHash as string | null | undefined) ?? null;
if (user && pfpUrl) { if (user && pfpUrl) {
await cloudAuth.setUser({ ...user, pfpUrl: cacheBustPfpUrl(pfpUrl) }); await cloudAuth.setUser({
...user,
pfpUrl: pfpUrlWithHash(pfpUrl, pfpHash),
pfpHash,
});
} }
if (userId) await clearCloudPfpCache(userId);
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
return { return {
+1 -1
View File
@@ -75,7 +75,7 @@ export interface SettingsState {
bsplus_client_id?: string; bsplus_client_id?: string;
bsplus_token?: string; bsplus_token?: string;
bsplus_refresh_token?: string; bsplus_refresh_token?: string;
bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; admin_level?: number }; bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; pfpHash?: string | null; admin_level?: number };
/** When not `false`, automatic cloud settings sync is enabled (default-on). */ /** When not `false`, automatic cloud settings sync is enabled (default-on). */
autoCloudSettingsSync?: boolean; autoCloudSettingsSync?: boolean;
} }