This commit is contained in:
2026-04-29 21:47:11 +09:30
21 changed files with 2685 additions and 210 deletions
+1 -1
View File
@@ -23,4 +23,4 @@ betterseqtaplus-safari/
.env .env
.env.submit .env.submit
dependency-graph.svg dependency-graph.svg
/src/resources/themes
+10 -3
View File
@@ -35,13 +35,16 @@ Upserts the callers settings backup.
```json ```json
{ {
"schemaVersion": 1, "schemaVersion": 1,
"themeId": "uuid-string-or-empty",
"data": { "data": {
"...": "flat key-value map mirroring extension storage (see Payload shape)" "...": "flat key-value map mirroring extension storage (see Payload shape)",
"selectedTheme": "uuid-or-empty-string"
} }
} }
``` ```
- **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations. - **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations.
- **`themeId`**: optional but recommended duplicate of **`data.selectedTheme`**. Should be the UUID of the **installed** BetterSEQTA store theme (`selectedTheme`). This may be a normal theme id **or** a **flavour (slave) variant** id from themes with **`flavours[]`** — the extension uses it after restore to prefetch `theme.json` when missing locally (same **`GET …/themes/{id}/download`** as the store UI). Persist and return **`themeId`** in sync with **`data.selectedTheme`**.
- **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`). - **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`).
**Success response:** HTTP `200` (or `201` if you prefer create semantics). Example: **Success response:** HTTP `200` (or `201` if you prefer create semantics). Example:
@@ -67,15 +70,19 @@ Returns the callers latest settings backup.
```json ```json
{ {
"schemaVersion": 1, "schemaVersion": 1,
"themeId": "uuid-string-or-empty",
"data": { }, "data": { },
"updated_at": "2026-04-07T12:00:00.000Z" "updated_at": "2026-04-07T12:00:00.000Z"
} }
``` ```
- **`data`**: required for restore; must be the same shape as accepted in `PUT` (flat map of storage keys). - **`data`**: required for restore; must be the same shape as accepted in `PUT` (flat map of storage keys).
- **`themeId`**: optional; if present must match **`data.selectedTheme`** (see `PUT`).
- **`schemaVersion`**: optional but recommended; should match what was stored. - **`schemaVersion`**: optional but recommended; should match what was stored.
- **`updated_at`**: optional; included for UX if the client shows “last backup” time. - **`updated_at`**: optional; included for UX if the client shows “last backup” time.
The extension resolves **`themeId`** (if non-empty), else **`data.selectedTheme`,** to [`resolveThemeIdForPostSyncDownload`](../src/seqta/utils/cloudSettingsSync.ts) after downloading the envelope — used only to reinstall theme assets from **`betterseqta.org`** when IndexedDB lacks that id (see **BetterSEQTA Cloud** flavour note in **[THEME_STORE_FLAVOURS_API](./THEME_STORE_FLAVOURS_API.md)** section “Cloud settings sync compatibility”).
**No backup yet:** HTTP **`404`**. The extension treats this as “nothing in the cloud” and shows an error to the user. **No backup yet:** HTTP **`404`**. The extension treats this as “nothing in the cloud” and shows an error to the user.
**Error responses:** `401` if the token is invalid, etc. **Error responses:** `401` if the token is invalid, etc.
@@ -128,6 +135,6 @@ This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages,
## Client reference (extension) ## Client reference (extension)
- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, and **`bsplus_cloud_settings_known_remote_updated_at`**). - Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, **`bsplus_pending_theme_ensure_after_cloud`**, and **`bsplus_cloud_settings_known_remote_updated_at`** — includes **`themeId`** aligned with **`selectedTheme`**).
- Download: `applyDownloadedEnvelope` after `GET`; local auth keys, sensitive device keys, and the client-only watermark key are merged back after `chrome.storage.local.clear()`. - Download: resolve id via **`resolveThemeIdForPostSyncDownload`** → **`applyDownloadedEnvelope`** after `GET` → prefetch theme blobs in page context if needed (**`prepareThemeAfterCloudSync`** in **`ThemeManager`**) → reload SEQTA tabs; local auth keys, sensitive device keys, client-only watermark, and **`bsplus_pending_theme_ensure_after_cloud`** semantics preserved as documented above.
- Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`. - Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`.
+5
View File
@@ -5,6 +5,7 @@ import {
initCloudSettingsAutoSync, initCloudSettingsAutoSync,
performCloudSettingsDownloadWithRetry, performCloudSettingsDownloadWithRetry,
performCloudSettingsUploadWithRetry, performCloudSettingsUploadWithRetry,
requestCloudSettingsDebouncedUpload,
runCloudSettingsPoll, runCloudSettingsPoll,
} from "./background/cloudSettingsAutoSync"; } from "./background/cloudSettingsAutoSync";
@@ -346,6 +347,10 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
void runCloudSettingsPoll(); void runCloudSettingsPoll();
return false; return false;
}, },
cloudSettingsRequestDebouncedUpload: () => {
requestCloudSettingsDebouncedUpload();
return false;
},
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => { getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
(async () => { (async () => {
try { try {
+21
View File
@@ -3,8 +3,10 @@ import {
applyDownloadedEnvelope, applyDownloadedEnvelope,
buildUploadPayload, buildUploadPayload,
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
isKeyIncludedInCloudUploadPayload, isKeyIncludedInCloudUploadPayload,
resolveThemeIdForPostSyncDownload,
setKnownRemoteUpdatedAt, setKnownRemoteUpdatedAt,
} from "@/seqta/utils/cloudSettingsSync"; } from "@/seqta/utils/cloudSettingsSync";
@@ -220,7 +222,15 @@ async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
error: data?.error ?? `Download failed (${r.status})`, error: data?.error ?? `Download failed (${r.status})`,
}; };
} }
const themeIdToEnsure = resolveThemeIdForPostSyncDownload(data);
await applyDownloadedEnvelope(data); await applyDownloadedEnvelope(data);
if (themeIdToEnsure) {
await browser.storage.local.set({
[BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY]: themeIdToEnsure,
});
} else {
await browser.storage.local.remove(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
}
reloadSeqtaPagesFn?.(); reloadSeqtaPagesFn?.();
const updated_at = data?.updated_at as string | undefined; const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at); await setKnownRemoteUpdatedAt(updated_at);
@@ -352,6 +362,17 @@ function scheduleDebouncedUpload(): void {
}, UPLOAD_DEBOUNCE_MS); }, UPLOAD_DEBOUNCE_MS);
} }
/** Call after store theme install (and similar) so cloud upload runs even if storage events are flaky. */
export function requestCloudSettingsDebouncedUpload(): void {
void (async () => {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
if (suppressAutoUploadDuringRestore) return;
if (!(await getAccessToken())) return;
scheduleDebouncedUpload();
})();
}
async function runDebouncedUploadJob(): Promise<void> { async function runDebouncedUploadJob(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>; const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return; if (!isAutoCloudSyncEnabled(all)) return;
@@ -1,10 +1,13 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { Theme } from '@/interface/types/Theme'; import type { ThemeCoverSlide } from '@/interface/types/Theme';
import emblaCarouselSvelte from 'embla-carousel-svelte'; import emblaCarouselSvelte from 'embla-carousel-svelte';
import Autoplay from 'embla-carousel-autoplay'; import Autoplay from 'embla-carousel-autoplay';
let { coverThemes, setDisplayTheme } = $props<{ coverThemes: Theme[], setDisplayTheme: (theme: Theme) => void }>(); let { slides, setDisplayTheme } = $props<{
slides: ThemeCoverSlide[];
setDisplayTheme: (theme: import('@/interface/types/Theme').Theme) => void;
}>();
let emblaApi = $state(); let emblaApi = $state();
const options = { loop: true }; const options = { loop: true };
@@ -12,8 +15,8 @@
Autoplay({ Autoplay({
delay: 5000, delay: 5000,
stopOnInteraction: false, stopOnInteraction: false,
stopOnMouseEnter: true stopOnMouseEnter: true,
}) }),
]; ];
function onInit(event: CustomEvent) { function onInit(event: CustomEvent) {
@@ -26,7 +29,7 @@
const slideNext = () => emblaApi?.scrollNext(); const slideNext = () => emblaApi?.scrollNext();
</script> </script>
{#if coverThemes.length > 0} {#if slides.length > 0}
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade> <div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
<div <div
class="w-full aspect-[5/1] max-h-[500px]" class="w-full aspect-[5/1] max-h-[500px]"
@@ -34,49 +37,69 @@
onemblaInit={onInit} onemblaInit={onInit}
> >
<div class="flex"> <div class="flex">
{#each coverThemes as theme} {#each slides as slide (slide.imageUrl + slide.title + (slide.subtitle ?? ''))}
<div <div
class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip" class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip"
role="button" role="button"
tabindex="0" tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }} onkeydown={(e) => {
onclick={() => setDisplayTheme(theme)} if (e.key === 'Enter') setDisplayTheme(slide.openTheme);
}}
onclick={() => setDisplayTheme(slide.openTheme)}
> >
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-full" /> <img src={slide.imageUrl} alt="" class="object-cover w-full h-full" />
{#if theme.featured === true} {#if slide.badgeFeatured === true}
<div class="absolute top-4 left-4 z-[2] pointer-events-none"> <div class="absolute top-4 left-4 z-[2] pointer-events-none">
<span <span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm" class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm"
aria-label="Featured theme" aria-label="Featured theme"
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" /> <path
fill-rule="evenodd"
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
clip-rule="evenodd"
/>
</svg> </svg>
Featured Featured
</span> </span>
</div> </div>
{/if} {/if}
<div class='absolute bottom-0 left-0 p-8 z-[1]'> <div class="absolute bottom-0 left-0 p-8 z-[1]">
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2> <h2 class="text-4xl font-bold text-white">{slide.title}</h2>
{#if theme.author} {#if slide.subtitle}
<p class="text-sm text-white/90 mt-1 mb-1 line-clamp-1">By {theme.author}</p> <p class="text-lg font-medium text-white/95 mt-1 line-clamp-2">{slide.subtitle}</p>
{/if}
{#if slide.openTheme.author && !slide.subtitle}
<p class="text-sm text-white/90 mt-1 mb-1 line-clamp-1">By {slide.openTheme.author}</p>
{/if}
{#if slide.openTheme.description && !slide.subtitle}
<p class="text-lg text-white line-clamp-3">{slide.openTheme.description}</p>
{/if} {/if}
<p class='text-lg text-white'>{theme.description}</p>
</div> </div>
<div class='absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80'></div> <div class="absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80"></div>
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
<!-- Navigation buttons --> <div class="flex absolute right-2 bottom-2 z-10 gap-2">
<div class='flex absolute right-2 bottom-2 z-10 gap-2'> <button
<button aria-label="Previous" onclick={slidePrev} class='flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800'> aria-label="Previous"
onclick={slidePrev}
type="button"
class="flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800 transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
</svg> </svg>
</button> </button>
<button aria-label="Next" onclick={slideNext} class='flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800'> <button
aria-label="Next"
onclick={slideNext}
type="button"
class="flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800 transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg> </svg>
@@ -1,14 +1,54 @@
<script lang="ts"> <script lang="ts">
import type { Theme } from '@/interface/types/Theme' import type { Theme } from '@/interface/types/Theme'
import {
masterGridDisplayDownloadCount,
gridCardPreviewImageUrls,
} from '@/interface/utils/themeStoreFlavours'
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{ import emblaCarouselSvelte from 'embla-carousel-svelte';
import Autoplay from 'embla-carousel-autoplay';
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn, allStoreThemeRows } = $props<{
theme: Theme; theme: Theme;
onClick: () => void; onClick: () => void;
toggleFavorite: (theme: Theme) => void; toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean; isLoggedIn: boolean;
onRequestSignIn?: () => void; onRequestSignIn?: () => void;
/** Raw API themes (includes hidden slaves) for aggregated master download totals */
allStoreThemeRows?: Theme[];
}>(); }>();
const displayDownloadCount = $derived(
allStoreThemeRows != null
? masterGridDisplayDownloadCount(theme, allStoreThemeRows)
: (theme.download_count ?? 0),
);
const gridRotatorUrls = $derived(gridCardPreviewImageUrls(theme, allStoreThemeRows));
/** Mirrors CoverSwiper (featured bar): horizontal slides + autoplay */
function prefersReducedMotion(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/** Read once synchronously where `window` exists so reduced-motion doesnt briefly mount carousel */
let allowSlideAutoplay = $state(!prefersReducedMotion());
const gridEmblaKey = $derived(gridRotatorUrls.join('|'));
const gridEmblaOptions = $derived({ loop: gridRotatorUrls.length > 1 });
const gridEmblaPlugins = $derived.by(() => {
if (!allowSlideAutoplay || gridRotatorUrls.length <= 1) return [];
return [
Autoplay({
delay: 2000,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
];
});
let menuOpen = $state(false); let menuOpen = $state(false);
let menuRef: HTMLDivElement; let menuRef: HTMLDivElement;
@@ -111,7 +151,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg> </svg>
{(theme.download_count ?? 0).toLocaleString()} {displayDownloadCount.toLocaleString()}
</span> </span>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
@@ -122,8 +162,40 @@
</div> </div>
</div> </div>
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div> <div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
<div class='w-full'> {#if gridRotatorUrls.length === 0}
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" /> <div class="relative w-full h-48 overflow-hidden rounded-md bg-zinc-200 dark:bg-zinc-700" aria-hidden="true"></div>
{:else if !allowSlideAutoplay || gridRotatorUrls.length === 1}
<div class="relative w-full h-48 overflow-hidden rounded-md">
<img
src={gridRotatorUrls[0] ?? theme.marqueeImage ?? theme.coverImage}
alt=""
class="object-cover w-full h-full"
draggable="false"
/>
</div> </div>
{:else}
{#key gridEmblaKey}
<div
class="relative w-full h-48 overflow-hidden rounded-md"
use:emblaCarouselSvelte={{
options: gridEmblaOptions,
plugins: gridEmblaPlugins,
}}
>
<div class="flex h-full">
{#each gridRotatorUrls as url (url)}
<div class="relative flex-[0_0_100%] min-w-0 h-full shrink-0">
<img
src={url}
alt=""
class="object-cover w-full h-full select-none"
draggable="false"
/>
</div>
{/each}
</div>
</div>
{/key}
{/if}
</div> </div>
</div> </div>
@@ -2,13 +2,23 @@
import type { Theme } from '@/interface/types/Theme' import type { Theme } from '@/interface/types/Theme'
import ThemeCard from './ThemeCard.svelte'; import ThemeCard from './ThemeCard.svelte';
let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{ let {
themes,
searchTerm,
setDisplayTheme,
toggleFavorite,
isLoggedIn,
onRequestSignIn,
allStoreThemeRows,
} = $props<{
themes: Theme[]; themes: Theme[];
searchTerm: string; searchTerm: string;
setDisplayTheme: (theme: Theme) => void; setDisplayTheme: (theme: Theme) => void;
toggleFavorite: (theme: Theme) => void; toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean; isLoggedIn: boolean;
onRequestSignIn?: () => void; onRequestSignIn?: () => void;
/** Raw API list (includes `slave` rows) for master download aggregation */
allStoreThemeRows?: Theme[];
}>(); }>();
let filteredThemes = $derived(themes.filter((theme: Theme) => let filteredThemes = $derived(themes.filter((theme: Theme) =>
@@ -25,6 +35,7 @@
{toggleFavorite} {toggleFavorite}
{isLoggedIn} {isLoggedIn}
{onRequestSignIn} {onRequestSignIn}
{allStoreThemeRows}
/> />
{/each} {/each}
File diff suppressed because it is too large Load Diff
+40 -19
View File
@@ -7,6 +7,7 @@
import SkeletonLoader from '../components/SkeletonLoader.svelte'; import SkeletonLoader from '../components/SkeletonLoader.svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState' import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import type { Theme } from '../types/Theme' import type { Theme } from '../types/Theme'
import { visibleStoreThemes, buildCoverSlidesForThemes } from '@/interface/utils/themeStoreFlavours'
import browser from 'webextension-polyfill' import browser from 'webextension-polyfill'
import ThemeModal from '../components/store/ThemeModal.svelte' import ThemeModal from '../components/store/ThemeModal.svelte'
import Header from '../components/store/Header.svelte' import Header from '../components/store/Header.svelte'
@@ -26,7 +27,12 @@
// State variables // State variables
let searchTerm = $state(''); let searchTerm = $state('');
let themes = $state<Theme[]>([]); let themes = $state<Theme[]>([]);
let coverThemes = $state<Theme[]>([]);
/** Grid/search/cover: hides flat-listed slaves when API sends them */
let listThemes = $derived(visibleStoreThemes(themes));
/** Cover marquee slides (master + flavour imagery for top masters) */
let coverSlides = $derived(buildCoverSlidesForThemes(listThemes.slice(0, 3)));
let loading = $state(true); let loading = $state(true);
let darkMode = $state(false); let darkMode = $state(false);
let displayTheme = $state<Theme | null>(null); let displayTheme = $state<Theme | null>(null);
@@ -108,7 +114,6 @@
throw new Error(data?.error || 'Failed to fetch themes'); throw new Error(data?.error || 'Failed to fetch themes');
} }
themes = [...data.data.themes].sort(compareStoreThemes); themes = [...data.data.themes].sort(compareStoreThemes);
coverThemes = themes.slice(0, 3);
loading = false; loading = false;
} catch (err) { } catch (err) {
@@ -128,13 +133,36 @@
// Filter themes (list is already featured-first, then newest; filter preserves order) // Filter themes (list is already featured-first, then newest; filter preserves order)
let filteredThemes = $derived( let filteredThemes = $derived(
themes.filter( listThemes.filter(
(theme) => (theme) =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
theme.description.toLowerCase().includes(searchTerm.toLowerCase()), theme.description.toLowerCase().includes(searchTerm.toLowerCase()),
), ),
); );
async function installThemeFromStore(themeId: string, meta: Theme) {
const fullRow = themes.find((x) => x.id === themeId);
if (fullRow) {
await themeManager.downloadTheme(fullRow);
} else {
const flavour = meta.flavours?.find((f) => f.id === themeId);
await themeManager.downloadTheme({
id: themeId,
name: flavour?.name ?? meta.name,
} as Theme);
}
await themeManager.setTheme(themeId);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
void browser.runtime.sendMessage({ type: 'cloudSettingsRequestDebouncedUpload' }).catch(() => {});
}
async function removeThemeFromStore(themeId: string) {
await themeManager.deleteTheme(themeId);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
$effect(() => { $effect(() => {
loadBackground(); loadBackground();
selectedBackground selectedBackground
@@ -172,12 +200,13 @@
<!-- Themes Tab Content --> <!-- Themes Tab Content -->
{#if activeTab === 'themes'} {#if activeTab === 'themes'}
{#if searchTerm === ''} {#if searchTerm === ''}
<CoverSwiper {coverThemes} {setDisplayTheme} /> <CoverSwiper slides={coverSlides} {setDisplayTheme} />
{/if} {/if}
<!-- ThemeGrid to display filtered themes --> <!-- ThemeGrid to display filtered themes -->
<ThemeGrid <ThemeGrid
themes={filteredThemes} themes={filteredThemes}
allStoreThemeRows={themes}
{searchTerm} {searchTerm}
{setDisplayTheme} {setDisplayTheme}
{toggleFavorite} {toggleFavorite}
@@ -188,28 +217,20 @@
{#if displayTheme} {#if displayTheme}
<ThemeModal <ThemeModal
currentThemes={currentThemes} currentThemes={currentThemes}
allThemes={themes} allThemes={listThemes}
allStoreThemeRows={themes}
theme={displayTheme} theme={displayTheme}
{displayTheme} {displayTheme}
{setDisplayTheme} {setDisplayTheme}
{toggleFavorite} {toggleFavorite}
isLoggedIn={cloudLoggedIn} isLoggedIn={cloudLoggedIn}
onRequestSignIn={() => (showSignInOverlay = true)} onRequestSignIn={() => (showSignInOverlay = true)}
onInstall={async () => { onInstall={async (themeId: string) => {
if (displayTheme) { if (displayTheme) await installThemeFromStore(themeId, displayTheme);
await themeManager.downloadTheme(displayTheme);
await themeManager.setTheme(displayTheme.id);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
}} }}
onRemove={async () => { onRemove={async (themeId: string) => {
if (displayTheme?.id) { console.debug('deleting theme', themeId);
console.debug('deleting theme', displayTheme.id); await removeThemeFromStore(themeId);
await themeManager.deleteTheme(displayTheme.id);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
}} }}
/> />
{/if} {/if}
+32
View File
@@ -1,3 +1,17 @@
export type ThemeRole = "standard" | "master" | "slave";
/** List/detail metadata for variants of a master theme (full theme.json fetched at install by id). */
export type ThemeFlavour = {
id: string;
name: string;
/** Mirrors theme.json accent (e.g. defaultColour); used for install picker buttons */
accent_color: string;
cover_image: string;
marquee_image?: string;
/** Per-variant installs when slaves are not returned as flat `theme_role` rows */
download_count?: number;
};
export type Theme = { export type Theme = {
id: string; id: string;
name: string; name: string;
@@ -15,4 +29,22 @@ export type Theme = {
created_at?: number; created_at?: number;
/** Unix seconds — last server update (GET /api/themes). */ /** Unix seconds — last server update (GET /api/themes). */
updated_at?: number; updated_at?: number;
/** Omitted / `standard` — show in grid. `slave` hides from grid. `master` can list `flavours`. */
theme_role?: ThemeRole;
/** Present when `theme_role === "slave"` and API returns a flat list during migration */
master_id?: string;
/** Variants nested on master rows; installs use flavour `id` */
flavours?: ThemeFlavour[];
};
/** One marquee slide (cover hero or modal carousel). */
export type ThemeCoverSlide = {
imageUrl: string;
/** Main line — usually master name */
title: string;
/** Subline — flavour name when applicable */
subtitle?: string;
/** Opening the modal uses this theme (always the grid row / master object) */
openTheme: Theme;
badgeFeatured?: boolean;
}; };
+165
View File
@@ -0,0 +1,165 @@
import type { Theme, ThemeCoverSlide, ThemeFlavour } from "@/interface/types/Theme";
export function isHiddenStoreTheme(theme: Theme): boolean {
return theme.theme_role === "slave";
}
/** Grid and search: omit slave rows (when API sends a flattened list). */
export function visibleStoreThemes(themes: Theme[]): Theme[] {
return themes.filter((t) => !isHiddenStoreTheme(t));
}
function marqueeOrCoverUrl(t: { marqueeImage?: string; coverImage: string }): string {
return t.marqueeImage || t.coverImage;
}
/**
* Builds slides for CoverSwiper: for each top-N master, first master image then each flavour image.
*/
export function buildCoverSlidesForThemes(topThemes: Theme[]): ThemeCoverSlide[] {
const slides: ThemeCoverSlide[] = [];
for (const theme of topThemes) {
const flavours = theme.flavours ?? [];
if (flavours.length === 0) {
slides.push({
imageUrl: marqueeOrCoverUrl(theme),
title: theme.name,
subtitle: theme.author ? `By ${theme.author}` : undefined,
openTheme: theme,
badgeFeatured: theme.featured === true,
});
continue;
}
slides.push({
imageUrl: marqueeOrCoverUrl(theme),
title: theme.name,
subtitle: theme.author ? `By ${theme.author}` : undefined,
openTheme: theme,
badgeFeatured: theme.featured === true,
});
for (const f of flavours) {
slides.push({
imageUrl: f.marquee_image || f.cover_image,
title: theme.name,
subtitle: f.name,
openTheme: theme,
badgeFeatured: theme.featured === true,
});
}
}
return slides;
}
export type ModalHeroSlide = { imageUrl: string; caption: string };
/** Preview image for carousel + flavour picker (matches hero slide order after master slide). */
export function flavourCarouselImageUrl(f: ThemeFlavour): string {
const u = (f.marquee_image || f.cover_image || "").trim();
return u;
}
/** Preview image for master variant tile (modal hero slide 0). */
export function masterCarouselImageUrl(t: Theme): string {
return (marqueeOrCoverUrl(t) || "").trim();
}
/**
* Ordered preview URLs for the store grid card rotator: master first, then each variant.
* Uses nested `flavours` when present; otherwise flat `slave` rows (same order as modal when possible).
*/
export function gridCardPreviewImageUrls(theme: Theme, allStoreRows?: Theme[]): string[] {
const urls: string[] = [];
const push = (raw: string) => {
const u = raw.trim();
if (u && !urls.includes(u)) urls.push(u);
};
push(marqueeOrCoverUrl(theme) || "");
const flavours = theme.flavours ?? [];
if (flavours.length > 0) {
for (const f of flavours) {
push(flavourCarouselImageUrl(f));
}
return urls.length > 0 ? urls : [(theme.coverImage || "").trim()].filter(Boolean);
}
if (allStoreRows) {
const slaves = allStoreRows
.filter((t) => t.theme_role === "slave" && t.master_id === theme.id)
.sort((a, b) => a.id.localeCompare(b.id));
for (const s of slaves) {
push((marqueeOrCoverUrl(s) || "").trim());
}
}
if (urls.length > 0) return urls;
const fallback = (theme.coverImage || "").trim();
return fallback ? [fallback] : [];
}
/**
* Downloads shown on the grid card for a master row: master's count plus each slave
* (flat `theme_role === "slave"` with `master_id`) and any flavour-only `download_count`
* when there is no matching flat slave id (nested-only API shape).
*/
export function masterGridDisplayDownloadCount(master: Theme, allStoreRows: Theme[]): number {
let total = master.download_count ?? 0;
const slaveRows = allStoreRows.filter(
(t) => t.theme_role === "slave" && t.master_id === master.id,
);
const countedIds = new Set(slaveRows.map((r) => r.id));
for (const r of slaveRows) {
total += r.download_count ?? 0;
}
for (const f of master.flavours ?? []) {
if (countedIds.has(f.id)) continue;
total += f.download_count ?? 0;
}
return total;
}
/** Modal hero: master first, then each flavour (plan order). */
export function buildModalHeroSlides(theme: Theme): ModalHeroSlide[] {
const slides: ModalHeroSlide[] = [];
slides.push({
imageUrl: marqueeOrCoverUrl(theme),
caption: theme.name,
});
const flavours = theme.flavours ?? [];
for (const f of flavours) {
slides.push({
imageUrl: f.marquee_image || f.cover_image,
caption: `${theme.name}${f.name}`,
});
}
return slides;
}
/**
* Relative luminance 01 for rgba/rgb/hex-ish strings; fallback 0.5 → white text
*/
export function pickContrastingTextColor(backgroundCss: string): "#ffffff" | "#0a0a0a" {
const s = backgroundCss.trim();
const rgba = s.match(
/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i,
);
if (rgba) {
const r = Number(rgba[1]) / 255;
const g = Number(rgba[2]) / 255;
const b = Number(rgba[3]) / 255;
const a = rgba[4] !== undefined ? Number(rgba[4]) : 1;
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const effective = lum * a + 0.05 * (1 - a);
return effective > 0.45 ? "#0a0a0a" : "#ffffff";
}
const hex = s.match(/^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
if (hex) {
const r = parseInt(hex[1], 16) / 255;
const g = parseInt(hex[2], 16) / 255;
const b = parseInt(hex[3], 16) / 255;
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return lum > 0.45 ? "#0a0a0a" : "#ffffff";
}
return "#ffffff";
}
@@ -28,9 +28,17 @@ async function fetchJSON(url: string, body: any) {
async function loadSubjects() { async function loadSubjects() {
const res = await fetchJSON("/seqta/student/load/subjects?", {}); const res = await fetchJSON("/seqta/student/load/subjects?", {});
return res.payload const activeGroup = res.payload.find((s: any) => s.active === 1);
.filter((s: any) => s.active === 1) const activeYear = activeGroup?.year;
const allSubjects = res.payload
.filter((s: any) => s.year === activeYear)
.flatMap((s: any) => s.subjects); .flatMap((s: any) => s.subjects);
const seen = new Set<string>();
return allSubjects.filter((s: Subject) => {
if (seen.has(s.code)) return false;
seen.add(s.code);
return true;
});
} }
async function loadPrefs(student: number) { async function loadPrefs(student: number) {
@@ -0,0 +1,748 @@
import type { Plugin } from "../../core/types";
import { booleanSetting } from "@/plugins/core/settingsHelpers";
import { waitForElm } from "@/seqta/utils/waitForElm";
import styles from "./styles.css?inline";
const messageFoldersSettings = {
showTagsInAllMessages: booleanSetting({
default: true,
title: "Show folder tags in All Messages",
description:
"When off, folder tags are not shown on the message list until you select a folder.",
}),
hideFolderedMessagesInAll: booleanSetting({
default: true,
title: "Hide foldered messages in All Messages",
description:
"When on, messages assigned to a custom folder are hidden from the inbox until you open that folder.",
}),
} as const;
interface Folder {
id: string;
name: string;
color: string;
}
interface MessageFoldersStorage {
folders: Folder[];
messageAssignments: Record<string, string[]>;
}
const FOLDER_COLORS = [
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b",
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316",
];
const FOLDER_ICON_SVG = `<svg style="width:24px;height:24px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const PLUS_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`;
const CHECK_SVG_WHITE = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#fff" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>`;
const CLOSE_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>`;
const EDIT_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>`;
const TRASH_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`;
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFoldersStorage> = {
id: "messageFolders",
name: "Message Folders",
description: "Organize direct messages into custom folders",
version: "1.0.0",
settings: messageFoldersSettings,
disableToggle: true,
defaultEnabled: true,
run: async (api) => {
const styleEl = document.createElement("style");
styleEl.textContent = styles;
document.head.appendChild(styleEl);
await api.storage.loaded;
if (!api.storage.folders) api.storage.folders = [];
if (!api.storage.messageAssignments) api.storage.messageAssignments = {};
let activeFolderId: string | null = null;
let messageListObserver: MutationObserver | null = null;
let sidebarObserver: MutationObserver | null = null;
let actionsObserver: MutationObserver | null = null;
let openDropdown: HTMLElement | null = null;
let dropdownCloseHandler: ((e: MouseEvent) => void) | null = null;
const unregisters: Array<{ unregister: () => void }> = [];
// ── Storage accessors ──
const getFolders = (): Folder[] => api.storage.folders ?? [];
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
const saveFolders = (folders: Folder[]) => {
api.storage.folders = [...folders];
};
const saveAssignments = (assignments: Record<string, string[]>) => {
api.storage.messageAssignments = { ...assignments };
};
const getMessageFolderIds = (messageId: string): string[] => {
const assignments = getAssignments();
const ids: string[] = [];
for (const [folderId, msgIds] of Object.entries(assignments)) {
if (msgIds.includes(messageId)) ids.push(folderId);
}
return ids;
};
const toggleMessageInFolder = (messageId: string, folderId: string) => {
const assignments = getAssignments();
if (!assignments[folderId]) assignments[folderId] = [];
const idx = assignments[folderId].indexOf(messageId);
if (idx >= 0) {
assignments[folderId].splice(idx, 1);
} else {
assignments[folderId].push(messageId);
}
saveAssignments(assignments);
};
const getFolderMessageCount = (folderId: string): number => {
return (getAssignments()[folderId] ?? []).length;
};
const restoreSubjectPlain = (subject: Element) => {
subject.querySelector(".bsplus-msg-badges")?.remove();
const textWrap = subject.querySelector(".bsplus-subject-text");
if (textWrap) {
subject.textContent = textWrap.textContent ?? "";
}
};
const isMessageInAnyCustomFolder = (messageId: string): boolean => {
for (const msgIds of Object.values(getAssignments())) {
if (msgIds.includes(messageId)) return true;
}
return false;
};
const shouldShowBadgesInList = (): boolean => {
return api.settings.showTagsInAllMessages || activeFolderId !== null;
};
// ── Confirm modal ──
const showConfirmModal = (
title: string,
message: string,
onConfirm: () => void,
) => {
const overlay = document.createElement("div");
overlay.className = "bsplus-modal-overlay";
const modal = document.createElement("div");
modal.className = "bsplus-modal";
modal.innerHTML = `
<h3>${title}</h3>
<p>${message}</p>
<div class="bsplus-modal-actions">
<button class="bsplus-modal-btn-cancel">Cancel</button>
<button class="bsplus-modal-btn-danger">Delete</button>
</div>
`;
overlay.appendChild(modal);
const remove = () => {
overlay.remove();
document.removeEventListener("keydown", onKey);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") remove();
};
overlay.addEventListener("click", (e) => {
if (e.target === overlay) remove();
});
modal.querySelector(".bsplus-modal-btn-cancel")!.addEventListener("click", remove);
modal.querySelector(".bsplus-modal-btn-danger")!.addEventListener("click", () => {
onConfirm();
remove();
});
document.body.appendChild(overlay);
document.addEventListener("keydown", onKey);
};
// ── Sidebar folder UI ──
const renderSidebarFolders = () => {
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (!sidebar) return;
const ol = sidebar.querySelector("ol");
if (!ol) return;
let section = ol.querySelector(".bsplus-folders-section");
if (!section) {
section = document.createElement("div");
section.className = "bsplus-folders-section";
ol.appendChild(section);
}
const folders = getFolders();
const existingInput = section.querySelector(".bsplus-folder-input");
const existingColors = section.querySelector(".bsplus-folder-colors");
section.innerHTML = "";
// Header
const header = document.createElement("div");
header.className = "bsplus-folders-header";
const label = document.createElement("span");
label.textContent = "Folders";
header.appendChild(label);
const addBtn = document.createElement("button");
addBtn.className = "bsplus-folders-add-btn";
addBtn.title = "New folder";
addBtn.innerHTML = PLUS_SVG;
addBtn.addEventListener("click", (e) => {
e.stopPropagation();
showNewFolderInput(section!);
});
header.appendChild(addBtn);
section.appendChild(header);
// "All Messages" item
const allItem = document.createElement("div");
allItem.className = `bsplus-folder-item${activeFolderId === null ? " bsplus-folder-active" : ""}`;
allItem.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" style="fill: currentcolor; opacity: 0.5; flex-shrink: 0;"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
<span class="bsplus-folder-name">All Messages</span>
`;
allItem.addEventListener("click", () => {
activeFolderId = null;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
});
section.appendChild(allItem);
// Folder items
for (const folder of folders) {
const item = document.createElement("div");
item.className = `bsplus-folder-item${activeFolderId === folder.id ? " bsplus-folder-active" : ""}`;
item.dataset.folderId = folder.id;
const dot = document.createElement("div");
dot.className = "bsplus-folder-dot";
dot.style.background = folder.color;
item.appendChild(dot);
const name = document.createElement("span");
name.className = "bsplus-folder-name";
name.textContent = folder.name;
item.appendChild(name);
const actions = document.createElement("div");
actions.className = "bsplus-folder-actions";
const editBtn = document.createElement("button");
editBtn.className = "bsplus-folder-action-btn";
editBtn.title = "Rename";
editBtn.innerHTML = EDIT_SVG;
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
showEditFolderInput(section!, folder);
});
actions.appendChild(editBtn);
const deleteBtn = document.createElement("button");
deleteBtn.className = "bsplus-folder-action-btn";
deleteBtn.title = "Delete";
deleteBtn.innerHTML = TRASH_SVG;
deleteBtn.addEventListener("click", (e) => {
e.stopPropagation();
showConfirmModal(
"Delete folder",
`Remove "${folder.name}"? Messages won't be deleted.`,
() => {
const folders = getFolders().filter((f) => f.id !== folder.id);
saveFolders(folders);
const assignments = getAssignments();
delete assignments[folder.id];
saveAssignments(assignments);
if (activeFolderId === folder.id) activeFolderId = null;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
},
);
});
actions.appendChild(deleteBtn);
item.appendChild(actions);
const count = document.createElement("span");
count.className = "bsplus-folder-count";
const c = getFolderMessageCount(folder.id);
count.textContent = c > 0 ? String(c) : "";
item.appendChild(count);
item.addEventListener("click", () => {
activeFolderId = folder.id;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
});
section.appendChild(item);
}
// Restore input if it was open
if (existingInput || existingColors) {
// Don't restore let user re-trigger
}
};
const showNewFolderInput = (container: Element, editFolder?: Folder) => {
const existing = container.querySelector(".bsplus-folder-input");
if (existing) existing.remove();
container.querySelector(".bsplus-folder-colors")?.remove();
let selectedColor = editFolder?.color ?? FOLDER_COLORS[Math.floor(Math.random() * FOLDER_COLORS.length)];
const row = document.createElement("div");
row.className = "bsplus-folder-input";
const input = document.createElement("input");
input.type = "text";
input.placeholder = editFolder ? "Rename folder…" : "Folder name…";
input.value = editFolder?.name ?? "";
input.maxLength = 30;
const confirmBtn = document.createElement("button");
confirmBtn.className = "bsplus-folder-input-confirm";
confirmBtn.innerHTML = CHECK_SVG_WHITE;
const cancelBtn = document.createElement("button");
cancelBtn.className = "bsplus-folder-input-cancel";
cancelBtn.innerHTML = CLOSE_SVG;
row.appendChild(input);
row.appendChild(confirmBtn);
row.appendChild(cancelBtn);
// Color picker
const colorRow = document.createElement("div");
colorRow.className = "bsplus-folder-colors";
for (const color of FOLDER_COLORS) {
const swatch = document.createElement("button");
swatch.className = `bsplus-folder-color-opt${color === selectedColor ? " bsplus-color-selected" : ""}`;
swatch.style.background = color;
swatch.addEventListener("click", (e) => {
e.stopPropagation();
selectedColor = color;
colorRow.querySelectorAll(".bsplus-folder-color-opt").forEach((s) =>
s.classList.toggle("bsplus-color-selected", (s as HTMLElement).style.background === color),
);
});
colorRow.appendChild(swatch);
}
const confirm = () => {
const name = input.value.trim();
if (!name) return;
if (editFolder) {
const folders = getFolders().map((f) =>
f.id === editFolder.id ? { ...f, name, color: selectedColor } : f,
);
saveFolders(folders);
} else {
const folder: Folder = { id: generateId(), name, color: selectedColor };
saveFolders([...getFolders(), folder]);
}
applyBadges();
renderSidebarFolders();
};
confirmBtn.addEventListener("click", (e) => {
e.stopPropagation();
confirm();
});
cancelBtn.addEventListener("click", (e) => {
e.stopPropagation();
renderSidebarFolders();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") confirm();
if (e.key === "Escape") renderSidebarFolders();
});
container.appendChild(row);
container.appendChild(colorRow);
requestAnimationFrame(() => input.focus());
};
const showEditFolderInput = (container: Element, folder: Folder) => {
showNewFolderInput(container, folder);
};
// ── Intercept native sidebar clicks to clear folder filter ──
const attachNativeSidebarListeners = () => {
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (!sidebar) return;
const ol = sidebar.querySelector("ol");
if (!ol) return;
ol.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest(".bsplus-folders-section")) return;
const li = target.closest("li");
if (li && ol.contains(li)) {
if (activeFolderId !== null) {
activeFolderId = null;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
}
}
});
};
// ── "Add to folder" button in message action bar ──
const injectFolderButton = (actionsBar: Element) => {
if (actionsBar.querySelector(".bsplus-folder-btn")) return;
const wrapper = document.createElement("div");
wrapper.className = "bsplus-folder-btn";
wrapper.style.position = "relative";
wrapper.style.display = "inline-block";
const btn = document.createElement("button");
const btnClasses = actionsBar.querySelector("button")?.className ?? "";
btn.className = btnClasses;
btn.title = "Add to folder";
btn.innerHTML = FOLDER_ICON_SVG;
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
closeDropdown();
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
const messageId = selectedMsg?.getAttribute("data-message");
if (!messageId) return;
showFolderDropdown(wrapper, messageId);
});
wrapper.appendChild(btn);
const moreMenu = actionsBar.querySelector("[class*='MenuButton__Menu___']");
if (moreMenu) {
actionsBar.insertBefore(wrapper, moreMenu);
} else {
actionsBar.appendChild(wrapper);
}
};
const showFolderDropdown = (anchor: HTMLElement, messageId: string) => {
const dropdown = document.createElement("div");
dropdown.className = "bsplus-folder-dropdown";
const folders = getFolders();
const currentFolderIds = getMessageFolderIds(messageId);
if (folders.length === 0) {
const empty = document.createElement("div");
empty.className = "bsplus-folder-dropdown-empty";
empty.textContent = "No folders yet";
dropdown.appendChild(empty);
} else {
for (const folder of folders) {
const isChecked = currentFolderIds.includes(folder.id);
const item = document.createElement("button");
item.className = `bsplus-folder-dropdown-item${isChecked ? " bsplus-checked" : ""}`;
const check = document.createElement("div");
check.className = "bsplus-folder-dropdown-check";
check.style.borderColor = isChecked ? folder.color : "";
check.style.background = isChecked ? folder.color : "";
check.innerHTML = CHECK_SVG_WHITE;
const dot = document.createElement("div");
dot.className = "bsplus-folder-dot";
dot.style.background = folder.color;
const name = document.createElement("span");
name.textContent = folder.name;
item.appendChild(check);
item.appendChild(dot);
item.appendChild(name);
item.addEventListener("click", (e) => {
e.stopPropagation();
toggleMessageInFolder(messageId, folder.id);
const nowChecked = getMessageFolderIds(messageId).includes(folder.id);
item.classList.toggle("bsplus-checked", nowChecked);
check.style.borderColor = nowChecked ? folder.color : "";
check.style.background = nowChecked ? folder.color : "";
applyBadges();
applyFolderFilter();
renderSidebarFolders();
});
dropdown.appendChild(item);
}
}
anchor.appendChild(dropdown);
openDropdown = dropdown;
dropdownCloseHandler = (e: MouseEvent) => {
if (!dropdown.contains(e.target as Node) && !anchor.contains(e.target as Node)) {
closeDropdown();
}
};
setTimeout(() => {
document.addEventListener("click", dropdownCloseHandler!, true);
}, 0);
};
const closeDropdown = () => {
if (openDropdown) {
openDropdown.remove();
openDropdown = null;
}
if (dropdownCloseHandler) {
document.removeEventListener("click", dropdownCloseHandler, true);
dropdownCloseHandler = null;
}
};
// ── Message badges ──
const applyBadges = () => {
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
if (!shouldShowBadgesInList()) {
for (const li of messageItems) {
const subject = li.querySelector("[class*='MessageList__subject___']");
if (subject && (subject.querySelector(".bsplus-msg-badges") || subject.querySelector(".bsplus-subject-text"))) {
restoreSubjectPlain(subject);
} else {
li.querySelector(".bsplus-msg-badges")?.remove();
}
}
return;
}
const folders = getFolders();
const assignments = getAssignments();
for (const li of messageItems) {
const msgId = li.getAttribute("data-message");
if (!msgId) continue;
let badgeContainer = li.querySelector(".bsplus-msg-badges") as HTMLElement | null;
const folderIds = [];
for (const [fId, mIds] of Object.entries(assignments)) {
if (mIds.includes(msgId)) folderIds.push(fId);
}
if (folderIds.length === 0) {
badgeContainer?.remove();
continue;
}
if (!badgeContainer) {
badgeContainer = document.createElement("div");
badgeContainer.className = "bsplus-msg-badges";
const subject = li.querySelector("[class*='MessageList__subject___']");
if (subject) {
if (!subject.querySelector(".bsplus-subject-text")) {
const textWrap = document.createElement("span");
textWrap.className = "bsplus-subject-text";
textWrap.textContent = subject.textContent;
subject.textContent = "";
subject.appendChild(textWrap);
}
subject.appendChild(badgeContainer);
} else {
li.appendChild(badgeContainer);
}
}
badgeContainer.innerHTML = "";
for (const fId of folderIds) {
const folder = folders.find((f) => f.id === fId);
if (!folder) continue;
const badge = document.createElement("span");
badge.className = "bsplus-msg-badge";
badge.style.background = folder.color;
badge.textContent = folder.name;
badge.title = `Filter by "${folder.name}"`;
badge.addEventListener("click", (e) => {
e.stopPropagation();
activeFolderId = folder.id;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
});
badgeContainer.appendChild(badge);
}
}
};
// ── Folder filtering ──
const applyFolderFilter = () => {
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
const moreBtn = document.querySelector("[class*='MessageList__MessageList___'] ol > button");
if (activeFolderId === null) {
if (api.settings.hideFolderedMessagesInAll) {
for (const li of messageItems) {
const msgId = li.getAttribute("data-message");
if (msgId && isMessageInAnyCustomFolder(msgId)) {
li.classList.add("bsplus-folder-hidden");
} else {
li.classList.remove("bsplus-folder-hidden");
}
}
} else {
for (const li of messageItems) {
li.classList.remove("bsplus-folder-hidden");
}
}
if (moreBtn) (moreBtn as HTMLElement).classList.remove("bsplus-folder-hidden");
return;
}
const folderMsgIds = getAssignments()[activeFolderId] ?? [];
for (const li of messageItems) {
const msgId = li.getAttribute("data-message");
if (msgId && folderMsgIds.includes(msgId)) {
li.classList.remove("bsplus-folder-hidden");
} else {
li.classList.add("bsplus-folder-hidden");
}
}
if (moreBtn) (moreBtn as HTMLElement).classList.add("bsplus-folder-hidden");
};
// ── Observers ──
const setupMessageListObserver = () => {
const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol");
if (!messageList || messageListObserver) return;
messageListObserver = new MutationObserver(() => {
applyBadges();
applyFolderFilter();
});
messageListObserver.observe(messageList, { childList: true, subtree: false });
};
const setupActionsObserver = () => {
if (actionsObserver) return;
const target = document.querySelector("[class*='Viewer__Viewer___']") ?? document.querySelector("div.messages");
if (!target) return;
actionsObserver = new MutationObserver(() => {
const actionsBar = document.querySelector("[class*='Message__actions___']");
if (actionsBar && !actionsBar.querySelector(".bsplus-folder-btn")) {
injectFolderButton(actionsBar);
}
});
actionsObserver.observe(target, { childList: true, subtree: true });
};
// ── Main page handler ──
const handleMessagesPage = async () => {
await waitForElm("[class*='Viewer__sidebar___'] ol", true, 50, 100);
renderSidebarFolders();
attachNativeSidebarListeners();
await waitForElm("[class*='MessageList__MessageList___'] ol", true, 50, 100);
applyBadges();
applyFolderFilter();
setupMessageListObserver();
// The actions bar only exists when a message is selected/open,
// so we observe the whole viewer for it to appear dynamically
setupActionsObserver();
// If a message is already selected, inject immediately
const actionsBar = document.querySelector("[class*='Message__actions___']");
if (actionsBar) injectFolderButton(actionsBar);
// Re-observe the sidebar for SEQTA re-renders
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (sidebar && !sidebarObserver) {
sidebarObserver = new MutationObserver(() => {
const ol = sidebar.querySelector("ol");
if (ol && !ol.querySelector(".bsplus-folders-section")) {
renderSidebarFolders();
attachNativeSidebarListeners();
}
});
sidebarObserver.observe(sidebar, { childList: true, subtree: true });
}
};
// ── Lifecycle ──
const mountUnsub = api.seqta.onMount("div.messages", handleMessagesPage);
unregisters.push(mountUnsub);
unregisters.push(
api.settings.onChange("showTagsInAllMessages", () => {
applyBadges();
}),
);
unregisters.push(
api.settings.onChange("hideFolderedMessagesInAll", () => {
applyFolderFilter();
}),
);
return () => {
for (const u of unregisters) u.unregister();
messageListObserver?.disconnect();
sidebarObserver?.disconnect();
actionsObserver?.disconnect();
closeDropdown();
styleEl.remove();
document.querySelectorAll(".bsplus-folders-section").forEach((el) => el.remove());
document.querySelectorAll(".bsplus-folder-btn").forEach((el) => el.remove());
document.querySelectorAll(".bsplus-msg-badges").forEach((el) => el.remove());
document.querySelectorAll("[class*='MessageList__subject___']").forEach((subject) => {
if (subject.querySelector(".bsplus-subject-text")) {
restoreSubjectPlain(subject);
}
});
document.querySelectorAll(".bsplus-folder-hidden").forEach((el) =>
el.classList.remove("bsplus-folder-hidden"),
);
document.querySelectorAll(".bsplus-modal-overlay").forEach((el) => el.remove());
};
},
};
export default messageFoldersPlugin;
@@ -0,0 +1,491 @@
/* ── Sidebar folder section ── */
.bsplus-folders-section {
border-top: 1px solid var(--background-secondary, rgba(128, 128, 128, 0.2));
margin-top: 4px;
padding-top: 4px;
}
.bsplus-folders-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px 2px;
user-select: none;
}
.bsplus-folders-header span {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-primary, #666);
opacity: 0.5;
}
.bsplus-folders-add-btn {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 20px !important;
height: 20px !important;
min-width: 0 !important;
border: none !important;
background: transparent !important;
opacity: 0.5;
cursor: pointer;
border-radius: 4px !important;
padding: 0 !important;
margin: 0 !important;
transition: all 0.2s ease;
text-align: center !important;
}
.bsplus-folders-add-btn:hover {
opacity: 1;
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
}
/* ── Folder list items ── */
.bsplus-folder-item {
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
transition: background 0.15s ease;
position: relative;
gap: 8px;
user-select: none;
}
.bsplus-folder-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
}
.bsplus-folder-item.bsplus-folder-active {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.12));
}
.bsplus-folder-item.bsplus-folder-active::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--better-main, #007bff);
border-radius: 0 2px 2px 0;
}
.bsplus-folder-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.bsplus-folder-name {
font-size: 13px;
color: var(--text-primary, #333);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bsplus-folder-count {
font-size: 11px;
color: var(--text-primary, #999);
opacity: 0.5;
flex-shrink: 0;
}
.bsplus-folder-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.bsplus-folder-item:hover .bsplus-folder-actions {
opacity: 1;
}
.bsplus-folder-action-btn {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 20px !important;
height: 20px !important;
min-width: 0 !important;
border: none !important;
background: transparent !important;
opacity: 0.6;
cursor: pointer;
border-radius: 4px !important;
padding: 0 !important;
margin: 0 !important;
transition: all 0.15s ease;
}
.bsplus-folder-action-btn:hover {
opacity: 1;
background: var(--background-secondary, rgba(128, 128, 128, 0.15)) !important;
}
/* ── Inline folder name input ── */
.bsplus-folder-input {
display: flex;
align-items: center;
padding: 4px 12px;
gap: 6px;
}
.bsplus-folder-input input {
flex: 1;
min-width: 0;
padding: 4px 8px;
font-size: 13px;
border: 1px solid var(--background-secondary, #ccc);
border-radius: 6px;
background: var(--background-secondary, #f5f5f5);
color: var(--text-primary, #333);
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.bsplus-folder-input input:focus {
border-color: var(--better-main, #007bff);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2);
}
.bsplus-folder-input-confirm,
.bsplus-folder-input-cancel {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 24px !important;
height: 24px !important;
min-width: 0 !important;
border: none !important;
border-radius: 4px !important;
cursor: pointer;
padding: 0 !important;
margin: 0 !important;
transition: all 0.15s ease;
}
.bsplus-folder-input-confirm {
background: var(--better-main, #007bff) !important;
}
.bsplus-folder-input-confirm:hover {
transform: scale(1.1);
}
.bsplus-folder-input-cancel {
background: transparent !important;
opacity: 0.6;
}
.bsplus-folder-input-cancel:hover {
opacity: 1;
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
}
/* ── Color picker row ── */
.bsplus-folder-colors {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
padding: 4px 12px 6px;
max-width: 120px;
}
.bsplus-folder-color-opt {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.2s ease,
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1);
padding: 0;
background: none;
box-sizing: border-box;
}
.bsplus-folder-color-opt:hover {
transform: scale(1.25);
box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.15);
}
.bsplus-folder-color-opt.bsplus-color-selected {
border-color: var(--text-primary, #333);
transform: scale(1.15);
box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.2);
}
.bsplus-folder-color-opt.bsplus-color-selected:hover {
transform: scale(1.25);
}
/* ── "Add to folder" button in message actions bar ── */
.bsplus-folder-btn {
position: relative;
}
.bsplus-folder-btn svg {
fill: currentColor;
}
/* ── Folder dropdown ── */
.bsplus-folder-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 180px;
background: var(--background-primary, #fff);
border: 1px solid var(--background-secondary, #e0e0e0);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 1000;
overflow: hidden;
animation: bsplus-dropdown-in 0.15s ease-out;
}
@keyframes bsplus-dropdown-in {
from {
opacity: 0;
transform: translateY(-4px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.bsplus-folder-dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.1s ease;
border: none;
background: transparent;
width: 100%;
text-align: left;
color: var(--text-primary, #333);
font-size: 13px;
}
.bsplus-folder-dropdown-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
}
.bsplus-folder-dropdown-check {
width: 16px;
height: 16px;
border: 2px solid var(--background-secondary, #ccc);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s ease;
}
.bsplus-folder-dropdown-item.bsplus-checked .bsplus-folder-dropdown-check {
background: var(--better-main, #007bff);
border-color: var(--better-main, #007bff);
}
.bsplus-folder-dropdown-check svg {
width: 10px;
height: 10px;
color: white;
opacity: 0;
transition: opacity 0.1s ease;
}
.bsplus-folder-dropdown-item.bsplus-checked .bsplus-folder-dropdown-check svg {
opacity: 1;
}
.bsplus-folder-dropdown-empty {
padding: 12px;
text-align: center;
font-size: 12px;
color: var(--text-primary, #999);
opacity: 0.5;
}
/* ── Let primary column use available space instead of being clipped ── */
[class*='MessageList__primary___'] {
flex: 1 1 0% !important;
min-width: 0 !important;
overflow: hidden !important;
}
/* ── Make subject line a flex row so badges sit inline ── */
[class*='MessageList__subject___'] {
display: flex !important;
align-items: center;
gap: 6px;
min-width: 0 !important;
overflow: hidden !important;
}
/* ── Subject text truncates to make room for badges ── */
.bsplus-subject-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1 1 auto;
}
/* ── Shrink the secondary column to its content ── */
[class*='MessageList__secondary___'] {
flex: 0 0 auto !important;
width: auto !important;
min-width: 0 !important;
max-width: 200px !important;
}
/* ── Constrain the flags/attachment icon column ── */
[class*='MessageList__flags___'] {
width: 24px !important;
min-width: 0 !important;
flex-shrink: 0 !important;
}
/* ── Message list folder badges ── */
.bsplus-msg-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
margin-left: auto;
}
.bsplus-msg-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
border-radius: 8px;
font-size: 10px;
font-weight: 500;
line-height: 1.4;
color: white;
white-space: nowrap;
cursor: pointer;
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.bsplus-msg-badge:hover {
opacity: 0.85;
transform: scale(1.05);
}
/* ── Folder filtering (hide messages not in active folder) ── */
.bsplus-folder-hidden {
display: none !important;
}
/* ── Delete confirmation modal ── */
@keyframes bsplus-modal-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes bsplus-modal-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.bsplus-modal-overlay {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
animation: bsplus-modal-overlay-in 0.2s ease-out forwards;
}
.bsplus-modal {
padding: 1rem 1.5rem;
margin: 0 1rem;
min-width: 16rem;
max-width: 22rem;
width: 100%;
box-sizing: border-box;
background: var(--background-primary, #fff);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid var(--background-secondary, #e0e0e0);
animation: bsplus-modal-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.bsplus-modal h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #333);
}
.bsplus-modal p {
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--text-primary, #666);
opacity: 0.8;
}
.bsplus-modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.bsplus-modal-actions button {
padding: 0.4rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.bsplus-modal-btn-cancel {
background: transparent;
border: 1px solid var(--background-secondary, #ccc);
color: var(--text-primary, #333);
}
.bsplus-modal-btn-cancel:hover {
background: var(--background-secondary, rgba(128, 128, 128, 0.1));
}
.bsplus-modal-btn-danger {
background: #e53e3e;
border: none;
color: white;
}
.bsplus-modal-btn-danger:hover {
background: #c53030;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35);
}
+1
View File
@@ -10,6 +10,7 @@ const themesPlugin: Plugin = {
run: async (_) => { run: async (_) => {
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
await themeManager.prepareThemeAfterCloudSync();
await themeManager.initialize(); await themeManager.initialize();
}, },
}; };
@@ -6,6 +6,7 @@ import {
type LoadedCustomTheme, type LoadedCustomTheme,
shouldForceThemeAppearance, shouldForceThemeAppearance,
} from "@/types/CustomThemes"; } from "@/types/CustomThemes";
import { BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY } from "@/seqta/utils/cloudSettingsSync";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import debounce from "@/seqta/utils/debounce"; import debounce from "@/seqta/utils/debounce";
import { themeUpdates } from "@/interface/hooks/ThemeUpdates"; import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
@@ -166,6 +167,33 @@ export class ThemeManager {
} }
} }
/**
* After cloud restore, IndexedDB/theme storage is only reachable from page context (not MV3 SW).
* Background sets BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY; we fetch the store JSON here before setTheme().
* The resolved id matches cloud sync **`themeId` / `selectedTheme`**: it may be a standard theme uuid or a
* flavour (slave) variant id — **`downloadAndInstallStoreTheme`** is the same code path as the theme store installer.
*/
public async prepareThemeAfterCloudSync(): Promise<void> {
try {
const snap = await browser.storage.local.get(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
const pending = snap[BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY];
if (pending === undefined) return;
await browser.storage.local.remove(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
if (typeof pending !== "string") return;
const id = pending.trim();
if (!id) return;
const existing = (await localforage.getItem(id)) as CustomTheme | null;
if (existing) return;
await this.downloadAndInstallStoreTheme({ id, name: id });
} catch (e) {
console.warn("[ThemeManager] prepareThemeAfterCloudSync:", e);
}
}
/** /**
* Initialize the theme system and restore previous state * Initialize the theme system and restore previous state
*/ */
+2
View File
@@ -10,6 +10,7 @@ import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
import profilePicturePlugin from "./built-in/profilePicture"; import profilePicturePlugin from "./built-in/profilePicture";
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview"; import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
import backgroundMusicPlugin from "./built-in/backgroundMusic"; import backgroundMusicPlugin from "./built-in/backgroundMusic";
import messageFoldersPlugin from "./built-in/messageFolders";
//import testPlugin from './built-in/test'; //import testPlugin from './built-in/test';
// Heavy plugins (lazy-loaded only when enabled) // Heavy plugins (lazy-loaded only when enabled)
@@ -28,6 +29,7 @@ pluginManager.registerPlugin(timetableEditPlugin);
pluginManager.registerPlugin(profilePicturePlugin); pluginManager.registerPlugin(profilePicturePlugin);
pluginManager.registerPlugin(assessmentsOverviewPlugin); pluginManager.registerPlugin(assessmentsOverviewPlugin);
pluginManager.registerPlugin(backgroundMusicPlugin); pluginManager.registerPlugin(backgroundMusicPlugin);
pluginManager.registerPlugin(messageFoldersPlugin);
//pluginManager.registerPlugin(testPlugin); //pluginManager.registerPlugin(testPlugin);
// Register heavy plugins with lazy loading // Register heavy plugins with lazy loading
Binary file not shown.
+10 -1
View File
@@ -113,7 +113,16 @@ export async function loadHomePage() {
callHomeTimetable(TodayFormatted, true); callHomeTimetable(TodayFormatted, true);
const activeClass = classes.find((c: any) => c.hasOwnProperty("active")); const activeClass = classes.find((c: any) => c.hasOwnProperty("active"));
const activeSubjects = activeClass?.subjects || []; const activeYear = activeClass?.year;
const allSubjectsInYear = classes
.filter((c: any) => c.year === activeYear)
.flatMap((c: any) => c.subjects || []);
const seen = new Set<string>();
const activeSubjects = allSubjectsInYear.filter((s: any) => {
if (seen.has(s.code)) return false;
seen.add(s.code);
return true;
});
const activeSubjectCodes = activeSubjects.map((s: any) => s.code); const activeSubjectCodes = activeSubjects.map((s: any) => s.code);
const currentAssessments = assessments const currentAssessments = assessments
.filter((a: any) => activeSubjectCodes.includes(a.code)) .filter((a: any) => activeSubjectCodes.includes(a.code))
+9 -2
View File
@@ -35,10 +35,16 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;"> <div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
<h1>3.6.4 - Patch for alpine theme</h1> <h1>3.6.4 - DM Folders, Theme falvours and fixes, Upcoming Assements improvement</h1>
<li>Added single channel adaptive CSS variables (r/g/b)</li> <li>Added advanced colour adjustments variables for theme customisation.</li>
<li>Improved logic for upcoming assements dashlet to improve compatibility.</li>
<li>BS Cloud can now automatically download themes from other devices.</li>
<li>Added theme flavour's for multiple colour variations of the same theme.</li>
<li>Added custom message folder, customizable in settings.</li>
<h1>3.6.3 - Assessment overview fix</h1> <h1>3.6.3 - Assessment overview fix</h1>
<li>Fixed assessments overview failing to load.</li> <li>Fixed assessments overview failing to load.</li>
<h1>3.6.2 - Cloud backup, various fixes & SEQTA Engage support</h1> <h1>3.6.2 - Cloud backup, various fixes & SEQTA Engage support</h1>
<li>BetterSEQTA Cloud: back up and restore extension settings from your account (General settings).</li> <li>BetterSEQTA Cloud: back up and restore extension settings from your account (General settings).</li>
<li>Optional automatic cloud sync if signed in (on by default).</li> <li>Optional automatic cloud sync if signed in (on by default).</li>
@@ -52,6 +58,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
<li>Updated outdated in-app links and update some under the hood code (Vite 8).</li> <li>Updated outdated in-app links and update some under the hood code (Vite 8).</li>
<li>Added a notifications panel animation to work like settings.</li> <li>Added a notifications panel animation to work like settings.</li>
<li>Fix timetable edit plugin not working correctly.</li> <li>Fix timetable edit plugin not working correctly.</li>
<h1>3.5.3 - Adaptive theme updates</h1> <h1>3.5.3 - Adaptive theme updates</h1>
<li>Fixed adaptive theming on current-year course and assessment pages.</li> <li>Fixed adaptive theming on current-year course and assessment pages.</li>
+56 -1
View File
@@ -10,6 +10,13 @@ export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1;
export const BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY = export const BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY =
"bsplus_cloud_settings_known_remote_updated_at"; "bsplus_cloud_settings_known_remote_updated_at";
/**
* Written by the service worker after applying a cloud settings envelope; the SEQTA pages
* ThemeManager reads and clears it (SW cannot share localforage/IndexedDB with the page).
*/
export const BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY =
"bsplus_pending_theme_ensure_after_cloud";
/** /**
* Never uploaded to the cloud backup (OAuth and legacy keys). * Never uploaded to the cloud backup (OAuth and legacy keys).
* IndexedDB (e.g. Global Searchs `betterseqta-index` database) is not part of * IndexedDB (e.g. Global Searchs `betterseqta-index` database) is not part of
@@ -39,6 +46,7 @@ export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = ["plugin.global-search.stor
const CLIENT_ONLY_CLOUD_KEYS_EXACT = [ const CLIENT_ONLY_CLOUD_KEYS_EXACT = [
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
"bsplus_lastCloudPoll", "bsplus_lastCloudPoll",
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
] as const; ] as const;
/** After restoring from cloud, keep local session so the user stays signed in. */ /** After restoring from cloud, keep local session so the user stays signed in. */
@@ -101,8 +109,15 @@ function stripExcludedKeysFromRemoteData(remote: Record<string, unknown>): Recor
return out; return out;
} }
/** Stored theme id (`selectedTheme`); trims whitespace; empty string clears. */
export function normalizeThemeIdForSync(raw: unknown): string {
if (typeof raw !== "string") return "";
return raw.trim();
}
export function buildUploadPayload(all: Record<string, unknown>): { export function buildUploadPayload(all: Record<string, unknown>): {
schemaVersion: number; schemaVersion: number;
themeId: string;
data: Record<string, unknown>; data: Record<string, unknown>;
} { } {
const filtered: Record<string, unknown> = {}; const filtered: Record<string, unknown> = {};
@@ -111,17 +126,57 @@ export function buildUploadPayload(all: Record<string, unknown>): {
filtered[k] = v; filtered[k] = v;
} }
const data = migrateLegacyToPluginSettings(filtered); const data = migrateLegacyToPluginSettings(filtered);
return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, data }; const themeId = normalizeThemeIdForSync(all.selectedTheme);
return {
schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
themeId,
data,
};
} }
export async function getSnapshotForUpload(): Promise<{ export async function getSnapshotForUpload(): Promise<{
schemaVersion: number; schemaVersion: number;
themeId: string;
data: Record<string, unknown>; data: Record<string, unknown>;
}> { }> {
const all = await browser.storage.local.get(); const all = await browser.storage.local.get();
return buildUploadPayload(all as Record<string, unknown>); return buildUploadPayload(all as Record<string, unknown>);
} }
/**
* Theme to ensure is installed locally after a downloaded envelope (explicit `themeId` overrides `data.selectedTheme`).
* Works for any store-backed id, including **flavour (slave) variants** nested under masters in the catalogue.
*/
export function resolveThemeIdForPostSyncDownload(envelope: unknown): string | undefined {
if (envelope && typeof envelope === "object" && "themeId" in envelope) {
const top = normalizeThemeIdForSync(
(envelope as Record<string, unknown>).themeId,
);
if (top) return top;
}
let remoteFlat: Record<string, unknown>;
if (
envelope &&
typeof envelope === "object" &&
"data" in envelope &&
(envelope as { data?: unknown }).data !== undefined &&
typeof (envelope as { data?: unknown }).data === "object" &&
(envelope as { data?: unknown }).data !== null &&
!Array.isArray((envelope as { data?: unknown }).data)
) {
remoteFlat = (envelope as { data: Record<string, unknown> }).data;
} else if (envelope && typeof envelope === "object" && !Array.isArray(envelope)) {
remoteFlat = envelope as Record<string, unknown>;
} else {
return undefined;
}
const migrated = migrateLegacyToPluginSettings(remoteFlat);
const fromData = normalizeThemeIdForSync(migrated.selectedTheme);
return fromData === "" ? undefined : fromData;
}
export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise<void> { export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise<void> {
if (!iso || typeof iso !== "string") return; if (!iso || typeof iso !== "string") return;
await browser.storage.local.set({ [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY]: iso }); await browser.storage.local.set({ [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY]: iso });