mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-13 07:04:39 +00:00
Merge pull request #447 from StroepWafel/Popout-TOTM
super clean popout for TOTM + PFP caching
This commit is contained in:
+327
-18
@@ -3809,19 +3809,306 @@ div.day-empty {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.themeOfTheMonthBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 47;
|
||||
background: color-mix(in srgb, #000 52%, transparent);
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.55s cubic-bezier(0.76, 0, 0.24, 1);
|
||||
}
|
||||
.themeOfTheMonthBackdropVisible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.themeOfTheMonthCard {
|
||||
position: fixed;
|
||||
right: max(18px, env(safe-area-inset-right));
|
||||
bottom: max(18px, env(safe-area-inset-bottom));
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
z-index: 48;
|
||||
margin: 0;
|
||||
width: min(360px, calc(100vw - 36px));
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||
border-radius: 20px;
|
||||
background: var(--background-primary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
|
||||
animation: themeOfTheMonthCardIn 0.24s ease-out;
|
||||
transform-origin: bottom right;
|
||||
transition: none;
|
||||
animation: themeOfTheMonthCardIn 0.28s ease-out;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded {
|
||||
transform-origin: center center;
|
||||
}
|
||||
/* translate(x,y) is set inline; transition enabled after mount */
|
||||
.themeOfTheMonthCardMorphReady:not(.themeOfTheMonthCardReducedMotion) {
|
||||
transition:
|
||||
transform 0.55s cubic-bezier(0.76, 0, 0.24, 1),
|
||||
width 0.55s cubic-bezier(0.76, 0, 0.24, 1),
|
||||
height 0.55s cubic-bezier(0.76, 0, 0.24, 1),
|
||||
max-height 0.55s cubic-bezier(0.76, 0, 0.24, 1),
|
||||
border-radius 0.55s cubic-bezier(0.76, 0, 0.24, 1);
|
||||
}
|
||||
.themeOfTheMonthCardAnchoredBottom,
|
||||
.themeOfTheMonthCardCollapsing {
|
||||
transform-origin: 100% 100% !important;
|
||||
}
|
||||
/* Expanded: fixed shell; copy scrolls; actions pinned to the bottom. */
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardBody {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardDescription {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
margin: 8px 0 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardActions {
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
padding-top: 14px;
|
||||
}
|
||||
.themeOfTheMonthCardReducedMotion {
|
||||
transition: none !important;
|
||||
}
|
||||
.themeOfTheMonthCardMedia {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#theme-of-the-month-card .themeOfTheMonthCardPopout {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 6;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 50% !important;
|
||||
aspect-ratio: 1;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--background-primary) 80%, transparent);
|
||||
box-shadow:
|
||||
0 2px 10px rgba(0, 0, 0, 0.24),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--text-primary) 14%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.themeOfTheMonthCardPopoutIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.themeOfTheMonthCardPopout:hover {
|
||||
background: color-mix(in srgb, var(--background-primary) 95%, transparent);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.themeOfTheMonthCardPopout:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.themeOfTheMonthCardPopout[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.themeOfTheMonthCardCompactMedia {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: 20px 20px 0 0;
|
||||
line-height: 0;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery .themeOfTheMonthCardCompactMedia {
|
||||
display: none;
|
||||
}
|
||||
.themeOfTheMonthCardExpandedPanel {
|
||||
display: none;
|
||||
}
|
||||
.themeOfTheMonthCardExpandedPanel[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery
|
||||
.themeOfTheMonthCardExpandedPanel:not([hidden]) {
|
||||
display: block;
|
||||
}
|
||||
.themeOfTheMonthCardGallery {
|
||||
position: relative;
|
||||
}
|
||||
.themeOfTheMonthCardHeroEmboss {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
pointer-events: none;
|
||||
}
|
||||
.themeOfTheMonthCardHeroEmbossScrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, #000 8%, transparent) 40%,
|
||||
color-mix(in srgb, #000 55%, transparent) 72%,
|
||||
color-mix(in srgb, #000 82%, transparent) 100%
|
||||
);
|
||||
}
|
||||
.themeOfTheMonthCardHeroEmbossContent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmboss {
|
||||
display: flex;
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossTitle {
|
||||
margin: 0;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
color: #fff;
|
||||
text-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.55),
|
||||
0 2px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossAuthor {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: color-mix(in srgb, #fff 88%, transparent);
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossDescription {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.4;
|
||||
color: color-mix(in srgb, #fff 92%, transparent);
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossVariants {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: color-mix(in srgb, #fff 72%, transparent);
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery
|
||||
.themeOfTheMonthCardGallerySlide
|
||||
figcaption {
|
||||
display: none;
|
||||
}
|
||||
.themeOfTheMonthCardGalleryTrack {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.themeOfTheMonthCardGalleryTrack::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.themeOfTheMonthCardGallerySlide {
|
||||
flex: 0 0 100%;
|
||||
margin: 0;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.themeOfTheMonthCardGallerySlide img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: min(42vh, 280px);
|
||||
object-fit: cover;
|
||||
}
|
||||
.themeOfTheMonthCardGallerySlide figcaption {
|
||||
padding: 8px 14px 0;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.3;
|
||||
color: color-mix(in srgb, var(--text-primary) 68%, transparent);
|
||||
}
|
||||
.themeOfTheMonthCardGalleryPrev,
|
||||
.themeOfTheMonthCardGalleryNext {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin-top: -17px;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.themeOfTheMonthCardGalleryPrev {
|
||||
left: 10px;
|
||||
}
|
||||
.themeOfTheMonthCardGalleryNext {
|
||||
right: 10px;
|
||||
}
|
||||
.themeOfTheMonthCardGalleryDots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px 0;
|
||||
}
|
||||
.themeOfTheMonthCardGalleryDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
background: color-mix(in srgb, var(--text-primary) 28%, transparent);
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.themeOfTheMonthCardGalleryDotActive {
|
||||
background: var(--better-pri, #6366f1);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.themeOfTheMonthCardDescriptionTyping {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: unset;
|
||||
}
|
||||
.themeOfTheMonthCard::before {
|
||||
content: "";
|
||||
@@ -3908,16 +4195,25 @@ div.day-empty {
|
||||
.themeOfTheMonthCardConfirmAccept:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.themeOfTheMonthCardImage {
|
||||
#theme-of-the-month-card .themeOfTheMonthCardImage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
height: 150px !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
margin: 0;
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
object-fit: cover;
|
||||
object-position: center center;
|
||||
}
|
||||
.themeOfTheMonthCardExpanded .themeOfTheMonthCardGallerySlide img {
|
||||
border-radius: 22px 22px 0 0;
|
||||
}
|
||||
.themeOfTheMonthCardBody {
|
||||
padding: 14px 16px 16px;
|
||||
padding: 14px 16px 12px;
|
||||
}
|
||||
.themeOfTheMonthCardEyebrow {
|
||||
margin: 0 0 6px;
|
||||
@@ -3933,14 +4229,26 @@ div.day-empty {
|
||||
line-height: 1.2;
|
||||
}
|
||||
.themeOfTheMonthCardDescription {
|
||||
display: -webkit-box;
|
||||
margin: 8px 0 14px;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
margin: 8px 0 10px;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
|
||||
overflow-wrap: anywhere;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.themeOfTheMonthCardDescriptionClipped {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
.themeOfTheMonthCardDescriptionExpanded {
|
||||
display: block;
|
||||
-webkit-line-clamp: unset;
|
||||
}
|
||||
.themeOfTheMonthCardExpanding:not(.themeOfTheMonthCardExpandedShell)
|
||||
.themeOfTheMonthCardDescriptionExpanded {
|
||||
overflow: hidden;
|
||||
}
|
||||
.themeOfTheMonthCardActions {
|
||||
display: flex;
|
||||
@@ -4028,21 +4336,22 @@ div.day-empty {
|
||||
@keyframes themeOfTheMonthCardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes themeOfTheMonthCardOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
}
|
||||
.themeOfTheMonthCardExpanded.themeOfTheMonthCardClosing {
|
||||
animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.themeOfTheMonthCard {
|
||||
.themeOfTheMonthCard,
|
||||
.themeOfTheMonthBackdrop {
|
||||
z-index: 2147483645;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { animate } from "motion";
|
||||
import { delay } from "@/seqta/utils/delay.ts";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
|
||||
|
||||
const { hidePanel } = $props<{
|
||||
hidePanel: () => void;
|
||||
@@ -105,12 +106,12 @@
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if cloudState.user?.pfpUrl}
|
||||
<img
|
||||
src={cloudState.user.pfpUrl}
|
||||
alt=""
|
||||
<CloudPfpAvatar
|
||||
user={cloudState.user}
|
||||
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">
|
||||
{getInitials()}
|
||||
</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">
|
||||
import { onMount } from "svelte";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
|
||||
|
||||
let { alwaysShowUserName = false, onClick = undefined } = $props<{
|
||||
alwaysShowUserName?: boolean;
|
||||
@@ -72,12 +73,12 @@
|
||||
>
|
||||
{#if cloudState.isLoggedIn}
|
||||
{#if cloudState.user?.pfpUrl}
|
||||
<img
|
||||
src={cloudState.user.pfpUrl}
|
||||
alt=""
|
||||
<CloudPfpAvatar
|
||||
user={cloudState.user}
|
||||
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]">
|
||||
{getInitials()}
|
||||
</div>
|
||||
@@ -111,12 +112,12 @@
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if cloudState.user?.pfpUrl}
|
||||
<img
|
||||
src={cloudState.user.pfpUrl}
|
||||
alt=""
|
||||
<CloudPfpAvatar
|
||||
user={cloudState.user}
|
||||
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">
|
||||
{getInitials()}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import browser from "webextension-polyfill";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import { resolveCloudPfp } from "@/seqta/utils/cloudPfpCache";
|
||||
import styles from "./styles.css?inline";
|
||||
import localforage from "localforage";
|
||||
|
||||
@@ -65,15 +66,18 @@ const profilePicturePlugin: Plugin<typeof settings> = {
|
||||
const useCloud = api.settings.useCloudPfp;
|
||||
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.className = "userInfoImg";
|
||||
const base = pfpUrl.split("?")[0]!;
|
||||
img.src = `${base}?v=${Date.now()}`;
|
||||
img.src = resolved.src;
|
||||
if (svg) svg.style.display = "none";
|
||||
container.appendChild(img);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await store.getItem<Blob>("profile-picture");
|
||||
if (blob && blob instanceof Blob) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
|
||||
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
|
||||
@@ -16,6 +17,7 @@ export type CloudUser = {
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
pfpUrl?: string;
|
||||
pfpHash?: string | null;
|
||||
admin_level?: number;
|
||||
};
|
||||
|
||||
@@ -201,6 +203,8 @@ class CloudAuthService {
|
||||
}
|
||||
|
||||
public async logout(): Promise<void> {
|
||||
const userId = this._state.user?.id;
|
||||
if (userId) await clearCloudPfpCache(userId);
|
||||
await browser.storage.local.remove([
|
||||
STORAGE_KEYS.accessToken,
|
||||
STORAGE_KEYS.refreshToken,
|
||||
|
||||
@@ -5,11 +5,13 @@ import { closePopup } from "./PopupManager";
|
||||
import { getApiBase } from "../DevApiBase";
|
||||
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
|
||||
import { cloudAuth } from "../CloudAuth";
|
||||
import type { Theme } from "@/interface/types/Theme";
|
||||
import {
|
||||
buildModalHeroSlides,
|
||||
normalizeStoreTheme,
|
||||
} from "@/interface/utils/themeStoreFlavours";
|
||||
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
|
||||
|
||||
/**
|
||||
* Server response shape from `/api/theme-of-the-month/current`.
|
||||
* Hero image is resolved client-side via the theme store API when `theme_id` is set.
|
||||
*/
|
||||
export interface ThemeOfTheMonthEntry {
|
||||
id: string;
|
||||
month: string;
|
||||
@@ -22,12 +24,6 @@ export interface ThemeOfTheMonthEntry {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current month's Theme of the Month entry from the API.
|
||||
* Returns `null` when no entry is configured for this month, or when the
|
||||
* request fails (we never want a network problem to block other startup
|
||||
* popups).
|
||||
*/
|
||||
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
|
||||
@@ -45,7 +41,6 @@ export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | nul
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the current month's entry should appear in the startup queue. */
|
||||
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
|
||||
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
|
||||
return settingsState.themeOfTheMonthDismissedMonth !== entry.month;
|
||||
@@ -67,7 +62,6 @@ function formatMonthLabel(month: string): string {
|
||||
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
|
||||
}
|
||||
|
||||
/** Same priority as the theme store: marquee, then cover/banner. */
|
||||
function heroUrlFromStoreTheme(theme: {
|
||||
marqueeImage?: string | null;
|
||||
coverImage?: string | null;
|
||||
@@ -76,41 +70,484 @@ function heroUrlFromStoreTheme(theme: {
|
||||
return url || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads hero image for a store theme via the background script (same path as
|
||||
* {@link ThemeSelector} / theme store detail fetches).
|
||||
*/
|
||||
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
|
||||
export async function fetchThemeStoreTheme(themeId: string): Promise<Theme | null> {
|
||||
try {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: "fetchThemeDetails",
|
||||
themeId,
|
||||
token: token ?? undefined,
|
||||
})) as { success?: boolean; data?: { theme?: { marqueeImage?: string; coverImage?: string } } };
|
||||
})) as { success?: boolean; data?: { theme?: Record<string, unknown> } };
|
||||
|
||||
if (!res?.success || !res?.data?.theme) return null;
|
||||
return heroUrlFromStoreTheme(res.data.theme);
|
||||
return normalizeStoreTheme(res.data.theme);
|
||||
} catch (err) {
|
||||
console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err);
|
||||
console.warn("[ThemeOfTheMonth] Failed to fetch theme store details:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Linked theme store image, else optional admin-uploaded cover. */
|
||||
async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise<string | null> {
|
||||
const themeId = entry.theme_id ?? entry.theme?.id;
|
||||
if (themeId) {
|
||||
const fromStore = await fetchThemeStoreHeroImage(themeId);
|
||||
if (fromStore) return fromStore;
|
||||
}
|
||||
const fallback = entry.cover_image?.trim();
|
||||
return fallback || null;
|
||||
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
|
||||
const theme = await fetchThemeStoreTheme(themeId);
|
||||
return theme ? heroUrlFromStoreTheme(theme) : null;
|
||||
}
|
||||
|
||||
function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
|
||||
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
|
||||
type PopupGallerySlide = { imageUrl: string; caption: string };
|
||||
|
||||
function buildPopupGallerySlides(
|
||||
entry: ThemeOfTheMonthEntry,
|
||||
storeTheme: Theme | null,
|
||||
heroUrl: string | null,
|
||||
): PopupGallerySlide[] {
|
||||
if (storeTheme) {
|
||||
return buildModalHeroSlides(storeTheme).filter((s) => s.imageUrl.trim());
|
||||
}
|
||||
if (heroUrl) {
|
||||
return [{ imageUrl: heroUrl, caption: entry.title }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Store theme identity on the hero — not the TOTM notice copy in the body. */
|
||||
function renderHeroEmbossHtml(storeTheme: Theme, entry: ThemeOfTheMonthEntry): string {
|
||||
const name = (storeTheme.name || entry.title).trim();
|
||||
const author = storeTheme.author?.trim() ?? "";
|
||||
const storeDescription = storeTheme.description?.trim() ?? "";
|
||||
const entryDesc = entry.description.trim();
|
||||
const showDescription =
|
||||
storeDescription.length > 0 && storeDescription !== entryDesc;
|
||||
const flavourCount = storeTheme.flavours?.length ?? 0;
|
||||
const flavourLine =
|
||||
flavourCount > 0
|
||||
? `${flavourCount} colour variant${flavourCount === 1 ? "" : "s"}`
|
||||
: "";
|
||||
|
||||
if (!name && !author && !showDescription && !flavourLine) return "";
|
||||
|
||||
return `
|
||||
<div class="themeOfTheMonthCardHeroEmboss">
|
||||
<div class="themeOfTheMonthCardHeroEmbossScrim" aria-hidden="true"></div>
|
||||
<div class="themeOfTheMonthCardHeroEmbossContent">
|
||||
<h3 class="themeOfTheMonthCardHeroEmbossTitle">${escapeHTML(name)}</h3>
|
||||
${author ? `<p class="themeOfTheMonthCardHeroEmbossAuthor">By ${escapeHTML(author)}</p>` : ""}
|
||||
${
|
||||
showDescription
|
||||
? `<p class="themeOfTheMonthCardHeroEmbossDescription">${escapeHTML(storeDescription).replace(/\n/g, "<br />")}</p>`
|
||||
: ""
|
||||
}
|
||||
${flavourLine ? `<p class="themeOfTheMonthCardHeroEmbossVariants">${escapeHTML(flavourLine)}</p>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGallerySlidesHtml(slides: PopupGallerySlide[]): string {
|
||||
if (slides.length === 0) return "";
|
||||
const slidesHtml = slides
|
||||
.map(
|
||||
(s, i) => `
|
||||
<figure class="themeOfTheMonthCardGallerySlide" data-slide="${i}">
|
||||
<img src="${escapeHTML(s.imageUrl)}" alt="${escapeHTML(s.caption)}" loading="lazy" />
|
||||
<figcaption>${escapeHTML(s.caption)}</figcaption>
|
||||
</figure>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
const nav =
|
||||
slides.length > 1
|
||||
? `
|
||||
<button type="button" class="themeOfTheMonthCardGalleryPrev" aria-label="Previous image">‹</button>
|
||||
<button type="button" class="themeOfTheMonthCardGalleryNext" aria-label="Next image">›</button>
|
||||
<div class="themeOfTheMonthCardGalleryDots" role="tablist" aria-label="Theme previews">
|
||||
${slides
|
||||
.map(
|
||||
(_, i) =>
|
||||
`<button type="button" class="themeOfTheMonthCardGalleryDot${i === 0 ? " themeOfTheMonthCardGalleryDotActive" : ""}" data-slide="${i}" role="tab" aria-label="Image ${i + 1} of ${slides.length}" aria-selected="${i === 0 ? "true" : "false"}"></button>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
return `
|
||||
<div class="themeOfTheMonthCardGallery">
|
||||
<div class="themeOfTheMonthCardGalleryTrack">${slidesHtml}</div>
|
||||
${nav}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const POPOUT_EXPAND_SVG = /* svg */ `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>`;
|
||||
const POPOUT_COLLAPSE_SVG = /* svg */ `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 14h6v6"/><path d="M20 10h-6V4"/><path d="M14 10l7-7"/><path d="M3 21l7-7"/></svg>`;
|
||||
|
||||
const TOTM_MARGIN_PX = 18;
|
||||
const TOTM_EXPANDED_SHELL_MAX_PX = 560;
|
||||
const TOTM_EASE = "cubic-bezier(0.76, 0, 0.24, 1)";
|
||||
const TOTM_MORPH_MS = 550;
|
||||
const TOTM_LAYOUT_SWAP_MS = TOTM_MORPH_MS / 2;
|
||||
|
||||
let themeOfTheMonthAnimGen = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dimension helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function themeOfTheMonthCollapsedWidth(): number {
|
||||
return Math.min(360, window.innerWidth - TOTM_MARGIN_PX * 2);
|
||||
}
|
||||
|
||||
function themeOfTheMonthExpandedWidth(): number {
|
||||
return Math.min(520, window.innerWidth - 32);
|
||||
}
|
||||
|
||||
function themeOfTheMonthMaxCardHeight(): number {
|
||||
return window.innerHeight - TOTM_MARGIN_PX * 2;
|
||||
}
|
||||
|
||||
/** Fixed expanded card height — stable morph target; footer pinned inside via CSS. */
|
||||
function themeOfTheMonthExpandedShellHeight(): number {
|
||||
return Math.min(TOTM_EXPANDED_SHELL_MAX_PX, themeOfTheMonthMaxCardHeight());
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure-transform positioning
|
||||
//
|
||||
// The card sits at position: fixed; top: 0; left: 0 at all times.
|
||||
// All movement is expressed as translate(x, y) so CSS transitions drive
|
||||
// the full path — no top/left changes mid-animation that would cause snapping.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the translate values that place the card at the correct position.
|
||||
* Collapsed → bottom-right corner. Expanded → viewport centre.
|
||||
* Both states are expressed purely as transform offsets from (0, 0).
|
||||
*/
|
||||
function computeCardTranslate(
|
||||
cardWidth: number,
|
||||
cardHeight: number,
|
||||
expanded: boolean,
|
||||
): { x: number; y: number } {
|
||||
if (expanded) {
|
||||
const x = Math.round(
|
||||
Math.max(
|
||||
TOTM_MARGIN_PX,
|
||||
Math.min(
|
||||
(window.innerWidth - cardWidth) / 2,
|
||||
window.innerWidth - TOTM_MARGIN_PX - cardWidth,
|
||||
),
|
||||
),
|
||||
);
|
||||
const y = Math.round(
|
||||
Math.max(
|
||||
TOTM_MARGIN_PX,
|
||||
Math.min(
|
||||
(window.innerHeight - cardHeight) / 2,
|
||||
window.innerHeight - TOTM_MARGIN_PX - cardHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
return { x, y };
|
||||
} else {
|
||||
const x = Math.round(
|
||||
Math.max(
|
||||
TOTM_MARGIN_PX,
|
||||
Math.min(
|
||||
window.innerWidth - cardWidth - TOTM_MARGIN_PX,
|
||||
window.innerWidth - TOTM_MARGIN_PX - cardWidth,
|
||||
),
|
||||
),
|
||||
);
|
||||
const y = Math.round(
|
||||
Math.max(
|
||||
TOTM_MARGIN_PX,
|
||||
window.innerHeight - cardHeight - TOTM_MARGIN_PX,
|
||||
),
|
||||
);
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply card dimensions + border-radius, then set transform so the card
|
||||
* lands at the right position.
|
||||
*
|
||||
* `targetHeight` must be passed explicitly — never read scrollHeight here,
|
||||
* because content may be hidden/shown mid-animation and scrollHeight would
|
||||
* give the wrong value, causing the snap-to-full-height bug.
|
||||
*/
|
||||
function applyThemeOfTheMonthCardPosition(
|
||||
card: HTMLElement,
|
||||
expanded: boolean,
|
||||
animate: boolean,
|
||||
targetHeight?: number,
|
||||
): void {
|
||||
const width = expanded
|
||||
? themeOfTheMonthExpandedWidth()
|
||||
: themeOfTheMonthCollapsedWidth();
|
||||
|
||||
card.style.width = `${width}px`;
|
||||
card.style.maxHeight = expanded ? `${themeOfTheMonthMaxCardHeight()}px` : "";
|
||||
card.style.borderRadius = expanded ? "22px" : "20px";
|
||||
|
||||
const h = targetHeight ?? card.offsetHeight;
|
||||
const { x, y } = computeCardTranslate(width, h, expanded);
|
||||
|
||||
const canAnimate =
|
||||
animate &&
|
||||
settingsState.animations &&
|
||||
card.classList.contains("themeOfTheMonthCardMorphReady");
|
||||
|
||||
if (canAnimate) {
|
||||
card.style.transition = [
|
||||
`transform ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
|
||||
`width ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
|
||||
`height ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
|
||||
`border-radius ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
|
||||
].join(", ");
|
||||
} else {
|
||||
card.style.transition = "none";
|
||||
}
|
||||
|
||||
// Force a reflow so the browser registers the pre-transition state.
|
||||
void card.offsetHeight;
|
||||
|
||||
if (targetHeight !== undefined) card.style.height = `${targetHeight}px`;
|
||||
card.style.transform = `translate(${x}px, ${y}px)`;
|
||||
|
||||
if (!canAnimate) {
|
||||
requestAnimationFrame(() => {
|
||||
if (card.isConnected) card.style.transition = "";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Height helpers — keep card height explicit during animation so transforms
|
||||
// can be calculated correctly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expand / collapse animations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function applyExpandedCardShell(card: HTMLElement): number {
|
||||
const h = themeOfTheMonthExpandedShellHeight();
|
||||
card.classList.add("themeOfTheMonthCardExpandedShell");
|
||||
card.style.height = `${h}px`;
|
||||
card.style.maxHeight = `${themeOfTheMonthMaxCardHeight()}px`;
|
||||
card.style.overflow = "hidden";
|
||||
return h;
|
||||
}
|
||||
|
||||
function clearExpandedCardShell(card: HTMLElement): void {
|
||||
card.classList.remove("themeOfTheMonthCardExpandedShell");
|
||||
card.style.height = "";
|
||||
card.style.maxHeight = "";
|
||||
card.style.overflow = "";
|
||||
}
|
||||
|
||||
function applyExpandedLayout(card: HTMLElement, descriptionHtml: string): void {
|
||||
const desc = card.querySelector<HTMLElement>(".themeOfTheMonthCardDescription");
|
||||
const expandedPanel = card.querySelector<HTMLElement>(".themeOfTheMonthCardExpandedPanel");
|
||||
|
||||
card.classList.add("themeOfTheMonthCardExpanded", "themeOfTheMonthCardShowGallery");
|
||||
expandedPanel?.removeAttribute("hidden");
|
||||
if (expandedPanel) {
|
||||
expandedPanel.style.opacity = "";
|
||||
expandedPanel.style.transition = "";
|
||||
}
|
||||
if (desc) {
|
||||
desc.innerHTML = descriptionHtml;
|
||||
desc.classList.add("themeOfTheMonthCardDescriptionExpanded");
|
||||
desc.classList.remove("themeOfTheMonthCardDescriptionClipped");
|
||||
}
|
||||
}
|
||||
|
||||
function applyCollapsedLayout(card: HTMLElement, descriptionHtml: string): void {
|
||||
const desc = card.querySelector<HTMLElement>(".themeOfTheMonthCardDescription");
|
||||
const expandedPanel = card.querySelector<HTMLElement>(".themeOfTheMonthCardExpandedPanel");
|
||||
const body = card.querySelector<HTMLElement>(".themeOfTheMonthCardBody");
|
||||
card.classList.remove(
|
||||
"themeOfTheMonthCardExpanded",
|
||||
"themeOfTheMonthCardShowGallery",
|
||||
"themeOfTheMonthCardExpandedShell",
|
||||
);
|
||||
clearExpandedCardShell(card);
|
||||
expandedPanel?.setAttribute("hidden", "");
|
||||
if (expandedPanel) {
|
||||
expandedPanel.style.opacity = "";
|
||||
expandedPanel.style.transition = "";
|
||||
}
|
||||
if (body) {
|
||||
body.style.opacity = "";
|
||||
body.style.transition = "";
|
||||
}
|
||||
if (desc) {
|
||||
desc.innerHTML = descriptionHtml;
|
||||
desc.classList.remove("themeOfTheMonthCardDescriptionExpanded");
|
||||
desc.classList.add("themeOfTheMonthCardDescriptionClipped");
|
||||
}
|
||||
}
|
||||
|
||||
function clearCardInlineSizeForMeasure(card: HTMLElement): void {
|
||||
card.style.height = "";
|
||||
card.style.maxHeight = "";
|
||||
card.style.overflow = "";
|
||||
}
|
||||
|
||||
function measureCollapsedTargetHeight(card: HTMLElement, descriptionHtml: string): number {
|
||||
applyCollapsedLayout(card, descriptionHtml);
|
||||
card.style.width = `${themeOfTheMonthCollapsedWidth()}px`;
|
||||
clearCardInlineSizeForMeasure(card);
|
||||
void card.offsetHeight;
|
||||
return Math.min(card.scrollHeight, themeOfTheMonthMaxCardHeight());
|
||||
}
|
||||
|
||||
async function runThemeOfTheMonthExpand(
|
||||
card: HTMLElement,
|
||||
backdrop: HTMLElement | null,
|
||||
descriptionHtml: string,
|
||||
): Promise<void> {
|
||||
const gen = ++themeOfTheMonthAnimGen;
|
||||
|
||||
const fromH = card.offsetHeight;
|
||||
const toH = themeOfTheMonthExpandedShellHeight();
|
||||
|
||||
// Morph starts in mini layout; swap to expanded layout halfway through the move.
|
||||
applyCollapsedLayout(card, descriptionHtml);
|
||||
card.style.width = `${themeOfTheMonthCollapsedWidth()}px`;
|
||||
|
||||
card.classList.add("themeOfTheMonthCardExpanding");
|
||||
card.style.height = `${fromH}px`;
|
||||
card.style.overflow = "hidden";
|
||||
|
||||
if (backdrop) {
|
||||
backdrop.hidden = false;
|
||||
backdrop.setAttribute("aria-hidden", "false");
|
||||
requestAnimationFrame(() => backdrop.classList.add("themeOfTheMonthBackdropVisible"));
|
||||
}
|
||||
|
||||
applyThemeOfTheMonthCardPosition(card, true, true, toH);
|
||||
|
||||
await sleep(TOTM_LAYOUT_SWAP_MS);
|
||||
if (gen !== themeOfTheMonthAnimGen) return;
|
||||
applyExpandedLayout(card, descriptionHtml);
|
||||
applyExpandedCardShell(card);
|
||||
|
||||
await sleep(TOTM_LAYOUT_SWAP_MS);
|
||||
if (gen !== themeOfTheMonthAnimGen) return;
|
||||
|
||||
card.classList.remove("themeOfTheMonthCardExpanding");
|
||||
card.style.transition = "";
|
||||
const finalH = themeOfTheMonthExpandedShellHeight();
|
||||
card.style.height = `${finalH}px`;
|
||||
applyThemeOfTheMonthCardPosition(card, true, false, finalH);
|
||||
}
|
||||
|
||||
async function runThemeOfTheMonthCollapse(
|
||||
card: HTMLElement,
|
||||
backdrop: HTMLElement | null,
|
||||
descriptionHtml: string,
|
||||
): Promise<void> {
|
||||
const gen = ++themeOfTheMonthAnimGen;
|
||||
|
||||
const fromH = card.offsetHeight;
|
||||
const toH = measureCollapsedTargetHeight(card, descriptionHtml);
|
||||
|
||||
// Restore expanded visuals, then run one morph (size + position + height together).
|
||||
applyExpandedLayout(card, descriptionHtml);
|
||||
card.style.width = `${themeOfTheMonthExpandedWidth()}px`;
|
||||
|
||||
card.classList.add("themeOfTheMonthCardExpanding", "themeOfTheMonthCardCollapsing");
|
||||
card.style.height = `${fromH}px`;
|
||||
card.style.overflow = "hidden";
|
||||
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove("themeOfTheMonthBackdropVisible");
|
||||
backdrop.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
applyThemeOfTheMonthCardPosition(card, false, true, toH);
|
||||
|
||||
await sleep(TOTM_LAYOUT_SWAP_MS);
|
||||
if (gen !== themeOfTheMonthAnimGen) return;
|
||||
applyCollapsedLayout(card, descriptionHtml);
|
||||
|
||||
await sleep(TOTM_LAYOUT_SWAP_MS);
|
||||
if (gen !== themeOfTheMonthAnimGen) return;
|
||||
|
||||
card.classList.remove(
|
||||
"themeOfTheMonthCardExpanding",
|
||||
"themeOfTheMonthCardCollapsing",
|
||||
);
|
||||
card.style.height = `${toH}px`;
|
||||
card.style.overflow = "";
|
||||
card.style.transition = "";
|
||||
|
||||
if (backdrop) backdrop.hidden = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instant (reduced-motion) state setter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setThemeOfTheMonthExpandedInstant(
|
||||
card: HTMLElement,
|
||||
backdrop: HTMLElement | null,
|
||||
expanded: boolean,
|
||||
descriptionHtml: string,
|
||||
): void {
|
||||
themeOfTheMonthAnimGen++;
|
||||
|
||||
card.classList.toggle("themeOfTheMonthCardExpanded", expanded);
|
||||
updateThemeOfTheMonthPopoutUi(card, expanded);
|
||||
|
||||
if (expanded) {
|
||||
applyExpandedLayout(card, descriptionHtml);
|
||||
if (backdrop) {
|
||||
backdrop.hidden = false;
|
||||
backdrop.setAttribute("aria-hidden", "false");
|
||||
backdrop.classList.add("themeOfTheMonthBackdropVisible");
|
||||
}
|
||||
applyExpandedCardShell(card);
|
||||
} else {
|
||||
applyCollapsedLayout(card, descriptionHtml);
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove("themeOfTheMonthBackdropVisible");
|
||||
backdrop.setAttribute("aria-hidden", "true");
|
||||
backdrop.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
applyThemeOfTheMonthCardPosition(
|
||||
card,
|
||||
expanded,
|
||||
false,
|
||||
expanded ? themeOfTheMonthExpandedShellHeight() : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function updateThemeOfTheMonthPopoutUi(card: HTMLElement, expanded: boolean): void {
|
||||
const popout = card.querySelector<HTMLButtonElement>(".themeOfTheMonthCardPopout");
|
||||
const popoutIcon = popout?.querySelector(".themeOfTheMonthCardPopoutIcon");
|
||||
if (popoutIcon) {
|
||||
popoutIcon.innerHTML = expanded ? POPOUT_COLLAPSE_SVG : POPOUT_EXPAND_SVG;
|
||||
}
|
||||
if (popout) {
|
||||
popout.setAttribute("aria-label", expanded ? "Collapse" : "Expand");
|
||||
popout.title = expanded ? "Collapse" : "Expand";
|
||||
}
|
||||
}
|
||||
|
||||
function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void): void {
|
||||
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
|
||||
card.classList.add("themeOfTheMonthCardClosing");
|
||||
window.setTimeout(() => {
|
||||
card.remove();
|
||||
@@ -118,38 +555,127 @@ function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
|
||||
}, 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Theme of the Month announcement card.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gallery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initThemeOfTheMonthGallery(card: HTMLElement): void {
|
||||
const track = card.querySelector<HTMLElement>(".themeOfTheMonthCardGalleryTrack");
|
||||
if (!track) return;
|
||||
|
||||
const slides = [...track.querySelectorAll<HTMLElement>(".themeOfTheMonthCardGallerySlide")];
|
||||
if (slides.length <= 1) return;
|
||||
|
||||
const dots = [...card.querySelectorAll<HTMLButtonElement>(".themeOfTheMonthCardGalleryDot")];
|
||||
let activeIndex = 0;
|
||||
|
||||
const scrollToIndex = (index: number) => {
|
||||
const clamped = ((index % slides.length) + slides.length) % slides.length;
|
||||
activeIndex = clamped;
|
||||
const slide = slides[clamped];
|
||||
track.scrollTo({ left: slide.offsetLeft, behavior: "smooth" });
|
||||
for (const dot of dots) {
|
||||
const isActive = Number(dot.dataset.slide) === clamped;
|
||||
dot.classList.toggle("themeOfTheMonthCardGalleryDotActive", isActive);
|
||||
dot.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
}
|
||||
};
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardGalleryPrev")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
scrollToIndex(activeIndex - 1);
|
||||
});
|
||||
card.querySelector(".themeOfTheMonthCardGalleryNext")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
scrollToIndex(activeIndex + 1);
|
||||
});
|
||||
for (const dot of dots) {
|
||||
dot.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
scrollToIndex(Number((e.currentTarget as HTMLButtonElement).dataset.slide));
|
||||
});
|
||||
}
|
||||
|
||||
const syncDotsFromScroll = () => {
|
||||
const mid = track.scrollLeft + track.clientWidth / 2;
|
||||
let nearest = 0;
|
||||
let nearestDist = Infinity;
|
||||
slides.forEach((slide, i) => {
|
||||
const center = slide.offsetLeft + slide.offsetWidth / 2;
|
||||
const dist = Math.abs(center - mid);
|
||||
if (dist < nearestDist) {
|
||||
nearestDist = dist;
|
||||
nearest = i;
|
||||
}
|
||||
});
|
||||
if (nearest === activeIndex) return;
|
||||
activeIndex = nearest;
|
||||
for (const dot of dots) {
|
||||
const isActive = Number(dot.dataset.slide) === nearest;
|
||||
dot.classList.toggle("themeOfTheMonthCardGalleryDotActive", isActive);
|
||||
dot.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
}
|
||||
};
|
||||
|
||||
track.addEventListener("scroll", syncDotsFromScroll, { passive: true });
|
||||
}
|
||||
|
||||
function attachPopupImages(root: ParentNode): void {
|
||||
for (const img of root.querySelectorAll<HTMLImageElement>("img")) {
|
||||
attachPopupMediaFullscreen(img);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function OpenThemeOfTheMonthPopup(
|
||||
entry: ThemeOfTheMonthEntry,
|
||||
onDismissed?: () => void,
|
||||
) {
|
||||
): Promise<void> {
|
||||
document.getElementById("theme-of-the-month-card")?.remove();
|
||||
document.getElementById("theme-of-the-month-backdrop")?.remove();
|
||||
|
||||
const monthLabel = formatMonthLabel(entry.month);
|
||||
const heroUrl = await resolvePopupHeroImageUrl(entry);
|
||||
const description = escapeHTML(entry.description).replace(/\n/g, " ");
|
||||
const linkedThemeId = entry.theme_id ?? entry.theme?.id;
|
||||
const storeTheme = linkedThemeId ? await fetchThemeStoreTheme(linkedThemeId) : null;
|
||||
const heroUrl =
|
||||
(storeTheme ? heroUrlFromStoreTheme(storeTheme) : null) ??
|
||||
entry.cover_image?.trim() ??
|
||||
null;
|
||||
const gallerySlides = buildPopupGallerySlides(entry, storeTheme, heroUrl);
|
||||
const hasExpandableContent = gallerySlides.length > 0 || entry.description.trim().length > 0;
|
||||
|
||||
const descriptionHtml = escapeHTML(entry.description).replace(/\n/g, "<br />");
|
||||
const heroEmbossHtml =
|
||||
heroUrl && storeTheme ? renderHeroEmbossHtml(storeTheme, entry) : "";
|
||||
|
||||
const backdrop = stringToHTML(/* html */ `
|
||||
<div id="theme-of-the-month-backdrop" class="themeOfTheMonthBackdrop" hidden aria-hidden="true"></div>
|
||||
`).firstChild as HTMLElement;
|
||||
|
||||
const card = stringToHTML(/* html */ `
|
||||
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
|
||||
${
|
||||
heroUrl
|
||||
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
|
||||
: ""
|
||||
}
|
||||
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard${settingsState.animations ? "" : " themeOfTheMonthCardReducedMotion"}" role="dialog" aria-label="Theme of the Month">
|
||||
<div class="themeOfTheMonthCardMedia">
|
||||
<button type="button" class="themeOfTheMonthCardPopout" aria-label="Expand" title="Expand"${hasExpandableContent ? "" : " hidden"}>
|
||||
<span class="themeOfTheMonthCardPopoutIcon">${POPOUT_EXPAND_SVG}</span>
|
||||
</button>
|
||||
<div class="themeOfTheMonthCardCompactMedia"${heroUrl ? "" : " hidden"}>
|
||||
${heroUrl ? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />` : ""}
|
||||
</div>
|
||||
<div class="themeOfTheMonthCardExpandedPanel" hidden>
|
||||
${renderGallerySlidesHtml(gallerySlides)}
|
||||
</div>
|
||||
${heroEmbossHtml}
|
||||
</div>
|
||||
<div class="themeOfTheMonthCardBody">
|
||||
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
|
||||
<h2>${escapeHTML(entry.title)}</h2>
|
||||
<p class="themeOfTheMonthCardDescription">${description}</p>
|
||||
<p class="themeOfTheMonthCardDescription themeOfTheMonthCardDescriptionClipped">${descriptionHtml}</p>
|
||||
<div class="themeOfTheMonthCardActions">
|
||||
<div class="themeOfTheMonthCardActionsStart">
|
||||
${
|
||||
linkedThemeId
|
||||
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
|
||||
: ""
|
||||
}
|
||||
${linkedThemeId ? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>` : ""}
|
||||
</div>
|
||||
<div class="themeOfTheMonthCardActionsEnd">
|
||||
<button type="button" class="themeOfTheMonthCardSecondary">Close</button>
|
||||
@@ -170,32 +696,97 @@ export async function OpenThemeOfTheMonthPopup(
|
||||
</aside>
|
||||
`).firstChild as HTMLElement;
|
||||
|
||||
const autoCloseTimeout = window.setTimeout(() => {
|
||||
closeThemeOfTheMonthCard(card, onDismissed);
|
||||
}, 30_000);
|
||||
let isExpanded = false;
|
||||
let expandAnimating = false;
|
||||
|
||||
const dismiss = () => {
|
||||
window.clearTimeout(autoCloseTimeout);
|
||||
const applyExpandedState = async (expanded: boolean): Promise<void> => {
|
||||
updateThemeOfTheMonthPopoutUi(card, expanded);
|
||||
if (!settingsState.animations) {
|
||||
setThemeOfTheMonthExpandedInstant(card, backdrop, expanded, descriptionHtml);
|
||||
return;
|
||||
}
|
||||
expandAnimating = true;
|
||||
try {
|
||||
if (expanded) {
|
||||
await runThemeOfTheMonthExpand(card, backdrop, descriptionHtml);
|
||||
} else {
|
||||
await runThemeOfTheMonthCollapse(card, backdrop, descriptionHtml);
|
||||
}
|
||||
} finally {
|
||||
expandAnimating = false;
|
||||
updateThemeOfTheMonthPopoutUi(card, expanded);
|
||||
}
|
||||
};
|
||||
|
||||
const onDocKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key !== "Escape") return;
|
||||
if (!isExpanded || expandAnimating) return;
|
||||
ev.stopPropagation();
|
||||
isExpanded = false;
|
||||
void applyExpandedState(false);
|
||||
};
|
||||
|
||||
let autoCloseTimeout = 0;
|
||||
const pauseAutoClose = () => window.clearTimeout(autoCloseTimeout);
|
||||
const onResize = () => {
|
||||
if (isExpanded) applyExpandedCardShell(card);
|
||||
applyThemeOfTheMonthCardPosition(
|
||||
card,
|
||||
isExpanded,
|
||||
false,
|
||||
isExpanded ? themeOfTheMonthExpandedShellHeight() : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const dismissWithCleanup = () => {
|
||||
pauseAutoClose();
|
||||
window.removeEventListener("resize", onResize);
|
||||
backdrop.remove();
|
||||
document.removeEventListener("keydown", onDocKey, true);
|
||||
closeThemeOfTheMonthCard(card, onDismissed);
|
||||
};
|
||||
|
||||
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true });
|
||||
autoCloseTimeout = window.setTimeout(dismissWithCleanup, 30_000);
|
||||
card.addEventListener("mouseenter", pauseAutoClose, { once: true });
|
||||
|
||||
initThemeOfTheMonthGallery(card);
|
||||
attachPopupImages(card);
|
||||
|
||||
const confirmEl = card.querySelector<HTMLElement>(".themeOfTheMonthCardConfirm");
|
||||
|
||||
const toggleExpanded = () => {
|
||||
if (expandAnimating) return;
|
||||
isExpanded = !isExpanded;
|
||||
pauseAutoClose();
|
||||
void applyExpandedState(isExpanded);
|
||||
};
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardPopout")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpanded();
|
||||
});
|
||||
|
||||
backdrop.addEventListener("click", () => {
|
||||
if (!isExpanded || expandAnimating) return;
|
||||
isExpanded = false;
|
||||
void applyExpandedState(false);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", onDocKey, true);
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
|
||||
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
||||
dismiss();
|
||||
dismissWithCleanup();
|
||||
});
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
|
||||
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
||||
dismiss();
|
||||
dismissWithCleanup();
|
||||
openThemeStoreWithHighlight(linkedThemeId!);
|
||||
});
|
||||
|
||||
const openDontShowConfirm = () => {
|
||||
window.clearTimeout(autoCloseTimeout);
|
||||
pauseAutoClose();
|
||||
if (!confirmEl) return;
|
||||
confirmEl.hidden = false;
|
||||
requestAnimationFrame(() => confirmEl.classList.add("themeOfTheMonthCardConfirmVisible"));
|
||||
@@ -206,23 +797,37 @@ export async function OpenThemeOfTheMonthPopup(
|
||||
card.querySelector(".themeOfTheMonthCardConfirmCancel")?.addEventListener("click", () => {
|
||||
if (!confirmEl) return;
|
||||
confirmEl.classList.remove("themeOfTheMonthCardConfirmVisible");
|
||||
window.setTimeout(() => {
|
||||
confirmEl.hidden = true;
|
||||
}, 160);
|
||||
window.setTimeout(() => { confirmEl.hidden = true; }, 160);
|
||||
});
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardConfirmAccept")?.addEventListener("click", () => {
|
||||
settingsState.themeOfTheMonthDisabled = true;
|
||||
dismiss();
|
||||
dismissWithCleanup();
|
||||
});
|
||||
|
||||
document.body.appendChild(card);
|
||||
// Mount — card at top:0; left:0, all positioning via transform.
|
||||
card.style.position = "fixed";
|
||||
card.style.top = "0";
|
||||
card.style.left = "0";
|
||||
|
||||
document.body.append(backdrop, card);
|
||||
|
||||
// Set initial collapsed position instantly (no transition).
|
||||
applyThemeOfTheMonthCardPosition(card, false, false);
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
// Enable morph-ready class after two frames so the initial snap doesn't
|
||||
// accidentally play a transition.
|
||||
if (settingsState.animations) {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
card.classList.add("themeOfTheMonthCardMorphReady");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev helper: fetch the current month's entry and show the popup immediately,
|
||||
* even if the user dismissed it for this calendar month.
|
||||
*/
|
||||
export async function showThemeOfTheMonthPopupNow(): Promise<void> {
|
||||
const entry = await fetchThemeOfTheMonth();
|
||||
if (!entry) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import localforage from "localforage";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import { clearCloudPfpCache, pfpUrlWithHash } from "@/seqta/utils/cloudPfpCache";
|
||||
|
||||
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
|
||||
const PLUGIN_SETTINGS_KEY = "plugin.profile-picture.settings";
|
||||
@@ -10,9 +11,32 @@ const profileStore = localforage.createInstance({
|
||||
storeName: "profilePicture",
|
||||
});
|
||||
|
||||
function cacheBustPfpUrl(url: string): string {
|
||||
const base = url.split("?")[0]!;
|
||||
return `${base}?v=${Date.now()}`;
|
||||
/** Downscale before upload to reduce ingress (server still normalizes). */
|
||||
async function downscaleForUpload(blob: Blob, maxEdge = 512): Promise<Blob> {
|
||||
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> {
|
||||
@@ -41,6 +65,9 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
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");
|
||||
|
||||
try {
|
||||
@@ -57,10 +84,10 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
||||
if (!res.ok) {
|
||||
return { success: false, error: (data.error as string) ?? `Clear failed (${res.status})` };
|
||||
}
|
||||
const user = cloudAuth.state.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 };
|
||||
}
|
||||
|
||||
@@ -71,8 +98,9 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
||||
return { success: false, error: "File too large (max 5MB)" };
|
||||
}
|
||||
|
||||
const uploadBlob = await downscaleForUpload(blob);
|
||||
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`, {
|
||||
method: "POST",
|
||||
@@ -85,10 +113,15 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
||||
}
|
||||
|
||||
const pfpUrl = data.pfpUrl as string | undefined;
|
||||
const user = cloudAuth.state.user;
|
||||
const pfpHash = (data.pfpHash as string | null | undefined) ?? null;
|
||||
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 };
|
||||
} catch (err) {
|
||||
return {
|
||||
|
||||
@@ -75,7 +75,7 @@ export interface SettingsState {
|
||||
bsplus_client_id?: string;
|
||||
bsplus_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). */
|
||||
autoCloudSettingsSync?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user