mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus
This commit is contained in:
+1
-1
@@ -23,4 +23,4 @@ betterseqtaplus-safari/
|
|||||||
.env
|
.env
|
||||||
.env.submit
|
.env.submit
|
||||||
dependency-graph.svg
|
dependency-graph.svg
|
||||||
/src/resources/themes
|
|
||||||
|
|||||||
@@ -35,13 +35,16 @@ Upserts the caller’s 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 caller’s 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,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 {
|
||||||
|
|||||||
@@ -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,57 +29,77 @@
|
|||||||
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]"
|
||||||
use:emblaCarouselSvelte={{ options, plugins }}
|
use:emblaCarouselSvelte={{ options, plugins }}
|
||||||
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 doesn’t 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>
|
||||||
</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>
|
||||||
|
{: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
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 0–1 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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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.
@@ -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))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 page’s
|
||||||
|
* 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 Search’s `betterseqta-index` database) is not part of
|
* IndexedDB (e.g. Global Search’s `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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user