Compare commits

...

113 Commits

Author SHA1 Message Date
SethBurkart123 304ce2e128 feat: refine startup announcement cards 2026-05-23 22:53:06 +10:00
AdenMGB 0bc6beb0f1 chore: bump ver & release notes 2026-05-23 09:08:31 +09:30
AdenMGB 68173a8b75 fix: fix custom teacher names not applying to popup 2026-05-23 08:58:21 +09:30
Aden Lindsay 7583d0ee47 Merge pull request #439 from StroepWafel/main
feat: Theme of the Month
2026-05-19 20:36:01 +09:30
codefactor-io 6c79fe3588 [CodeFactor] Apply fixes 2026-05-19 10:53:27 +00:00
StroepWafel c0a8a76105 feat: Theme Of The Month 2026-05-19 20:19:50 +09:30
StroepWafel 6ad214bb09 Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus 2026-05-18 20:29:35 +09:30
AdenMGB b4598668d4 feat: re enable message folders with improvments 2026-05-13 13:30:27 +09:30
AdenMGB 01e679eab6 Revert "fix: add some better detection logic for assements widget #429"
This reverts commit 01cd5d1428.
2026-05-06 17:31:41 +09:30
Aden Lindsay f57bd107b9 Merge pull request #436 from Jaxx7594/asessment-average-manual-input
Feat: ability to manually input weightings for assessments
2026-05-05 19:51:00 +09:30
Jaxon Lewis-Wilson aa5d193e55 assessmentsAverage: Fix inaccurate weight when a weight == N/A
N/A weights were automatically set to a weight of 1 for some reason. I removed it from the calculations completely with this commit.
2026-05-05 18:14:06 +08:00
Jaxon Lewis-Wilson da5bc7ab11 assessmentsAverage: Fix weight display upon setting override 2026-05-05 18:10:13 +08:00
Jaxon Lewis-Wilson b0857054eb assessmentsAverage: Fix unmarked/upcoming assessment indexing and weight display 2026-05-05 17:56:06 +08:00
Jaxon Lewis-Wilson f721bf6609 Revert "feat: dont inject weightings page in assements without results"
This reverts commit 2aecd63850.
Reverting so that I can solve the indexing issue. Only marked assessments are getting indexed, which is incorrect behaviour that slipped testing when the plugin was first made.
2026-05-05 16:32:12 +08:00
AdenMGB 2aecd63850 feat: dont inject weightings page in assements without results 2026-05-05 17:44:58 +09:30
Jaxx7594 f35520029f assessmentAverage: Remove remnant comment 2026-05-04 22:53:05 +08:00
Jaxx7594 95994fcd3a Merge branch 'main' into asessment-average-manual-input 2026-05-04 22:46:29 +08:00
Jaxon Lewis-Wilson 999f12e958 assessmentsAverage: Add changes to changelog 2026-05-04 22:39:53 +08:00
Jaxon Lewis-Wilson 260afac294 assessmentsAverage: Fix display of missing weighting, and minor change to override section. 2026-05-04 18:36:16 +08:00
Jaxon Lewis-Wilson 678a958351 assessmentsAverage: Add ability to override/set weighting per assessment. 2026-05-04 18:32:32 +08:00
SethBurkart123 608fc96c4e chore: temporarily disable message folders plugin and remove from changelog 2026-05-01 15:39:20 +10:00
SethBurkart123 23ccac4836 update bun.lock 2026-05-01 12:15:53 +10:00
Aden Lindsay a875f35f1a Merge pull request #432 from StroepWafel/patch-3 2026-04-29 22:25:18 +09:30
StroepWafel e2cf9afbf9 Fix typos in WhatsNewPopup text 2026-04-29 22:05:46 +09:30
StroepWafel a1131cf6cd Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus 2026-04-29 21:47:11 +09:30
Aden Lindsay 3f493ac716 chore: add forgoten custom messages editor to whatsnew 2026-04-29 18:53:13 +09:30
Aden Lindsay e64ef7f95c Custom Message Folders (#431)
* feat: start custom messages plugin

* feat: finish custom message folders
2026-04-29 18:51:05 +09:30
AdenMGB c118b5b8dd chore: update whats new 2026-04-29 11:59:21 +09:30
AdenMGB 6535ec6932 feat: update whatsnew and update video 2026-04-29 11:59:03 +09:30
AdenMGB fba5d09c75 feat: theme flavours for theme varients 2026-04-29 11:13:32 +09:30
AdenMGB b88d29967d fix: ensure the theme store triggers the cloud sync upon install 2026-04-29 10:25:41 +09:30
AdenMGB 1b87d20a27 feat: auto install themes if not present locally with BS Cloud 2026-04-29 10:21:45 +09:30
Aden Lindsay 7bd3158b05 fix: fix wording 2026-04-29 09:56:00 +09:30
StroepWafel 7a4fa1e5bf feat: add RGB handler for alpine theme (#427)
* add handlers for individual Channels

* add notes

* patch fix theme overrides for adaptive colour

* idk

* Update package.json

* fix issue spelling

* Update OpenWhatsNewPopup.ts

* Update OpenWhatsNewPopup.ts

* fix: remove debug line from .gitignore

* chore: fix up patch notes to be a bit more user friendly

* chore: finalise patch notes and fix grammer

* Add empty line to .gitignore

---------

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
2026-04-29 09:54:26 +09:30
Jaxon Lewis-Wilson f7d9199500 assessmentsAverage: Minor WEIGHT label styling fixes 2026-04-27 00:28:51 +08:00
AdenMGB 01cd5d1428 fix: add some better detection logic for assements widget #429 2026-04-23 17:26:58 +09:30
StroepWafel 5178408f39 Update OpenWhatsNewPopup.ts 2026-04-22 06:28:48 +09:30
StroepWafel 0b51db5434 Update OpenWhatsNewPopup.ts 2026-04-22 06:27:01 +09:30
StroepWafel 9c47fa38ae fix issue spelling 2026-04-22 06:25:47 +09:30
StroepWafel 5c4d7e1be3 Update package.json 2026-04-21 21:35:41 +09:30
StroepWafel acbbac8266 Merge branch 'main' into main 2026-04-21 21:34:17 +09:30
StroepWafel fa8f36f3d5 idk 2026-04-21 20:31:06 +09:30
SethBurkart123 fcc856e798 fix: resolve assessments overview failing to load in production builds
Replace dynamic import of ./ui with static import to prevent Vite from
code-splitting into a separate chunk that CRXJS cannot resolve at runtime.

Bumps version to 3.6.3.
2026-04-21 19:08:30 +10:00
StroepWafel 44116edca5 patch fix theme overrides for adaptive colour 2026-04-20 22:50:39 +09:30
StroepWafel 37be31859f add notes 2026-04-20 21:47:05 +09:30
StroepWafel 10667f17b4 Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus 2026-04-20 21:43:15 +09:30
StroepWafel 0ca0c7cf43 add handlers for individual Channels 2026-04-20 21:43:05 +09:30
SethBurkart123 a0038ac871 chore: bump version to 3.6.2 2026-04-20 15:53:20 +10:00
SethBurkart123 49824e9eab refactor: remove alarms permission, throttle cloud sync to once per day on page load 2026-04-20 15:47:22 +10:00
SethBurkart123 01eeb18638 perf: throttle theme update check to daily and remove redundant cloud poll on page load 2026-04-20 15:39:37 +10:00
SethBurkart123 3702443ece chore: bump version to 3.6.1 2026-04-20 15:33:04 +10:00
SethBurkart123 87ba75ff41 feat: simplify startup popups and convert engage announcement to toast
Remove auto-showing privacy statement and BS Cloud announcement from
startup queue. Convert SEQTA Engage announcement from a blocking modal
to a subtle bottom-right dismissable toast.
2026-04-20 14:59:24 +10:00
SethBurkart123 f9406fb469 feat: redesign Cloud settings UI and switch to OAuth redirect login
- Move Cloud section inline with other settings, remove dedicated header bar
- Replace in-extension login form with browser redirect to accounts.betterseqta.org
- Background script intercepts OAuth callback URL to capture tokens
- Add animated CloudPanel overlay (same pattern as ColourPicker)
- Hide cloud sync details and profile picture setting when not signed in
- Simplify CloudSettingsSync UI, reduce text verbosity
- Fix settings download to merge keys instead of clear+set
- Add legacy-to-plugin settings migration for cloud sync
- Shorten profile picture and default page descriptions
- Make DisclaimerModal title/message dynamic
- Update CloudHeader button styling to match other buttons
2026-04-20 13:42:49 +10:00
AdenMGB 690792fd62 chore: update chaneglog 2026-04-17 15:59:04 +09:30
AdenMGB f6ac112329 fix: fix the timetable edit plugin 2026-04-17 15:55:32 +09:30
AdenMGB ec68cec0ca feat: add smooth animation to notifications opening like settings 2026-04-17 15:51:25 +09:30
AdenMGB e2270602a3 feat: finish custom message folders 2026-04-17 15:39:58 +09:30
AdenMGB 8b1e5b2ee7 feat: start custom messages plugin 2026-04-16 20:22:00 +09:30
AdenMGB 44a029057a feat: auto download settings upon login 2026-04-13 19:42:11 +09:30
AdenMGB 249d1c1b4a feat: auto sync popoup 2026-04-13 19:39:57 +09:30
AdenMGB 6748b15024 feat: tweak engage popup 2026-04-13 19:33:28 +09:30
Aden Lindsay 8b26d07865 Merge pull request #426 from StroepWafel/main
update video (and edit notes)
2026-04-13 19:12:35 +09:30
Aden Lindsay 55d96a5e8f Update theme name in WhatsNewPopup 2026-04-13 19:12:18 +09:30
StroepWafel 133a5197aa Update update-video.webm 2026-04-13 19:10:32 +09:30
StroepWafel c0fa1576f3 Update update-video.webm 2026-04-12 23:28:30 +09:30
AdenMGB 48fbcde6ae fix: fix last two engage bugs 2026-04-12 20:14:52 +09:30
AdenMGB 89f50f774f feat: image full screen overlay for popoups 2026-04-12 20:06:03 +09:30
AdenMGB 1d9b8f3747 feat: queue popups and new engage popup 2026-04-12 19:54:43 +09:30
StroepWafel 0a5359df72 update video (and edit notes) 2026-04-12 19:19:19 +09:30
AdenMGB 2e9a643a8c fix: fix engage not always applying 2026-04-10 21:21:19 +09:30
AdenMGB 39d0b60024 feat: engage homepag 2026-04-08 11:27:38 +09:30
AdenMGB c7a6bf051c chore: bump ver and changelog 2026-04-08 08:49:37 +09:30
AdenMGB ea4a2c1ff0 feat: auto sync for cloud and fix some firefox weirdness 2026-04-08 08:29:25 +09:30
AdenMGB 71b7c9eb64 tweak: tweak cloud UI a lil bit 2026-04-08 07:40:20 +09:30
Aden Lindsay 0cac3022f5 Merge pull request #425 from StroepWafel/cloud-settings-backup
Cloud settings backup
2026-04-08 07:25:09 +09:30
StroepWafel 8b16a21d48 tweaks and fixes to UI 2026-04-07 22:39:09 +09:30
Aden Lindsay 2085ebe189 Upgrade project to Vite 8 + some cleanup (#424)
* Upgrade project to Vite 8

* Fix vite
2026-04-07 21:29:20 +09:30
AdenMGB 01c657d247 Merge branch 'main' of https://github.com/BetterSEQTA/BetterSEQTA-Plus 2026-04-07 21:25:15 +09:30
AdenMGB 1f26fb26d7 fix: somehwat fix overlap with vanilla seqta averages 2026-04-07 21:25:07 +09:30
Jones8683 dbd8e2be8e Merge branch 'main' of https://github.com/Jones8683/BetterSEQTA-Plus 2026-04-07 21:22:47 +09:30
Jones8683 6b5f00add0 Fix vite 2026-04-07 21:22:45 +09:30
Jones Jankovic 1f5eef2fb1 Merge branch 'BetterSEQTA:main' into main 2026-04-07 21:21:42 +09:30
StroepWafel 1e6e57ddcd feat: bs cloud header in settings, cloud pfp as local pfp option & doc updates
updates to docs and also profile
2026-04-07 21:15:29 +09:30
Aden Lindsay e18853ba3a Merge pull request #422 from StroepWafel/patch-2
Update README.md
2026-04-07 21:10:43 +09:30
Aden Lindsay 140cd66c9b chore: update old and non existant links 2026-04-07 21:09:55 +09:30
Jones8683 5684857456 Upgrade project to Vite 8 2026-04-07 20:13:47 +09:30
StroepWafel 24fee7a743 updates to docs and also profile 2026-04-07 20:02:30 +09:30
StroepWafel cef99b7278 Update README.md 2026-04-07 19:55:06 +09:30
StroepWafel aad5bcd97e Update README.md 2026-04-07 19:50:24 +09:30
StroepWafel 423aaa6b84 Update README.md 2026-04-07 19:41:45 +09:30
AdenMGB 97a1226eaf feat(engage): add icon-only sidebar support and Engage-specific titlebar styles
Made-with: Cursor
2026-04-07 15:11:26 +09:30
AdenMGB 72cab5905e fix(engage): disable assessments overview plugin on Engage
Made-with: Cursor
2026-04-07 15:11:15 +09:30
AdenMGB 088b745600 feat(engage): inject settings button, dark/light toggle, and user info with logout
Made-with: Cursor
2026-04-07 15:11:07 +09:30
AdenMGB 8801d5a435 feat(engage): fix loading screen hang and handle login/logout flow
Made-with: Cursor
2026-04-07 15:10:58 +09:30
Aden Lindsay b63a3d95f3 fix today's lessons tweaking out (#420) 2026-04-07 09:14:08 +09:30
StroepWafel eca8327420 fix today's lessons tweaking out 2026-04-07 09:05:13 +09:30
AdenMGB 05cf380e86 feat: some messed up stuff to fix some stuff 2026-04-07 08:46:28 +09:30
StroepWafel 73f005d645 Better theme displaying (#419)
* add more data

add more info for users about themes and also make related themes actually show related themes

* sorting and similar
2026-04-07 08:22:56 +09:30
StroepWafel f2fa9c39a9 Merge pull request #418 from StroepWafel/theme-updates
Theme updates
2026-04-07 08:13:27 +09:30
Aden Lindsay 783ff65fb5 Merge pull request #417 from StroepWafel/updated-sign-in
feat: Unified portaled sign-in overlay
2026-04-06 17:22:10 +09:30
Aden Lindsay 3e7ea3bc03 Adaptive custom themes (#416)
* feat: Smooth change in colour, no hard cut

Added option smoothing on colour change so there is no hard cut made when switching subjects

* feat: Themes can adapt to colour

* quick fix to forced mode as well

* [CodeFactor] Apply fixes

---------

Co-authored-by: codefactor-io <support@codefactor.io>
2026-04-06 17:21:38 +09:30
StroepWafel 398029eecd Merge branch 'main' into adaptive-custom-themes 2026-04-06 15:01:38 +09:30
StroepWafel a55cb84a69 feat: Smooth change in colour, no hard cut (#415)
Added option smoothing on colour change so there is no hard cut made when switching subjects
2026-04-06 14:58:09 +09:30
StroepWafel 94d54f65bf feat: Unified portaled sign-in overlay
Updated the sign-in overlay to be unified across the site, improving UX
2026-04-06 14:48:03 +09:30
codefactor-io 8123c5dd33 [CodeFactor] Apply fixes 2026-04-06 04:55:44 +00:00
StroepWafel ac1ee702ae quick fix to forced mode as well 2026-04-06 14:24:08 +09:30
StroepWafel e657152e3f feat: Themes can adapt to colour 2026-04-06 14:11:19 +09:30
StroepWafel f667ff9e9b feat: Smooth change in colour, no hard cut
Added option smoothing on colour change so there is no hard cut made when switching subjects
2026-04-06 13:39:25 +09:30
AdenMGB 3c613f4938 chore: bump ver 2026-04-03 10:55:41 +10:30
AdenMGB 04843a90fe fix: fix adaptive themeing to support current year subjects 2026-04-03 10:47:56 +10:30
AdenMGB 834d585ac7 chore: release ntoe 2026-03-29 20:26:19 +10:30
AdenMGB 343fa7ca9f feat: migrate pdfjs to local & bump ver 2026-03-29 20:25:06 +10:30
AdenMGB e049f34a5e feat: WIP Engage progress 2026-03-28 09:06:54 +10:30
91 changed files with 9181 additions and 863 deletions
+5
View File
@@ -6,6 +6,10 @@ pnpm-lock.yaml
yarn.lock yarn.lock
bun.lock bun.lock
# PDF.js extension assets (copied by postinstall from pdfjs-dist)
src/public/resources/pdfjs/pdf.worker.min.mjs
src/public/resources/pdfjs/pdf.legacy.min.mjs
# Build # Build
extension.zip extension.zip
build/ build/
@@ -19,3 +23,4 @@ betterseqtaplus-safari/
.env .env
.env.submit .env.submit
dependency-graph.svg dependency-graph.svg
+5 -5
View File
@@ -6,9 +6,9 @@ Hey there! 👋 Thanks for your interest in contributing to BetterSEQTA+! We're
**Never contributed to an open source project before?** No worries! We've made it super easy to get started: **Never contributed to an open source project before?** No worries! We've made it super easy to get started:
- **📖 Read our [Getting Started Guide](./docs/GETTING_STARTED_CONTRIBUTING.md)** - This walks you through everything step-by-step, from setting up your development environment to making your first pull request. - **📖 Read our [contributing guide](https://docs.betterseqta.org/contributing/)** - This walks you through everything step-by-step, from setting up your development environment to making your first pull request.
- **🏗️ Understand the codebase** with our [Architecture Guide](./docs/ARCHITECTURE.md) - **🏗️ Understand the codebase** with the [architecture guide](https://docs.betterseqta.org/architecture/)
- **🔧 Having issues?** Check our [Troubleshooting Guide](./docs/TROUBLESHOOTING.md) - **🔧 Having issues?** Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
We have lots of [`good first issue`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/good%20first%20issue) labels that are perfect for beginners! We have lots of [`good first issue`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/good%20first%20issue) labels that are perfect for beginners!
@@ -33,8 +33,8 @@ Join our community channels to discuss the project, get help, and connect with o
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides: If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Creating Your First Plugin](./docs/plugins/creating-plugins.md) - [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API Reference](./docs/advanced/plugin-api.md) - [Plugin API](https://docs.betterseqta.org/plugin-api/)
## Pull Request Process ## Pull Request Process
+25 -56
View File
@@ -16,17 +16,15 @@
<img src="https://img.shields.io/chrome-web-store/rating/afdgaoaclhkhemfkkkonemoapeinchel" /> <img src="https://img.shields.io/chrome-web-store/rating/afdgaoaclhkhemfkkkonemoapeinchel" />
</div> </div>
## Table of contents ## 📚 Documentation
All documentation has been moved to the [official docs site](https://docs.betterseqta.org):
- [Features](#features) Includes:
- [Creating Custom Themes](#creating-custom-themes) - Getting started
- [Getting Started](#getting-started) - Development setup
- [Running Development](#running-development) - Architecture
- [Building for production](#building-for-production) - Plugin system
- [Folder Structure](#folder-structure) - Theme creation
- [Contributors](#contributors)
- [Credits](#credits)
- [Star History](#star-history)
## Features ## Features
@@ -50,64 +48,32 @@
## Creating Custom Themes ## Creating Custom Themes
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://betterseqta.gitbook.io/betterseqta-docs). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator). If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://docs.betterseqta.org/theme-creation/). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
Don't worry- if you get stuck feel free to ask around in the [discord](https://discord.gg/YzmbnCDkat). We're open and happy to help out! Happy creating :) Don't worry- if you get stuck feel free to ask around in the [discord](https://discord.gg/YzmbnCDkat). We're open and happy to help out! Happy creating :)
## 🚀 Want to Contribute? ## 🚀 Contributing
**New contributors welcome!**
- 📖 Start here: https://docs.betterseqta.org/install/
- 🧠 Architecture: https://docs.betterseqta.org/architecture/
- 🧩 Plugins: https://docs.betterseqta.org/plugins/
- 💬 Discord: https://discord.gg/YzmbnCDkat
**New contributors welcome!** 🎉 We've made it easy to get started:
- **👋 New to the project?** Start with our [Getting Started Guide](./docs/GETTING_STARTED_CONTRIBUTING.md) ## ⚡ Quick Start
- **🏗️ Want to understand the code?** Check out our [Architecture Guide](./docs/ARCHITECTURE.md)
- **🧩 Interested in plugins?** Read our [Plugin Development Guide](./docs/plugins/README.md)
- **🐛 Found a bug?** Open an [issue](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) or fix it yourself!
- **💬 Need help?** Join our [Discord community](https://discord.gg/YzmbnCDkat)
We have lots of https://github.com/BetterSEQTA/BetterSEQTA-Plus/labels/good%20first%20issue labels perfect for beginners!
## Quick Development Setup
&nbsp;&nbsp;&nbsp; **1. Fork & Clone**
```bash ```bash
git clone https://github.com/YOUR_USERNAME/BetterSEQTA-Plus git clone https://github.com/YOUR_USERNAME_FORKED_WITH/BetterSEQTA-Plus
cd BetterSEQTA-Plus cd BetterSEQTA-Plus
```
&nbsp;&nbsp;&nbsp; **2. Install & Run**
```bash
npm install --legacy-peer-deps npm install --legacy-peer-deps
npm run dev npm run dev
``` ````
&nbsp;&nbsp;&nbsp; **3. Load in Browser** Then load `dist` in `chrome://extensions` (Developer Mode → Load unpacked).
1. Go to `chrome://extensions`
2. Enable "Developer mode"
3. Click "Load unpacked" → Select `dist` folder
4. Visit a SEQTA page to see it work! 🎉
> [!WARNING]
> Whenever you update the extension while not in dev mode, you will need to use the reload button on the extension page.
📚 **Need more details?** Check our [detailed setup guide](./docs/GETTING_STARTED_CONTRIBUTING.md#your-first-30-minutes)
### Building for Production Full setup guide:
[https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment](https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment)
```bash
npm run build # Build for all browsers
npm run zip # Package for distribution (requires 7-Zip)
```
## Folder Structure
The folder structure is as follows:
- The `src` folder contains source files that are compiled to the build directory.
- The `src/plugins` folder contains vital loaders required for BetterSEQTA+ functionality.
- The `src/interface` folder contains source React & Svelte files that are required for the Settings page.
- The `dist` folder is where the compiled code ends up, this is the folder what you need to load into chrome as an unpacked extension for development.
## Contributors ## Contributors
@@ -115,11 +81,14 @@ The folder structure is as follows:
<img src="https://contrib.rocks/image?repo=betterseqta/betterseqta-plus" /> <img src="https://contrib.rocks/image?repo=betterseqta/betterseqta-plus" />
</a> </a>
Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plus/blob/main/CONTRIBUTING.md) Want to contribute? [Click Here!](https://docs.betterseqta.org/contributing/)
## Credits ## Credits
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers. This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers.
Originally developed by [Nulkem](https://github.com/Nulkem/betterseqta)
Ported to Manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68)
Maintained by [SethBurkart123](https://github.com/SethBurkart123), [Crazypersonalph](https://github.com/Crazypersonalph) & the rest of the BetterSEQTA team!
## Star History ## Star History
+122 -22
View File
@@ -5,7 +5,7 @@
"": { "": {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"dependencies": { "dependencies": {
"@bedframe/core": "^0.0.46", "@bedframe/core": "^0.1.0",
"@codemirror/autocomplete": "^6.18.6", "@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0", "@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
@@ -13,7 +13,7 @@
"@codemirror/search": "^6.5.10", "@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4", "@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4", "@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.1.4", "@types/chrome": "^0.1.4",
@@ -52,17 +52,17 @@
"react-dom": "17", "react-dom": "17",
"rss-parser": "^3.13.0", "rss-parser": "^3.13.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"svelte": "^5.22.6", "svelte": "^5.46.4",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vite": "^6.2.1", "vite": "^8.0.5",
"webextension-polyfill": "^0.12.0", "webextension-polyfill": "^0.12.0",
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9", "@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9", "@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.95", "@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.2.0", "@crxjs/vite-plugin": "^2.4.0",
"@types/mime-types": "^3.0.1", "@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@@ -127,9 +127,9 @@
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], "@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
"@bedframe/cli": ["@bedframe/cli@0.0.95", "", { "dependencies": { "@bedframe/core": "0.0.46", "commander": "14.0.0", "execa": "9.6.0", "kolorist": "1.8.0", "listr": "0.14.3", "nanospinner": "1.2.2", "node-fetch": "3.3.2", "pkg-install": "1.0.0", "prompts": "2.4.2", "vite": "6.3.5" }, "peerDependencies": { "concurrently": "8.2.2" }, "bin": { "bedframe": "dist/bedframe.js", "create-bedframe": "dist/create-bedframe.js" } }, "sha512-WSb0HhHCfH7/tS5dDC/HL/VgKrIFGLmI0OesmcwQntrJdHVirtjrDVjcTFG3lC3LB5Ax85P1CY50Gy5aDNc0sQ=="], "@bedframe/cli": ["@bedframe/cli@0.1.2", "", { "dependencies": { "@bedframe/core": "0.1.0", "commander": "^14.0.2", "execa": "^9.6.1", "kolorist": "^1.8.0", "listr": "^0.14.3", "nanospinner": "^1.2.2", "node-fetch": "^3.3.2", "pkg-install": "^1.0.0", "prompts": "^2.4.2", "vite": "^6.4.1" }, "peerDependencies": { "concurrently": "^8.2.1" }, "bin": { "bedframe": "dist/bedframe.js", "create-bedframe": "dist/create-bedframe.js" } }, "sha512-nu0VSfGLhY9f62w+fDRQi2YnfoY9c6u28ZlJ8rH6f57ItLo5TNrZetfw37fYNnh8yK2RSAWU7+6KCkdVm0Fokg=="],
"@bedframe/core": ["@bedframe/core@0.0.46", "", { "dependencies": { "@crxjs/vite-plugin": "2.0.2" }, "peerDependencies": { "vite-plugin-dts": "3.9.1", "vite-plugin-externalize-deps": "0.7.0", "vitest": "0.34.6" } }, "sha512-cOshFUrBksWnVQ08chunlvAetwhuytkX7NdH6blNNylYzsgCaLGBbCJ2EZ0d18kimFVNZoODrc+812if5dio/w=="], "@bedframe/core": ["@bedframe/core@0.1.0", "", { "dependencies": { "@crxjs/vite-plugin": "2.3.0" }, "peerDependencies": { "vite-plugin-dts": "^3.7.0", "vite-plugin-externalize-deps": "^0.7.0", "vitest": "^0.34.6" } }, "sha512-bM9vuYG67m9lVTui966AmkoxPPdEHEDOKKjzAWV/Ymgur818fRhMMpblx3+PLs8kTCek1m79fjYKoE8PhqJ22g=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "6.11.3", "@codemirror/state": "6.5.2", "@codemirror/view": "6.38.1", "@lezer/common": "1.2.3" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="], "@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "6.11.3", "@codemirror/state": "6.5.2", "@codemirror/view": "6.38.1", "@lezer/common": "1.2.3" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="],
@@ -147,7 +147,13 @@
"@codemirror/view": ["@codemirror/view@6.38.1", "", { "dependencies": { "@codemirror/state": "6.5.2", "crelt": "1.0.6", "style-mod": "4.1.2", "w3c-keyname": "2.2.8" } }, "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ=="], "@codemirror/view": ["@codemirror/view@6.38.1", "", { "dependencies": { "@codemirror/state": "6.5.2", "crelt": "1.0.6", "style-mod": "4.1.2", "w3c-keyname": "2.2.8" } }, "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ=="],
"@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.2.0", "", { "dependencies": { "@rollup/pluginutils": "4.2.1", "@webcomponents/custom-elements": "1.6.0", "acorn-walk": "8.3.4", "cheerio": "1.1.2", "convert-source-map": "1.9.0", "debug": "4.4.1", "es-module-lexer": "0.10.5", "fast-glob": "3.3.3", "fs-extra": "10.1.0", "jsesc": "3.1.0", "magic-string": "0.30.18", "pathe": "2.0.3", "picocolors": "1.1.1", "react-refresh": "0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-HpT1GLbUQy42nlpN4sGzFgulacBraMM778s8Q+oPo4cb26DwO9tTwdndlvAS8fe6vEProFXvbdt37objp/0IQA=="], "@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.4.0", "", { "dependencies": { "@rollup/pluginutils": "^4.1.2", "@webcomponents/custom-elements": "^1.5.0", "acorn-walk": "^8.2.0", "convert-source-map": "^1.7.0", "debug": "^4.3.3", "es-module-lexer": "^0.10.0", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "jsesc": "^3.0.2", "magic-string": "^0.30.12", "node-html-parser": "^7.0.2", "pathe": "^2.0.1", "picocolors": "^1.1.1", "react-refresh": "^0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" }, "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bDLdq0W2V1SkMQDJjrcYyjK9/uKtdl4joT7GRImcootCjZdKRiRYt+cv9z8tJoU/tK3o1lX48LTqN7JMsk5AQg=="],
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
@@ -293,12 +299,16 @@
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="], "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "1.19.1" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "1.19.1" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "1.0.3", "is-glob": "4.0.3", "micromatch": "4.0.8", "node-addon-api": "7.1.1" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="], "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "1.0.3", "is-glob": "4.0.3", "micromatch": "4.0.8", "node-addon-api": "7.1.1" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="], "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
@@ -349,6 +359,38 @@
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
"@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "2.0.2", "picomatch": "2.3.1" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="], "@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "2.0.2", "picomatch": "2.3.1" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="],
@@ -409,14 +451,14 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "4.0.1", "debug": "4.4.1", "deepmerge": "4.3.1", "kleur": "4.1.5", "magic-string": "0.30.18", "vitefu": "1.1.1" }, "peerDependencies": { "svelte": "5.38.6", "vite": "6.3.5" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "5.1.1", "svelte": "5.38.6", "vite": "6.3.5" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.10", "", { "dependencies": { "mini-svg-data-uri": "1.4.4" }, "peerDependencies": { "tailwindcss": "3.4.17" } }, "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw=="], "@tailwindcss/forms": ["@tailwindcss/forms@0.5.10", "", { "dependencies": { "mini-svg-data-uri": "1.4.4" }, "peerDependencies": { "tailwindcss": "3.4.17" } }, "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw=="],
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.5", "", {}, "sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ=="], "@tsconfig/svelte": ["@tsconfig/svelte@5.0.5", "", {}, "sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="],
"@types/chai": ["@types/chai@4.3.20", "", {}, "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ=="], "@types/chai": ["@types/chai@4.3.20", "", {}, "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ=="],
@@ -527,7 +569,7 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="], "assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="],
@@ -631,7 +673,7 @@
"colors-named-hex": ["colors-named-hex@1.0.2", "", {}, "sha512-k6kq1e1pUCQvSVwIaGFq2l0LrkAPQZWyeuZn1Z8nOiYSEZiKoFj4qx690h2Kd34DFl9Me0gKS6MUwAMBJj8nuA=="], "colors-named-hex": ["colors-named-hex@1.0.2", "", {}, "sha512-k6kq1e1pUCQvSVwIaGFq2l0LrkAPQZWyeuZn1Z8nOiYSEZiKoFj4qx690h2Kd34DFl9Me0gKS6MUwAMBJj8nuA=="],
"commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="], "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"complex.js": ["complex.js@2.4.2", "", {}, "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g=="], "complex.js": ["complex.js@2.4.2", "", {}, "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g=="],
@@ -693,6 +735,8 @@
"detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
"devalue": ["devalue@5.7.1", "", {}, "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
@@ -773,7 +817,7 @@
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
"esrap": ["esrap@2.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA=="], "esrap": ["esrap@2.2.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
@@ -787,7 +831,7 @@
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"execa": ["execa@9.6.0", "", { "dependencies": { "@sindresorhus/merge-streams": "4.0.0", "cross-spawn": "7.0.6", "figures": "6.1.0", "get-stream": "9.0.1", "human-signals": "8.0.1", "is-plain-obj": "4.1.0", "is-stream": "4.0.1", "npm-run-path": "6.0.0", "pretty-ms": "9.2.0", "signal-exit": "4.1.0", "strip-final-newline": "4.0.0", "yoctocolors": "2.1.2" } }, "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw=="], "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
@@ -995,6 +1039,30 @@
"lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "3.0.6" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="], "lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "3.0.6" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -1125,6 +1193,8 @@
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="],
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -1143,6 +1213,8 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
@@ -1293,6 +1365,8 @@
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
"rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="], "rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="],
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "2.2.0", "xml2js": "0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="], "rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "2.2.0", "xml2js": "0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
@@ -1397,7 +1471,7 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.38.6", "", { "dependencies": { "@jridgewell/remapping": "2.3.5", "@jridgewell/sourcemap-codec": "1.5.5", "@sveltejs/acorn-typescript": "1.0.5", "@types/estree": "1.0.8", "acorn": "8.15.0", "aria-query": "5.3.2", "axobject-query": "4.1.0", "clsx": "2.1.1", "esm-env": "1.2.2", "esrap": "2.1.0", "is-reference": "3.0.3", "locate-character": "3.0.0", "magic-string": "0.30.18", "zimmerframe": "1.1.2" } }, "sha512-ltBPlkvqk3bgCK7/N323atUpP3O3Y+DrGV4dcULrsSn4fZaaNnOmdplNznwfdWclAgvSr5rxjtzn/zJhRm6TKg=="], "svelte": ["svelte@5.55.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ=="],
"symbol-observable": ["symbol-observable@1.2.0", "", {}, "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="], "symbol-observable": ["symbol-observable@1.2.0", "", {}, "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="],
@@ -1423,7 +1497,7 @@
"tinycolor2": ["tinycolor2@1.4.2", "", {}, "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="], "tinycolor2": ["tinycolor2@1.4.2", "", {}, "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="],
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinypool": ["tinypool@0.7.0", "", {}, "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww=="], "tinypool": ["tinypool@0.7.0", "", {}, "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww=="],
@@ -1477,7 +1551,7 @@
"validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="], "validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="],
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "0.25.9", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.49.0", "tinyglobby": "0.2.14" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "jiti": "1.21.7", "sass": "1.91.0", "yaml": "2.8.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], "vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
"vite-node": ["vite-node@0.34.6", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.1", "mlly": "1.8.0", "pathe": "1.1.2", "picocolors": "1.1.1", "vite": "5.4.19" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA=="], "vite-node": ["vite-node@0.34.6", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.1", "mlly": "1.8.0", "pathe": "1.1.2", "picocolors": "1.1.1", "vite": "5.4.19" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA=="],
@@ -1485,7 +1559,7 @@
"vite-plugin-externalize-deps": ["vite-plugin-externalize-deps@0.7.0", "", { "peerDependencies": { "vite": "6.3.5" } }, "sha512-do2gPrR79Tm8UKcqsw3RTAtN4YO8GkVRBckWdJWINZ3Qdp3KN9S1oyUZxKszTB/iyg4zdOUweLOeBI8t86QVow=="], "vite-plugin-externalize-deps": ["vite-plugin-externalize-deps@0.7.0", "", { "peerDependencies": { "vite": "6.3.5" } }, "sha512-do2gPrR79Tm8UKcqsw3RTAtN4YO8GkVRBckWdJWINZ3Qdp3KN9S1oyUZxKszTB/iyg4zdOUweLOeBI8t86QVow=="],
"vitefu": ["vitefu@1.1.1", "", { "optionalDependencies": { "vite": "6.3.5" } }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"vitest": ["vitest@0.34.6", "", { "dependencies": { "@types/chai": "4.3.20", "@types/chai-subset": "1.3.6", "@types/node": "24.3.0", "@vitest/expect": "0.34.6", "@vitest/runner": "0.34.6", "@vitest/snapshot": "0.34.6", "@vitest/spy": "0.34.6", "@vitest/utils": "0.34.6", "acorn": "8.15.0", "acorn-walk": "8.3.4", "cac": "6.7.14", "chai": "4.5.0", "debug": "4.4.1", "local-pkg": "0.4.3", "magic-string": "0.30.18", "pathe": "1.1.2", "picocolors": "1.1.1", "std-env": "3.9.0", "strip-literal": "1.3.0", "tinybench": "2.9.0", "tinypool": "0.7.0", "vite": "5.4.19", "vite-node": "0.34.6", "why-is-node-running": "2.3.0" }, "bin": { "vitest": "vitest.mjs" } }, "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q=="], "vitest": ["vitest@0.34.6", "", { "dependencies": { "@types/chai": "4.3.20", "@types/chai-subset": "1.3.6", "@types/node": "24.3.0", "@vitest/expect": "0.34.6", "@vitest/runner": "0.34.6", "@vitest/snapshot": "0.34.6", "@vitest/spy": "0.34.6", "@vitest/utils": "0.34.6", "acorn": "8.15.0", "acorn-walk": "8.3.4", "cac": "6.7.14", "chai": "4.5.0", "debug": "4.4.1", "local-pkg": "0.4.3", "magic-string": "0.30.18", "pathe": "1.1.2", "picocolors": "1.1.1", "std-env": "3.9.0", "strip-literal": "1.3.0", "tinybench": "2.9.0", "tinypool": "0.7.0", "vite": "5.4.19", "vite-node": "0.34.6", "why-is-node-running": "2.3.0" }, "bin": { "vitest": "vitest.mjs" } }, "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q=="],
@@ -1555,7 +1629,9 @@
"@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@bedframe/core/@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.0.2", "", { "dependencies": { "@rollup/pluginutils": "4.2.1", "@webcomponents/custom-elements": "1.6.0", "acorn-walk": "8.3.4", "cheerio": "1.1.2", "convert-source-map": "1.9.0", "debug": "4.4.1", "es-module-lexer": "0.10.5", "fast-glob": "3.3.3", "fs-extra": "10.1.0", "jsesc": "3.1.0", "magic-string": "0.30.18", "pathe": "2.0.3", "picocolors": "1.1.1", "react-refresh": "0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-BeaVEkCTmna2tzl5DL9nw1kxll1IpIFZ+wbl2+iILz4fNJy1xRD6c1nF8w8/CvrWUuPYTFTpyX9K+A30ISDXHA=="], "@bedframe/cli/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
"@bedframe/core/@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.3.0", "", { "dependencies": { "@rollup/pluginutils": "^4.1.2", "@webcomponents/custom-elements": "^1.5.0", "acorn-walk": "^8.2.0", "cheerio": "^1.0.0-rc.10", "convert-source-map": "^1.7.0", "debug": "^4.3.3", "es-module-lexer": "^0.10.0", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "jsesc": "^3.0.2", "magic-string": "^0.30.12", "pathe": "^2.0.1", "picocolors": "^1.1.1", "react-refresh": "^0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-+0CNVGS4bB30OoaF1vUsHVwWU1Lm7MxI0XWY9Fd/Ob+ZVTZgEFNqJ1ZC69IVwQsoYhY0sMQLvpLWiFIuDz8htg=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -1591,6 +1667,8 @@
"@samverschueren/stream-to-observable/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="], "@samverschueren/stream-to-observable/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
"@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@vitest/runner/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "@vitest/runner/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
@@ -1621,6 +1699,8 @@
"concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"dependency-cruiser/commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"eslint/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "eslint/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
@@ -1633,6 +1713,8 @@
"htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"lightningcss/detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
"listr/is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="], "listr/is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="],
"listr/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="], "listr/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
@@ -1715,9 +1797,13 @@
"tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="], "vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"vite-node/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "vite-node/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
@@ -1725,6 +1811,8 @@
"vite-plugin-dts/@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "1.0.8", "estree-walker": "2.0.2", "picomatch": "4.0.3" }, "optionalDependencies": { "rollup": "4.49.0" } }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="], "vite-plugin-dts/@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "1.0.8", "estree-walker": "2.0.2", "picomatch": "4.0.3" }, "optionalDependencies": { "rollup": "4.49.0" } }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="],
"vite-plugin-dts/vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "0.25.9", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.49.0", "tinyglobby": "0.2.14" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "jiti": "1.21.7", "sass": "1.91.0", "yaml": "2.8.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
"vitest/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "vitest/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"vitest/vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.6", "rollup": "4.49.0" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "sass": "1.91.0" }, "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="], "vitest/vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.6", "rollup": "4.49.0" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "sass": "1.91.0" }, "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="],
@@ -1741,6 +1829,14 @@
"z-schema/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "z-schema/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"@bedframe/cli/vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"@bedframe/cli/vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"@bedframe/cli/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"@bedframe/core/@crxjs/vite-plugin/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
@@ -1865,6 +1961,10 @@
"vite-plugin-dts/@rollup/pluginutils/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="], "vite-plugin-dts/@rollup/pluginutils/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"vite-plugin-dts/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"vite-plugin-dts/vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"vitest/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], "vitest/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"vitest/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="], "vitest/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
+3 -1
View File
@@ -1,5 +1,7 @@
# BetterSEQTA+ Architecture # BetterSEQTA+ Architecture
**Published version:** [docs.betterseqta.org/architecture/](https://docs.betterseqta.org/architecture/)
Hey there! 👋 New to the codebase and feeling a bit lost? Don't worry - this guide will help you understand how everything fits together! Hey there! 👋 New to the codebase and feeling a bit lost? Don't worry - this guide will help you understand how everything fits together!
## Table of Contents ## Table of Contents
@@ -221,7 +223,7 @@ console.log(settingsState.[the setting name])
Ready to contribute? Here's what to do next: Ready to contribute? Here's what to do next:
1. **Read the code**: Start with `src/SEQTA.ts` and follow the flow 1. **Read the code**: Start with `src/SEQTA.ts` and follow the flow
2. **Try creating a simple plugin**: Follow our [plugin guide](./plugins/README.md) 2. **Try creating a simple plugin**: Follow the [plugin documentation](https://docs.betterseqta.org/plugins/)
3. **Look at existing issues**: Check our [GitHub issues](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) for "good first issue" labels 3. **Look at existing issues**: Check our [GitHub issues](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) for "good first issue" labels
4. **Join our Discord**: Get help from the community! 4. **Join our Discord**: Get help from the community!
+140
View File
@@ -0,0 +1,140 @@
# BetterSEQTA Cloud — settings sync (server specification)
This document describes the HTTP API the BetterSEQTA+ extension expects for **cloud backup of extension settings**. The client is implemented in the extension repo; the accounts service (`accounts.betterseqta.org`) must implement these endpoints.
## Purpose
- Store **one JSON document per authenticated BetterSEQTA Cloud user** representing a snapshot of the extensions `chrome.storage.local` data (theme, layout, plugin settings, `plugin.*` keys, etc.).
- The extension **does not upload OAuth tokens** (`bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`). Those remain only on the client.
- **Download** replaces local storage with the stored snapshot, then the client reapplies the current devices session tokens so the user stays signed in.
## Base URL
All routes below are relative to:
`https://accounts.betterseqta.org`
## Authentication
Every request must include:
```http
Authorization: Bearer <access_token>
```
Use the same **access tokens** issued by the existing BetterSEQTA+ OAuth flows (`/api/bsplus/login`, `/api/bsplus/refresh`). Resolve the user from the token; the document is scoped to that user.
## Endpoints
### `PUT /api/bsplus/settings/sync`
Upserts the callers settings backup.
**Request body (JSON):**
```json
{
"schemaVersion": 1,
"themeId": "uuid-string-or-empty",
"data": {
"...": "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.
- **`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`).
**Success response:** HTTP `200` (or `201` if you prefer create semantics). Example:
```json
{
"updated_at": "2026-04-07T12:00:00.000Z"
}
```
`updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields.
**Error responses:** Standard JSON error body if applicable, e.g. `{ "error": "message" }`, with appropriate HTTP status (`401`, `413`, `422`, etc.).
---
### `GET /api/bsplus/settings/sync`
Returns the callers latest settings backup.
**Success response:** HTTP `200` with body:
```json
{
"schemaVersion": 1,
"themeId": "uuid-string-or-empty",
"data": { },
"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).
- **`themeId`**: optional; if present must match **`data.selectedTheme`** (see `PUT`).
- **`schemaVersion`**: optional but recommended; should match what was stored.
- **`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.
**Error responses:** `401` if the token is invalid, etc.
---
## Suggested database shape
Example relational layout:
| Column | Type | Notes |
|---------------|-------------|--------|
| `user_id` | FK → users | Unique per backup row (one row per user). |
| `payload` | JSON / JSONB| Store `{ "schemaVersion", "data" }` or only `data` + separate `schema_version` column. |
| `updated_at` | timestamptz | Set on each successful `PUT`. |
Unique constraint on `user_id`.
## Semantics
- **Last write wins:** each `PUT` replaces the stored backup for that user.
- **Optional later:** `If-Unmodified-Since` or a `revision` field for conflict detection (not required for v1).
## Security and privacy
- **Encryption at rest** for `payload` is recommended.
- Payload may contain **school-related UI preferences** and plugin data; treat as **user data** under your privacy policy.
- **Do not require or store** refresh/access tokens in the payload; the extension already strips them on upload.
### Never included in the sync payload (`chrome.storage.local` only)
The backup is a flat JSON map of **`chrome.storage.local`** keys. It does **not** include:
- **IndexedDB** — e.g. the Global Search index (`betterseqta-index` and related DBs) lives outside extension storage and is never serialized here.
- **OAuth / session keys** — `bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`, plus legacy `cloudAccessToken` / `cloudUsername`.
- **Assessment Averages caches** — `plugin.assessments-average.storage.assessments`, `plugin.assessments-average.storage.weightings` (school assessment data).
- **Keys under** `plugin.global-search.storage.*` — reserved so any future plugin storage cache there is not synced.
- **`bsplus_cloud_settings_known_remote_updated_at`** — client-only watermark for auto-sync (not part of the cloud backup blob).
On restore, those keys are **not** taken from the server; the device keeps its current local values.
## Related endpoint: `GET /api/user/cloud-summary`
The extension may call **`GET /api/user/cloud-summary`** (same host, `Authorization: Bearer`) for a **small** JSON summary (e.g. whether DesQTA / BetterSEQTA+ cloud settings exist and **`bsplus.updated_at`** / **`schemaVersion`**). It does **not** return the large settings `data` blob.
- **Auto-sync flow:** compare `bsplus.updated_at` to a **client-only** watermark stored in extension storage as **`bsplus_cloud_settings_known_remote_updated_at`** (never uploaded, never applied from the server payload; preserved on restore).
- If the server timestamp is newer (and `schemaVersion` is not ahead of the client), the client then calls **`GET /api/bsplus/settings/sync`** and applies the full envelope as usual.
This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages, `storage`) and works on **Chromium and Firefox** builds (see `webextension-polyfill`).
## Client reference (extension)
- 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: 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`.
+3 -1
View File
@@ -1,5 +1,7 @@
# Getting Started as a Contributor # Getting Started as a Contributor
**Published version:** [docs.betterseqta.org/contributing/](https://docs.betterseqta.org/contributing/)
Welcome to BetterSEQTA+! 🎉 This guide will walk you through making your first contribution, even if you're completely new to the project. Welcome to BetterSEQTA+! 🎉 This guide will walk you through making your first contribution, even if you're completely new to the project.
## Table of Contents ## Table of Contents
@@ -222,7 +224,7 @@ git push origin your-branch-name
### Stuck? Here's How to Get Unstuck ### Stuck? Here's How to Get Unstuck
1. **Check the docs** - [Architecture guide](./ARCHITECTURE.md) explains everything 1. **Check the docs** - The [architecture guide](https://docs.betterseqta.org/architecture/) explains everything
2. **Search existing issues** - Someone might have had the same problem 2. **Search existing issues** - Someone might have had the same problem
3. **Ask in Discord** - Our community is super helpful 3. **Ask in Discord** - Our community is super helpful
4. **Create an issue** - If you found a bug or need help 4. **Create an issue** - If you found a bug or need help
+21 -21
View File
@@ -1,30 +1,36 @@
# BetterSEQTA+ Documentation # BetterSEQTA+ Documentation
🚧 DOCS UNDER CONSTRUCTION! 🚧 **Canonical documentation lives at [docs.betterseqta.org](https://docs.betterseqta.org/).** The pages below are the same topics; use the site for search, navigation, and the latest updates.
Welcome to the BetterSEQTA+ documentation! This documentation will help you understand how BetterSEQTA+ works and how to extend it with plugins and new features.
## Table of Contents ## Table of Contents
### Getting Started ### Getting Started
- [Project Overview](./README.md) - This file - [Documentation home](https://docs.betterseqta.org/)
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+ - [Installation](https://docs.betterseqta.org/install/)
- [Getting Started Contributing](./GETTING_STARTED_CONTRIBUTING.md) - **Start here!** Complete beginner-friendly guide - [Contributing](https://docs.betterseqta.org/contributing/)
- [Architecture Guide](./ARCHITECTURE.md) - How BetterSEQTA+ works under the hood - [Architecture](https://docs.betterseqta.org/architecture/)
- [Contributing Guide](../CONTRIBUTING.md) - Official contribution guidelines - [Contribution guidelines (repository)](../CONTRIBUTING.md)
- [Troubleshooting](./TROUBLESHOOTING.md) - Common issues and solutions - [Troubleshooting](https://docs.betterseqta.org/troubleshooting/)
### Plugin System ### Features & customization
- [Creating Your First Plugin](./plugins/README.md) - A comprehensive, beginner-friendly guide to creating plugins - [Features](https://docs.betterseqta.org/features/)
- [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs - [Themes & customization](https://docs.betterseqta.org/customization/)
- [Theme creation](https://docs.betterseqta.org/theme-creation/)
### Plugin system
- [Plugins overview](https://docs.betterseqta.org/plugins/)
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
- [Example plugin](https://docs.betterseqta.org/example-plugin/)
## Core Concepts ## Core Concepts
BetterSEQTA+ is built around several core concepts: BetterSEQTA+ is built around several core concepts:
1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. Check out our [plugin guide](./plugins/README.md) to learn how to create your own! 1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. See the [plugins documentation](https://docs.betterseqta.org/plugins/).
2. **Type-Safe Settings**: Each plugin can define settings that are type-safe and automatically rendered in the settings UI. The settings system uses TypeScript decorators to make it easy to define settings with proper typing. 2. **Type-Safe Settings**: Each plugin can define settings that are type-safe and automatically rendered in the settings UI. The settings system uses TypeScript decorators to make it easy to define settings with proper typing.
@@ -36,19 +42,13 @@ BetterSEQTA+ is built around several core concepts:
If you need help with BetterSEQTA+, you can: If you need help with BetterSEQTA+, you can:
- [Open an Issue](https://github.com/SeqtaLearning/betterseqta-plus/issues) - Report bugs or request features - [Open an Issue](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) - Report bugs or request features
- [Join the Discord](https://discord.gg/YzmbnCDkat) - Chat with the community - [Join the Discord](https://discord.gg/YzmbnCDkat) - Chat with the community
- [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly - [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly
## Contributing to the Documentation ## Contributing to the Documentation
We welcome contributions to the documentation! If you find something unclear or missing, please open an issue or submit a pull request. We welcome contributions to the documentation. The published site is built from the documentation repository; see [Contributing](https://docs.betterseqta.org/contributing/) for how to help.
To contribute to the documentation:
1. Fork the repository
2. Make your changes to the documentation files
3. Submit a pull request with a clear description of your changes
## License ## License
+2
View File
@@ -1,5 +1,7 @@
# Theme Creation Guide # Theme Creation Guide
**Published version:** [docs.betterseqta.org/theme-creation/](https://docs.betterseqta.org/theme-creation/)
This guide covers everything you need to know about creating custom themes for BetterSEQTA+. This guide covers everything you need to know about creating custom themes for BetterSEQTA+.
## Table of Contents ## Table of Contents
+2
View File
@@ -1,5 +1,7 @@
# Troubleshooting Guide # Troubleshooting Guide
**Published version:** [docs.betterseqta.org/troubleshooting/](https://docs.betterseqta.org/troubleshooting/)
Having issues with BetterSEQTA+ development? This guide covers the most common problems and their solutions. Having issues with BetterSEQTA+ development? This guide covers the most common problems and their solutions.
## Table of Contents ## Table of Contents
+5 -3
View File
@@ -1,5 +1,7 @@
# Contributing to BetterSEQTA+ # Contributing to BetterSEQTA+
**Published version:** [docs.betterseqta.org/contributing/](https://docs.betterseqta.org/contributing/)
Thank you for your interest in contributing to BetterSEQTA+! This document provides guidelines and instructions for contributing to the project. Thank you for your interest in contributing to BetterSEQTA+! This document provides guidelines and instructions for contributing to the project.
## Table of Contents ## Table of Contents
@@ -57,7 +59,7 @@ Key points:
5. **Install in Chrome/Firefox** 5. **Install in Chrome/Firefox**
Follow the [installation instructions](./installation.md#development-installation) to load the development version into your browser. Follow the [installation instructions](https://docs.betterseqta.org/install/) to load the development version into your browser.
### Project Structure ### Project Structure
@@ -246,8 +248,8 @@ Join our community channels to discuss the project, get help, and connect with o
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides: If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Creating Your First Plugin](./plugins/creating-plugins.md) - [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API Reference](./advanced/plugin-api.md) - [Plugin API](https://docs.betterseqta.org/plugin-api/)
## Recognition ## Recognition
+4 -2
View File
@@ -1,5 +1,7 @@
# Installing BetterSEQTA+ # Installing BetterSEQTA+
**Published version:** [docs.betterseqta.org/install/](https://docs.betterseqta.org/install/)
This guide will walk you through the process of installing and setting up BetterSEQTA+ for development or usage. This guide will walk you through the process of installing and setting up BetterSEQTA+ for development or usage.
## Prerequisites ## Prerequisites
@@ -178,5 +180,5 @@ bun run dev
Now that you have BetterSEQTA+ installed, you can: Now that you have BetterSEQTA+ installed, you can:
- [Getting Started with Plugins](./plugins/getting-started.md) - [Plugins](https://docs.betterseqta.org/plugins/)
- [Contribute to the project](../CONTRIBUTING.md) - [Contribute to the project](https://docs.betterseqta.org/contributing/) · [Repository CONTRIBUTING.md](../CONTRIBUTING.md)
+4 -2
View File
@@ -1,5 +1,7 @@
# Example Plugin Template # Example Plugin Template
**Published version:** [docs.betterseqta.org/example-plugin/](https://docs.betterseqta.org/example-plugin/)
This is a complete, working example of a simple BetterSEQTA+ plugin. You can copy this code and modify it to create your own plugin! This is a complete, working example of a simple BetterSEQTA+ plugin. You can copy this code and modify it to create your own plugin!
## What This Example Does ## What This Example Does
@@ -328,8 +330,8 @@ Once you've got this working:
## Need Help? ## Need Help?
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat) - 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
- 📚 Read our [Plugin Development Guide](./README.md) - 📚 Read the [plugin documentation](https://docs.betterseqta.org/plugins/)
- 🐛 Check the [Troubleshooting Guide](../TROUBLESHOOTING.md) - 🐛 Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
- 📝 Open an issue on GitHub - 📝 Open an issue on GitHub
Happy coding! 🎉 Happy coding! 🎉
+3 -1
View File
@@ -1,5 +1,7 @@
# Creating Plugins for BetterSEQTA+ # Creating Plugins for BetterSEQTA+
**Published version:** [docs.betterseqta.org/plugins/](https://docs.betterseqta.org/plugins/) · [Plugin development](https://docs.betterseqta.org/plugin-development/) · [Plugin API](https://docs.betterseqta.org/plugin-api/)
Hey there! 👋 So you want to create a plugin for BetterSEQTA+? That's awesome! This guide will walk you through everything you need to know, from the very basics to more advanced features. Don't worry if you're new to this - we'll explain everything step by step. Hey there! 👋 So you want to create a plugin for BetterSEQTA+? That's awesome! This guide will walk you through everything you need to know, from the very basics to more advanced features. Don't worry if you're new to this - we'll explain everything step by step.
## What is a Plugin? ## What is a Plugin?
@@ -294,4 +296,4 @@ Got stuck? No worries! Here's where you can get help:
- Check out the built-in plugins in the `src/plugins/built-in` folder - Check out the built-in plugins in the `src/plugins/built-in` folder
- Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues) - Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues)
Happy coding and feel free to checkout the api reference [here](./api-reference.md) Happy coding and feel free to check out the [plugin API](https://docs.betterseqta.org/plugin-api/) on the documentation site.
+3 -1
View File
@@ -1,6 +1,8 @@
# Plugin API Reference # Plugin API Reference
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see [Creating Your First Plugin](./README.md). **Published version:** [docs.betterseqta.org/plugin-api/](https://docs.betterseqta.org/plugin-api/)
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see the [plugins section](https://docs.betterseqta.org/plugins/) at [docs.betterseqta.org](https://docs.betterseqta.org/).
## Plugin Structure ## Plugin Structure
+43
View File
@@ -0,0 +1,43 @@
import type { Plugin } from "vite";
/**
* Firefox extension pages forbid eval / `Function` constructor. Some deps still emit:
* - `Function(\`return this\`)()` (lodash-style global)
* - `try { return Function(\`\`) / new Function("") … }` (feature probes, e.g. PDF.js / ORT)
*/
export function firefoxStripFunctionProbe(): Plugin {
return {
name: "firefox-strip-function-probe",
apply: "build",
enforce: "post",
generateBundle(_options, bundle) {
if ((process.env.MODE || "chrome").toLowerCase() !== "firefox") return;
const literalReplacements: [string, string][] = [
['try{return new Function(""),!0}catch{return!1}', "return!1"],
["try{return new Function(''),!0}catch{return!1}", "return!1"],
['try{return new Function(""),true}catch{return false}', "return false"],
["try{return new Function(''),true}catch{return false}", "return false"],
// Empty template literal probe (minifier output)
["try{return Function(``),!0}catch{return!1}", "return!1"],
];
for (const chunk of Object.values(bundle)) {
if (chunk.type !== "chunk" || typeof chunk.code !== "string") continue;
let { code } = chunk;
code = code.replace(/Function\(`return this`\)\(\)/g, "(globalThis)");
code = code.replace(/Function\("return this"\)\(\)/g, "(globalThis)");
code = code.replace(/Function\('return this'\)\(\)/g, "(globalThis)");
for (const [from, to] of literalReplacements) {
if (code.includes(from)) {
code = code.split(from).join(to);
}
}
chunk.code = code;
}
},
};
}
+10 -9
View File
@@ -1,10 +1,11 @@
{ {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"version": "3.5.1", "version": "3.6.5",
"type": "module", "type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!", "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
"browserslist": "> 0.5%, last 2 versions, not dead", "browserslist": "> 0.5%, last 2 versions, not dead",
"scripts": { "scripts": {
"postinstall": "node scripts/copy-pdfjs-assets.mjs",
"autoaudit": "npm audit && npm audit fix && npm run build", "autoaudit": "npm audit && npm audit fix && npm run build",
"dev": "cross-env MODE=chrome vite dev", "dev": "cross-env MODE=chrome vite dev",
"dev:firefox": "cross-env MODE=firefox vite build --watch", "dev:firefox": "cross-env MODE=firefox vite build --watch",
@@ -31,14 +32,14 @@
"author": { "author": {
"name": "SethBurkart123", "name": "SethBurkart123",
"email": "betterseqta.plus@gmail.com", "email": "betterseqta.plus@gmail.com",
"url": "https://github.com/BetterSEQTA/BetterSEQTA-plus" "url": "https://github.com/BetterSEQTA/BetterSEQTA-Plus"
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9", "@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9", "@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.95", "@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.2.0", "@crxjs/vite-plugin": "^2.4.0",
"@types/mime-types": "^3.0.1", "@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@@ -57,7 +58,7 @@
"url": "^0.11.4" "url": "^0.11.4"
}, },
"dependencies": { "dependencies": {
"@bedframe/core": "^0.0.46", "@bedframe/core": "^0.1.0",
"@codemirror/autocomplete": "^6.18.6", "@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0", "@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
@@ -65,7 +66,7 @@
"@codemirror/search": "^6.5.10", "@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4", "@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4", "@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.1.4", "@types/chrome": "^0.1.4",
@@ -104,10 +105,10 @@
"react-dom": "17", "react-dom": "17",
"rss-parser": "^3.13.0", "rss-parser": "^3.13.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"svelte": "^5.22.6", "svelte": "^5.46.4",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vite": "^6.2.1", "vite": "^8.0.5",
"webextension-polyfill": "^0.12.0" "webextension-polyfill": "^0.12.0"
} }
} }
+17
View File
@@ -0,0 +1,17 @@
import { copyFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const pdfjsRoot = join(root, "node_modules", "pdfjs-dist");
const outDir = join(root, "src", "public", "resources", "pdfjs");
mkdirSync(outDir, { recursive: true });
copyFileSync(
join(pdfjsRoot, "build", "pdf.worker.min.mjs"),
join(outDir, "pdf.worker.min.mjs"),
);
copyFileSync(
join(pdfjsRoot, "legacy", "build", "pdf.min.mjs"),
join(outDir, "pdf.legacy.min.mjs"),
);
+10 -1
View File
@@ -50,10 +50,19 @@ if (document.childNodes[1]) {
} }
async function init() { async function init() {
if (hasSEQTAText && document.title.includes("SEQTA Learn") && !IsSEQTAPage) { if (
hasSEQTAText &&
(document.title.includes("SEQTA Learn") ||
document.title.includes("SEQTA Engage")) &&
!IsSEQTAPage
) {
IsSEQTAPage = true; IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page"); console.info("[BetterSEQTA+] Verified SEQTA Page");
if (typeof window !== "undefined" && window === window.top) {
void browser.runtime.sendMessage({ type: "cloudSettingsPoll" }).catch(() => {});
}
registerFetchSeqtaAppLinkListener(); registerFetchSeqtaAppLinkListener();
const documentLoadStyle = document.createElement("style"); const documentLoadStyle = document.createElement("style");
+175 -4
View File
@@ -1,12 +1,37 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import type { SettingsState } from "@/types/storage"; import type { SettingsState } from "@/types/storage";
import { fetchNews } from "./background/news"; import { fetchNews } from "./background/news";
import {
initCloudSettingsAutoSync,
performCloudSettingsDownloadWithRetry,
performCloudSettingsUploadWithRetry,
requestCloudSettingsDebouncedUpload,
runCloudSettingsPoll,
} from "./background/cloudSettingsAutoSync";
/**
* Session-only dev-mode override of the content API base.
*
* Stored in a module-level variable (not `chrome.storage`) so it is wiped
* automatically when the browser/service-worker process restarts. Content
* scripts re-sync this on every page load via `setDevApiBase` so the value
* survives transient service-worker terminations within the same browser
* session.
*/
const DEFAULT_API_BASE = "https://betterseqta.org";
let DEV_API_BASE: string | null = null;
function apiBase(): string {
return DEV_API_BASE ?? DEFAULT_API_BASE;
}
function reloadSeqtaPages() { function reloadSeqtaPages() {
const result = browser.tabs.query({}); const result = browser.tabs.query({});
function open(tabs: any) { function open(tabs: any) {
for (let tab of tabs) { for (let tab of tabs) {
if (tab.title.includes("SEQTA Learn")) { if (
tab.title?.includes("SEQTA Learn") ||
tab.title?.includes("SEQTA Engage")
) {
browser.tabs.reload(tab.id); browser.tabs.reload(tab.id);
} }
} }
@@ -19,7 +44,7 @@ type MessageSender = { (response?: unknown): void };
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean { function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
const { token } = request; const { token } = request;
const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`; const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`; const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -47,7 +72,7 @@ function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boo
} }
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers }) fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers })
.then((r) => r.json()) .then((r) => r.json())
.then(sendResponse) .then(sendResponse)
.catch((err) => { .catch((err) => {
@@ -124,6 +149,74 @@ function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
return true; return true;
} }
function handleCloudStartLogin(request: any, sendResponse: MessageSender): boolean {
const { client_id, redirect_uri } = request;
if (!client_id || !redirect_uri) {
sendResponse({ error: "Missing client_id or redirect_uri" });
return true;
}
const authorizeUrl = `https://accounts.betterseqta.org/login?redirect=${encodeURIComponent(`/oauth/authorize?client_id=${client_id}&redirect_uri=${encodeURIComponent(redirect_uri)}`)}`;
browser.tabs.create({ url: authorizeUrl }).then(() => {
sendResponse({ success: true });
}).catch((err) => {
console.error("[Background] cloudStartLogin error:", err);
sendResponse({ error: err?.message ?? "Failed to open login page" });
});
return true;
}
const CALLBACK_URL_PREFIX = "https://accounts.betterseqta.org/auth/bsplus/callback";
function initCloudLoginCallbackListener() {
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.url && changeInfo.url.startsWith(CALLBACK_URL_PREFIX)) {
try {
const url = new URL(changeInfo.url);
const token = url.searchParams.get("token");
const refreshToken = url.searchParams.get("refresh_token");
const userId = url.searchParams.get("user_id");
if (token && refreshToken) {
// Store tokens
void (async () => {
try {
await browser.storage.local.set({
bsplus_token: token,
bsplus_refresh_token: refreshToken,
});
// Fetch full user info
const userRes = await fetch("https://accounts.betterseqta.org/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});
if (userRes.ok) {
const user = await userRes.json();
await browser.storage.local.set({ bsplus_user: user });
} else if (userId) {
await browser.storage.local.set({ bsplus_user: { id: userId } });
}
// Trigger cloud settings download
void performCloudSettingsDownloadWithRetry(token).catch((err) => {
console.warn("[Background] Cloud settings download after login:", err);
});
} catch (err) {
console.error("[Background] Failed to process login callback:", err);
}
})();
// Close the callback tab
void browser.tabs.remove(tabId);
}
} catch (err) {
console.error("[Background] Error parsing callback URL:", err);
}
}
});
}
initCloudLoginCallbackListener();
function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean { function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
const { refresh_token, client_id } = request; const { refresh_token, client_id } = request;
if (!refresh_token || !client_id) { if (!refresh_token || !client_id) {
@@ -147,6 +240,57 @@ function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean
return true; return true;
} }
function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const res = await performCloudSettingsUploadWithRetry(token);
sendResponse({
success: res.success,
error: res.error,
updated_at: res.updated_at,
});
} catch (err) {
console.error("[Background] cloudSettingsUpload error:", err);
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Upload failed",
});
}
})();
return true;
}
function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const res = await performCloudSettingsDownloadWithRetry(token);
sendResponse({
success: res.success,
notFound: res.notFound,
error: res.error,
updated_at: res.updated_at,
});
} catch (err) {
console.error("[Background] cloudSettingsDownload error:", err);
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Download failed",
});
}
})();
return true;
}
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean { function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
const { themeId, token, action } = request; const { themeId, token, action } = request;
if (!themeId || !token) { if (!themeId || !token) {
@@ -154,7 +298,7 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean
return false; return false;
} }
const isFavorite = action === "favorite"; const isFavorite = action === "favorite";
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, { fetch(`${apiBase()}/api/themes/${themeId}/favorite`, {
method: isFavorite ? "POST" : "DELETE", method: isFavorite ? "POST" : "DELETE",
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
@@ -181,8 +325,19 @@ function isSeqtaOrigin(origin: string): boolean {
} }
} }
function handleSetDevApiBase(request: any): boolean {
const url = typeof request?.url === "string" ? request.url.trim() : null;
if (url && /^https?:\/\//.test(url)) {
DEV_API_BASE = url.replace(/\/$/, "");
} else {
DEV_API_BASE = null;
}
return false;
}
const MESSAGE_HANDLERS: Record<string, MessageHandler> = { const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
reloadTabs: () => reloadSeqtaPages(), reloadTabs: () => reloadSeqtaPages(),
setDevApiBase: handleSetDevApiBase,
extensionPages: (req) => { extensionPages: (req) => {
browser.tabs.query({}).then((tabs) => { browser.tabs.query({}).then((tabs) => {
for (const tab of tabs) { for (const tab of tabs) {
@@ -209,8 +364,19 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
fetchFromUrl: handleFetchFromUrl, fetchFromUrl: handleFetchFromUrl,
cloudReserveClient: handleCloudReserveClient, cloudReserveClient: handleCloudReserveClient,
cloudLogin: handleCloudLogin, cloudLogin: handleCloudLogin,
cloudStartLogin: handleCloudStartLogin,
cloudRefresh: handleCloudRefresh, cloudRefresh: handleCloudRefresh,
cloudFavorite: handleCloudFavorite, cloudFavorite: handleCloudFavorite,
cloudSettingsUpload: handleCloudSettingsUpload,
cloudSettingsDownload: handleCloudSettingsDownload,
cloudSettingsPoll: () => {
void runCloudSettingsPoll();
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 {
@@ -328,6 +494,9 @@ function getDefaultValues(): SettingsState {
iconOnlySidebar: false, iconOnlySidebar: false,
adaptiveThemeColour: false, adaptiveThemeColour: false,
adaptiveThemeGradient: false, adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true,
themeOfTheMonthDisabled: false,
autoCloudSettingsSync: true,
}; };
} }
@@ -345,3 +514,5 @@ browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.set({ justupdated: true }); browser.storage.local.set({ justupdated: true });
} }
}); });
initCloudSettingsAutoSync({ reloadSeqtaPages });
+420
View File
@@ -0,0 +1,420 @@
import browser from "webextension-polyfill";
import {
applyDownloadedEnvelope,
buildUploadPayload,
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
isKeyIncludedInCloudUploadPayload,
resolveThemeIdForPostSyncDownload,
setKnownRemoteUpdatedAt,
} from "@/seqta/utils/cloudSettingsSync";
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
export const CLOUD_SUMMARY_URL = `${ACCOUNTS_BASE}/api/user/cloud-summary`;
const CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`;
const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`;
const UPLOAD_DEBOUNCE_MS = 2000;
const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000;
const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll";
type CloudSummaryResponse = {
desqta?: unknown;
bsplus?: { updated_at: string; schemaVersion: number } | null;
};
let reloadSeqtaPagesFn: (() => void) | null = null;
let suppressAutoUploadDuringRestore = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let pollInFlight: Promise<void> | null = null;
function isAutoCloudSyncEnabled(all: Record<string, unknown>): boolean {
return all.autoCloudSettingsSync !== false;
}
async function parseJsonResponse(r: Response): Promise<any> {
const text = await r.text();
try {
return text ? JSON.parse(text) : {};
} catch {
return {};
}
}
async function getAccessToken(): Promise<string | null> {
const { bsplus_token } = await browser.storage.local.get("bsplus_token");
return typeof bsplus_token === "string" && bsplus_token.length > 0 ? bsplus_token : null;
}
async function tryRefreshTokens(): Promise<boolean> {
const result = await browser.storage.local.get([
"bsplus_refresh_token",
"bsplus_client_id",
"bsplus_user",
]);
const refresh_token = result.bsplus_refresh_token as string | undefined;
const client_id = result.bsplus_client_id as string | undefined;
if (!refresh_token || !client_id) return false;
try {
const r = await fetch(REFRESH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token, client_id }),
});
const data = await parseJsonResponse(r);
if (!r.ok || !data.access_token || !data.refresh_token) return false;
await browser.storage.local.set({
bsplus_token: data.access_token,
bsplus_refresh_token: data.refresh_token,
bsplus_user: data.user ?? result.bsplus_user,
});
return true;
} catch {
return false;
}
}
function isServerTimestampNewer(serverIso: string, localIso: string | undefined): boolean {
const a = Date.parse(serverIso);
if (Number.isNaN(a)) return false;
if (localIso === undefined || localIso === "") return true;
const b = Date.parse(localIso);
if (Number.isNaN(b)) return true;
return a > b;
}
async function fetchCloudSummaryOnce(
token: string,
): Promise<
| { ok: true; data: CloudSummaryResponse }
| { ok: false; unauthorized: boolean; error?: string }
> {
try {
const r = await fetch(CLOUD_SUMMARY_URL, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = (await parseJsonResponse(r)) as CloudSummaryResponse;
if (r.status === 401) return { ok: false, unauthorized: true };
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: (data as { error?: string })?.error ?? `Summary failed (${r.status})`,
};
}
return { ok: true, data };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Network error",
};
}
}
async function fetchCloudSummaryWithAuthRetry(
token: string,
): Promise<CloudSummaryResponse | null> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await fetchCloudSummaryOnce(t);
if (res.ok) return res.data;
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) break;
const next = await getAccessToken();
if (!next) break;
t = next;
continue;
}
if (res.error) console.warn("[BS+ cloud sync] cloud-summary:", res.error);
break;
}
return null;
}
type PutResult =
| { ok: true; updated_at?: string }
| { ok: false; unauthorized: boolean; error?: string };
async function putSettingsOnce(token: string): Promise<PutResult> {
try {
const all = await browser.storage.local.get();
const payload = buildUploadPayload(all as Record<string, unknown>);
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await parseJsonResponse(r);
if (r.status === 401) return { ok: false, unauthorized: true };
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: data?.error ?? `Upload failed (${r.status})`,
};
}
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Upload failed",
};
}
}
export async function performCloudSettingsUploadWithRetry(
token: string,
): Promise<{ success: boolean; error?: string; updated_at?: string }> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await putSettingsOnce(t);
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
const next = await getAccessToken();
if (!next) return { success: false, error: "Not authenticated" };
t = next;
continue;
}
return { success: false, error: res.error ?? "Upload failed" };
}
return { success: false, error: "Upload failed" };
}
type GetResult =
| { ok: true; updated_at?: string }
| { ok: false; notFound?: boolean; unauthorized: boolean; error?: string };
async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
try {
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = await parseJsonResponse(r);
if (r.status === 401) return { ok: false, unauthorized: true };
if (r.status === 404) {
return {
ok: false,
notFound: true,
unauthorized: false,
error: "No settings backup found in the cloud",
};
}
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: data?.error ?? `Download failed (${r.status})`,
};
}
const themeIdToEnsure = resolveThemeIdForPostSyncDownload(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?.();
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Download failed",
};
}
}
export async function performCloudSettingsDownloadWithRetry(
token: string,
): Promise<{ success: boolean; notFound?: boolean; error?: string; updated_at?: string }> {
suppressAutoUploadDuringRestore = true;
try {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await getSettingsAndApplyOnce(t);
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
const next = await getAccessToken();
if (!next) return { success: false, error: "Not authenticated" };
t = next;
continue;
}
return {
success: false,
notFound: res.notFound,
error: res.error ?? "Download failed",
};
}
return { success: false, error: "Download failed" };
} finally {
suppressAutoUploadDuringRestore = false;
}
}
async function maybeUploadBaseline(token: string): Promise<void> {
const res = await performCloudSettingsUploadWithRetry(token);
if (!res.success) {
console.warn("[BS+ cloud sync] Baseline upload failed:", res.error);
}
}
async function downloadIfNeeded(token: string): Promise<void> {
const res = await performCloudSettingsDownloadWithRetry(token);
if (!res.success && !res.notFound) {
console.warn("[BS+ cloud sync] Auto-download failed:", res.error);
}
}
async function runCloudSettingsPollInner(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
let token = await getAccessToken();
if (!token) return;
const summary = await fetchCloudSummaryWithAuthRetry(token);
if (!summary) return;
const bsplus = summary.bsplus;
const watermark = all[BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as string | undefined;
if (
bsplus &&
typeof bsplus.schemaVersion === "number" &&
bsplus.schemaVersion > CLOUD_SETTINGS_SYNC_SCHEMA_VERSION
) {
console.warn(
"[BS+ cloud sync] Server schemaVersion newer than client; skip auto-download",
);
return;
}
token = (await getAccessToken()) ?? token;
if (!watermark) {
if (!bsplus?.updated_at) {
await maybeUploadBaseline(token);
return;
}
await downloadIfNeeded(token);
return;
}
if (!bsplus?.updated_at) return;
if (isServerTimestampNewer(bsplus.updated_at, watermark)) {
await downloadIfNeeded(token);
}
}
export function runCloudSettingsPoll(): Promise<void> {
if (pollInFlight) return pollInFlight;
pollInFlight = (async () => {
try {
const { [POLL_THROTTLE_KEY]: last } = await browser.storage.local.get(POLL_THROTTLE_KEY);
if (Date.now() - (Number(last) || 0) < POLL_THROTTLE_MS) return;
await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() });
await runCloudSettingsPollInner();
} catch (e) {
console.error("[BS+ cloud sync] Poll error:", e);
} finally {
pollInFlight = null;
}
})();
return pollInFlight;
}
function clearUploadDebounce(): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
}
function scheduleDebouncedUpload(): void {
if (suppressAutoUploadDuringRestore) return;
clearUploadDebounce();
debounceTimer = setTimeout(() => {
debounceTimer = null;
void runDebouncedUploadJob();
}, 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> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
const token = await getAccessToken();
if (!token) return;
const res = await performCloudSettingsUploadWithRetry(token);
if (!res.success) {
console.warn("[BS+ cloud sync] Auto-upload failed:", res.error);
}
}
async function syncAutoUploadWithStorage(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) {
clearUploadDebounce();
}
}
function onStorageChanged(
changes: Record<string, browser.storage.StorageChange>,
area: string,
): void {
if (area !== "local") return;
if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) {
void syncAutoUploadWithStorage();
}
const keys = Object.keys(changes);
if (!keys.some((k) => isKeyIncludedInCloudUploadPayload(k))) return;
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();
})();
}
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
browser.storage.onChanged.addListener(onStorageChanged);
}
+476 -2
View File
@@ -1,3 +1,4 @@
@use "sass:meta"; @use "sass:meta";
@import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,600,700"); @import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,600,700");
@@ -555,6 +556,31 @@ body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) {
flex-shrink: 0 !important; flex-shrink: 0 !important;
} }
/* Engage: hide text nodes in labels via font-size trick, restore SVG size */
#menu .logo-link li > label,
#menu .logo-link section > label {
font-size: 0 !important;
justify-content: center !important;
svg {
width: 24px !important;
height: 24px !important;
font-size: initial !important;
flex-shrink: 0 !important;
margin: 0 auto !important;
}
}
/* Engage: hide chevron arrows on hasChildren items */
#menu .logo-link li > svg {
display: none !important;
}
/* Engage: hide the name/details in #userActions */
#menu #userActions .details {
display: none !important;
}
} }
[class*="notifications__items___"] { [class*="notifications__items___"] {
-ms-overflow-style: none !important; -ms-overflow-style: none !important;
@@ -1628,6 +1654,13 @@ html.transparencyEffects
box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.4); box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.4);
} }
/* Smoothed by attachNotificationsPanelAnimation (matches #ExtensionPopup spring) */
.bsplus-notifications-panel {
transform-origin: top right;
will-change: opacity, transform;
filter: drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.35));
}
#menu li.active { #menu li.active {
color: #ffffff !important; color: #ffffff !important;
background: rgba(0, 0, 0, 0.35); background: rgba(0, 0, 0, 0.35);
@@ -2577,7 +2610,7 @@ body {
[class*="MessageList__unread___"] { [class*="MessageList__unread___"] {
position: relative; position: relative;
background: rgb(228 225 225); background: var(--background-secondary, rgb(228 225 225));
} }
.dark [class*="MessageList__unread___"] { .dark [class*="MessageList__unread___"] {
@@ -2703,7 +2736,7 @@ body {
[class*="MessageList__MessageList___"] [class*="MessageList__MessageList___"]
> ol > ol
> li[class*="MessageList__selected___"] { > li[class*="MessageList__selected___"] {
background: rgb(228 225 225); background: var(--background-secondary, rgb(228 225 225));
color: var(--text-primary); color: var(--text-primary);
box-shadow: none !important; box-shadow: none !important;
position: relative; position: relative;
@@ -3296,6 +3329,88 @@ div.day-empty {
position: absolute; position: absolute;
right: 250px; right: 250px;
} }
.engage-titlebar {
right: 250px;
top: 0;
}
/* Engage parent home: same timetable DOM as Learn; title+student replace the lone h2 — give the cluster Learns h2 margin/inset. */
.timetable-container .home-subtitle > .engage-timetable-title-cluster {
align-items: center;
box-sizing: border-box;
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 0.75rem 1rem;
margin: 20px;
min-width: 0;
}
.timetable-container .engage-timetable-title-cluster > h2 {
font-size: 20px;
font-weight: 400;
margin: 0 !important;
}
#engage-home-root.home-root {
box-sizing: border-box;
min-height: 100%;
}
.engage-child-select {
background: var(--background-primary);
border: 1px solid var(--border-primary, rgba(128, 128, 128, 0.35));
border-radius: 0.5rem;
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25;
max-width: 16rem;
min-width: 10rem;
padding: 0.35rem 0.6rem;
transition: border-color 0.2s ease-in-out, color 0.2s ease-in-out;
}
.engage-child-select:focus {
outline: none;
box-shadow: 0 0 0 2px var(--background-primary), 0 0 0 4px rgba(59, 130, 246, 0.45);
}
#engage-day-container:has(> .day-empty) {
align-content: center;
display: flex;
grid-auto-columns: unset;
grid-auto-flow: unset;
justify-content: center;
min-height: 12rem;
padding: 1.5rem;
}
#engage-day-container .day-empty {
text-align: center;
}
#engage-logouttooltip {
width: 50px !important;
margin-left: -28px !important;
top: 105% !important;
.engage-logout {
background: none !important;
border: none;
cursor: pointer;
color: var(--background-primary) !important;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
svg {
fill: var(--background-primary) !important;
}
}
}
.pagename { .pagename {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -3417,6 +3532,26 @@ div.day-empty {
font-size: 1em; font-size: 1em;
color: var(--text-primary); color: var(--text-primary);
} }
.whatsnewHeader.engageParentsAnnouncementHeader {
height: auto;
min-height: unset;
}
.whatsnewHeader.engageParentsAnnouncementHeader h1 {
line-height: 1.2;
}
.whatsnewHeader.engageParentsAnnouncementHeader .engageParentsSubheading {
margin-top: 0.35rem;
font-size: 1.05rem;
font-weight: 600;
opacity: 0.92;
}
.seqtaEngageAccent {
color: #ea580c;
font-weight: 700;
}
.dark .seqtaEngageAccent {
color: #fb923c;
}
.whatsnewBackground { .whatsnewBackground {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -3545,6 +3680,282 @@ div.day-empty {
object-fit: cover; object-fit: cover;
margin-bottom: 12px; margin-bottom: 12px;
} }
.whatsnewTextContainer .engageParentsPromoWrap {
width: 100%;
margin-bottom: 12px;
border-radius: 16px;
overflow: hidden;
aspect-ratio: 16 / 9;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.28);
background: color-mix(in srgb, var(--background-secondary) 88%, var(--text-primary) 12%);
}
.whatsnewTextContainer .engageParentsPromoWrap .engageParentsPromoImg {
display: block;
width: 100%;
height: 100%;
margin: 0;
border-radius: 0;
aspect-ratio: unset;
object-fit: contain;
object-position: center;
}
.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader {
height: auto;
min-height: unset;
}
.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader h1 {
line-height: 1.2;
}
.bsCloudAccent {
color: #059669;
font-weight: 700;
}
.dark .bsCloudAccent {
color: #34d399;
}
.whatsnewTextContainer .bsCloudAutoSyncSignupCallout {
margin: 1.5rem 0 0;
padding: 1.25rem 1rem 0;
border-top: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
font-size: clamp(1.35rem, 3.8vw, 1.85rem);
font-weight: 800;
line-height: 1.35;
letter-spacing: -0.02em;
text-align: center;
color: var(--text-primary);
}
.themeOfTheMonthCard {
position: fixed;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
z-index: 48;
width: min(360px, calc(100vw - 36px));
overflow: visible;
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
border-radius: 20px;
background: var(--background-primary);
color: var(--text-primary);
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
animation: themeOfTheMonthCardIn 0.24s ease-out;
}
.themeOfTheMonthCard::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
overflow: hidden;
border-radius: inherit;
background: inherit;
}
.themeOfTheMonthCardClosing {
pointer-events: none;
animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
}
.themeOfTheMonthCardClose {
position: absolute !important;
top: 4px !important;
right: 4px !important;
z-index: 2;
width: 32px;
height: 32px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 16px !important;
background: rgba(0, 0, 0, 0.42);
color: white;
cursor: pointer;
font-size: 1.35rem;
line-height: 1;
}
.themeOfTheMonthCardImage {
display: block;
width: 100%;
height: 150px;
margin: 0;
border-radius: 20px 20px 0 0;
object-fit: cover;
}
.themeOfTheMonthCardBody {
padding: 14px 16px 16px;
}
.themeOfTheMonthCardEyebrow {
margin: 0 0 6px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--better-pri, #6366f1) 82%, var(--text-primary) 18%);
}
.themeOfTheMonthCard h2 {
margin: 0;
font-size: 1.2rem;
line-height: 1.2;
}
.themeOfTheMonthCardDescription {
display: -webkit-box;
margin: 8px 0 14px;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
font-size: 0.92rem;
line-height: 1.45;
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
}
.themeOfTheMonthCardActions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.themeOfTheMonthCardPrimary,
.themeOfTheMonthCardSecondary {
appearance: none;
border: none;
cursor: pointer;
border-radius: 9999px;
padding: 0.58rem 0.9rem;
font-size: 0.86rem;
font-weight: 700;
transition: transform 0.15s ease, filter 0.15s ease, background 0.15s ease;
}
.themeOfTheMonthCardPrimary {
background: var(--better-pri, #6366f1);
color: white;
}
.themeOfTheMonthCardSecondary {
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
color: var(--text-primary);
}
.themeOfTheMonthCardPrimary:hover,
.themeOfTheMonthCardSecondary:hover {
filter: brightness(1.08);
transform: translateY(-1px);
}
.themeOfTheMonthCardPrimary:active,
.themeOfTheMonthCardSecondary:active {
transform: translateY(0);
}
@keyframes themeOfTheMonthCardIn {
from {
opacity: 0;
transform: translateY(18px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes themeOfTheMonthCardOut {
to {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
}
@media (max-width: 900px) {
.themeOfTheMonthCard {
z-index: 2147483645;
}
}
.bsplus-theme-highlight {
animation: bsplusThemeHighlightPulse 1.4s ease-in-out 2;
}
@keyframes bsplusThemeHighlightPulse {
0%, 100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--better-pri, #6366f1) 0%, transparent);
}
50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--better-pri, #6366f1) 60%, transparent);
}
}
.popup-media-fullscreenable {
cursor: pointer;
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
}
.popup-media-fullscreenable:hover {
opacity: 0.95;
}
.popup-media-fullscreenable:focus {
outline: none;
}
.popup-media-fullscreenable:focus-visible {
outline: 2px solid color-mix(in srgb, var(--text-primary) 70%, transparent);
outline-offset: 4px;
}
.bsplus-popup-media-overlay-backdrop {
position: fixed;
inset: 0;
z-index: 2147483646;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(20px, 4vw, 48px);
box-sizing: border-box;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
opacity: 0;
transition: opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1);
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible {
opacity: 1;
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant {
transition: none;
}
.bsplus-popup-media-overlay-inner {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
max-width: min(96vw, 1320px);
max-height: calc(100vh - clamp(40px, 10vw, 96px));
border-radius: 20px;
overflow: hidden;
background: var(--background-primary);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
opacity: 0;
transform: scale(0.94) translateY(12px);
transition:
opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1),
transform 0.28s cubic-bezier(0.22, 0.03, 0.26, 1);
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible
.bsplus-popup-media-overlay-inner {
opacity: 1;
transform: scale(1) translateY(0);
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant
.bsplus-popup-media-overlay-inner {
transition: none;
}
.bsplus-popup-media-overlay-slot {
width: 100%;
max-height: inherit;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(16px, 3vw, 28px);
box-sizing: border-box;
}
.bsplus-popup-media-overlay-media {
max-width: 100%;
max-height: calc(100vh - clamp(120px, 22vh, 200px));
width: auto;
height: auto;
object-fit: contain;
border-radius: 12px;
}
@keyframes shimmer { @keyframes shimmer {
0% { 0% {
@@ -4105,3 +4516,66 @@ h2.home-subtitle {
font-size: 20px; font-size: 20px;
font-weight: 400; font-weight: 400;
} }
.bsplus-toast {
position: fixed;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
z-index: 10000;
width: min(360px, calc(100vw - 36px));
padding: 14px 16px 16px;
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
border-radius: 20px;
background: var(--background-primary, #fff);
color: var(--text-primary, #1a1a1a);
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
font-size: 0.9rem;
line-height: 1.45;
}
.bsplus-toast-eyebrow {
margin: 0 0 6px !important;
font-size: 0.72rem !important;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, #ea580c 82%, var(--text-primary) 18%);
opacity: 1 !important;
}
.dark .bsplus-toast-eyebrow {
color: color-mix(in srgb, #fb923c 82%, var(--text-primary) 18%);
}
.bsplus-toast-content strong {
display: block;
padding-right: 34px;
font-size: 1.2rem;
line-height: 1.2;
}
.bsplus-toast-content p:not(.bsplus-toast-eyebrow) {
display: -webkit-box;
margin: 8px 0 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
font-size: 0.92rem;
line-height: 1.45;
}
.bsplus-toast-close {
position: absolute !important;
top: 4px !important;
right: 4px !important;
z-index: 2;
width: 32px;
height: 32px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 16px !important;
background: rgba(0, 0, 0, 0.42);
color: white;
cursor: pointer;
font-size: 1.35rem;
line-height: 1;
transition: filter 0.15s ease;
}
.bsplus-toast-close:hover {
filter: brightness(1.08);
}
+15 -2
View File
@@ -1,7 +1,20 @@
<script lang="ts"> <script lang="ts">
let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>(); let {
onClick,
text,
disabled = false,
} = $props<{
onClick: () => void;
text: string;
disabled?: boolean;
}>();
</script> </script>
<button onclick={onClick} class='px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg'> <button
type="button"
onclick={onClick}
disabled={disabled}
class="px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
>
{text} {text}
</button> </button>
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from "svelte";
import { animate } from "motion";
import { delay } from "@/seqta/utils/delay.ts";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
const { hidePanel } = $props<{
hidePanel: () => void;
}>();
let cloudState = $state(cloudAuth.state);
let background = $state<HTMLDivElement | null>(null);
let content = $state<HTMLDivElement | null>(null);
let loginError = $state<string | null>(null);
onMount(() => {
const unsub = cloudAuth.subscribe((s) => {
cloudState = s;
});
if (background && content) {
animate(
background,
{ opacity: [0, 1] },
{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }
);
animate(
content,
{ scale: [0.4, 1], opacity: [0, 1] },
{ type: "spring", stiffness: 400, damping: 30 }
);
}
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closePanel();
};
document.addEventListener("keydown", handleEscapeKey);
return () => {
unsub();
document.removeEventListener("keydown", handleEscapeKey);
};
});
async function closePanel() {
if (!background || !content) return;
animate(
content,
{ scale: [1, 0.4], opacity: [1, 0] },
{ type: "spring", stiffness: 400, damping: 30 }
);
animate(
background,
{ opacity: [1, 0] },
{ ease: [0.4, 0, 0.2, 1] }
);
await delay(400);
hidePanel();
}
function handleBackgroundClick(event: MouseEvent) {
if (event.target === background) closePanel();
}
async function handleSignIn() {
loginError = null;
const result = await cloudAuth.startLogin();
if (result.success) {
closePanel();
} else {
loginError = result.error ?? "Failed to open login page";
}
}
async function handleLogout() {
await cloudAuth.logout();
}
function getInitials(): string {
const u = cloudState.user;
if (!u) return "?";
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
if (u.username) return u.username.slice(0, 2).toUpperCase();
if (u.email) return u.email.slice(0, 2).toUpperCase();
return "?";
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={background}
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50"
onclick={handleBackgroundClick}
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }}
>
<div
bind:this={content}
class="p-5 w-[320px] bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
>
<h3 class="text-lg font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
<p class="mt-0.5 text-sm text-zinc-500 dark:text-zinc-400">Account & sync</p>
<div class="mt-4">
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()}
</div>
{/if}
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-zinc-900 dark:text-white truncate">
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</p>
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
<p class="text-xs text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
{/if}
</div>
</div>
<button
type="button"
onclick={handleLogout}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Sign out
</button>
</div>
{:else}
<div class="flex flex-col gap-3">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Sign in to sync settings across devices, use your cloud profile picture, and more.
</p>
<button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
{#if loginError}
<p class="text-xs text-red-600 dark:text-red-400">{loginError}</p>
{/if}
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,120 @@
<script lang="ts">
import browser from "webextension-polyfill";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import Button from "./Button.svelte";
import Switch from "./Switch.svelte";
let { showDisclaimer } = $props<{
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
}>();
let cloudState = $state(cloudAuth.state);
let busy = $state(false);
let statusMessage = $state<string | null>(null);
let statusError = $state<string | null>(null);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => {
cloudState = s;
});
return unsub;
});
async function upload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsUpload",
token,
})) as { success?: boolean; error?: string };
if (res?.success) {
statusMessage = "Settings uploaded.";
} else {
statusError = res?.error ?? "Upload failed";
}
} catch (e) {
statusError = e instanceof Error ? e.message : "Upload failed";
} finally {
busy = false;
}
}
function promptDownload() {
showDisclaimer(confirmDownload, () => {});
}
async function confirmDownload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsDownload",
token,
})) as { success?: boolean; error?: string; notFound?: boolean };
if (res?.success) {
statusMessage = "Settings restored.";
} else {
statusError = res?.error ?? "Download failed";
}
} catch (e) {
statusError = e instanceof Error ? e.message : "Download failed";
} finally {
busy = false;
}
}
</script>
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-2.5">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-[11px] font-semibold text-zinc-800 dark:text-zinc-100">Automatic sync</p>
<p class="text-[10px] text-zinc-500 dark:text-zinc-400">Syncs settings when SEQTA loads and when you make changes</p>
</div>
<div class="shrink-0">
<Switch
state={$settingsState.autoCloudSettingsSync !== false}
onChange={(isOn: boolean) => (settingsState.autoCloudSettingsSync = isOn)}
/>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button
text={busy ? "Please wait\u2026" : "Upload"}
onClick={upload}
disabled={busy}
/>
<Button
text={busy ? "Please wait\u2026" : "Download"}
onClick={promptDownload}
disabled={busy}
/>
</div>
{#if statusMessage}
<p class="text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
{/if}
{#if statusError}
<p class="text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
{/if}
<p class="text-[10px] text-zinc-400 dark:text-zinc-500">
Passwords and tokens are never synced.
<a
href="https://betterseqta.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 rounded-sm"
>Privacy policy</a>
</p>
</div>
{/if}
@@ -1,29 +1,35 @@
<script lang="ts"> <script lang="ts">
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { animate } from "motion"; import { animate } from "motion";
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"; import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let { onClose } = $props<{ onClose: () => void }>(); let { onClose } = $props<{ onClose: () => void }>();
let modalElement: HTMLElement; let modalElement: HTMLElement;
onMount(() => {
return cloudAuth.subscribe((s) => {
if (s.isLoggedIn) onClose();
});
});
$effect(() => { $effect(() => {
if (modalElement) { if (modalElement) {
animate(modalElement, { scale: [0.9, 1], opacity: [0, 1] }, { type: "spring", stiffness: 300, damping: 25 }); animate(
modalElement,
{ scale: [0.9, 1], opacity: [0, 1] },
{ type: "spring", stiffness: 300, damping: 25 },
);
} }
}); });
function handleSignIn() { async function handleSignIn() {
onClose(); await cloudAuth.startLogin();
if (document.getElementById("ExtensionPopup")) {
closeExtensionPopup();
} else {
window.close();
}
} }
</script> </script>
<div <div
class="flex fixed inset-0 z-[10000] justify-center items-center bg-black/50" class="flex fixed inset-0 z-[99999] justify-center items-center bg-black/50"
onclick={(e) => { onclick={(e) => {
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
}} }}
@@ -37,7 +43,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
bind:this={modalElement} bind:this={modalElement}
class="p-4 mx-4 w-full max-w-md bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white" class="p-4 mx-4 w-full max-w-md max-h-[90vh] overflow-y-auto bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}
> >
@@ -45,32 +51,28 @@
Sign in to favorite themes Sign in to favorite themes
</h2> </h2>
<p class="mb-6 text-zinc-600 dark:text-zinc-400"> <p class="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
Sign in in the Theme Store to save favorites across devices, or create an account to get started. Sign in to the Theme Store to save favorites across devices, or create an account to get started.
</p> </p>
<div class="flex flex-wrap gap-2 justify-end"> <button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
<p class="mt-2 text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
<div class="flex justify-end mt-4">
<button <button
type="button" type="button"
onclick={onClose} onclick={onClose}
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200" class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
> >
OK Close
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
>
Create account
</a>
<button
type="button"
onclick={handleSignIn}
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in
</button> </button>
</div> </div>
</div> </div>
@@ -2,10 +2,11 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
let username = $state(""); let { alwaysShowUserName = false, onClick = undefined } = $props<{
let password = $state(""); alwaysShowUserName?: boolean;
let loading = $state(false); onClick?: () => void;
let error = $state<string | null>(null); }>();
let cloudState = $state(cloudAuth.state); let cloudState = $state(cloudAuth.state);
let open = $state(false); let open = $state(false);
let dropdownEl: HTMLElement; let dropdownEl: HTMLElement;
@@ -35,32 +36,24 @@
} }
}); });
async function handleLogin() {
if (loading) return;
error = null;
if (!username.trim() || !password) {
error = "Please enter username and password";
return;
}
loading = true;
try {
const result = await cloudAuth.login(username.trim(), password);
if (result.success) {
password = "";
open = false;
} else {
error = result.error ?? "Login failed";
}
} finally {
loading = false;
}
}
async function handleLogout() { async function handleLogout() {
await cloudAuth.logout(); await cloudAuth.logout();
open = false; open = false;
} }
async function handleSignIn() {
await cloudAuth.startLogin();
open = false;
}
function handleButtonClick() {
if (onClick) {
onClick();
} else {
open = !open;
}
}
function getInitials(): string { function getInitials(): string {
const u = cloudState.user; const u = cloudState.user;
if (!u) return "?"; if (!u) return "?";
@@ -74,31 +67,35 @@
<div class="relative flex items-center" bind:this={dropdownEl}> <div class="relative flex items-center" bind:this={dropdownEl}>
<button <button
type="button" type="button"
onclick={() => (open = !open)} onclick={handleButtonClick}
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-100/80 dark:bg-zinc-700/80 hover:bg-zinc-200/80 dark:hover:bg-zinc-600/80 transition-colors duration-200 text-base font-medium text-zinc-900 dark:text-white" class="flex items-center gap-2 px-3 py-1.5 text-[0.75rem] rounded-lg shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white transition-colors duration-200"
> >
{#if cloudState.isLoggedIn} {#if cloudState.isLoggedIn}
{#if cloudState.user?.pfpUrl} {#if cloudState.user?.pfpUrl}
<img <img
src={cloudState.user.pfpUrl} src={cloudState.user.pfpUrl}
alt="" alt=""
class="w-8 h-8 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600" class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
/> />
{:else} {:else}
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-sm"> <div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
{getInitials()} {getInitials()}
</div> </div>
{/if} {/if}
<span class="hidden max-w-24 truncate sm:inline text-base"> <span
class={alwaysShowUserName
? "inline max-w-[10rem] truncate text-[0.75rem]"
: "hidden max-w-24 truncate sm:inline text-[0.75rem]"}
>
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"} {cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</span> </span>
{:else} {:else}
<span class="text-xl font-IconFamily" aria-hidden="true">{'\ued53'}</span> <span class="text-sm font-IconFamily" aria-hidden="true">{'\ued53'}</span>
<span class="text-base font-medium">Sign in</span> <span class="text-[0.75rem]">Sign in</span>
{/if} {/if}
</button> </button>
{#if open} {#if !onClick && open}
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
@@ -142,58 +139,21 @@
</button> </button>
</div> </div>
{:else} {:else}
<p class="mb-4 text-base text-zinc-600 dark:text-zinc-400"> <div class="flex flex-col gap-3">
Sign in to favorite themes. Your favorites sync across devices when logged in. <p class="text-sm text-zinc-600 dark:text-zinc-400">
Sign in to sync favorites across devices.
</p> </p>
<form
class="flex flex-col gap-3"
autocomplete="off"
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
<input
type="text"
name="betterseqta-cloud-username"
autocomplete="off"
placeholder="Email or username"
bind:value={username}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute('readonly')}
class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
/>
<input
type="password"
name="betterseqta-cloud-password"
autocomplete="new-password"
placeholder="Password"
bind:value={password}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute('readonly')}
class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
/>
{#if error}
<p class="text-base text-red-600 dark:text-red-400">{error}</p>
{/if}
<button <button
type="submit" type="button"
disabled={loading} onclick={handleSignIn}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200" class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
> >
{loading ? "Signing in..." : "Sign in"} Sign in with BetterSEQTA Cloud
</button> </button>
<a <p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
href="https://accounts.betterseqta.org/register" Opens accounts.betterseqta.org in a new tab
target="_blank" </p>
rel="noopener noreferrer" </div>
class="inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
>
Create account
</a>
</form>
{/if} {/if}
</div> </div>
</div> </div>
@@ -0,0 +1,111 @@
<script lang="ts">
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let {
introText,
onSuccess,
compact = false,
} = $props<{
introText?: string;
onSuccess?: () => void;
/** Smaller padding/text for overlays (e.g. SignInToFavoriteModal) */
compact?: boolean;
}>();
let username = $state("");
let password = $state("");
let loading = $state(false);
let error = $state<string | null>(null);
const inputClass = $derived(
compact
? "w-full px-4 py-2 text-sm rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
: "w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200",
);
const btnClass = $derived(
compact
? "w-full px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200"
: "w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200",
);
const linkClass = $derived(
compact
? "inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
: "inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200",
);
async function handleLogin() {
if (loading) return;
error = null;
if (!username.trim() || !password) {
error = "Please enter username and password";
return;
}
loading = true;
try {
const result = await cloudAuth.login(username.trim(), password);
if (result.success) {
password = "";
onSuccess?.();
} else {
error = result.error ?? "Login failed";
}
} finally {
loading = false;
}
}
</script>
{#if introText}
<p
class="mb-4 text-zinc-600 dark:text-zinc-400 {compact ? 'text-sm' : 'text-base'}"
>
{introText}
</p>
{/if}
<form
class="flex flex-col gap-3"
autocomplete="off"
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
<input
type="text"
name="betterseqta-cloud-username"
autocomplete="off"
placeholder="Email or username"
bind:value={username}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute("readonly")}
class={inputClass}
/>
<input
type="password"
name="betterseqta-cloud-password"
autocomplete="new-password"
placeholder="Password"
bind:value={password}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute("readonly")}
class={inputClass}
/>
{#if error}
<p class="text-red-600 dark:text-red-400 {compact ? 'text-sm' : 'text-base'}">{error}</p>
{/if}
<button type="submit" disabled={loading} class={btnClass}>
{loading ? "Signing in..." : "Sign in"}
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class={linkClass}
>
Create account
</a>
</form>
@@ -1,10 +1,13 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { Theme } from '@/interface/types/Theme'; import type { ThemeCoverSlide } from '@/interface/types/Theme';
import emblaCarouselSvelte from 'embla-carousel-svelte'; import emblaCarouselSvelte from 'embla-carousel-svelte';
import Autoplay from 'embla-carousel-autoplay'; import Autoplay from 'embla-carousel-autoplay';
let { coverThemes, setDisplayTheme } = $props<{ coverThemes: Theme[], setDisplayTheme: (theme: Theme) => void }>(); let { slides, setDisplayTheme } = $props<{
slides: ThemeCoverSlide[];
setDisplayTheme: (theme: import('@/interface/types/Theme').Theme) => void;
}>();
let emblaApi = $state(); let emblaApi = $state();
const options = { loop: true }; const options = { loop: true };
@@ -12,8 +15,8 @@
Autoplay({ Autoplay({
delay: 5000, delay: 5000,
stopOnInteraction: false, stopOnInteraction: false,
stopOnMouseEnter: true stopOnMouseEnter: true,
}) }),
]; ];
function onInit(event: CustomEvent) { function onInit(event: CustomEvent) {
@@ -26,7 +29,7 @@
const slideNext = () => emblaApi?.scrollNext(); const slideNext = () => emblaApi?.scrollNext();
</script> </script>
{#if coverThemes.length > 0} {#if slides.length > 0}
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade> <div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
<div <div
class="w-full aspect-[5/1] max-h-[500px]" class="w-full aspect-[5/1] max-h-[500px]"
@@ -34,33 +37,69 @@
onemblaInit={onInit} onemblaInit={onInit}
> >
<div class="flex"> <div class="flex">
{#each coverThemes as theme} {#each slides as slide (slide.imageUrl + slide.title + (slide.subtitle ?? ''))}
<div <div
class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip" class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip"
role="button" role="button"
tabindex="0" tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }} onkeydown={(e) => {
onclick={() => setDisplayTheme(theme)} if (e.key === 'Enter') setDisplayTheme(slide.openTheme);
}}
onclick={() => setDisplayTheme(slide.openTheme)}
> >
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-full" /> <img src={slide.imageUrl} alt="" class="object-cover w-full h-full" />
<div class='absolute bottom-0 left-0 p-8 z-[1]'> {#if slide.badgeFeatured === true}
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2> <div class="absolute top-4 left-4 z-[2] pointer-events-none">
<p class='text-lg text-white'>{theme.description}</p> <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"
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">
<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>
Featured
</span>
</div> </div>
<div class='absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80'></div> {/if}
<div class="absolute bottom-0 left-0 p-8 z-[1]">
<h2 class="text-4xl font-bold text-white">{slide.title}</h2>
{#if slide.subtitle}
<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}
</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>
+105 -14
View File
@@ -1,17 +1,55 @@
<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';
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte'; import emblaCarouselSvelte from 'embla-carousel-svelte';
import Autoplay from 'embla-carousel-autoplay';
let { theme, onClick, toggleFavorite, isLoggedIn } = $props<{ 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;
/** Raw API themes (includes hidden slaves) for aggregated master download totals */
allStoreThemeRows?: Theme[];
}>(); }>();
const displayDownloadCount = $derived(
allStoreThemeRows != null
? masterGridDisplayDownloadCount(theme, allStoreThemeRows)
: (theme.download_count ?? 0),
);
const gridRotatorUrls = $derived(gridCardPreviewImageUrls(theme, allStoreThemeRows));
/** Mirrors CoverSwiper (featured bar): horizontal slides + autoplay */
function prefersReducedMotion(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/** Read once synchronously where `window` exists so reduced-motion doesnt briefly mount carousel */
let allowSlideAutoplay = $state(!prefersReducedMotion());
const gridEmblaKey = $derived(gridRotatorUrls.join('|'));
const gridEmblaOptions = $derived({ loop: gridRotatorUrls.length > 1 });
const gridEmblaPlugins = $derived.by(() => {
if (!allowSlideAutoplay || gridRotatorUrls.length <= 1) return [];
return [
Autoplay({
delay: 2000,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
];
});
let menuOpen = $state(false); let menuOpen = $state(false);
let showSignInModal = $state(false);
let menuRef: HTMLDivElement; let menuRef: HTMLDivElement;
onMount(() => { onMount(() => {
@@ -34,14 +72,36 @@
if (isLoggedIn) { if (isLoggedIn) {
toggleFavorite(theme); toggleFavorite(theme);
} else { } else {
showSignInModal = true; onRequestSignIn?.();
} }
menuOpen = false; menuOpen = false;
} }
</script> </script>
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={handleCardClick}> <div
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade> class="relative z-0 hover:z-20 w-full cursor-pointer"
role="button"
tabindex="-1"
onkeydown={onClick}
onclick={handleCardClick}
>
<div
class="bg-gray-50 w-full transition-all duration-500 ease-out relative group flex flex-col rounded-xl overflow-clip border hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto"
transition:fade
>
{#if theme.featured === true}
<div class="absolute top-2 left-2 z-20 pointer-events-none">
<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"
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">
<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>
Featured
</span>
</div>
{/if}
<!-- Menu dropdown --> <!-- Menu dropdown -->
<div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}> <div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}>
<button <button
@@ -83,12 +143,15 @@
</div> </div>
<div class="absolute bottom-1 left-3 right-3 z-10 mb-1 flex flex-col gap-0.5"> <div class="absolute bottom-1 left-3 right-3 z-10 mb-1 flex flex-col gap-0.5">
<span class="text-xl font-bold text-white drop-shadow-md">{theme.name}</span> <span class="text-xl font-bold text-white drop-shadow-md">{theme.name}</span>
{#if theme.author}
<span class="text-xs text-white/85 drop-shadow-md line-clamp-1">By {theme.author}</span>
{/if}
<div class="flex gap-3 text-xs font-medium text-white/90 drop-shadow-sm"> <div class="flex gap-3 text-xs font-medium text-white/90 drop-shadow-sm">
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<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">
@@ -99,12 +162,40 @@
</div> </div>
</div> </div>
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div> <div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
<div class='w-full'> {#if gridRotatorUrls.length === 0}
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" /> <div class="relative w-full h-48 overflow-hidden rounded-md bg-zinc-200 dark:bg-zinc-700" aria-hidden="true"></div>
{:else if !allowSlideAutoplay || gridRotatorUrls.length === 1}
<div class="relative w-full h-48 overflow-hidden rounded-md">
<img
src={gridRotatorUrls[0] ?? theme.marqueeImage ?? theme.coverImage}
alt=""
class="object-cover w-full h-full"
draggable="false"
/>
</div> </div>
{:else}
{#key gridEmblaKey}
<div
class="relative w-full h-48 overflow-hidden rounded-md"
use:emblaCarouselSvelte={{
options: gridEmblaOptions,
plugins: gridEmblaPlugins,
}}
>
<div class="flex h-full">
{#each gridRotatorUrls as url (url)}
<div class="relative flex-[0_0_100%] min-w-0 h-full shrink-0">
<img
src={url}
alt=""
class="object-cover w-full h-full select-none"
draggable="false"
/>
</div>
{/each}
</div>
</div>
{/key}
{/if}
</div> </div>
</div> </div>
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if}
@@ -2,12 +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 } = $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;
/** 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) =>
@@ -23,12 +34,14 @@
onClick={() => setDisplayTheme(theme)} onClick={() => setDisplayTheme(theme)}
{toggleFavorite} {toggleFavorite}
{isLoggedIn} {isLoggedIn}
{onRequestSignIn}
{allStoreThemeRows}
/> />
{/each} {/each}
{#if filteredThemes.length !== 0} {#if filteredThemes.length !== 0}
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='w-full cursor-pointer'> <a href="https://docs.betterseqta.org/theme-creation/" class="block relative z-0 hover:z-20 w-full cursor-pointer">
<div class="bg-zinc-50 h-48 w-full transition-all hover:scale-105 duration-500 relative justify-center items-center group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] rounded-xl overflow-clip border"> <div class="bg-zinc-50 h-48 w-full transition-all duration-500 ease-out relative overflow-clip rounded-xl border group group/card flex flex-col justify-center items-center hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1]">
<div class="text-2xl font-IconFamily">{'\uecb3'}</div> <div class="text-2xl font-IconFamily">{'\uecb3'}</div>
<div class="text-xl font-bold text-center transition-all duration-500 dark:text-white"> <div class="text-xl font-bold text-center transition-all duration-500 dark:text-white">
Got a Theme Idea? Got a Theme Idea?
@@ -43,7 +56,7 @@
<div class="absolute top-0 flex flex-col items-center justify-center w-full text-center h-96"> <div class="absolute top-0 flex flex-col items-center justify-center w-full text-center h-96">
<h1 class="mt-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl">That doesn't exist! 😭😭😭</h1> <h1 class="mt-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl">That doesn't exist! 😭😭😭</h1>
<p class="mt-6 text-lg leading-7 text-zinc-600 dark:text-zinc-300">Sorry, we couldn't find the theme you're looking for. Maybe... you could create it?</p> <p class="mt-6 text-lg leading-7 text-zinc-600 dark:text-zinc-300">Sorry, we couldn't find the theme you're looking for. Maybe... you could create it?</p>
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'> <a href="https://docs.betterseqta.org/theme-creation/" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
Show me how! Show me how!
</a> </a>
</div> </div>
File diff suppressed because it is too large Load Diff
@@ -85,7 +85,7 @@
try { try {
const result = JSON.parse(event.target?.result as string); const result = JSON.parse(event.target?.result as string);
tempTheme = result; tempTheme = result;
await themeManager.installTheme(result); await themeManager.installTheme(result, { fromStore: false });
await fetchThemes(); await fetchThemes();
} catch (error) { } catch (error) {
console.error('Error parsing file:', error); console.error('Error parsing file:', error);
+23 -11
View File
@@ -15,6 +15,7 @@
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup"; //import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
import ColourPicker from "../components/ColourPicker.svelte"; import ColourPicker from "../components/ColourPicker.svelte";
import CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte"; import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "../hooks/SettingsPopup"; import { settingsPopup } from "../hooks/SettingsPopup";
@@ -22,6 +23,8 @@
let settingsActiveTab = $state(0); let settingsActiveTab = $state(0);
let showDisclaimerModal = $state(false); let showDisclaimerModal = $state(false);
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null); let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
let disclaimerTitle = $state("Confirm");
let disclaimerMessage = $state("");
const handleDevModeToggle = () => { const handleDevModeToggle = () => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -66,15 +69,23 @@
let { standalone } = $props<{ standalone?: boolean }>(); let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state<boolean>(false); let showColourPicker = $state<boolean>(false);
let showCloudPanel = $state<boolean>(false);
const showDisclaimer = (onConfirm: () => void, onCancel: () => void) => { const openCloudPanel = () => {
showCloudPanel = true;
};
const showDisclaimer = (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => {
disclaimerCallbacks = { onConfirm, onCancel }; disclaimerCallbacks = { onConfirm, onCancel };
disclaimerTitle = title ?? "Confirm";
disclaimerMessage = message ?? "";
showDisclaimerModal = true; showDisclaimerModal = true;
}; };
onMount(() => { onMount(() => {
settingsPopup.addListener(() => { settingsPopup.addListener(() => {
showColourPicker = false; showColourPicker = false;
showCloudPanel = false;
}); });
if (standalone) { if (standalone) {
@@ -282,7 +293,7 @@
{ {
title: "Settings", title: "Settings",
Content: Settings, Content: Settings,
props: { showColourPicker: openColourPicker, showDisclaimer }, props: { showColourPicker: openColourPicker, showDisclaimer, showCloudPanel: openCloudPanel },
}, },
{ title: "Shortcuts", Content: Shortcuts }, { title: "Shortcuts", Content: Shortcuts },
{ title: "Themes", Content: Theme }, { title: "Themes", Content: Theme },
@@ -297,19 +308,20 @@
}} }}
/> />
{/if} {/if}
{#if showCloudPanel}
<CloudPanel
hidePanel={() => {
showCloudPanel = false;
}}
/>
{/if}
</div> </div>
{#if showDisclaimerModal && disclaimerCallbacks} {#if showDisclaimerModal && disclaimerCallbacks}
<DisclaimerModal <DisclaimerModal
title="Assessment Averages Disclaimer" title={disclaimerTitle}
message="This feature calculates a simple average of your assessment grades. It does not take into account: message={disclaimerMessage}
• Assessment weightings
• Different grading scales
• Other factors used in official reports
The displayed average may be inaccurate compared to your actual marks found in reports.
Do you want to enable this feature?"
onConfirm={() => { onConfirm={() => {
disclaimerCallbacks?.onConfirm(); disclaimerCallbacks?.onConfirm();
showDisclaimerModal = false; showDisclaimerModal = false;
+150 -18
View File
@@ -11,8 +11,38 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts" import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import PickerSwatch from "@/interface/components/PickerSwatch.svelte" import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte" import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
import CloudSettingsSync from "@/interface/components/CloudSettingsSync.svelte"
import CloudHeader from "@/interface/components/store/CloudHeader.svelte"
import { cloudAuth } from "@/seqta/utils/CloudAuth"
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification" import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
import { showThemeOfTheMonthPopupNow } from "@/seqta/utils/Openers/OpenThemeOfTheMonthPopup"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup" import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase"
let devApiBaseInput = $state<string>(getStoredOverride() ?? "")
let devApiBaseActive = $state<string | null>(getStoredOverride())
function applyDevApiBase() {
const trimmed = devApiBaseInput.trim()
if (trimmed === "") {
setApiBase(null)
devApiBaseActive = null
return
}
if (!/^https?:\/\//.test(trimmed)) {
alert("Please enter a full URL starting with http:// or https://")
return
}
setApiBase(trimmed)
devApiBaseActive = trimmed.replace(/\/$/, "")
}
function clearDevApiBase() {
devApiBaseInput = ""
setApiBase(null)
devApiBaseActive = null
}
import { getAllPluginSettings } from "@/plugins" import { getAllPluginSettings } from "@/plugins"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types" import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
@@ -52,6 +82,12 @@
const pluginSettings = getAllPluginSettings() as Plugin[]; const pluginSettings = getAllPluginSettings() as Plugin[];
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({}); const pluginSettingsValues = $state<Record<string, Record<string, any>>>({});
let cloudState = $state(cloudAuth.state);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => { cloudState = s; });
return unsub;
});
async function loadPluginSettings() { async function loadPluginSettings() {
for (const plugin of pluginSettings) { for (const plugin of pluginSettings) {
if (Object.keys(plugin.settings).length === 0) continue; if (Object.keys(plugin.settings).length === 0) continue;
@@ -93,10 +129,24 @@
loadPluginSettings(); loadPluginSettings();
}) })
const { showColourPicker, showDisclaimer } = $props<{ const { showColourPicker, showDisclaimer, showCloudPanel } = $props<{
showColourPicker: () => void; showColourPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void; showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void;
showCloudPanel: () => void;
}>(); }>();
async function exportCloudSettingsJsonToFile() {
const payload = await getSnapshotForUpload();
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `betterseqta-plus-settings-export-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
a.click();
URL.revokeObjectURL(url);
}
</script> </script>
{#snippet Setting({ title, description, Component, props }: SettingsList) } {#snippet Setting({ title, description, Component, props }: SettingsList) }
@@ -181,22 +231,22 @@
}, },
{ {
title: "Default Page", title: "Default Page",
description: "The page to load when SEQTA Learn is opened", description: "Choose which page loads first when you open SEQTA",
id: 10, id: 10,
Component: Select, Component: Select,
props: { props: {
state: $settingsState.defaultPage, state: $settingsState.defaultPage ?? "home",
onChange: (value: string) => settingsState.defaultPage = value, onChange: (value: string) => (settingsState.defaultPage = value),
options: [ options: [
{ value: 'home', label: 'Home' }, { value: "home", label: "Home" },
{ value: 'dashboard', label: 'Dashboard' }, { value: "dashboard", label: "Dashboard" },
{ value: 'timetable', label: 'Timetable' }, { value: "timetable", label: "Timetable" },
{ value: 'welcome', label: 'Welcome' }, { value: "welcome", label: "Welcome" },
{ value: 'messages', label: 'Messages' }, { value: "messages", label: "Messages" },
{ value: 'documents', label: 'Documents' }, { value: "documents", label: "Documents" },
{ value: 'reports', label: 'Reports' }, { value: "reports", label: "Reports" },
] ],
} },
}, },
{ {
title: "News Feed Source", title: "News Feed Source",
@@ -252,6 +302,18 @@
/> />
</div> </div>
</div> </div>
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
<div class="pr-4">
<h2 class="text-sm font-bold">Smooth colour transition</h2>
<p class="text-xs">Ease between class/subject colours when navigating instead of switching instantly</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeColourTransition ?? true}
onChange={(isOn: boolean) => settingsState.adaptiveThemeColourTransition = isOn}
/>
</div>
</div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -282,8 +344,9 @@
async () => { async () => {
await updatePluginSetting(plugin.pluginId, 'enabled', true); await updatePluginSetting(plugin.pluginId, 'enabled', true);
}, },
() => { () => {},
} "Assessment Averages Disclaimer",
"This feature calculates a simple average of your assessment grades. It does not take into account:\n• Assessment weightings\n• Different grading scales\n• Other factors used in official reports\n\nThe displayed average may be inaccurate compared to your actual marks found in reports.\n\nDo you want to enable this feature?"
); );
return; return;
} }
@@ -296,8 +359,8 @@
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)} {#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]} {#each Object.entries(plugin.settings) as [key, setting]}
<!-- Skip the 'enabled' setting if it's part of the settings object --> <!-- Skip the 'enabled' setting and hide cloud-only settings when not signed in -->
{#if key !== 'enabled'} {#if key !== 'enabled' && !(key === 'useCloudPfp' && !cloudState.isLoggedIn)}
<div class="flex justify-between items-center px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
<div class="pr-4"> <div class="pr-4">
<h2 class="text-sm font-bold">{setting.title || key}</h2> <h2 class="text-sm font-bold">{setting.title || key}</h2>
@@ -360,6 +423,25 @@
</div> </div>
{/each} {/each}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">BetterSEQTA Cloud</h2>
<p class="text-xs">Account & sync</p>
</div>
<div>
<CloudHeader alwaysShowUserName onClick={showCloudPanel} />
</div>
</div>
{#if cloudState.isLoggedIn}
<div class="px-3 pb-3">
<CloudSettingsSync showDisclaimer={(onConfirm, onCancel) => showDisclaimer(onConfirm, onCancel, "Restore from cloud?", "This will replace your local settings with the cloud backup. Continue?")} />
</div>
{/if}
</div>
</div>
<div class="p-1 border-none"></div> <div class="p-1 border-none"></div>
{@render Setting({ {@render Setting({
@@ -427,6 +509,56 @@
/> />
</div> </div>
</div> </div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Show Theme of the Month</h2>
<p class="text-xs">Fetch and show the current month's popup now (ignores dismissed state)</p>
</div>
<div>
<Button
onClick={async () => {
closeExtensionPopup();
await new Promise((resolve) => setTimeout(resolve, 100));
await showThemeOfTheMonthPopupNow();
}}
text="Show Now"
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
<p class="text-xs">Download the same payload as cloud sync (OAuth tokens stripped). For debugging and server testing.</p>
</div>
<div>
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
</div>
</div>
<div class="flex flex-col gap-2 px-4 py-3">
<div class="flex justify-between items-start gap-3">
<div class="pr-4">
<h2 class="text-sm font-bold">API Base URL (session only)</h2>
<p class="text-xs">Override the content API host for this browser session. Cleared on restart. Affects themes, theme of the month, and other server-driven content.</p>
{#if devApiBaseActive}
<p class="text-xs mt-1 text-amber-600 dark:text-amber-400">
Override active: <span class="font-mono">{devApiBaseActive}</span>
</p>
{/if}
</div>
</div>
<div class="flex gap-2 items-center">
<input
type="text"
placeholder="https://betterseqta.org"
bind:value={devApiBaseInput}
class="flex-1 px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
/>
<Button onClick={applyDevApiBase} text="Apply" />
{#if devApiBaseActive}
<Button onClick={clearDevApiBase} text="Clear" />
{/if}
</div>
</div>
</div> </div>
{/if} {/if}
</div> </div>
+93 -26
View File
@@ -7,6 +7,7 @@
import SkeletonLoader from '../components/SkeletonLoader.svelte'; import SkeletonLoader from '../components/SkeletonLoader.svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState' import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import type { Theme } from '../types/Theme' import type { Theme } from '../types/Theme'
import { visibleStoreThemes, buildCoverSlidesForThemes } from '@/interface/utils/themeStoreFlavours'
import browser from 'webextension-polyfill' import browser from 'webextension-polyfill'
import ThemeModal from '../components/store/ThemeModal.svelte' import ThemeModal from '../components/store/ThemeModal.svelte'
import Header from '../components/store/Header.svelte' import Header from '../components/store/Header.svelte'
@@ -16,6 +17,8 @@
import { loadBackground } from '@/seqta/ui/ImageBackgrounds' import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
import Backgrounds from '../components/store/Backgrounds.svelte' import Backgrounds from '../components/store/Backgrounds.svelte'
import { cloudAuth } from '@/seqta/utils/CloudAuth' import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
import { consumePendingHighlightThemeId } from '@/seqta/utils/openThemeStoreWithHighlight'
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn); let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
@@ -25,7 +28,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);
@@ -34,6 +42,7 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let selectedBackground = $state<string | null>(null); let selectedBackground = $state<string | null>(null);
let showSignInOverlay = $state(false);
const fetchCurrentThemes = async () => { const fetchCurrentThemes = async () => {
const themes = await themeManager.getAvailableThemes(); const themes = await themeManager.getAvailableThemes();
@@ -52,6 +61,17 @@
activeTab = tab; activeTab = tab;
}; };
/** Featured themes first; within each group, newest by `created_at` (API: Unix seconds). */
function compareStoreThemes(a: Theme, b: Theme): number {
const fa = a.featured === true ? 1 : 0;
const fb = b.featured === true ? 1 : 0;
if (fa !== fb) return fb - fa;
const ca = a.created_at ?? 0;
const cb = b.created_at ?? 0;
if (ca !== cb) return cb - ca;
return a.name.localeCompare(b.name);
}
const toggleFavorite = async (theme: Theme) => { const toggleFavorite = async (theme: Theme) => {
const token = await cloudAuth.getStoredToken(); const token = await cloudAuth.getStoredToken();
if (!token) return; if (!token) return;
@@ -94,11 +114,7 @@
if (!data?.success || !data?.data?.themes) { if (!data?.success || !data?.data?.themes) {
throw new Error(data?.error || 'Failed to fetch themes'); throw new Error(data?.error || 'Failed to fetch themes');
} }
themes = data.data.themes; themes = [...data.data.themes].sort(compareStoreThemes);
// Shuffle for cover themes
const shuffled = [...themes].sort(() => 0.5 - Math.random());
coverThemes = shuffled.slice(0, 3);
loading = false; loading = false;
} catch (err) { } catch (err) {
@@ -107,20 +123,72 @@
} }
}; };
function focusThemeById(themeId: string) {
const match = themes.find((t) => t.id === themeId)
?? themes.find((t) => t.flavours?.some((f) => f.id === themeId));
if (match) {
activeTab = 'themes';
searchTerm = '';
displayTheme = match;
}
}
function onHighlightThemeEvent(e: Event) {
const detail = (e as CustomEvent).detail;
if (detail?.themeId && typeof detail.themeId === 'string') {
focusThemeById(detail.themeId);
}
}
// On mount // On mount
onMount(async () => { onMount(async () => {
window.addEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
await fetchThemes(); await fetchThemes();
await fetchCurrentThemes(); await fetchCurrentThemes();
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true'; darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
darkMode = $settingsState.DarkMode; darkMode = $settingsState.DarkMode;
const pending = consumePendingHighlightThemeId();
if (pending) focusThemeById(pending);
return () => {
window.removeEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
};
}); });
// Filter themes based on search term // Filter themes (list is already featured-first, then newest; filter preserves order)
let filteredThemes = $derived(themes.filter(theme => let filteredThemes = $derived(
listThemes.filter(
(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();
@@ -159,42 +227,37 @@
<!-- 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}
isLoggedIn={cloudLoggedIn} isLoggedIn={cloudLoggedIn}
onRequestSignIn={() => (showSignInOverlay = true)}
/> />
{#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}
onInstall={async () => { onRequestSignIn={() => (showSignInOverlay = true)}
if (displayTheme) { onInstall={async (themeId: string) => {
await themeManager.downloadTheme(displayTheme); if (displayTheme) await installThemeFromStore(themeId, 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}
@@ -204,4 +267,8 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if showSignInOverlay}
<SignInToFavoriteModal onClose={() => (showSignInOverlay = false)} />
{/if}
</div> </div>
+57 -10
View File
@@ -4,7 +4,10 @@
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { type LoadedCustomTheme } from '@/types/CustomThemes' import {
type LoadedCustomTheme,
shouldForceThemeAppearance,
} from '@/types/CustomThemes'
import { settingsState } from '@/seqta/utils/listeners/SettingsState' import { settingsState } from '@/seqta/utils/listeners/SettingsState'
@@ -21,9 +24,9 @@
handleImageVariableChange, handleImageVariableChange,
handleCoverImageUpload handleCoverImageUpload
} from '../utils/themeImageHandlers'; } from '../utils/themeImageHandlers';
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager' import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
const { themeID } = $props<{ themeID: string }>() const { themeID } = $props<{ themeID: string }>()
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
@@ -40,7 +43,9 @@
coverImage: null, coverImage: null,
isEditable: true, isEditable: true,
hideThemeName: false, hideThemeName: false,
forceDark: undefined forceTheme: undefined,
forceDark: undefined,
adaptiveCssVariables: [],
}) })
let closedAccordions = $state<string[]>([]) let closedAccordions = $state<string[]>([])
let themeLoaded = $state(false); let themeLoaded = $state(false);
@@ -80,7 +85,13 @@
})) }))
} }
theme = loadedTheme theme = {
...loadedTheme,
adaptiveCssVariables: loadedTheme.adaptiveCssVariables ?? [],
forceTheme:
loadedTheme.forceTheme ??
(loadedTheme.forceDark !== undefined ? true : undefined),
}
themeLoaded = true themeLoaded = true
} else { } else {
themeLoaded = true themeLoaded = true
@@ -114,6 +125,14 @@
blob: image.blob blob: image.blob
})) }))
themeClone.coverImage = theme.coverImage themeClone.coverImage = theme.coverImage
themeClone.userEdited = true
if (shouldForceThemeAppearance(themeClone)) {
themeClone.forceTheme = true;
} else {
themeClone.forceTheme = false;
themeClone.forceDark = undefined;
}
themeManager.clearPreview(); themeManager.clearPreview();
await themeManager.saveTheme(themeClone); await themeManager.saveTheme(themeClone);
@@ -270,7 +289,7 @@
<h1 class='text-xl font-semibold'>Theme Creator</h1> <h1 class='text-xl font-semibold'>Theme Creator</h1>
<a href='https://betterseqta.gitbook.io/betterseqta-docs' target='_blank' class='text-sm font-light text-zinc-500 dark:text-zinc-400'> <a href='https://docs.betterseqta.org/theme-creation/' target='_blank' rel='noopener noreferrer' class='text-sm font-light text-zinc-500 dark:text-zinc-400'>
<span class='pr-0.5 no-underline font-IconFamily'>{'\ueb44'}</span> <span class='pr-0.5 no-underline font-IconFamily'>{'\ueb44'}</span>
<span class='underline'> <span class='underline'>
Need help? Check out the docs! Need help? Check out the docs!
@@ -317,6 +336,27 @@
<Divider /> <Divider />
<div class="py-3">
<h2 class="text-sm font-bold">Adaptive CSS variables</h2>
<p class="text-xs text-zinc-600 dark:text-zinc-400">
One per line, each must start with <code class="text-xs">--</code>. These receive the same colour as the adaptive accent when &quot;Adaptive theme colour&quot; is enabled in general settings. Use them in Custom CSS, e.g. <code class="text-xs">border-color: var(--my-accent);</code>
</p>
<textarea
placeholder="--my-accent&#10;--class-banner"
value={theme.adaptiveCssVariables?.join('\n') ?? ''}
oninput={(e) => {
const lines = e.currentTarget.value
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
theme = { ...theme, adaptiveCssVariables: lines };
}}
class="p-2 mt-2 w-full min-h-[5rem] font-mono text-sm rounded-lg border-0 transition dark:placeholder-zinc-400 bg-zinc-200 dark:bg-zinc-700 focus:outline-none focus:ring-1 focus:ring-zinc-100 dark:focus:ring-zinc-700 focus:bg-zinc-300/50 dark:focus:bg-zinc-600"
></textarea>
</div>
<Divider />
{#each [ {#each [
{ {
type: 'switch', type: 'switch',
@@ -332,21 +372,28 @@
title: 'Force Theme', title: 'Force Theme',
description: 'Force users to use either dark or light mode', description: 'Force users to use either dark or light mode',
props: { props: {
state: theme.forceDark !== undefined, state: shouldForceThemeAppearance(theme),
onChange: (value: boolean) => theme = { ...theme, forceDark: value ? false : undefined } onChange: (value: boolean) => {
if (value) {
theme = { ...theme, forceTheme: true, forceDark: false };
} else {
theme = { ...theme, forceTheme: false, forceDark: undefined };
}
}
} }
}, },
{ {
type: 'conditional', type: 'conditional',
props: { props: {
condition: theme.forceDark !== undefined, condition: shouldForceThemeAppearance(theme),
children: { children: {
type: 'lightDarkToggle', type: 'lightDarkToggle',
title: 'Mode', title: 'Mode',
description: 'Choose whether to force light or dark mode', description: 'Choose whether to force light or dark mode',
props: { props: {
state: theme.forceDark === true, state: theme.forceDark === true,
onChange: (value: boolean) => theme = { ...theme, forceDark: value } onChange: (value: boolean) =>
(theme = { ...theme, forceDark: value, forceTheme: true })
} }
} }
} }
+39
View File
@@ -1,3 +1,17 @@
export type ThemeRole = "standard" | "master" | "slave";
/** List/detail metadata for variants of a master theme (full theme.json fetched at install by id). */
export type ThemeFlavour = {
id: string;
name: string;
/** Mirrors theme.json accent (e.g. defaultColour); used for install picker buttons */
accent_color: string;
cover_image: string;
marquee_image?: string;
/** Per-variant installs when slaves are not returned as flat `theme_role` rows */
download_count?: number;
};
export type Theme = { export type Theme = {
id: string; id: string;
name: string; name: string;
@@ -8,4 +22,29 @@ export type Theme = {
is_favorited?: boolean; is_favorited?: boolean;
favorite_count?: number; favorite_count?: number;
download_count?: number; download_count?: number;
author?: string;
featured?: boolean;
tags?: string[];
/** Unix time in seconds (API list/detail). */
created_at?: number;
/** Unix seconds — last server update (GET /api/themes). */
updated_at?: number;
/** Omitted / `standard` — show in grid. `slave` hides from grid. `master` can list `flavours`. */
theme_role?: ThemeRole;
/** Present when `theme_role === "slave"` and API returns a flat list during migration */
master_id?: string;
/** Variants nested on master rows; installs use flavour `id` */
flavours?: ThemeFlavour[];
};
/** One marquee slide (cover hero or modal carousel). */
export type ThemeCoverSlide = {
imageUrl: string;
/** Main line — usually master name */
title: string;
/** Subline — flavour name when applicable */
subtitle?: string;
/** Opening the modal uses this theme (always the grid row / master object) */
openTheme: Theme;
badgeFeatured?: boolean;
}; };
+165
View File
@@ -0,0 +1,165 @@
import type { Theme, ThemeCoverSlide, ThemeFlavour } from "@/interface/types/Theme";
export function isHiddenStoreTheme(theme: Theme): boolean {
return theme.theme_role === "slave";
}
/** Grid and search: omit slave rows (when API sends a flattened list). */
export function visibleStoreThemes(themes: Theme[]): Theme[] {
return themes.filter((t) => !isHiddenStoreTheme(t));
}
function marqueeOrCoverUrl(t: { marqueeImage?: string; coverImage: string }): string {
return t.marqueeImage || t.coverImage;
}
/**
* Builds slides for CoverSwiper: for each top-N master, first master image then each flavour image.
*/
export function buildCoverSlidesForThemes(topThemes: Theme[]): ThemeCoverSlide[] {
const slides: ThemeCoverSlide[] = [];
for (const theme of topThemes) {
const flavours = theme.flavours ?? [];
if (flavours.length === 0) {
slides.push({
imageUrl: marqueeOrCoverUrl(theme),
title: theme.name,
subtitle: theme.author ? `By ${theme.author}` : undefined,
openTheme: theme,
badgeFeatured: theme.featured === true,
});
continue;
}
slides.push({
imageUrl: marqueeOrCoverUrl(theme),
title: theme.name,
subtitle: theme.author ? `By ${theme.author}` : undefined,
openTheme: theme,
badgeFeatured: theme.featured === true,
});
for (const f of flavours) {
slides.push({
imageUrl: f.marquee_image || f.cover_image,
title: theme.name,
subtitle: f.name,
openTheme: theme,
badgeFeatured: theme.featured === true,
});
}
}
return slides;
}
export type ModalHeroSlide = { imageUrl: string; caption: string };
/** Preview image for carousel + flavour picker (matches hero slide order after master slide). */
export function flavourCarouselImageUrl(f: ThemeFlavour): string {
const u = (f.marquee_image || f.cover_image || "").trim();
return u;
}
/** Preview image for master variant tile (modal hero slide 0). */
export function masterCarouselImageUrl(t: Theme): string {
return (marqueeOrCoverUrl(t) || "").trim();
}
/**
* Ordered preview URLs for the store grid card rotator: master first, then each variant.
* Uses nested `flavours` when present; otherwise flat `slave` rows (same order as modal when possible).
*/
export function gridCardPreviewImageUrls(theme: Theme, allStoreRows?: Theme[]): string[] {
const urls: string[] = [];
const push = (raw: string) => {
const u = raw.trim();
if (u && !urls.includes(u)) urls.push(u);
};
push(marqueeOrCoverUrl(theme) || "");
const flavours = theme.flavours ?? [];
if (flavours.length > 0) {
for (const f of flavours) {
push(flavourCarouselImageUrl(f));
}
return urls.length > 0 ? urls : [(theme.coverImage || "").trim()].filter(Boolean);
}
if (allStoreRows) {
const slaves = allStoreRows
.filter((t) => t.theme_role === "slave" && t.master_id === theme.id)
.sort((a, b) => a.id.localeCompare(b.id));
for (const s of slaves) {
push((marqueeOrCoverUrl(s) || "").trim());
}
}
if (urls.length > 0) return urls;
const fallback = (theme.coverImage || "").trim();
return fallback ? [fallback] : [];
}
/**
* Downloads shown on the grid card for a master row: master's count plus each slave
* (flat `theme_role === "slave"` with `master_id`) and any flavour-only `download_count`
* when there is no matching flat slave id (nested-only API shape).
*/
export function masterGridDisplayDownloadCount(master: Theme, allStoreRows: Theme[]): number {
let total = master.download_count ?? 0;
const slaveRows = allStoreRows.filter(
(t) => t.theme_role === "slave" && t.master_id === master.id,
);
const countedIds = new Set(slaveRows.map((r) => r.id));
for (const r of slaveRows) {
total += r.download_count ?? 0;
}
for (const f of master.flavours ?? []) {
if (countedIds.has(f.id)) continue;
total += f.download_count ?? 0;
}
return total;
}
/** Modal hero: master first, then each flavour (plan order). */
export function buildModalHeroSlides(theme: Theme): ModalHeroSlide[] {
const slides: ModalHeroSlide[] = [];
slides.push({
imageUrl: marqueeOrCoverUrl(theme),
caption: theme.name,
});
const flavours = theme.flavours ?? [];
for (const f of flavours) {
slides.push({
imageUrl: f.marquee_image || f.cover_image,
caption: `${theme.name}${f.name}`,
});
}
return slides;
}
/**
* Relative luminance 01 for rgba/rgb/hex-ish strings; fallback 0.5 white text
*/
export function pickContrastingTextColor(backgroundCss: string): "#ffffff" | "#0a0a0a" {
const s = backgroundCss.trim();
const rgba = s.match(
/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i,
);
if (rgba) {
const r = Number(rgba[1]) / 255;
const g = Number(rgba[2]) / 255;
const b = Number(rgba[3]) / 255;
const a = rgba[4] !== undefined ? Number(rgba[4]) : 1;
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const effective = lum * a + 0.05 * (1 - a);
return effective > 0.45 ? "#0a0a0a" : "#ffffff";
}
const hex = s.match(/^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
if (hex) {
const r = parseInt(hex[1], 16) / 255;
const g = parseInt(hex[2], 16) / 255;
const b = parseInt(hex[3], 16) / 255;
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return lum > 0.45 ? "#0a0a0a" : "#ffffff";
}
return "#ffffff";
}
+27
View File
@@ -0,0 +1,27 @@
import * as pdfjs from "pdfjs-dist";
import browser from "webextension-polyfill";
/** Static copies in `src/public` (see `scripts/copy-pdfjs-assets.mjs`, manifest web_accessible_resources). */
const PDF_WORKER_RESOURCE = "resources/pdfjs/pdf.worker.min.mjs";
const PDF_LEGACY_RESOURCE = "resources/pdfjs/pdf.legacy.min.mjs";
function extensionAssetUrl(relativePath: string): string {
return browser.runtime.getURL(relativePath.replace(/^\/+/, ""));
}
let workerConfigured = false;
/** Required before pdfjs spawns a worker (content-script / extension isolate). */
export function ensurePdfjsWorker(): void {
if (workerConfigured) return;
pdfjs.GlobalWorkerOptions.workerSrc = extensionAssetUrl(PDF_WORKER_RESOURCE);
workerConfigured = true;
}
/** Page-context script on Firefox must load these chrome-extension:// URLs (see web_accessible_resources). */
export function getPdfjsPageContextUrls(): { lib: string; worker: string } {
return {
lib: extensionAssetUrl(PDF_LEGACY_RESOURCE),
worker: extensionAssetUrl(PDF_WORKER_RESOURCE),
};
}
+6 -1
View File
@@ -32,7 +32,12 @@
], ],
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": ["resources/icons/*", "resources/update-image.webp"], "resources": [
"resources/icons/*",
"resources/update-image.webp",
"resources/pdfjs/pdf.worker.min.mjs",
"resources/pdfjs/pdf.legacy.min.mjs"
],
"matches": ["*://*/*"] "matches": ["*://*/*"]
} }
] ]
@@ -7,11 +7,11 @@ import {
import { type Plugin } from "@/plugins/core/types"; import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML"; import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import { import {
clearStuck, clearStuck,
getClassByPattern, getClassByPattern,
initStorage, initStorage,
injectWeightingsTab,
letterToNumber, letterToNumber,
parseAssessments, parseAssessments,
processAssessments, processAssessments,
@@ -20,6 +20,7 @@ import {
interface weightingsStorage { interface weightingsStorage {
weightings: Record<string, string>; weightings: Record<string, string>;
assessments: Record<string, string>; assessments: Record<string, string>;
weightingOverrides: Record<string, string>;
} }
const settings = defineSettings({ const settings = defineSettings({
@@ -37,6 +38,8 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
const instance = new AssessmentsAveragePluginClass(); const instance = new AssessmentsAveragePluginClass();
let overrideListenerController: AbortController | null = null;
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = { const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
id: "assessments-average", id: "assessments-average",
name: "Assessment Averages", name: "Assessment Averages",
@@ -58,17 +61,56 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
); );
await parseAssessments(api); await parseAssessments(api);
await renderSubjectAverage(api);
overrideListenerController?.abort();
overrideListenerController = new AbortController();
document.addEventListener(
"betterseqta:overrideChanged",
() => renderSubjectAverage(api),
{ signal: overrideListenerController.signal },
);
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
const observer = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
}
});
api.seqta.onMount("[class*='SelectedAssessment__']", () => {
injectWeightingsTab(api);
});
},
};
let renderInFlight = false;
async function renderSubjectAverage(api: any) {
if (renderInFlight) return;
renderInFlight = true;
try {
const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
if (!assessmentsList) return;
// Remove existing subject average before re-rendering
Array.from(
assessmentsList.querySelectorAll(`[class*='AssessmentItem__title___']`),
)
.find((el) => el.textContent === "Subject Average")
?.closest("[class*='AssessmentItem__AssessmentItem___']")
?.remove();
const sampleAssessmentItem = document.querySelector( const sampleAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___']", "[class*='AssessmentItem__AssessmentItem___']",
); );
if (!sampleAssessmentItem) return; if (!sampleAssessmentItem) return;
const assessmentItemClass = const assessmentItemClass =
Array.from(sampleAssessmentItem.classList).find((c) => Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"), c.startsWith("AssessmentItem__AssessmentItem___"),
) || ""; ) || "";
const metaContainerClass = getClassByPattern( const metaContainerClass = getClassByPattern(
sampleAssessmentItem, sampleAssessmentItem,
"AssessmentItem__metaContainer___", "AssessmentItem__metaContainer___",
@@ -86,35 +128,6 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
"AssessmentItem__title___", "AssessmentItem__title___",
); );
const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
if (!thermoscoreElement) return;
const thermoscoreClass =
Array.from(thermoscoreElement.classList).find((c) =>
c.startsWith("Thermoscore__Thermoscore___"),
) || "";
const fillClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__fill___",
);
const textClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__text___",
);
const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
if (!assessmentsList) return;
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
if (!marks || !marks.length) return;
const assessmentItems = Array.from( const assessmentItems = Array.from(
assessmentsList.querySelectorAll( assessmentsList.querySelectorAll(
`[class*='AssessmentItem__AssessmentItem___']`, `[class*='AssessmentItem__AssessmentItem___']`,
@@ -128,9 +141,25 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } = const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
await processAssessments(api, assessmentItems); await processAssessments(api, assessmentItems);
if (!count || totalWeight === 0) return; if (!count || totalWeight === 0) return;
const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
if (!thermoscoreElement) return;
const thermoscoreClass =
Array.from(thermoscoreElement.classList).find((c) =>
c.startsWith("Thermoscore__Thermoscore___"),
) || "";
const fillClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__fill___",
);
const textClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__text___",
);
const avg = weightedTotal / totalWeight; const avg = weightedTotal / totalWeight;
const rounded = Math.ceil(avg / 5) * 5; const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce( const numberToLetter = Object.entries(letterToNumber).reduce(
@@ -140,17 +169,8 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
}, },
{} as Record<number, string>, {} as Record<number, string>,
); );
const letterAvg = numberToLetter[rounded] ?? "N/A"; const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
? letterAvg
: `${avg.toFixed(2)}%`;
const existing = assessmentsList.querySelector(
`[class*='AssessmentItem__title___']`,
);
if (existing?.textContent === "Subject Average") return;
let warningHTML = ""; let warningHTML = "";
if (hasInaccurateWeighting) { if (hasInaccurateWeighting) {
warningHTML = /* html */ ` warningHTML = /* html */ `
@@ -159,7 +179,6 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
</div> </div>
`; `;
} }
assessmentsList.insertBefore( assessmentsList.insertBefore(
stringToHTML(/* html */ ` stringToHTML(/* html */ `
<div class="${assessmentItemClass}"> <div class="${assessmentItemClass}">
@@ -180,21 +199,11 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
`).firstChild!, `).firstChild!,
assessmentsList.firstChild, assessmentsList.firstChild,
); );
applySubjectColourToOverallResult(); applySubjectColourToOverallResult();
} finally {
const observer = new MutationObserver(() => { renderInFlight = false;
applySubjectColourToOverallResult();
});
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
} }
}); }
},
};
function applySubjectColourToOverallResult() { function applySubjectColourToOverallResult() {
const selectedAssessmentItem = document.querySelector( const selectedAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']", "[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
+374 -48
View File
@@ -1,8 +1,12 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts"; import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
import ReactFiber from "@/seqta/utils/ReactFiber.ts"; import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import {
ensurePdfjsWorker,
getPdfjsPageContextUrls,
} from "@/lib/pdfjsExtension.ts";
import * as pdfjs from "pdfjs-dist"; import * as pdfjs from "pdfjs-dist";
pdfjs.GlobalWorkerOptions.workerSrc =
`https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; ensurePdfjsWorker();
export async function initStorage(api: any) { export async function initStorage(api: any) {
await api.storage.loaded; await api.storage.loaded;
@@ -13,6 +17,9 @@ export async function initStorage(api: any) {
if (!api.storage.assessments) { if (!api.storage.assessments) {
api.storage.assessments = {}; api.storage.assessments = {};
} }
if (!api.storage.weightingOverrides) {
api.storage.weightingOverrides = {};
}
} }
export function clearStuck(api: any) { export function clearStuck(api: any) {
@@ -75,47 +82,84 @@ function createWeightLabel(
assessmentItem: Element, assessmentItem: Element,
weighting: string | undefined, weighting: string | undefined,
) { ) {
const statsContainer = assessmentItem.querySelector( let statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___']`, `[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
) as HTMLElement; ) as HTMLElement | null;
if ( if (!statsContainer) {
!statsContainer || const statsClass = getClassByPattern(document, "AssessmentItem__stats___");
statsContainer.querySelector(".betterseqta-weight-label") statsContainer = document.createElement("div");
) statsContainer.className = statsClass;
return; statsContainer.classList.add("betterseqta-stats-container");
const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`);
if (thermoscore) {
thermoscore.insertAdjacentElement("afterend", statsContainer);
} else {
assessmentItem.appendChild(statsContainer);
}
}
const label = statsContainer.querySelector( const hasNativeLabel = !!statsContainer.querySelector(
`[class*='Label__Label___']`, `[class*='Label__Label___']:not(.betterseqta-weight-label)`,
) as HTMLElement;
if (!label) return;
const weightLabel = label.cloneNode(true) as HTMLElement;
weightLabel.classList.add("betterseqta-weight-label");
const innerTextDiv = weightLabel.querySelector(
`[class*='Label__innerText___']`,
); );
statsContainer.style.justifyContent = hasNativeLabel
? "space-between"
: "flex-end";
const displayText =
weighting && weighting !== "processing" && weighting !== "N/A"
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
: "N/A";
const existingLabel = statsContainer.querySelector(
".betterseqta-weight-label",
) as HTMLElement | null;
if (existingLabel) {
const textNodes = Array.from(existingLabel.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE,
);
if (textNodes.length) textNodes[0].textContent = displayText;
return;
}
statsContainer.style.display = "flex";
statsContainer.style.alignItems = "center";
statsContainer.style.width = "100%";
// Try to clone an existing label from the stats container first,
// fall back to building from scratch if none exists
const existingNativeLabel = statsContainer.querySelector(
`[class*='Label__Label___']`,
) as HTMLElement | null;
const weightLabel = existingNativeLabel
? (existingNativeLabel.cloneNode(true) as HTMLElement)
: (() => {
const labelClass = getClassByPattern(document, "Label__Label___");
const innerTextClass = getClassByPattern(document, "Label__innerText___");
const el = document.createElement("label");
el.className = labelClass;
el.innerHTML = `<div class="${innerTextClass}">Weight</div>`;
return el;
})();
weightLabel.classList.add("betterseqta-weight-label");
weightLabel.style.flex = "none";
weightLabel.style.width = "fit-content";
const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`);
if (innerTextDiv) innerTextDiv.textContent = "Weight"; if (innerTextDiv) innerTextDiv.textContent = "Weight";
const textNodes = Array.from(weightLabel.childNodes).filter( const textNodes = Array.from(weightLabel.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE, (node) => node.nodeType === Node.TEXT_NODE,
); );
if (textNodes.length) { if (textNodes.length) {
textNodes[0].textContent = textNodes[0].textContent = displayText;
weighting && weighting !== "processing" } else {
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%` weightLabel.appendChild(document.createTextNode(displayText));
: "N/A";
} }
statsContainer.style.position = "relative";
weightLabel.style.position = "absolute";
weightLabel.style.right = "0";
weightLabel.style.top = "50%";
weightLabel.style.transform = "translateY(-50%)";
statsContainer.appendChild(weightLabel); statsContainer.appendChild(weightLabel);
} }
@@ -219,6 +263,13 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
export async function extractPDFText(url: string): Promise<string> { export async function extractPDFText(url: string): Promise<string> {
try { try {
if (isFirefox) { if (isFirefox) {
const { lib: pdfLibUrl, worker: pdfWorkerUrl } =
getPdfjsPageContextUrls();
const escJsSingleQuoted = (s: string) =>
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const script = document.createElement("script"); const script = document.createElement("script");
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`; const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
@@ -232,13 +283,15 @@ export async function extractPDFText(url: string): Promise<string> {
(function() { (function() {
const requestId = '${requestId}'; const requestId = '${requestId}';
const url = '${escapedUrl}'; const url = '${escapedUrl}';
const pdfLibSrc = '${pdfLibInj}';
const pdfWorkerSrc = '${pdfWorkerInj}';
if (window.pdfjsLib) { if (window.pdfjsLib) {
extractPDF(); extractPDF();
} else { } else {
const pdfjsScript = document.createElement('script'); const pdfjsScript = document.createElement('script');
pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.min.js'; pdfjsScript.src = pdfLibSrc;
pdfjsScript.type = 'text/javascript'; pdfjsScript.type = 'module';
pdfjsScript.onload = function() { pdfjsScript.onload = function() {
extractPDF(); extractPDF();
@@ -256,7 +309,7 @@ export async function extractPDFText(url: string): Promise<string> {
function extractPDF() { function extractPDF() {
try { try {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = ''; window.pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('GET', url, true); xhr.open('GET', url, true);
@@ -502,7 +555,11 @@ export async function parseAssessments(api: any) {
"[class*='AssessmentList__items___']", "[class*='AssessmentList__items___']",
).getState(); ).getState();
const marks = state["marks"]; const marks = [
...(state["marks"] ?? []),
...(state["upcoming"] ?? []),
...(state["pending"] ?? []),
];
if (!marks) return; if (!marks) return;
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api))); await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
@@ -515,15 +572,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
let count = 0; let count = 0;
for (const assessmentItem of assessmentItems) { for (const assessmentItem of assessmentItems) {
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
const titleEl = assessmentItem.querySelector( const titleEl = assessmentItem.querySelector(
`[class*='AssessmentItem__title___']`, `[class*='AssessmentItem__title___']`,
); );
@@ -533,12 +581,23 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
if (!title) continue; if (!title) continue;
const assessmentID = api.storage.assessments?.[title]; const assessmentID = api.storage.assessments?.[title];
const weighting = assessmentID const autoWeighting = assessmentID
? api.storage.weightings?.[assessmentID] ? api.storage.weightings?.[assessmentID]
: undefined; : undefined;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
const weighting = override ?? autoWeighting;
createWeightLabel(assessmentItem, weighting); createWeightLabel(assessmentItem, weighting);
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
if ( if (
weighting === null || weighting === null ||
weighting === undefined || weighting === undefined ||
@@ -546,8 +605,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
weighting === "processing" weighting === "processing"
) { ) {
hasInaccurateWeighting = true; hasInaccurateWeighting = true;
weightedTotal += grade; continue
totalWeight += 1;
} else { } else {
const weight = parseFloat(weighting); const weight = parseFloat(weighting);
@@ -570,3 +628,271 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
count, count,
}; };
} }
function resolveTabSetClasses(): Record<string, string> {
const patterns = [
"TabSet__tabsheet___",
"TabSet__hidden___",
"TabSet__selected___",
"TabSet__disappearToLeft___",
"TabSet__disappearToRight___",
"TabSet__appearFromRight___",
"TabSet__appearFromLeft___",
];
const resolved: Record<string, string> = {};
// First pass: scan live DOM elements (fast, covers currently-applied classes)
const allClasses = Array.from(
document.querySelectorAll('[class*="TabSet__"]'),
).flatMap((el) => Array.from(el.classList));
for (const pattern of patterns) {
const found = allClasses.find((c) => c.startsWith(pattern));
if (found) resolved[pattern] = found;
}
// Second pass: scan stylesheets for any classes not yet in the DOM
// (e.g. animation classes that haven't been applied yet)
const missing = patterns.filter((p) => !resolved[p]);
if (missing.length > 0) {
try {
for (const sheet of Array.from(document.styleSheets)) {
if (missing.every((p) => resolved[p])) break;
try {
for (const rule of Array.from(sheet.cssRules ?? [])) {
if (!(rule instanceof CSSStyleRule)) continue;
const selectorClasses =
rule.selectorText.match(/\.([\w-]+)/g) ?? [];
for (const pattern of missing) {
if (!resolved[pattern]) {
const match = selectorClasses.find((c) =>
c.slice(1).startsWith(pattern),
);
if (match) resolved[pattern] = match.slice(1);
}
}
}
} catch {
// Cross-origin stylesheet
}
}
} catch {}
}
// Fallback: use the base pattern as-is so the function doesn't crash,
// though styles won't apply if the hash is truly unknown.
for (const pattern of patterns) {
if (!resolved[pattern]) resolved[pattern] = pattern;
}
return resolved;
}
function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
const titleEl = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']",
);
const title = titleEl?.textContent?.trim();
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
const rawWeight = assessmentID
? api.storage.weightings?.[assessmentID]
: undefined;
const weightingUnavailable = rawWeight === "N/A";
const autoWeight =
rawWeight && rawWeight !== "processing" && rawWeight !== "N/A"
? rawWeight
: undefined;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
const statusNote = !assessmentID
? ""
: rawWeight === "processing"
? "Weighting is still being detected."
: weightingUnavailable
? "No weighting was found in the marksheet. Set one manually."
: "Overrides the auto-detected value.";
sheet.innerHTML = `
<style>
#betterseqta-weight-override::placeholder {
opacity: 0.4;
}
</style>
<div style="padding:16px;max-width:360px">
<h2 style="margin:0 0 4px;font-size:15px;font-weight:600">Weighting Override</h2>
<p style="margin:0 0 16px;font-size:12px;opacity:0.6">
Set the weighting for this assessment.
${statusNote}
</p>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<label style="font-size:13px;opacity:0.7;flex-shrink:0">Auto-detected</label>
<span style="font-size:13px;opacity:${autoWeight != null ? "1" : "0.4"}">${autoWeight != null ? `${autoWeight}%` : "none"}</span>
</div>
<div style="display:flex;align-items:center;gap:12px">
<label for="betterseqta-weight-override" style="font-size:13px;opacity:0.7;flex-shrink:0">Override %</label>
<input
id="betterseqta-weight-override"
type="number"
min="0"
step="5"
placeholder="${autoWeight ?? ""}"
value="${override ?? ""}"
${!assessmentID ? "disabled" : ""}
style="
width:90px;
padding:5px 8px;
border-radius:6px;
border:1px solid rgba(128,128,128,0.3);
background:rgba(128,128,128,0.08);
color:inherit;
font-size:13px;
outline:none;
"
/>
</div>
<div style="margin-top:10px;min-height:18px">
<span class="betterseqta-save-status" style="font-size:12px;opacity:0.5"></span>
</div>
${!assessmentID ? `<p style="font-size:12px;color:rgba(255,80,80,0.8);margin-top:8px">Assessment not yet indexed — try refreshing.</p>` : ""}
</div>
`;
if (!assessmentID) return;
const input = sheet.querySelector(
"#betterseqta-weight-override",
) as HTMLInputElement;
const statusEl = sheet.querySelector(
".betterseqta-save-status",
) as HTMLElement;
const save = () => {
const raw = input.value.trim();
if (raw === "") {
const { [assessmentID]: _, ...rest } = api.storage.weightingOverrides;
api.storage.weightingOverrides = rest;
} else {
const val = parseFloat(raw);
if (isNaN(val) || val < 0) {
input.style.borderColor = "rgba(255,80,80,0.6)";
statusEl.textContent = "Invalid. Must be 0 or greater";
statusEl.style.color = "rgba(255,80,80,0.8)";
return;
}
input.style.borderColor = "rgba(128,128,128,0.3)";
api.storage.weightingOverrides = {
...api.storage.weightingOverrides,
[assessmentID]: String(val),
};
}
statusEl.textContent = "Saved";
statusEl.style.color = "";
setTimeout(() => (statusEl.textContent = ""), 2000);
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
};
input.addEventListener("blur", save);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
input.blur();
save();
}
});
input.addEventListener("input", () => {
input.style.borderColor = "rgba(128,128,128,0.3)";
if (statusEl.textContent === "Invalid. Must be 0 or greater.")
statusEl.textContent = "";
});
}
export function injectWeightingsTab(api: any) {
const tabList = document.querySelector(
'[class*="TabSet__tabs___"]',
) as HTMLElement;
const container = document.querySelector(
'[class*="TabSet__tabContainer___"]',
) as HTMLElement;
if (!tabList || !container) return;
if (tabList.querySelector(".betterseqta-weightings-tab")) return;
const cls = resolveTabSetClasses();
const prefix = (tabList.querySelector("li") as HTMLElement).id.replace(
/-tab-\d+$/,
"",
);
const newIndex = tabList.querySelectorAll("li").length;
const newTab = document.createElement("li");
newTab.id = `${prefix}-tab-${newIndex}`;
newTab.className = "";
newTab.setAttribute("aria-selected", "false");
newTab.setAttribute("aria-controls", `${prefix}-tabsheet-${newIndex}`);
newTab.classList.add("betterseqta-weightings-tab");
newTab.textContent = "Weightings";
tabList.appendChild(newTab);
const newSheet = document.createElement("div");
newSheet.id = `${prefix}-tabsheet-${newIndex}`;
newSheet.setAttribute("aria-labelledby", `${prefix}-tab-${newIndex}`);
newSheet.className = [
cls["TabSet__tabsheet___"],
cls["TabSet__hidden___"],
cls["TabSet__disappearToRight___"],
].join(" ");
container.appendChild(newSheet);
let populated = false;
newTab.addEventListener("click", () => {
if (!populated) {
buildWeightingsTabContent(api, newSheet);
populated = true;
}
});
const allTabs = Array.from(tabList.querySelectorAll("li"));
const allSheets = Array.from(
container.querySelectorAll('[class*="tabsheet"]'),
);
allTabs.forEach((tab, i) => {
tab.addEventListener("click", () => {
const currentIndex = allTabs.findIndex((t) =>
t.className.includes("TabSet__selected___"),
);
if (i === currentIndex) return;
const goingRight = i > currentIndex;
allTabs.forEach((t) => {
t.className = "";
t.setAttribute("aria-selected", "false");
});
allSheets[currentIndex].className = [
cls["TabSet__tabsheet___"],
cls["TabSet__hidden___"],
goingRight
? cls["TabSet__disappearToLeft___"]
: cls["TabSet__disappearToRight___"],
].join(" ");
allSheets[i].className = [
cls["TabSet__tabsheet___"],
cls["TabSet__selected___"],
goingRight
? cls["TabSet__appearFromRight___"]
: cls["TabSet__appearFromLeft___"],
].join(" ");
tab.className = cls["TabSet__selected___"];
tab.setAttribute("aria-selected", "true");
});
});
}
@@ -1,9 +1,10 @@
import type { Plugin } from "../../core/types"; import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api"; import { getAssessmentsData } from "./api";
import { renderErrorState, renderSkeletonLoader } from "./ui"; import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
const assessmentsOverviewPlugin: Plugin<{}> = { const assessmentsOverviewPlugin: Plugin<{}> = {
id: "assessments-overview", id: "assessments-overview",
@@ -16,6 +17,8 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
styles, styles,
run: async () => { run: async () => {
if (isSeqtaEngageExperience()) return;
const menu = (await waitForElm( const menu = (await waitForElm(
'[data-key="assessments"] > .sub > ul', '[data-key="assessments"] > .sub > ul',
true, true,
@@ -65,7 +68,6 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
try { try {
const data = await getAssessmentsData(); const data = await getAssessmentsData();
const { renderGrid } = await import("./ui");
renderGrid(container, data); renderGrid(container, data);
} catch (err) { } catch (err) {
console.error("Failed to load assessments:", err); console.error("Failed to load assessments:", err);
@@ -120,7 +120,6 @@ export default defineLazyPlugin({
settings, settings,
disableToggle: true, disableToggle: true,
defaultEnabled: false, defaultEnabled: false,
beta: true,
styles: styles, styles: styles,
// Lazy loader - only imports the heavy plugin when actually needed // Lazy loader - only imports the heavy plugin when actually needed
@@ -145,7 +145,6 @@ const globalSearchPlugin: Plugin<typeof settings> = {
settings: settingsInstance.settings, settings: settingsInstance.settings,
disableToggle: true, disableToggle: true,
defaultEnabled: false, defaultEnabled: false,
beta: true,
styles: styles, styles: styles,
run: async (api) => { run: async (api) => {
@@ -0,0 +1,962 @@
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;
emoji: string;
}
interface MessageFoldersStorage {
folders: Folder[];
messageAssignments: Record<string, string[]>;
}
const FOLDER_COLORS = [
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b",
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316",
];
const FOLDER_HEROICONS = [
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`,
];
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>`;
const CHEVRON_SVG = `<svg style="width:12px;height:12px;flex-shrink:0;transition:transform .2s" viewBox="0 0 24 24"><path fill="#888" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>`;
const DRAG_SVG = `<svg style="width:14px;height:14px;flex-shrink:0;cursor:grab" viewBox="0 0 24 24"><path fill="#888" d="M6.5 12.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/></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: "2.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;
let foldedSection: HTMLElement | null = null;
const unregisters: Array<{ unregister: () => void }> = [];
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 assignMessageToFolder = (messageId: string, folderId: string, add: boolean) => {
const assignments = getAssignments();
if (!assignments[folderId]) assignments[folderId] = [];
const idx = assignments[folderId].indexOf(messageId);
if (add && idx < 0) {
assignments[folderId].push(messageId);
} else if (!add && idx >= 0) {
assignments[folderId].splice(idx, 1);
}
saveAssignments(assignments);
};
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;
};
const getSelectedMessageId = (): string | null => {
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
return selectedMsg?.getAttribute("data-message") ?? null;
};
const getMessageIdFromEvent = (target: HTMLElement): string | null => {
const li = target.closest("li[data-message]");
return li?.getAttribute("data-message") ?? null;
};
const getAllVisibleMessageIds = (): string[] => {
const ids: string[] = [];
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
const id = li.getAttribute("data-message");
if (id) ids.push(id);
});
return ids;
};
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);
};
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") as HTMLElement;
if (!section) {
section = document.createElement("div");
section.className = "bsplus-folders-section";
ol.appendChild(section);
}
foldedSection = section;
const folders = getFolders();
section.innerHTML = "";
const header = document.createElement("div");
header.className = "bsplus-folders-header";
header.dataset.folded = "false";
const collapseBtn = document.createElement("button");
collapseBtn.className = "bsplus-folders-collapse";
collapseBtn.innerHTML = CHEVRON_SVG;
collapseBtn.title = "Collapse";
collapseBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isFolded = collapseBtn.classList.toggle("bsplus-folded");
section.classList.toggle("bsplus-section-folded", isFolded);
collapseBtn.title = isFolded ? "Expand" : "Collapse";
});
header.appendChild(collapseBtn);
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);
const allItem = document.createElement("div");
allItem.className = `bsplus-folder-item bsplus-all-msgs${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();
setTimeout(() => {
applyFolderFilter();
applyBadges();
}, 100);
});
section.appendChild(allItem);
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;
item.draggable = true;
const dragHandle = document.createElement("div");
dragHandle.className = "bsplus-folder-drag";
dragHandle.innerHTML = DRAG_SVG;
item.appendChild(dragHandle);
const dot = document.createElement("div");
dot.className = "bsplus-folder-dot";
dot.style.background = folder.color;
item.appendChild(dot);
const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
item.appendChild(iconSpan);
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();
setTimeout(() => {
applyFolderFilter();
applyBadges();
}, 100);
});
item.addEventListener("dragstart", (e) => {
e.dataTransfer?.setData("text/plain", `reorder:${folder.id}`);
item.classList.add("bsplus-dragging");
});
item.addEventListener("dragend", () => {
item.classList.remove("bsplus-dragging");
document.querySelectorAll(".bsplus-folder-item").forEach((el) => el.classList.remove("bsplus-drag-over"));
});
item.addEventListener("dragover", (e) => {
e.preventDefault();
const data = e.dataTransfer?.getData("text/plain") || "";
if (data.startsWith("reorder:") && !data.includes(folder.id)) {
item.classList.add("bsplus-drag-over");
}
});
item.addEventListener("dragleave", () => {
item.classList.remove("bsplus-drag-over");
});
item.addEventListener("drop", (e) => {
e.preventDefault();
item.classList.remove("bsplus-drag-over");
const data = e.dataTransfer?.getData("text/plain") || "";
if (data.startsWith("reorder:")) {
const draggedId = data.replace("reorder:", "");
const folders = getFolders();
const draggedIdx = folders.findIndex((f) => f.id === draggedId);
const targetIdx = folders.findIndex((f) => f.id === folder.id);
if (draggedIdx >= 0 && targetIdx >= 0 && draggedIdx !== targetIdx) {
const [removed] = folders.splice(draggedIdx, 1);
folders.splice(targetIdx, 0, removed);
saveFolders(folders);
renderSidebarFolders();
}
}
});
section.appendChild(item);
}
section.addEventListener("dragover", (e) => {
e.preventDefault();
});
section.addEventListener("drop", (e) => {
e.preventDefault();
const data = e.dataTransfer?.getData("text/plain") || "";
if (data.startsWith("msg:")) {
const messageId = data.replace("msg:", "");
const folderId = (e.target as HTMLElement).closest("[data-folder-id]")?.getAttribute("data-folder-id");
if (messageId && folderId) {
assignMessageToFolder(messageId, folderId, true);
applyBadges();
applyFolderFilter();
renderSidebarFolders();
}
}
});
attachDragListeners();
};
const attachDragListeners = () => {
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
if (li.getAttribute("data-bsplus-drag") === "true") return;
li.setAttribute("data-bsplus-drag", "true");
li.draggable = true;
li.addEventListener("dragstart", (e) => {
const id = li.getAttribute("data-message");
if (id) {
e.dataTransfer?.setData("text/plain", `msg:${id}`);
li.classList.add("bsplus-msg-dragging");
}
});
li.addEventListener("dragend", () => {
li.classList.remove("bsplus-msg-dragging");
document.querySelectorAll(".bsplus-folder-item").forEach((el) => el.classList.remove("bsplus-drag-over"));
});
});
};
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)];
let selectedIcon = editFolder?.emoji ?? FOLDER_HEROICONS[Math.floor(Math.random() * FOLDER_HEROICONS.length)];
const row = document.createElement("div");
row.className = "bsplus-folder-input";
const input = document.createElement("input");
input.type = "text";
input.placeholder = editFolder ? "Rename folder\u2026" : "Folder name\u2026";
input.value = editFolder?.name ?? "";
input.maxLength = 30;
const iconBtn = document.createElement("button");
iconBtn.className = "bsplus-folder-icon-btn";
iconBtn.title = "Pick icon";
iconBtn.innerHTML = selectedIcon;
iconBtn.addEventListener("click", (e) => {
e.stopPropagation();
const picker = container.querySelector(".bsplus-icon-picker") as HTMLElement | null;
if (picker) {
picker.remove();
return;
}
showIconPicker(container, (iconSvg) => {
selectedIcon = iconSvg;
iconBtn.innerHTML = iconSvg;
});
});
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(iconBtn);
row.appendChild(input);
row.appendChild(confirmBtn);
row.appendChild(cancelBtn);
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, emoji: selectedIcon } : f,
);
saveFolders(folders);
} else {
const folder: Folder = { id: generateId(), name, color: selectedColor, emoji: selectedIcon };
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 showIconPicker = (container: Element, onSelect: (iconSvg: string) => void) => {
const existing = container.querySelector(".bsplus-icon-picker");
if (existing) existing.remove();
const picker = document.createElement("div");
picker.className = "bsplus-icon-picker";
for (const icon of FOLDER_HEROICONS) {
const btn = document.createElement("button");
btn.className = "bsplus-icon-opt";
btn.innerHTML = icon;
btn.addEventListener("click", (e) => {
e.stopPropagation();
onSelect(icon);
picker.remove();
});
picker.appendChild(btn);
}
container.appendChild(picker);
};
const showEditFolderInput = (container: Element, folder: Folder) => {
showNewFolderInput(container, folder);
};
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();
}
}
});
};
const closeDropdown = () => {
if (openDropdown) {
openDropdown.remove();
openDropdown = null;
}
if (dropdownCloseHandler) {
document.removeEventListener("click", dropdownCloseHandler, true);
dropdownCloseHandler = null;
}
};
const showFolderDropdown = (anchor: HTMLElement, messageId: string) => {
closeDropdown();
const dropdown = document.createElement("div");
dropdown.className = "bsplus-folder-dropdown";
dropdown.dataset.msgId = messageId;
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" : ""}`;
item.dataset.folderId = folder.id;
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 iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
const name = document.createElement("span");
name.textContent = folder.name;
item.appendChild(check);
item.appendChild(dot);
item.appendChild(iconSpan);
item.appendChild(name);
item.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
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 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();
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 showContextMenu = (e: MouseEvent, messageId: string) => {
e.preventDefault();
e.stopPropagation();
closeDropdown();
const existing = document.querySelector(".bsplus-context-menu");
if (existing) existing.remove();
const menu = document.createElement("div");
menu.className = "bsplus-context-menu";
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
const title = document.createElement("div");
title.className = "bsplus-context-title";
title.textContent = "Add to folder";
menu.appendChild(title);
const folders = getFolders();
const currentFolderIds = getMessageFolderIds(messageId);
if (folders.length === 0) {
const empty = document.createElement("div");
empty.className = "bsplus-context-empty";
empty.textContent = "No folders";
menu.appendChild(empty);
} else {
for (const folder of folders) {
const isChecked = currentFolderIds.includes(folder.id);
const item = document.createElement("button");
item.className = `bsplus-context-item${isChecked ? " bsplus-context-checked" : ""}`;
const dot = document.createElement("div");
dot.className = "bsplus-folder-dot";
dot.style.background = folder.color;
const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
const name = document.createElement("span");
name.textContent = folder.name;
item.appendChild(dot);
item.appendChild(iconSpan);
item.appendChild(name);
if (isChecked) {
const check = document.createElement("span");
check.className = "bsplus-context-checkmark";
check.textContent = "\u2713";
item.appendChild(check);
}
item.addEventListener("click", (e) => {
e.stopPropagation();
toggleMessageInFolder(messageId, folder.id);
applyBadges();
applyFolderFilter();
renderSidebarFolders();
menu.remove();
});
menu.appendChild(item);
}
}
document.body.appendChild(menu);
const closeMenu = (ev: MouseEvent) => {
if (!menu.contains(ev.target as Node)) {
menu.remove();
document.removeEventListener("click", closeMenu);
}
};
setTimeout(() => document.addEventListener("click", closeMenu), 0);
};
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: string[] = [];
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.innerHTML = `${folder.emoji ? `<span style="display:inline-flex;vertical-align:middle;margin-right:2px">${folder.emoji}</span>` : ""}${folder.name}`;
badge.title = `Filter by "${folder.name}"`;
badge.addEventListener("click", (e) => {
e.stopPropagation();
activeFolderId = folder.id;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
});
badgeContainer.appendChild(badge);
}
}
};
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");
};
const setupMessageListObserver = () => {
const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol");
if (!messageList || messageListObserver) return;
messageListObserver = new MutationObserver(() => {
applyBadges();
applyFolderFilter();
attachDragListeners();
attachContextMenuListeners();
});
messageListObserver.observe(messageList, { childList: true, subtree: false });
};
const attachContextMenuListeners = () => {
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
if (li.getAttribute("data-bsplus-ctx") === "true") return;
li.setAttribute("data-bsplus-ctx", "true");
li.addEventListener("contextmenu", (e) => {
const msgId = li.getAttribute("data-message");
if (msgId) {
showContextMenu(e, msgId);
}
});
});
};
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 });
};
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();
setupActionsObserver();
attachDragListeners();
attachContextMenuListeners();
const actionsBar = document.querySelector("[class*='Message__actions___']");
if (actionsBar) injectFolderButton(actionsBar);
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 });
}
};
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(".bsplus-context-menu").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,731 @@
/* ── 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;
transition: opacity .2s;
}
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-item,
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-input,
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-colors,
.bsplus-folders-section.bsplus-section-folded .bsplus-emoji-picker,
.bsplus-folders-section.bsplus-section-folded .bsplus-all-msgs {
display: none !important;
}
.bsplus-folders-header {
display: flex;
align-items: center;
gap: 4px;
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;
flex: 1;
}
.bsplus-folders-collapse {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 18px !important;
height: 18px !important;
min-width: 0 !important;
border: none !important;
background: transparent !important;
opacity: 0.4;
cursor: pointer;
border-radius: 4px !important;
padding: 0 !important;
margin: 0 !important;
transition: all .2s;
}
.bsplus-folders-collapse:hover {
opacity: 0.8;
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
}
.bsplus-folders-collapse.bsplus-folded svg {
transform: rotate(-90deg);
}
.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, opacity 0.2s;
position: relative;
gap: 6px;
user-select: none;
}
.bsplus-folder-item.bsplus-dragging {
opacity: 0.4;
}
.bsplus-folder-item.bsplus-drag-over {
background: var(--better-main, #007bff22) !important;
border-radius: 4px;
}
.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-drag {
display: flex;
align-items: center;
opacity: 0;
transition: opacity .15s;
margin-right: -4px;
}
.bsplus-folder-item:hover .bsplus-folder-drag {
opacity: 0.5;
}
.bsplus-folder-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.bsplus-folder-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--text-primary, #333);
}
.bsplus-folder-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
}
.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;
min-width: 16px;
text-align: right;
}
.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-icon-btn {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 28px !important;
height: 28px !important;
min-width: 0 !important;
border: 1px solid var(--background-secondary, #ccc) !important;
border-radius: 6px !important;
background: var(--background-secondary, #f5f5f5) !important;
cursor: pointer;
padding: 0 !important;
margin: 0 !important;
transition: all .15s;
color: var(--text-primary, #333);
}
.bsplus-folder-icon-btn:hover {
transform: scale(1.1);
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.1)) !important;
}
.bsplus-folder-icon-btn svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
}
.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;
}
/* ── Icon picker ── */
.bsplus-icon-picker {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
padding: 4px 12px 6px;
max-width: 140px;
}
.bsplus-icon-opt {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 28px !important;
height: 28px !important;
min-width: 0 !important;
border: none !important;
border-radius: 6px !important;
background: transparent !important;
cursor: pointer;
padding: 0 !important;
transition: all .15s;
color: var(--text-primary, #333);
}
.bsplus-icon-opt svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
}
.bsplus-icon-opt:hover {
transform: scale(1.3);
background: var(--theme-offset-bg-more, 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;
}
/* ── Context menu ── */
.bsplus-context-menu {
position: fixed;
min-width: 160px;
background: var(--background-primary, #fff) !important;
border: 1px solid var(--background-secondary, #e0e0e0) !important;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 2147483646;
overflow: hidden;
animation: bsplus-dropdown-in 0.12s ease-out;
padding: 4px 0;
}
.bsplus-context-title {
padding: 6px 12px 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-primary, #999) !important;
opacity: 0.5;
user-select: none;
}
.bsplus-context-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08)) !important;
}
.bsplus-context-item span {
flex: 1;
}
.bsplus-context-checkmark {
color: var(--better-main, #007bff) !important;
font-weight: bold;
flex: 0 !important;
}
.bsplus-context-item {
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
gap: 8px;
padding: 7px 12px !important;
font-size: 13px;
cursor: pointer;
border: none !important;
background: transparent !important;
width: 100%;
text-align: left !important;
color: var(--text-primary, #333) !important;
transition: background .1s;
font-family: inherit;
}
.bsplus-context-item .bsplus-folder-icon {
color: var(--text-primary, #333) !important;
width: 16px;
height: 16px;
}
.bsplus-context-item .bsplus-folder-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
}
.bsplus-context-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
}
.bsplus-context-item span {
flex: 1;
}
.bsplus-context-checkmark {
color: var(--better-main, #007bff) !important;
font-weight: bold;
flex: 0 !important;
}
.bsplus-context-empty {
padding: 12px;
text-align: center;
font-size: 12px;
color: var(--text-primary, #999);
opacity: 0.5;
}
/* ── Drag feedback ── */
.bsplus-msg-dragging {
opacity: 0.4;
}
[class*='MessageList__MessageList___'] ol > li[data-message] {
transition: opacity .15s;
}
/* ── Layout fixes ── */
[class*='MessageList__primary___'] {
flex: 1 1 0% !important;
min-width: 0 !important;
overflow: hidden !important;
}
[class*='MessageList__subject___'] {
display: flex !important;
align-items: center;
gap: 6px;
min-width: 0 !important;
overflow: hidden !important;
}
.bsplus-subject-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1 1 auto;
}
[class*='MessageList__secondary___'] {
flex: 0 0 auto !important;
width: auto !important;
min-width: 0 !important;
max-width: 200px !important;
}
[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 ── */
.bsplus-folder-hidden {
display: none !important;
}
/* ── Delete confirmation modal ── */
@keyframes bsplus-modal-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes bsplus-modal-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.bsplus-modal-overlay {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
animation: bsplus-modal-overlay-in 0.2s ease-out forwards;
}
.bsplus-modal {
padding: 1rem 1.5rem;
margin: 0 1rem;
min-width: 16rem;
max-width: 22rem;
width: 100%;
box-sizing: border-box;
background: var(--background-primary, #fff);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid var(--background-secondary, #e0e0e0);
animation: bsplus-modal-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.bsplus-modal h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #333);
}
.bsplus-modal p {
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--text-primary, #666);
opacity: 0.8;
}
.bsplus-modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.bsplus-modal-actions button {
padding: 0.4rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.bsplus-modal-btn-cancel {
background: transparent;
border: 1px solid var(--background-secondary, #ccc);
color: var(--text-primary, #333);
}
.bsplus-modal-btn-cancel:hover {
background: var(--background-secondary, rgba(128, 128, 128, 0.1));
}
.bsplus-modal-btn-danger {
background: #e53e3e;
border: none;
color: white;
}
.bsplus-modal-btn-danger:hover {
background: #c53030;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35);
}
@@ -1,4 +1,5 @@
import type { Plugin } from "../../core/types"; import type { Plugin } from "../../core/types";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
interface NotificationCollectorStorage { interface NotificationCollectorStorage {
lastNotificationCount: number; lastNotificationCount: number;
@@ -15,6 +16,10 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
disableToggle: true, disableToggle: true,
run: async (api) => { run: async (api) => {
if (isSeqtaEngageExperience()) {
return () => {};
}
let pollInterval: number | null = null; let pollInterval: number | null = null;
let isVisible = !document.hidden; let isVisible = !document.hidden;
let baseInterval = 30000; // 30 seconds let baseInterval = 30000; // 30 seconds
+42 -11
View File
@@ -1,11 +1,21 @@
import type { Plugin } from "@/plugins/core/types"; import type { Plugin } from "@/plugins/core/types";
import { componentSetting, defineSettings } from "@/plugins/core/settingsHelpers"; import {
booleanSetting,
componentSetting,
defineSettings,
} from "@/plugins/core/settingsHelpers";
import ProfilePictureSetting from "./ProfilePictureSetting.svelte"; import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import localforage from "localforage"; import localforage from "localforage";
const settings = defineSettings({ const settings = defineSettings({
useCloudPfp: booleanSetting({
default: false,
title: "Use BetterSEQTA Cloud profile picture",
description: "Use your cloud account avatar instead of the uploaded image below",
}),
picture: componentSetting({ picture: componentSetting({
title: "Profile Picture", title: "Profile Picture",
description: "Upload or remove your custom profile image", description: "Upload or remove your custom profile image",
@@ -17,7 +27,7 @@ const profilePicturePlugin: Plugin<typeof settings> = {
id: "profile-picture", id: "profile-picture",
name: "Custom Profile Picture", name: "Custom Profile Picture",
description: "Use your own image in place of the profile icon", description: "Use your own image in place of the profile icon",
version: "1.1.0", version: "1.2.0",
settings: settings, settings: settings,
disableToggle: true, disableToggle: true,
defaultEnabled: false, defaultEnabled: false,
@@ -36,14 +46,12 @@ const profilePicturePlugin: Plugin<typeof settings> = {
let img: HTMLImageElement | null = null; let img: HTMLImageElement | null = null;
let currentBlobUrl: string | undefined; let currentBlobUrl: string | undefined;
// Setup localforage instance
const store = localforage.createInstance({ const store = localforage.createInstance({
name: "profile-picture-store", name: "profile-picture-store",
storeName: "profilePicture", storeName: "profilePicture",
}); });
async function updateImageFromStore() { async function applyProfileImage() {
// Remove old image if present
if (img) { if (img) {
img.remove(); img.remove();
img = null; img = null;
@@ -52,6 +60,19 @@ const profilePicturePlugin: Plugin<typeof settings> = {
URL.revokeObjectURL(currentBlobUrl); URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = undefined; currentBlobUrl = undefined;
} }
const useCloud = api.settings.useCloudPfp;
const pfpUrl = cloudAuth.state.user?.pfpUrl;
if (useCloud && pfpUrl) {
img = document.createElement("img");
img.className = "userInfoImg";
img.src = pfpUrl;
if (svg) svg.style.display = "none";
container.appendChild(img);
return;
}
const blob = await store.getItem<Blob>("profile-picture"); const blob = await store.getItem<Blob>("profile-picture");
if (blob && blob instanceof Blob) { if (blob && blob instanceof Blob) {
currentBlobUrl = URL.createObjectURL(blob); currentBlobUrl = URL.createObjectURL(blob);
@@ -65,15 +86,25 @@ const profilePicturePlugin: Plugin<typeof settings> = {
} }
} }
// Initial load await applyProfileImage();
await updateImageFromStore();
// Listen for profile picture updates const onLocalPictureUpdated = () => {
const handler = () => { updateImageFromStore(); }; void applyProfileImage();
window.addEventListener('profile-picture-updated', handler); };
window.addEventListener("profile-picture-updated", onLocalPictureUpdated);
const cloudUnsub = cloudAuth.subscribe(() => {
void applyProfileImage();
});
const useCloudUnreg = api.settings.onChange("useCloudPfp", () => {
void applyProfileImage();
});
return () => { return () => {
window.removeEventListener("profile-picture-updated", handler); useCloudUnreg.unregister();
cloudUnsub();
window.removeEventListener("profile-picture-updated", onLocalPictureUpdated);
if (img) img.remove(); if (img) img.remove();
if (svg) svg.style.display = ""; if (svg) svg.style.display = "";
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl); if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
+3 -4
View File
@@ -1,11 +1,10 @@
import renderSvelte from "@/interface/main"; import renderSvelte from "@/interface/main";
import themeCreator from "@/interface/pages/themeCreator.svelte";
import { unmount } from "svelte";
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager"; import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
import { unmount } from "svelte";
import themeCreator from "@/interface/pages/themeCreator.svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
let themeCreatorSvelteApp: any = null; let themeCreatorSvelteApp: any = null;
const themeManager = ThemeManager.getInstance();
/** /**
* Open the Theme Creator sidebar, it is an embedded page loaded similar to the extension popup * Open the Theme Creator sidebar, it is an embedded page loaded similar to the extension popup
@@ -41,7 +40,7 @@ export function OpenThemeCreator(themeID: string = "") {
closeButton.textContent = "×"; closeButton.textContent = "×";
closeButton.addEventListener("click", () => { closeButton.addEventListener("click", () => {
CloseThemeCreator(); CloseThemeCreator();
themeManager.clearPreview(); ThemeManager.getInstance().clearPreview();
}); });
document.body.appendChild(closeButton); document.body.appendChild(closeButton);
+1
View File
@@ -10,6 +10,7 @@ const themesPlugin: Plugin = {
run: async (_) => { run: async (_) => {
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
await themeManager.prepareThemeAfterCloudSync();
await themeManager.initialize(); await themeManager.initialize();
}, },
}; };
+280 -42
View File
@@ -1,8 +1,22 @@
import localforage from "localforage"; import localforage from "localforage";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes"; import {
type CustomTheme,
getForcedDarkMode,
type LoadedCustomTheme,
shouldForceThemeAppearance,
} 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 { cloudAuth } from "@/seqta/utils/CloudAuth";
import { getApiBase } from "@/seqta/utils/DevApiBase";
import { updateAllColors } from "@/seqta/ui/colors/Manager";
import {
clearCustomThemeAdaptiveCssVariables,
setCustomThemeAdaptiveCssVariables,
} from "@/seqta/ui/colors/customThemeAdaptiveBindings";
type ThemeContent = { type ThemeContent = {
id: string; id: string;
@@ -13,8 +27,19 @@ type ThemeContent = {
CanChangeColour?: boolean; CanChangeColour?: boolean;
CustomCSS?: string; CustomCSS?: string;
hideThemeName?: boolean; hideThemeName?: boolean;
forceTheme?: boolean;
forceDark?: boolean; forceDark?: boolean;
images: { id: string; variableName: string; data: string }[]; // data: base64 adaptiveCssVariables?: string[];
images?: { id: string; variableName: string; data: string }[]; // data: base64
};
export type InstallThemeMeta = {
fromStore: boolean;
/** Server list `updated_at` (Unix seconds); set when installing from store. */
serverUpdatedAtSec?: number;
forceTheme?: boolean;
adaptiveCssVariables?: string[];
images?: { id: string; variableName: string; data: string }[]; // data: base64
}; };
export class ThemeManager { export class ThemeManager {
@@ -27,6 +52,7 @@ export class ThemeManager {
private originalPreviewTheme: boolean | null = null; private originalPreviewTheme: boolean | null = null;
private imageUrlCache: Map<string, string> = new Map(); private imageUrlCache: Map<string, string> = new Map();
private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 }; private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 };
private storeUpdateCheckRunning = false;
private constructor() { private constructor() {
console.debug("[ThemeManager] Initializing..."); console.debug("[ThemeManager] Initializing...");
@@ -142,6 +168,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
*/ */
@@ -175,6 +228,8 @@ export class ThemeManager {
} }
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error during initialization:", error); console.error("[ThemeManager] Error during initialization:", error);
} finally {
void this.checkStoreThemeUpdates();
} }
} }
@@ -209,7 +264,7 @@ export class ThemeManager {
console.debug("[ThemeManager] Storing original settings"); console.debug("[ThemeManager] Storing original settings");
settingsState.originalSelectedColor = settingsState.selectedColor; settingsState.originalSelectedColor = settingsState.selectedColor;
if (theme.forceDark) { if (shouldForceThemeAppearance(theme)) {
settingsState.originalDarkMode = settingsState.DarkMode; settingsState.originalDarkMode = settingsState.DarkMode;
} }
} }
@@ -240,6 +295,7 @@ export class ThemeManager {
this.currentTheme = theme; this.currentTheme = theme;
settingsState.selectedTheme = themeId; settingsState.selectedTheme = themeId;
} }
void updateAllColors();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error setting theme:", error); console.error("[ThemeManager] Error setting theme:", error);
} }
@@ -270,9 +326,10 @@ export class ThemeManager {
} }
// Apply theme settings // Apply theme settings
if (theme.forceDark !== undefined) { if (shouldForceThemeAppearance(theme)) {
console.debug("[ThemeManager] Setting dark mode:", theme.forceDark); const dark = getForcedDarkMode(theme);
settingsState.DarkMode = theme.forceDark; console.debug("[ThemeManager] Setting dark mode:", dark);
settingsState.DarkMode = dark;
} }
// Use the stored selected color if available, otherwise use the default // Use the stored selected color if available, otherwise use the default
@@ -289,6 +346,8 @@ export class ThemeManager {
); );
settingsState.selectedColor = theme.defaultColour; settingsState.selectedColor = theme.defaultColour;
} }
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error applying theme:", error); console.error("[ThemeManager] Error applying theme:", error);
} }
@@ -345,9 +404,18 @@ export class ThemeManager {
if (this.currentTheme) { if (this.currentTheme) {
// Store the current color with the theme before removing it // Store the current color with the theme before removing it
const selectedColor = settingsState.selectedColor;
const markUserEditedForColor =
this.currentTheme.userEdited !== true &&
this.currentTheme.installedFromStore !== false &&
selectedColor &&
this.currentTheme.defaultColour &&
selectedColor.trim().toLowerCase() !==
this.currentTheme.defaultColour.trim().toLowerCase();
await localforage.setItem(this.currentTheme.id, { await localforage.setItem(this.currentTheme.id, {
...this.currentTheme, ...this.currentTheme,
selectedColor: settingsState.selectedColor, selectedColor,
...(markUserEditedForColor ? { userEdited: true } : {}),
}); });
} }
@@ -373,6 +441,7 @@ export class ThemeManager {
if (clearSelectedTheme) { if (clearSelectedTheme) {
settingsState.selectedTheme = ""; settingsState.selectedTheme = "";
} }
clearCustomThemeAdaptiveCssVariables();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error removing theme:", error); console.error("[ThemeManager] Error removing theme:", error);
} }
@@ -427,18 +496,24 @@ export class ThemeManager {
public async saveTheme(theme: LoadedCustomTheme): Promise<void> { public async saveTheme(theme: LoadedCustomTheme): Promise<void> {
console.debug("[ThemeManager] Saving theme:", theme.name); console.debug("[ThemeManager] Saving theme:", theme.name);
try { try {
await localforage.setItem(theme.id, theme); const existing = (await localforage.getItem(theme.id)) as CustomTheme | null;
let toSave = theme;
if (existing?.userEdited === true && theme.userEdited !== false) {
toSave = { ...theme, userEdited: true };
}
await localforage.setItem(toSave.id, toSave);
const themeIds = (await localforage.getItem("customThemes")) as const themeIds = (await localforage.getItem("customThemes")) as
| string[] | string[]
| null; | null;
if (themeIds) { if (themeIds) {
if (!themeIds.includes(theme.id)) { if (!themeIds.includes(toSave.id)) {
themeIds.push(theme.id); themeIds.push(toSave.id);
await localforage.setItem("customThemes", themeIds); await localforage.setItem("customThemes", themeIds);
} }
} else { } else {
await localforage.setItem("customThemes", [theme.id]); await localforage.setItem("customThemes", [toSave.id]);
} }
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error saving theme:", error); console.error("[ThemeManager] Error saving theme:", error);
@@ -471,7 +546,10 @@ export class ThemeManager {
} }
} }
private readonly THEME_API_BASE = 'https://betterseqta.org/api'; /** Use a getter so dev-mode session-only base URL overrides take effect immediately. */
private get THEME_API_BASE(): string {
return `${getApiBase()}/api`;
}
private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes'; private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes';
/** /**
@@ -496,39 +574,62 @@ export class ThemeManager {
description?: string; description?: string;
coverImage?: string; coverImage?: string;
theme_json_url?: string; theme_json_url?: string;
updated_at?: number;
}): Promise<void> { }): Promise<void> {
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
try { try {
if (!themeContent.id) return; await this.downloadAndInstallStoreTheme(themeContent);
let themeData: ThemeContent;
try {
// Try API first (increments download_count)
const downloadData = (await this.fetchFromUrl(
`${this.THEME_API_BASE}/themes/${themeContent.id}/download`
)) as { success?: boolean; data?: { theme_json_url: string } };
if (!downloadData?.success || !downloadData?.data?.theme_json_url) {
throw new Error("Failed to get theme download URL");
}
themeData = (await this.fetchFromUrl(downloadData.data.theme_json_url)) as ThemeContent;
} catch (apiError) {
// Fallback to GitHub if API is unreachable
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
const githubUrl = `${this.GITHUB_THEMES_BASE}/${themeContent.id}/theme.json`;
themeData = (await this.fetchFromUrl(githubUrl)) as ThemeContent;
}
await this.installTheme(themeData);
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error downloading theme:", error); console.error("[ThemeManager] Error downloading theme:", error);
} }
} }
/**
* Fetch theme.json from the store and install (throws on failure).
*/
private async downloadAndInstallStoreTheme(themeContent: {
id: string;
name: string;
description?: string;
coverImage?: string;
theme_json_url?: string;
updated_at?: number;
}): Promise<void> {
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
if (!themeContent.id) {
throw new Error("Missing theme id");
}
let themeData: ThemeContent;
try {
const downloadData = (await this.fetchFromUrl(
`${this.THEME_API_BASE}/themes/${themeContent.id}/download`,
)) as { success?: boolean; data?: { theme_json_url: string } };
if (!downloadData?.success || !downloadData?.data?.theme_json_url) {
throw new Error("Failed to get theme download URL");
}
themeData = (await this.fetchFromUrl(
downloadData.data.theme_json_url,
)) as ThemeContent;
} catch (apiError) {
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
const githubUrl = `${this.GITHUB_THEMES_BASE}/${themeContent.id}/theme.json`;
themeData = (await this.fetchFromUrl(githubUrl)) as ThemeContent;
}
await this.installTheme(themeData, {
fromStore: true,
serverUpdatedAtSec: themeContent.updated_at,
});
}
/** /**
* Install a theme from theme data * Install a theme from theme data
*/ */
public async installTheme(themeData: ThemeContent): Promise<void> { public async installTheme(
themeData: ThemeContent,
meta?: InstallThemeMeta,
): Promise<void> {
console.debug("[ThemeManager] Installing theme:", themeData.name); console.debug("[ThemeManager] Installing theme:", themeData.name);
try { try {
// Validate required fields // Validate required fields
@@ -536,6 +637,9 @@ export class ThemeManager {
throw new Error("Theme is missing required fields (id or name)"); throw new Error("Theme is missing required fields (id or name)");
} }
const fromStore = meta?.fromStore ?? false;
const serverUpdatedAtSec = meta?.serverUpdatedAtSec;
// Handle cover image (optional) // Handle cover image (optional)
let coverImageBlob = null; let coverImageBlob = null;
if (themeData.coverImage) { if (themeData.coverImage) {
@@ -570,7 +674,6 @@ export class ThemeManager {
}) })
.filter((img) => img !== null) ?? []; .filter((img) => img !== null) ?? [];
// Create theme with defaults for optional fields
const theme: LoadedCustomTheme = { const theme: LoadedCustomTheme = {
id: themeData.id, id: themeData.id,
name: themeData.name, name: themeData.name,
@@ -585,6 +688,19 @@ export class ThemeManager {
isEditable: false, isEditable: false,
hideThemeName: themeData.hideThemeName ?? false, hideThemeName: themeData.hideThemeName ?? false,
forceDark: themeData.forceDark, forceDark: themeData.forceDark,
installedFromStore: fromStore,
userEdited: fromStore ? false : undefined,
storeSyncedAtSec:
fromStore && serverUpdatedAtSec != null
? serverUpdatedAtSec
: undefined,
forceTheme:
themeData.forceTheme !== undefined
? themeData.forceTheme
: themeData.forceDark !== undefined
? true
: undefined,
adaptiveCssVariables: themeData.adaptiveCssVariables,
}; };
await this.saveTheme(theme); await this.saveTheme(theme);
@@ -594,6 +710,115 @@ export class ThemeManager {
} }
} }
/**
* Compare installed store themes to GET /api/themes and refresh when the server is newer.
* Skips themes with userEdited: true (theme creator / popup save, or custom accent vs default).
*/
private static STORE_CHECK_KEY = "bsplus_lastStoreThemeCheck";
private static STORE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
public async checkStoreThemeUpdates(): Promise<void> {
if (this.storeUpdateCheckRunning) return;
const lastCheck = Number(localStorage.getItem(ThemeManager.STORE_CHECK_KEY) || 0);
if (Date.now() - lastCheck < ThemeManager.STORE_CHECK_INTERVAL_MS) return;
this.storeUpdateCheckRunning = true;
localStorage.setItem(ThemeManager.STORE_CHECK_KEY, String(Date.now()));
try {
const token = await cloudAuth.getStoredToken();
const res = (await browser.runtime.sendMessage({
type: "fetchThemes",
token: token ?? undefined,
})) as {
success?: boolean;
data?: { themes?: Array<{ id: string; updated_at?: number }> };
};
if (!res?.success || !res.data?.themes?.length) return;
const serverById = new Map<string, number>();
for (const t of res.data.themes) {
if (
typeof t.id === "string" &&
typeof t.updated_at === "number"
) {
serverById.set(t.id, t.updated_at);
}
}
if (serverById.size === 0) return;
const themeIds = (await localforage.getItem("customThemes")) as
| string[]
| null;
if (!themeIds?.length) return;
for (const id of themeIds) {
const theme = (await localforage.getItem(id)) as CustomTheme | null;
if (!theme || theme.userEdited === true) {
continue;
}
// File imports explicitly set installedFromStore: false — never auto-sync.
if (theme.installedFromStore === false) {
continue;
}
const serverUpdated = serverById.get(id);
if (serverUpdated == null) {
continue;
}
if (
theme.installedFromStore === true &&
theme.storeSyncedAtSec != null
) {
if (serverUpdated <= theme.storeSyncedAtSec) {
continue;
}
}
// Else: legacy installs from before store metadata (installedFromStore/storeSyncedAtSec
// unset) or incomplete rows — still listed on the server, so sync to latest once.
const wasSelected = settingsState.selectedTheme === id;
try {
if (wasSelected) {
await this.disableTheme();
}
await this.downloadAndInstallStoreTheme({
id: theme.id,
name: theme.name,
updated_at: serverUpdated,
});
console.log(
"[ThemeManager] Theme auto-updated from store:",
theme.name,
);
if (wasSelected) {
await this.setTheme(id, false);
}
themeUpdates.triggerUpdate();
} catch (err) {
console.error("[ThemeManager] Store theme auto-update failed:", id, err);
if (wasSelected) {
try {
await this.setTheme(id, false);
} catch (restoreErr) {
console.error(
"[ThemeManager] Failed to restore theme after update error:",
restoreErr,
);
}
}
}
}
} catch (e) {
console.error("[ThemeManager] checkStoreThemeUpdates error:", e);
} finally {
this.storeUpdateCheckRunning = false;
}
}
/** /**
* Share a theme by exporting it * Share a theme by exporting it
*/ */
@@ -614,6 +839,9 @@ export class ThemeManager {
isEditable, isEditable,
selectedColor, selectedColor,
allowBackgrounds, allowBackgrounds,
installedFromStore,
storeSyncedAtSec,
userEdited,
...themeBasics ...themeBasics
} = theme; } = theme;
@@ -651,7 +879,7 @@ export class ThemeManager {
public async previewTheme(theme: LoadedCustomTheme): Promise<void> { public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
console.debug("[ThemeManager] Previewing theme:", theme.name); console.debug("[ThemeManager] Previewing theme:", theme.name);
try { try {
const { CustomCSS, CustomImages, defaultColour, forceDark } = theme; const { CustomCSS, CustomImages, defaultColour } = theme;
// Store original settings only if this is a new theme // Store original settings only if this is a new theme
if (!theme.webURL) { if (!theme.webURL) {
@@ -697,13 +925,16 @@ export class ThemeManager {
this.previousImageVariableNames = newImageVariableNames; this.previousImageVariableNames = newImageVariableNames;
// Apply theme settings // Apply theme settings
if (forceDark !== undefined) { if (shouldForceThemeAppearance(theme)) {
settingsState.DarkMode = forceDark; settingsState.DarkMode = getForcedDarkMode(theme);
} }
if (defaultColour) { if (defaultColour) {
settingsState.selectedColor = defaultColour; settingsState.selectedColor = defaultColour;
} }
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
void updateAllColors();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error previewing theme:", error); console.error("[ThemeManager] Error previewing theme:", error);
} }
@@ -769,15 +1000,18 @@ export class ThemeManager {
this.previousImageVariableNames = newImageVariableNames; this.previousImageVariableNames = newImageVariableNames;
} }
// Always apply dark mode setting // Always apply dark mode setting when theme forces appearance
if (theme.forceDark !== undefined) { if (shouldForceThemeAppearance(theme as CustomTheme)) {
settingsState.DarkMode = theme.forceDark; settingsState.DarkMode = getForcedDarkMode(theme as CustomTheme);
} }
// Only apply color if this is a new theme // Only apply color if this is a new theme
if (!theme.webURL && theme.defaultColour) { if (!theme.webURL && theme.defaultColour) {
settingsState.selectedColor = theme.defaultColour; settingsState.selectedColor = theme.defaultColour;
} }
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
void updateAllColors();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error updating theme preview:", error); console.error("[ThemeManager] Error updating theme preview:", error);
} }
@@ -815,6 +1049,8 @@ export class ThemeManager {
this.previewStyleElement = null; this.previewStyleElement = null;
} }
clearCustomThemeAdaptiveCssVariables();
// Restore original settings // Restore original settings
const storedColor = localStorage.getItem("originalPreviewColor"); const storedColor = localStorage.getItem("originalPreviewColor");
@@ -844,6 +1080,8 @@ export class ThemeManager {
settingsState.DarkMode = this.originalPreviewTheme; settingsState.DarkMode = this.originalPreviewTheme;
this.originalPreviewTheme = null; this.originalPreviewTheme = null;
} }
void updateAllColors();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error clearing preview:", error); console.error("[ThemeManager] Error clearing preview:", error);
} }
+6 -1
View File
@@ -63,7 +63,12 @@ function resetTimetableStyles(): void {
} }
async function handleTimetable(): Promise<void> { async function handleTimetable(): Promise<void> {
await waitForElm(".time", true, 10); // SEQTA uses `.times` blocks on entries, not necessarily `.time`; avoid infinite polling on a missing selector.
try {
await waitForElm(".timetablepage .times, .timetablepage .entry.class", true, 50, 200);
} catch {
/* timetable body may render after the shell */
}
// Convert time format if needed // Convert time format if needed
if (settingsState.timeFormat == "12") { if (settingsState.timeFormat == "12") {
+100 -17
View File
@@ -145,8 +145,10 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
let observer: MutationObserver | null = null; let observer: MutationObserver | null = null;
let quickbarObserver: MutationObserver | null = null; let quickbarObserver: MutationObserver | null = null;
let quickbarSyncTimer: ReturnType<typeof setTimeout> | null = null;
let lastClickedCi: number | null = null; let lastClickedCi: number | null = null;
let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null; let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null;
let lastSyncedQuickbarCi: number | null = null;
const getOverrides = (): TimetableOverrides => const getOverrides = (): TimetableOverrides =>
api.storage.timetableOverrides ?? {}; api.storage.timetableOverrides ?? {};
@@ -186,9 +188,11 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff; if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
} }
const captureClick = (e: MouseEvent) => { const captureClick = () => {
lastClickedCi = ci; lastClickedCi = ci;
lastClickedEntry = { roomEl, teacherEl, item }; lastClickedEntry = { roomEl, teacherEl, item };
lastSyncedQuickbarCi = null;
scheduleQuickbarSync();
}; };
entry.addEventListener("click", captureClick, true); entry.addEventListener("click", captureClick, true);
}; };
@@ -199,6 +203,76 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
}); });
}; };
const getVisibleClassQuickbar = (): HTMLElement | null => {
const quickbar = document.querySelector(
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.above.visible, .timetablepage .quickbar.visible",
);
if (!quickbar || quickbar.getAttribute("data-type") !== "class") return null;
return quickbar as HTMLElement;
};
const applyOverridesToQuickbar = (quickbar: HTMLElement): void => {
if (lastClickedCi === null) return;
if (lastSyncedQuickbarCi === lastClickedCi) return;
const description =
quickbar.querySelector(".title")?.textContent?.trim() ??
lastClickedEntry?.item.description ??
"";
const override = getEffectiveOverride(lastClickedCi, description);
if (!override) {
lastSyncedQuickbarCi = lastClickedCi;
return;
}
const roomEl = quickbar.querySelector(".meta .room");
const teacherEl = quickbar.querySelector(".meta .teacher");
if (override.room !== undefined && !roomEl) return;
if (override.staff !== undefined && !teacherEl) return;
if (override.room !== undefined && roomEl && roomEl.textContent !== override.room) {
roomEl.textContent = override.room;
}
if (override.staff !== undefined && teacherEl && teacherEl.textContent !== override.staff) {
teacherEl.textContent = override.staff;
}
lastSyncedQuickbarCi = lastClickedCi;
};
const updateVisibleQuickbar = (room: string, staff: string): void => {
const quickbar = getVisibleClassQuickbar();
if (!quickbar) return;
const roomEl = quickbar.querySelector(".meta .room");
const teacherEl = quickbar.querySelector(".meta .teacher");
if (roomEl && roomEl.textContent !== room) roomEl.textContent = room;
if (teacherEl && teacherEl.textContent !== staff) teacherEl.textContent = staff;
if (lastClickedCi !== null) lastSyncedQuickbarCi = lastClickedCi;
};
const syncClassQuickbar = (quickbar: HTMLElement): void => {
applyOverridesToQuickbar(quickbar);
addEditButtonToQuickbar(quickbar);
};
const scheduleQuickbarSync = (): void => {
if (quickbarSyncTimer !== null) clearTimeout(quickbarSyncTimer);
let attempts = 0;
const trySync = (): void => {
const quickbar = getVisibleClassQuickbar();
if (quickbar && lastClickedCi !== null) {
syncClassQuickbar(quickbar);
return;
}
if (++attempts < 6) {
quickbarSyncTimer = setTimeout(trySync, 50);
}
};
requestAnimationFrame(trySync);
};
const addEditButtonToQuickbar = (quickbar: HTMLElement) => { const addEditButtonToQuickbar = (quickbar: HTMLElement) => {
if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return; if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return;
@@ -251,6 +325,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
} }
if (entryData.roomEl) entryData.roomEl.textContent = room; if (entryData.roomEl) entryData.roomEl.textContent = room;
if (entryData.teacherEl) entryData.teacherEl.textContent = staff; if (entryData.teacherEl) entryData.teacherEl.textContent = staff;
updateVisibleQuickbar(room, staff);
processAllEntries(); processAllEntries();
}, },
(ci) => { (ci) => {
@@ -262,6 +337,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
api.storage.timetableOverridesBySubject = bySubject; api.storage.timetableOverridesBySubject = bySubject;
if (entryData.roomEl) entryData.roomEl.textContent = item.room; if (entryData.roomEl) entryData.roomEl.textContent = item.room;
if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff; if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff;
updateVisibleQuickbar(item.room, item.staff);
processAllEntries(); processAllEntries();
}, },
); );
@@ -271,30 +347,30 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
}; };
const syncQuickbarFromDOM = () => { const syncQuickbarFromDOM = () => {
const quickbar = document.querySelector(".timetablepage .quickbar.visible"); const quickbar = getVisibleClassQuickbar();
if (quickbar && quickbar.getAttribute("data-type") === "class") { if (!quickbar || lastClickedCi === null || !lastClickedEntry) return;
const titleEl = quickbar.querySelector(".title"); syncClassQuickbar(quickbar);
const roomEl = quickbar.querySelector(".meta .room");
const teacherEl = quickbar.querySelector(".meta .teacher");
if (titleEl && roomEl && teacherEl && lastClickedCi !== null && lastClickedEntry) {
addEditButtonToQuickbar(quickbar as HTMLElement);
}
}
}; };
const setupQuickbarObserver = () => { const setupQuickbarObserver = () => {
const timetablePage = document.querySelector(".timetablepage"); const timetablePage = document.querySelector(".timetablepage");
if (!timetablePage || quickbarObserver) return; if (!timetablePage || quickbarObserver) return;
quickbarObserver = new MutationObserver(() => { quickbarObserver = new MutationObserver((mutations) => {
const quickbar = document.querySelector(".timetablepage .quickbar.visible"); const quickbarBecameVisible = mutations.some(
if (quickbar?.getAttribute("data-type") === "class") { (mutation) =>
addEditButtonToQuickbar(quickbar as HTMLElement); mutation.type === "attributes" &&
} mutation.attributeName === "class" &&
(mutation.target as HTMLElement).classList.contains("quickbar") &&
(mutation.target as HTMLElement).classList.contains("visible"),
);
if (!quickbarBecameVisible || lastClickedCi === null) return;
const quickbar = getVisibleClassQuickbar();
if (quickbar) syncClassQuickbar(quickbar);
}); });
quickbarObserver.observe(timetablePage, { quickbarObserver.observe(timetablePage, {
childList: true,
subtree: true, subtree: true,
attributes: true, attributes: true,
attributeFilter: ["class"], attributeFilter: ["class"],
@@ -302,7 +378,13 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
}; };
const handleTimetable = async () => { const handleTimetable = async () => {
await waitForElm(".timetablepage .entry", true, 10, 100); // Class entries (`div.entry.class`) load after the page shell; don't fail the whole
// setup if they are slow or briefly absent (e.g. navigation). Observers still catch them.
try {
await waitForElm(".timetablepage .entry.class", true, 50, 300);
} catch {
/* entries may appear later */
}
processAllEntries(); processAllEntries();
setupQuickbarObserver(); setupQuickbarObserver();
syncQuickbarFromDOM(); syncQuickbarFromDOM();
@@ -326,6 +408,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
unregister(); unregister();
observer?.disconnect(); observer?.disconnect();
quickbarObserver?.disconnect(); quickbarObserver?.disconnect();
if (quickbarSyncTimer !== null) clearTimeout(quickbarSyncTimer);
styleEl.remove(); styleEl.remove();
document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => { document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => {
el.removeAttribute("data-timetable-edit-processed"); el.removeAttribute("data-timetable-edit-processed");
+2
View File
@@ -10,6 +10,7 @@ import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
import profilePicturePlugin from "./built-in/profilePicture"; import profilePicturePlugin from "./built-in/profilePicture";
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview"; import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
import backgroundMusicPlugin from "./built-in/backgroundMusic"; import backgroundMusicPlugin from "./built-in/backgroundMusic";
import messageFoldersPlugin from "./built-in/messageFolders";
//import testPlugin from './built-in/test'; //import testPlugin from './built-in/test';
// Heavy plugins (lazy-loaded only when enabled) // Heavy plugins (lazy-loaded only when enabled)
@@ -28,6 +29,7 @@ pluginManager.registerPlugin(timetableEditPlugin);
pluginManager.registerPlugin(profilePicturePlugin); pluginManager.registerPlugin(profilePicturePlugin);
pluginManager.registerPlugin(assessmentsOverviewPlugin); pluginManager.registerPlugin(assessmentsOverviewPlugin);
pluginManager.registerPlugin(backgroundMusicPlugin); pluginManager.registerPlugin(backgroundMusicPlugin);
pluginManager.registerPlugin(messageFoldersPlugin);
//pluginManager.registerPlugin(testPlugin); //pluginManager.registerPlugin(testPlugin);
// Register heavy plugins with lazy loading // Register heavy plugins with lazy loading
+164 -27
View File
@@ -17,14 +17,19 @@ import { StorageChangeHandler } from "@/seqta/utils/listeners/StorageChanges";
import { eventManager } from "@/seqta/utils/listeners/EventManager"; import { eventManager } from "@/seqta/utils/listeners/EventManager";
// UI and theme management // UI and theme management
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import RegisterClickListeners from "@/seqta/utils/listeners/ClickListeners"; import RegisterClickListeners from "@/seqta/utils/listeners/ClickListeners";
import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements"; import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements";
import { updateAllColors } from "@/seqta/ui/colors/Manager"; import { updateAllColors } from "@/seqta/ui/colors/Manager";
import loading from "@/seqta/ui/Loading"; import loading from "@/seqta/ui/Loading";
import { SendNewsPage } from "@/seqta/utils/SendNewsPage"; import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
import { getEngageRoutePage } from "@/seqta/utils/engageRoute";
import {
loadEngageHomePage,
updateEngageHomeMenuActive,
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup"; import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification";
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes"; import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
@@ -82,7 +87,13 @@ export function hideSideBar() {
} }
} }
let betterSeqtaFinishLoadDone = false;
let engageHashListenerAttached = false;
export async function finishLoad() { export async function finishLoad() {
if (betterSeqtaFinishLoadDone) return;
betterSeqtaFinishLoadDone = true;
try { try {
document.querySelector(".legacy-root")?.classList.remove("hidden"); document.querySelector(".legacy-root")?.classList.remove("hidden");
@@ -94,14 +105,7 @@ export async function finishLoad() {
console.error("Error during loading cleanup:", err); console.error("Error during loading cleanup:", err);
} }
// Check and show privacy statement notification (before what's new) void runStartupPopupQueue();
if (!document.getElementById("privacy-notification")) {
await showPrivacyNotification();
}
if (settingsState.justupdated && !document.getElementById("whatsnewbk") && !document.getElementById("privacy-notification")) {
OpenWhatsNewPopup();
}
} }
export function GetCSSElement(file: string) { export function GetCSSElement(file: string) {
@@ -115,19 +119,19 @@ export function GetCSSElement(file: string) {
} }
function removeThemeTagsFromNotices() { function removeThemeTagsFromNotices() {
// Grabs an array of the notice iFrames
const userHTMLArray = document.getElementsByClassName("userHTML"); const userHTMLArray = document.getElementsByClassName("userHTML");
// Iterates through the array, applying the iFrame css
for (const item of userHTMLArray) { for (const item of userHTMLArray) {
// Grabs the HTML of the body tag const iframe = item as HTMLIFrameElement;
const item1 = item as HTMLIFrameElement; try {
const body = item1.contentWindow!.document.querySelectorAll("body")[0]; const doc = iframe.contentDocument;
if (body) { if (!doc?.body) continue;
// Replaces the theme tag with nothing const body = doc.body;
const bodyText = body.innerHTML; const bodyText = body.innerHTML;
body.innerHTML = bodyText body.innerHTML = bodyText
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "") .replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " "); .replace(/ +/, " ");
} catch {
// Cross-origin or otherwise inaccessible iframe (common during Engage load / filter frames)
} }
} }
} }
@@ -202,7 +206,20 @@ function SortMessagePageItems(messagesParentElement: any) {
async function LoadPageElements(): Promise<void> { async function LoadPageElements(): Promise<void> {
await AddBetterSEQTAElements(); await AddBetterSEQTAElements();
const sublink: string | undefined = window.location.href.split("/")[4]; const sublink: string | undefined = isSeqtaEngageExperience()
? getEngageRoutePage()
: window.location.href.split("/")[4];
if (isSeqtaEngageExperience() && !engageHashListenerAttached) {
engageHashListenerAttached = true;
window.addEventListener("hashchange", () => {
if (getEngageRoutePage() === "home") {
void loadEngageHomePage();
} else {
updateEngageHomeMenuActive(false);
}
});
}
eventManager.register( eventManager.register(
"messagesAdded", "messagesAdded",
@@ -296,6 +313,28 @@ async function handleNotices(node: Element): Promise<void> {
} }
async function handleSublink(sublink: string | undefined): Promise<void> { async function handleSublink(sublink: string | undefined): Promise<void> {
if (isSeqtaEngageExperience()) {
switch (sublink) {
case undefined:
window.location.replace(
`${location.origin}/#?page=/${settingsState.defaultPage}`,
);
if (settingsState.defaultPage === "home") void loadEngageHomePage();
finishLoad();
break;
case "home":
window.location.replace(`${location.origin}/#?page=/home`);
console.info("[BetterSEQTA+] Started Init (SEQTA Engage home)");
if (settingsState.onoff) void loadEngageHomePage();
finishLoad();
break;
default:
finishLoad();
break;
}
return;
}
switch (sublink) { switch (sublink) {
case "news": case "news":
await handleNewsPage(); await handleNewsPage();
@@ -382,8 +421,11 @@ async function handleDashboard(node: Element): Promise<void> {
document.head.append(style); document.head.append(style);
await waitForElm(".dashlet", true, 10); await waitForElm(".dashlet", true, 10);
try {
const children = document.querySelectorAll(".dashboard > *");
if (children.length) {
animate( animate(
".dashboard > *", children,
{ opacity: [0, 1], y: [10, 0] }, { opacity: [0, 1], y: [10, 0] },
{ {
delay: stagger(0.1), delay: stagger(0.1),
@@ -391,6 +433,10 @@ async function handleDashboard(node: Element): Promise<void> {
ease: [0.22, 0.03, 0.26, 1], ease: [0.22, 0.03, 0.26, 1],
}, },
); );
}
} catch {
// Avoid uncaught errors if motion hits an unexpected DOM state during load.
}
document.head.querySelector("style.dashboardHider")?.remove(); document.head.querySelector("style.dashboardHider")?.remove();
} }
@@ -400,8 +446,11 @@ async function handleDocuments(node: Element): Promise<void> {
if (!settingsState.animations) return; if (!settingsState.animations) return;
await waitForElm(".document", true, 10); await waitForElm(".document", true, 10);
try {
const rows = document.querySelectorAll(".documents tbody tr.document");
if (rows.length) {
animate( animate(
".documents tbody tr.document", rows,
{ opacity: [0, 1], y: [10, 0] }, { opacity: [0, 1], y: [10, 0] },
{ {
delay: stagger(0.05), delay: stagger(0.05),
@@ -409,6 +458,10 @@ async function handleDocuments(node: Element): Promise<void> {
ease: [0.22, 0.03, 0.26, 1], ease: [0.22, 0.03, 0.26, 1],
}, },
); );
}
} catch {
// ignore
}
} }
async function handleReports(node: Element): Promise<void> { async function handleReports(node: Element): Promise<void> {
@@ -416,8 +469,11 @@ async function handleReports(node: Element): Promise<void> {
if (!settingsState.animations) return; if (!settingsState.animations) return;
await waitForElm(".report", true, 10); await waitForElm(".report", true, 10);
try {
const items = document.querySelectorAll(".reports .item");
if (items.length) {
animate( animate(
".reports .item", items,
{ opacity: [0, 1], y: [10, 0] }, { opacity: [0, 1], y: [10, 0] },
{ {
delay: stagger(0.05, { startDelay: 0.2 }), delay: stagger(0.05, { startDelay: 0.2 }),
@@ -425,6 +481,10 @@ async function handleReports(node: Element): Promise<void> {
ease: [0.22, 0.03, 0.26, 1], ease: [0.22, 0.03, 0.26, 1],
}, },
); );
}
} catch {
// ignore
}
} }
function CheckNoticeTextColour(notice: any) { function CheckNoticeTextColour(notice: any) {
@@ -448,7 +508,86 @@ function CheckNoticeTextColour(notice: any) {
); );
} }
function watchForEngageLogin() {
if (!document.querySelector(".login")) {
return;
}
const observer = new MutationObserver(() => {
if (!document.querySelector(".login")) {
observer.disconnect();
location.reload();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
/** Wait until Engage shows either the login shell or the main app (`#content`), so we never call `LoadPageElements` while still on login (which would hang on `waitForElm("#content")`). */
function waitForEngageLoginOrContent(): Promise<"login" | "app" | "timeout"> {
if (document.querySelector(".login")) {
return Promise.resolve("login");
}
if (document.getElementById("content")) {
return Promise.resolve("app");
}
return new Promise((resolve) => {
let settled = false;
const finish = (mode: "login" | "app") => {
if (settled) return;
settled = true;
mo.disconnect();
window.clearTimeout(tid);
resolve(mode);
};
const check = () => {
if (document.querySelector(".login")) finish("login");
else if (document.getElementById("content")) finish("app");
};
const mo = new MutationObserver(check);
mo.observe(document.documentElement, { subtree: true, childList: true });
const tid = window.setTimeout(() => {
if (settled) return;
mo.disconnect();
settled = true;
if (document.querySelector(".login")) resolve("login");
else if (document.getElementById("content")) resolve("app");
else {
console.warn(
"[BetterSEQTA+] Engage: timed out waiting for .login or #content; unblocking load UI.",
);
resolve("timeout");
}
}, 120_000);
});
}
export function tryLoad() { export function tryLoad() {
if (isSeqtaEngageExperience()) {
updateIframesWithDarkMode();
window.addEventListener("load", () => removeThemeTagsFromNotices(), { once: true });
const runEngageLoad = async () => {
const mode = await waitForEngageLoginOrContent();
if (mode === "login") {
finishLoad();
watchForEngageLogin();
return;
}
if (mode === "timeout") {
finishLoad();
void waitForElm("#content").then(() => void LoadPageElements());
return;
}
await LoadPageElements();
};
if (document.readyState === "complete") {
void runEngageLoad();
} else {
window.addEventListener("load", () => void runEngageLoad(), { once: true });
}
return;
}
waitForElm(".login").then(() => { waitForElm(".login").then(() => {
finishLoad(); finishLoad();
}); });
@@ -466,13 +605,10 @@ export function tryLoad() {
}); });
updateIframesWithDarkMode(); updateIframesWithDarkMode();
// Waits for page to call on load, run scripts window.addEventListener(
document.addEventListener(
"load", "load",
function () { () => removeThemeTagsFromNotices(),
removeThemeTagsFromNotices(); { once: true },
},
true,
); );
} }
@@ -489,6 +625,7 @@ function ReplaceMenuSVG(element: HTMLElement, svg: string) {
const processedSymbol = Symbol("processed"); const processedSymbol = Symbol("processed");
export async function ObserveMenuItemPosition() { export async function ObserveMenuItemPosition() {
if (isSeqtaEngageExperience()) return;
await waitForElm("#menu > ul > li"); await waitForElm("#menu > ul > li");
eventManager.register( eventManager.register(
Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+169 -4
View File
@@ -1,7 +1,11 @@
import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings"; import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { SendNewsPage } from "@/seqta/utils/SendNewsPage"; import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
import { attachNotificationsPanelAnimation } from "@/seqta/utils/attachNotificationsPanelAnimation";
import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton"; import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour"; import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { appendBackgroundToUI } from "./ImageBackgrounds"; import { appendBackgroundToUI } from "./ImageBackgrounds";
@@ -42,6 +46,17 @@ export async function getUserInfo() {
} }
export async function AddBetterSEQTAElements() { export async function AddBetterSEQTAElements() {
if (isSeqtaEngageExperience()) {
await waitForElm("#content");
addExtensionSettings();
if (settingsState.onoff) {
await injectEngageHomeButton();
}
void setupEngageSettingsButton();
void addEngageUserInfo();
return;
}
if (settingsState.onoff) { if (settingsState.onoff) {
if (settingsState.DarkMode) { if (settingsState.DarkMode) {
document.documentElement.classList.add("dark"); document.documentElement.classList.add("dark");
@@ -75,6 +90,7 @@ export async function AddBetterSEQTAElements() {
addExtensionSettings(); addExtensionSettings();
await createSettingsButton(); await createSettingsButton();
setupSettingsButton(); setupSettingsButton();
attachNotificationsPanelAnimation();
} }
function createHomeButton(fragment: DocumentFragment, _: HTMLElement) { function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
@@ -257,8 +273,9 @@ function setupEventListeners() {
}); });
} }
async function createSettingsButton() { async function createSettingsButton(parent?: Element) {
document.getElementById("content")!.append( const target = parent ?? document.getElementById("content")!;
target.append(
stringToHTML(/* html */ ` stringToHTML(/* html */ `
<button class="addedButton tooltip" id="AddedSettings"> <button class="addedButton tooltip" id="AddedSettings">
<svg width="24" height="24" viewBox="0 0 24 24"> <svg width="24" height="24" viewBox="0 0 24 24">
@@ -270,17 +287,165 @@ async function createSettingsButton() {
); );
} }
/** Engage mounts the sidebar inside batched React trees; EventManager-based waitForElm can miss `#menu`. Polling `waitForElm` matches the real DOM reliably. */
async function waitForEngageMenuList(): Promise<HTMLElement | null> {
const poll = true as const;
const interval = 100;
const trySelectors: { selector: string; maxIterations: number }[] = [
{ selector: "#menu > ul > li", maxIterations: 500 },
{ selector: "#menu ul", maxIterations: 350 },
{ selector: "#menu", maxIterations: 350 },
];
for (const { selector, maxIterations } of trySelectors) {
try {
await waitForElm(selector, poll, interval, maxIterations);
} catch {
continue;
}
if (selector === "#menu > ul > li") {
const ul = document.querySelector("#menu > ul") as HTMLElement | null;
if (ul) return ul;
} else if (selector === "#menu ul") {
const ul = document.querySelector("#menu ul") as HTMLElement | null;
if (ul) return ul;
} else {
const menu = document.getElementById("menu");
const ul =
(menu?.querySelector("ul") as HTMLElement | null) ??
(menu?.firstElementChild as HTMLElement | null);
if (ul) return ul;
}
}
console.warn(
"[BetterSEQTA+] Engage: could not find a menu list to inject the home button",
);
return null;
}
async function injectEngageHomeButton() {
if (document.getElementById("homebutton")) return;
const menuList = await waitForEngageMenuList();
if (!menuList || document.getElementById("homebutton")) return;
const li = stringToHTML(
/* html */ `<li class="item" data-key="home" id="homebutton" data-path="/home" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" /></svg><span>Home</span></label></li>`,
).firstChild as HTMLElement;
menuList.insertBefore(li, menuList.firstElementChild);
document.getElementById("homebutton")?.addEventListener("click", () => {
const btn = document.getElementById("homebutton") as HTMLElement;
if (
btn.classList.contains("draggable") ||
btn.classList.contains("active")
) {
return;
}
window.location.replace(`${location.origin}/#?page=/home`);
void loadEngageHomePage();
});
}
async function getEngageUserInfo() {
const response = await fetch(`${location.origin}/seqta/parent/login`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
mode: "normal",
query: null,
redirect_url: location.origin + "/",
}),
});
return (await response.json()).payload as {
userDesc: string | null;
userName: string | null;
userCode: string | null;
email: string | null;
type: string | null;
};
}
async function addEngageUserInfo() {
const content = await waitForElm("#content") as HTMLElement;
let info: Awaited<ReturnType<typeof getEngageUserInfo>>;
try {
info = await getEngageUserInfo();
} catch (error) {
console.error("[BetterSEQTA+] Failed to get Engage user info:", error);
return;
}
const displayName = info.userDesc || info.userName || "";
const subText = info.userCode || info.email || "";
const titlebar = document.createElement("div");
titlebar.classList.add("titlebar", "engage-titlebar");
titlebar.append(
stringToHTML(/* html */ `
<div class="userInfo">
<div class="userInfoText">
${displayName ? `<p class="userInfoName">${displayName}</p>` : ""}
${subText ? `<p class="userInfoCode">${subText}</p>` : ""}
</div>
</div>
`).firstChild!,
);
const iconNode = stringToHTML(/* html */ `
<div class="userInfosvgdiv tooltip" id="engage-logouttooltip-wrap">
<svg class="userInfosvg" viewBox="0 0 24 24"><path fill="var(--text-primary)" d="M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"></path></svg>
<div class="tooltiptext topmenutooltip" id="engage-logouttooltip">
<button class="logout engage-logout">
<svg style="width:20px;height:20px;vertical-align:middle;" viewBox="0 0 24 24"><path fill="currentColor" d="M17 7L15.59 8.41L18.17 11H8V13H18.17L15.59 15.58L17 17L22 12M4 5H12V3H4C2.9 3 2 3.9 2 5V19C2 20.1 2.9 21 4 21H12V19H4V5Z"/></svg>
</button>
</div>
</div>
`).firstChild!;
titlebar.append(iconNode);
content.appendChild(titlebar);
titlebar.querySelector<HTMLElement>(".engage-logout")?.addEventListener("click", async () => {
await fetch(`${location.origin}/seqta/parent/logout`, { method: "POST" });
location.reload();
});
}
async function setupEngageSettingsButton() {
try {
const notificationsWrapper = await waitForElm(
"#content > div.connectedNotificationsWrapper > div",
);
const parent = notificationsWrapper.parentElement!;
await addDarkLightToggle(parent);
await createSettingsButton(parent);
setupSettingsButton();
attachNotificationsPanelAnimation();
} catch {
await addDarkLightToggle();
await createSettingsButton();
setupSettingsButton();
attachNotificationsPanelAnimation();
}
}
function GetLightDarkModeString() { function GetLightDarkModeString() {
return settingsState.DarkMode return settingsState.DarkMode
? "Switch to light theme" ? "Switch to light theme"
: "Switch to dark theme"; : "Switch to dark theme";
} }
async function addDarkLightToggle() { async function addDarkLightToggle(parent?: Element) {
const SUN_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_80"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_80)"><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -4,-2.2100000381469727 -4,0 C-4,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`; const SUN_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_80"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_80)"><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -4,-2.2100000381469727 -4,0 C-4,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
const MOON_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_263"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_263)"><g style="display: block;" transform="matrix(1.5,0,0,1.5,7,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -1.2920000553131104,-2.2100000381469727 -1.2920000553131104,0 C-1.2920000553131104,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(-1,0,0,-1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`; const MOON_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_263"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_263)"><g style="display: block;" transform="matrix(1.5,0,0,1.5,7,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -1.2920000553131104,-2.2100000381469727 -1.2920000553131104,0 C-1.2920000553131104,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(-1,0,0,-1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
document.getElementById("content")!.append( const toggleTarget = parent ?? document.getElementById("content")!;
toggleTarget.append(
stringToHTML(/* html */ ` stringToHTML(/* html */ `
<button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton"> <button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton">
<svg xmlns="http://www.w3.org/2000/svg">${settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG}</svg> <svg xmlns="http://www.w3.org/2000/svg">${settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG}</svg>
+2 -5
View File
@@ -18,12 +18,9 @@ export class SettingsResizer {
if (!iframePopup) return; if (!iframePopup) return;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
const idealHeight = viewportHeight - 80 - 15; // -80px for the top of the popup const rawIdeal = viewportHeight - 80 - 15; // room below top chrome
const idealHeight = Math.min(Math.max(rawIdeal, 280), 600);
if (idealHeight > 600) {
iframePopup.style.height = "600px";
} else {
iframePopup.style.height = `${idealHeight}px`; iframePopup.style.height = `${idealHeight}px`;
} }
}
} }
+175 -2
View File
@@ -5,16 +5,100 @@ import { lightenAndPaleColor } from "./lightenAndPaleColor";
import ColorLuminance from "./ColorLuminance"; import ColorLuminance from "./ColorLuminance";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour"; import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
import { getCustomThemeAdaptiveCssVariableBindings } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
import darkLogo from "@/resources/icons/betterseqta-light-full.png"; import darkLogo from "@/resources/icons/betterseqta-light-full.png";
import lightLogo from "@/resources/icons/betterseqta-dark-full.png"; import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
const ADAPTIVE_THEME_TRANSITION_MS = 400;
let colorTransitionRafId: number | null = null;
let lastInterpolatedHex: string | null = null;
// Helper functions // Helper functions
const setCSSVar = (varName: any, value: any) => const setCSSVar = (varName: any, value: any) =>
document.documentElement.style.setProperty(varName, value); document.documentElement.style.setProperty(varName, value);
const applyProperties = (props: any) => const applyProperties = (props: any) =>
Object.entries(props).forEach(([key, value]) => setCSSVar(key, value)); Object.entries(props).forEach(([key, value]) => setCSSVar(key, value));
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
/** Best-effort parse of a single sRGB hex from a colour string (hex, rgb, or gradient). */
function parseRepresentativeHex(s: string): string | null {
if (!s || !s.trim()) return null;
const trimmed = s.trim();
try {
return Color(trimmed).hex();
} catch {
// continue
}
if (trimmed.includes("gradient")) {
const regex =
/#[0-9a-fA-F]{6}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)/gi;
const stops = trimmed.match(regex);
if (stops?.length) {
try {
return Color(stops[0]).hex();
} catch {
// continue
}
}
}
const hexMatch = trimmed.match(/#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})\b/);
if (hexMatch) {
try {
return Color(hexMatch[0]).hex();
} catch {
// continue
}
}
const rgbaMatch = trimmed.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
if (rgbaMatch) {
try {
return Color.rgb(
Number(rgbaMatch[1]),
Number(rgbaMatch[2]),
Number(rgbaMatch[3]),
).hex();
} catch {
// continue
}
}
return null;
}
function getFromHex(): string | null {
const fromComputed = parseRepresentativeHex(
getComputedStyle(document.documentElement).getPropertyValue("--better-main").trim(),
);
if (fromComputed) return fromComputed;
return lastInterpolatedHex;
}
function cancelColorTransition() {
if (colorTransitionRafId !== null) {
cancelAnimationFrame(colorTransitionRafId);
colorTransitionRafId = null;
}
}
function getRepresentativeRgbChannels(s: string): { r: number; g: number; b: number } | null {
const parsedHex = parseRepresentativeHex(s);
if (!parsedHex) return null;
try {
const [r, g, b] = Color(parsedHex).rgb().array();
return {
r: Math.round(r),
g: Math.round(g),
b: Math.round(b),
};
} catch {
return null;
}
}
function applyColorsWith(selectedColor: string) { function applyColorsWith(selectedColor: string) {
if (settingsState.transparencyEffects) { if (settingsState.transparencyEffects) {
document.documentElement.classList.add("transparencyEffects"); document.documentElement.classList.add("transparencyEffects");
@@ -59,6 +143,39 @@ function applyColorsWith(selectedColor: string) {
// Apply all the properties // Apply all the properties
applyProperties({ ...commonProps, ...modeProps, ...dynamicProps }); applyProperties({ ...commonProps, ...modeProps, ...dynamicProps });
if (settingsState.selectedTheme) {
const channels = getRepresentativeRgbChannels(selectedColor);
for (const binding of getCustomThemeAdaptiveCssVariableBindings()) {
if (!binding.channel) {
setCSSVar(binding.cssVarName, selectedColor);
continue;
}
if (!channels) {
continue;
}
if (binding.channel === "r") {
setCSSVar(binding.cssVarName, String(channels.r));
} else if (binding.channel === "g") {
setCSSVar(binding.cssVarName, String(channels.g));
} else {
setCSSVar(binding.cssVarName, String(channels.b));
}
}
}
// Let themes opt-in to overriding only adaptive accent output.
// A theme can define `--adaptive-better-main` from adaptive channel bindings.
if (settingsState.selectedTheme && settingsState.adaptiveThemeColour) {
const adaptiveOverride = getComputedStyle(document.documentElement)
.getPropertyValue("--adaptive-better-main")
.trim();
if (adaptiveOverride) {
setCSSVar("--better-main", adaptiveOverride);
}
}
let alliframes = document.getElementsByTagName("iframe"); let alliframes = document.getElementsByTagName("iframe");
for (let i = 0; i < alliframes.length; i++) { for (let i = 0; i < alliframes.length; i++) {
@@ -89,15 +206,71 @@ export async function updateAllColors() {
? settingsState.selectedColor ? settingsState.selectedColor
: "#007bff"; : "#007bff";
let adaptiveHex: string | null = null;
if (settingsState.adaptiveThemeColour) { if (settingsState.adaptiveThemeColour) {
const adaptiveColor = await getAdaptiveColour(); const adaptiveColor = await getAdaptiveColour();
if (adaptiveColor) { if (adaptiveColor) {
effectiveColor = adaptiveHex = adaptiveColor;
settingsState.adaptiveThemeGradient effectiveColor = settingsState.adaptiveThemeGradient
? toSoftGradient(adaptiveColor) ? toSoftGradient(adaptiveColor)
: adaptiveColor; : adaptiveColor;
} }
} }
const baseSelected =
settingsState.selectedColor !== "" ? settingsState.selectedColor : "#007bff";
const toHex =
adaptiveHex ?? parseRepresentativeHex(baseSelected);
const shouldAnimate =
settingsState.adaptiveThemeColour &&
(settingsState.adaptiveThemeColourTransition ?? true) &&
!!toHex;
const applyImmediate = () => {
cancelColorTransition();
applyColorsWith(effectiveColor); applyColorsWith(effectiveColor);
if (toHex) lastInterpolatedHex = toHex;
};
if (!shouldAnimate) {
applyImmediate();
return;
}
const fromHex = getFromHex();
if (!fromHex || !toHex || fromHex === toHex) {
applyImmediate();
return;
}
const useSoftGradientOnFrames =
!!adaptiveHex && !!settingsState.adaptiveThemeGradient;
cancelColorTransition();
const start = performance.now();
const step = (now: number) => {
const elapsed = now - start;
const t = Math.min(1, elapsed / ADAPTIVE_THEME_TRANSITION_MS);
const eased = easeInOutCubic(t);
const interpolatedHex = Color(fromHex).mix(Color(toHex), eased).hex();
const display = useSoftGradientOnFrames
? toSoftGradient(interpolatedHex)
: interpolatedHex;
applyColorsWith(display);
if (t < 1) {
colorTransitionRafId = requestAnimationFrame(step);
} else {
colorTransitionRafId = null;
applyColorsWith(effectiveColor);
lastInterpolatedHex = toHex;
}
};
colorTransitionRafId = requestAnimationFrame(step);
} }
@@ -0,0 +1,74 @@
/** Tracks which author-declared CSS variables mirror the effective accent; not persisted in settings storage. */
const VALID_CUSTOM_PROP = /^--[a-zA-Z0-9_-]{1,120}$/;
const VALID_CHANNEL = /^(r|g|b)$/;
export type AdaptiveChannel = "r" | "g" | "b";
export type AdaptiveCssVariableBinding = {
cssVarName: string;
channel?: AdaptiveChannel;
};
let boundBindings: AdaptiveCssVariableBinding[] = [];
function parseAdaptiveBinding(
rawBinding: string,
): AdaptiveCssVariableBinding | null {
const trimmed = rawBinding.trim();
if (!trimmed) return null;
const [rawName, rawChannel] = trimmed.split(":", 2);
const cssVarName = rawName?.trim() ?? "";
if (!VALID_CUSTOM_PROP.test(cssVarName)) return null;
if (!rawChannel) return { cssVarName };
const channel = rawChannel.trim().toLowerCase();
if (!VALID_CHANNEL.test(channel)) return null;
return { cssVarName, channel: channel as AdaptiveChannel };
}
export function normalizeAdaptiveCssVariableBindings(
names: string[] | undefined,
): AdaptiveCssVariableBinding[] {
if (!names?.length) return [];
const out: AdaptiveCssVariableBinding[] = [];
const seen = new Set<string>();
for (const raw of names) {
const parsed = parseAdaptiveBinding(raw);
if (!parsed) continue;
const key = `${parsed.cssVarName}:${parsed.channel ?? "full"}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(parsed);
}
return out;
}
export function setCustomThemeAdaptiveCssVariables(
names: string[] | undefined,
): void {
for (const binding of boundBindings) {
document.documentElement.style.removeProperty(binding.cssVarName);
}
boundBindings = normalizeAdaptiveCssVariableBindings(names);
}
export function getCustomThemeAdaptiveCssVariableBindings(): AdaptiveCssVariableBinding[] {
return boundBindings;
}
// Backward-compatible helper for existing callsites.
export function getCustomThemeAdaptiveCssVariables(): string[] {
return boundBindings.map((b) => b.cssVarName);
}
export function clearCustomThemeAdaptiveCssVariables(): void {
for (const binding of boundBindings) {
document.documentElement.style.removeProperty(binding.cssVarName);
}
boundBindings = [];
}
+50 -21
View File
@@ -3,6 +3,8 @@ interface ElementConfig {
action: (element: Element) => void; action: (element: Element) => void;
/** When true, element is not added to processedElements so the action runs every time (e.g. overwriting container content) */ /** When true, element is not added to processedElements so the action runs every time (e.g. overwriting container content) */
alwaysRun?: boolean; alwaysRun?: boolean;
/** When true, never add to processedElements so the action can run again after DOM resets (e.g. home day column) */
neverMarkProcessed?: boolean;
} }
interface ContentConfig { interface ContentConfig {
@@ -12,6 +14,12 @@ interface ContentConfig {
// Track processed elements to avoid re-randomizing // Track processed elements to avoid re-randomizing
const processedElements = new WeakSet<Element>(); const processedElements = new WeakSet<Element>();
/** Marks mock-generated `.day` rows so granular rules do not re-randomize them */
const MOCK_DAY_ATTR = "data-bsp-mock-day";
/** Skip MutationObserver-driven reprocessing while we inject the home mock (avoids feedback loops) */
let suppressMockMutations = false;
function debounce(func: Function, wait: number): Function { function debounce(func: Function, wait: number): Function {
let timeout: NodeJS.Timeout; let timeout: NodeJS.Timeout;
return function executedFunction(...args: any[]) { return function executedFunction(...args: any[]) {
@@ -44,19 +52,19 @@ function getRandomDate(): Date {
const contentConfig: ContentConfig = { const contentConfig: ContentConfig = {
lessonTitle: { lessonTitle: {
selector: ".day h2", selector: `.day:not([${MOCK_DAY_ATTR}]) h2`,
action: (element) => { action: (element) => {
element.textContent = getRandomElement(mockData.subjects); element.textContent = getRandomElement(mockData.subjects);
}, },
}, },
teacher: { teacher: {
selector: ".day h3:first-of-type", selector: `.day:not([${MOCK_DAY_ATTR}]) h3:first-of-type`,
action: (element) => { action: (element) => {
element.textContent = getRandomElement(mockData.teachers); element.textContent = getRandomElement(mockData.teachers);
}, },
}, },
classroom: { classroom: {
selector: ".day h3:last-of-type", selector: `.day:not([${MOCK_DAY_ATTR}]) h3:last-of-type`,
action: (element) => { action: (element) => {
element.textContent = getRandomElement(mockData.classrooms); element.textContent = getRandomElement(mockData.classrooms);
}, },
@@ -283,13 +291,28 @@ const contentConfig: ContentConfig = {
// Home page: replace entire day with mock schedule (care + 7 lessons 8:553:15) // Home page: replace entire day with mock schedule (care + 7 lessons 8:553:15)
homeDayContainer: { homeDayContainer: {
selector: "#day-container", selector: "#day-container",
alwaysRun: true, neverMarkProcessed: true,
action: (element) => { action: (element) => {
const container = element as HTMLElement; const container = element as HTMLElement;
if (!container.closest(".timetable-container")) return; // only on home if (!container.closest(".timetable-container")) return; // only on home
if (container.classList.contains("loading") || container.innerHTML.trim() === "") {
delete container.dataset.bspMockSchedule;
return;
}
if (
container.dataset.bspMockSchedule === "1" &&
container.querySelector(`[${MOCK_DAY_ATTR}]`)
) {
return;
}
suppressMockMutations = true;
const schedule = getMockDaySchedule(); const schedule = getMockDaySchedule();
container.innerHTML = schedule; container.innerHTML = schedule;
container.classList.remove("loading"); container.classList.remove("loading");
container.dataset.bspMockSchedule = "1";
requestAnimationFrame(() => {
suppressMockMutations = false;
});
}, },
}, },
}; };
@@ -665,7 +688,7 @@ function getMockDaySchedule(): string {
return blocks return blocks
.map( .map(
(b, i) => (b, i) =>
`<div class="day" style="--item-colour: ${colours[i % colours.length]};"> `<div class="day" ${MOCK_DAY_ATTR} style="--item-colour: ${colours[i % colours.length]};">
<h2>${b.title}</h2> <h2>${b.title}</h2>
<h3>${b.teacher}</h3> <h3>${b.teacher}</h3>
<h3>${b.room}</h3> <h3>${b.room}</h3>
@@ -758,12 +781,12 @@ const debouncedProcessElements = debounce(processNewElements, 1);
function processNewElements() { function processNewElements() {
Object.entries(contentConfig).forEach(([_, config]) => { Object.entries(contentConfig).forEach(([_, config]) => {
const { selector, action, alwaysRun } = config; const { selector, action, alwaysRun, neverMarkProcessed } = config;
const elements = document.querySelectorAll(selector); const elements = document.querySelectorAll(selector);
elements.forEach((element: Element) => { elements.forEach((element: Element) => {
if (alwaysRun || !processedElements.has(element)) { if (alwaysRun || neverMarkProcessed || !processedElements.has(element)) {
action(element); action(element);
if (!alwaysRun) { if (!alwaysRun && !neverMarkProcessed) {
processedElements.add(element); processedElements.add(element);
} }
} }
@@ -772,7 +795,6 @@ function processNewElements() {
} }
let observer: MutationObserver | null = null; let observer: MutationObserver | null = null;
let intervalId: NodeJS.Timeout | null = null;
export default function hideSensitiveContent() { export default function hideSensitiveContent() {
// Initial processing of existing elements // Initial processing of existing elements
@@ -781,6 +803,8 @@ export default function hideSensitiveContent() {
// Set up MutationObserver if not already created // Set up MutationObserver if not already created
if (!observer) { if (!observer) {
observer = new MutationObserver((mutations) => { observer = new MutationObserver((mutations) => {
if (suppressMockMutations) return;
let shouldProcess = false; let shouldProcess = false;
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
@@ -802,9 +826,25 @@ export default function hideSensitiveContent() {
}); });
} }
// Also trigger on large DOM replacements (like page navigation) // Large DOM replacements (e.g. page navigation). Skip only when #day-container gains many *mock* rows (our inject).
if (mutation.addedNodes.length > 5 || mutation.removedNodes.length > 5) { if (mutation.addedNodes.length > 5 || mutation.removedNodes.length > 5) {
const target = mutation.target as Element;
if (target.id === "day-container") {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as Element;
if (
el.classList?.contains("day") &&
!el.hasAttribute(MOCK_DAY_ATTR)
) {
shouldProcess = true; shouldProcess = true;
break;
}
}
}
} else {
shouldProcess = true;
}
} }
} }
@@ -833,13 +873,6 @@ export default function hideSensitiveContent() {
attributeFilter: ['class', 'id'] // Watch for class/id changes that might affect our selectors attributeFilter: ['class', 'id'] // Watch for class/id changes that might affect our selectors
}); });
} }
// Fallback: periodic check for new elements (especially useful for SPA navigation)
if (!intervalId) {
intervalId = setInterval(() => {
debouncedProcessElements();
}, 500); // Check every 500ms as a fallback
}
} }
// Function to stop observing (useful for cleanup) // Function to stop observing (useful for cleanup)
@@ -848,8 +881,4 @@ export function stopHidingSensitiveContent() {
observer.disconnect(); observer.disconnect();
observer = null; observer = null;
} }
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
} }
+20 -15
View File
@@ -9,21 +9,8 @@ import Settings from "@/interface/pages/settings.svelte";
let isSettingsRendered = false; let isSettingsRendered = false;
export function addExtensionSettings() { function extensionOutsideClickHandler(extensionPopup: HTMLElement) {
const extensionPopup = document.createElement("div"); return (event: MouseEvent) => {
extensionPopup.classList.add("outside-container", "hide");
extensionPopup.id = "ExtensionPopup";
const extensionContainer = document.querySelector(
"#container",
) as HTMLDivElement;
if (extensionContainer) extensionContainer.appendChild(extensionPopup);
const container = document.getElementById("container");
new SettingsResizer();
container!.onclick = (event) => {
if (!SettingsClicked) return; if (!SettingsClicked) return;
if (!(event.target as HTMLElement).closest("#AddedSettings")) { if (!(event.target as HTMLElement).closest("#AddedSettings")) {
@@ -33,6 +20,24 @@ export function addExtensionSettings() {
}; };
} }
export function addExtensionSettings() {
if (document.getElementById("ExtensionPopup")) return;
const extensionPopup = document.createElement("div");
extensionPopup.classList.add("outside-container", "hide");
extensionPopup.id = "ExtensionPopup";
const extensionContainer =
document.querySelector("#container") ?? document.getElementById("container");
const mountParent = extensionContainer ?? document.body;
mountParent.appendChild(extensionPopup);
new SettingsResizer();
const handler = extensionOutsideClickHandler(extensionPopup);
(extensionContainer ?? document.body).addEventListener("click", handler, false);
}
export function renderSettingsIfNeeded() { export function renderSettingsIfNeeded() {
if (isSettingsRendered) return; if (isSettingsRendered) return;
+40
View File
@@ -83,6 +83,25 @@ class CloudAuthService {
} }
} }
/** Pull cloud settings backup after a fresh sign-in (matches manual “Download from cloud”). */
private triggerCloudSettingsDownloadAfterLogin(accessToken: string): void {
void browser.runtime
.sendMessage({
type: "cloudSettingsDownload",
token: accessToken,
})
.then((res: unknown) => {
const r = res as { success?: boolean; notFound?: boolean; error?: string } | undefined;
if (r?.success || r?.notFound) return;
if (r?.error) {
console.warn("[BetterSEQTA+] Cloud settings download after login:", r.error);
}
})
.catch((err) => {
console.warn("[BetterSEQTA+] Cloud settings download after login failed:", err);
});
}
public async getStoredToken(): Promise<string | null> { public async getStoredToken(): Promise<string | null> {
const result = await browser.storage.local.get(STORAGE_KEYS.accessToken); const result = await browser.storage.local.get(STORAGE_KEYS.accessToken);
return (result[STORAGE_KEYS.accessToken] as string) ?? null; return (result[STORAGE_KEYS.accessToken] as string) ?? null;
@@ -108,6 +127,26 @@ class CloudAuthService {
return clientId; return clientId;
} }
public async startLogin(): Promise<{ success: boolean; error?: string }> {
try {
const clientId = await this.getClientId();
const result = (await browser.runtime.sendMessage({
type: "cloudStartLogin",
client_id: clientId,
redirect_uri: REDIRECT_URI,
})) as { success?: boolean; error?: string };
if (result?.success) {
return { success: true };
}
return { success: false, error: result?.error ?? "Failed to open login page" };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to open login page",
};
}
}
public async login( public async login(
login: string, login: string,
password: string password: string
@@ -135,6 +174,7 @@ class CloudAuthService {
user: result.user ?? null, user: result.user ?? null,
}; };
this.notify(); this.notify();
this.triggerCloudSettingsDownloadAfterLogin(result.access_token);
return { success: true }; return { success: true };
} }
return { return {
+65
View File
@@ -0,0 +1,65 @@
import browser from "webextension-polyfill";
const DEFAULT_BASE = "https://betterseqta.org";
const KEY = "bsplus_dev_api_base";
/**
* Returns the current content-API base URL.
*
* Reads from `sessionStorage` so a developer can temporarily override the
* server for testing. The value is cleared when the browser session ends,
* leaving production traffic unaffected for normal users.
*/
export function getApiBase(): string {
try {
if (typeof sessionStorage === "undefined") return DEFAULT_BASE;
const v = sessionStorage.getItem(KEY);
if (v && /^https?:\/\//.test(v)) return v.replace(/\/$/, "");
} catch {
// sessionStorage may throw in some restricted contexts; fall back silently.
}
return DEFAULT_BASE;
}
/**
* Persist a session-scoped override and broadcast it to the background script
* so its `fetch` calls hit the same host.
*
* Pass `null` to clear the override.
*/
export function setApiBase(url: string | null): void {
try {
if (!url) {
sessionStorage.removeItem(KEY);
} else {
sessionStorage.setItem(KEY, url.replace(/\/$/, ""));
}
} catch {
// ignore
}
void browser.runtime
.sendMessage({ type: "setDevApiBase", url: url || null })
.catch(() => {});
}
/** Returns the override URL if one is currently set in this session. */
export function getStoredOverride(): string | null {
try {
if (typeof sessionStorage === "undefined") return null;
return sessionStorage.getItem(KEY);
} catch {
return null;
}
}
/**
* Send the current session override to the background script.
* Call this early in page load so the background context stays in sync after
* service-worker restarts.
*/
export function syncApiBaseToBackground(): void {
const override = getStoredOverride();
void browser.runtime
.sendMessage({ type: "setDevApiBase", url: override })
.catch(() => {});
}
@@ -0,0 +1,835 @@
import { animate } from "motion";
import browser from "webextension-polyfill";
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
import debounce from "@/seqta/utils/debounce";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
import {
type EngageParentChild,
type EngageParentTimetableItem,
fetchEngageParentChildren,
fetchEngageParentTimetableWeek,
isDateInCachedWeek,
toISODate,
weekRangeContaining,
} from "@/seqta/utils/Loaders/engageParentTimetable";
export function updateEngageHomeMenuActive(isHome: boolean): void {
const home = document.getElementById("homebutton");
if (!home) return;
if (isHome) {
for (const el of document.querySelectorAll("#menu li.active")) {
if (el !== home) el.classList.remove("active");
}
home.classList.add("active");
} else {
home.classList.remove("active");
}
}
const STORAGE_KEY_STUDENT = () =>
`bsplus.engageTimetable.student.${location.origin}`;
let engageViewDate = new Date();
let engageWeekFrom = "";
let engageWeekUntil = "";
let engageWeekItems: EngageParentTimetableItem[] = [];
let engageSelectedStudentId: string | null = null;
let engageListenersCleanup: (() => void) | null = null;
function formatDateString(date: Date): string {
return `${date.toLocaleString("en-us", { weekday: "short" })} ${date.toLocaleDateString("en-au")}`;
}
function setEngageTimetableSubtitle(): void {
const el = document.getElementById("engage-home-lesson-subtitle");
if (!el) return;
const today = new Date();
const isSameMonth =
today.getFullYear() === engageViewDate.getFullYear() &&
today.getMonth() === engageViewDate.getMonth();
if (isSameMonth) {
const dayDiff = today.getDate() - engageViewDate.getDate();
switch (dayDiff) {
case 0:
el.textContent = "Today's Lessons";
break;
case 1:
el.textContent = "Yesterday's Lessons";
break;
case -1:
el.textContent = "Tomorrow's Lessons";
break;
default:
el.textContent = formatDateString(engageViewDate);
}
} else {
el.textContent = formatDateString(engageViewDate);
}
}
function makeEngageLessonDiv(
lesson: EngageParentTimetableItem,
index: number,
): HTMLElement {
let from = lesson.from?.substring(0, 5) ?? "";
let until = lesson.until?.substring(0, 5) ?? "";
if (settingsState.timeFormat === "12") {
from = convertTo12HourFormat(from);
until = convertTo12HourFormat(until);
}
const title =
lesson.type === "class"
? lesson.description
: lesson.type || "Lesson";
const div = document.createElement("div");
div.className = "day";
div.id = `engage-lesson-${lesson.code}-${index}`;
div.style.cssText = "--item-colour: #8e8e8e;";
const h2 = document.createElement("h2");
h2.textContent = title;
const hStaff = document.createElement("h3");
hStaff.textContent = lesson.staff?.trim() || "—";
const hRoom = document.createElement("h3");
hRoom.textContent = lesson.room?.trim() || "—";
const hTime = document.createElement("h4");
hTime.textContent = `${from} ${until}`;
const hPeriod = document.createElement("h5");
hPeriod.textContent = lesson.period?.trim() || "";
div.append(h2, hStaff, hRoom, hTime, hPeriod);
return div;
}
function renderEngageDayLessons(): void {
const dayContainer = document.getElementById("engage-day-container");
if (!dayContainer) return;
const dayStr = toISODate(engageViewDate);
const lessons = engageWeekItems
.filter((item) => item.date === dayStr)
.sort((a, b) => a.from.localeCompare(b.from));
dayContainer.innerHTML = "";
if (lessons.length === 0) {
dayContainer.innerHTML = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
<p>No lessons for this day.</p>
</div>`;
} else {
lessons.forEach((lesson, i) => {
dayContainer.appendChild(makeEngageLessonDiv(lesson, i));
});
}
dayContainer.classList.remove("loading");
setEngageTimetableSubtitle();
}
async function fetchWeekAndRender(): Promise<void> {
const dayContainer = document.getElementById("engage-day-container");
if (!dayContainer || !engageSelectedStudentId) return;
dayContainer.classList.add("loading");
dayContainer.innerHTML = "";
const { from, until } = weekRangeContaining(engageViewDate);
try {
engageWeekItems = await fetchEngageParentTimetableWeek(
from,
until,
engageSelectedStudentId,
);
engageWeekFrom = from;
engageWeekUntil = until;
} catch (e) {
console.error("[BetterSEQTA+] Engage parent timetable failed:", e);
engageWeekItems = [];
engageWeekFrom = from;
engageWeekUntil = until;
}
renderEngageDayLessons();
}
function shiftEngageDay(delta: number): void {
const next = new Date(engageViewDate);
next.setDate(next.getDate() + delta);
engageViewDate = next;
const dayContainer = document.getElementById("engage-day-container");
dayContainer?.classList.add("loading");
dayContainer && (dayContainer.innerHTML = "");
if (
engageWeekFrom &&
engageWeekUntil &&
isDateInCachedWeek(engageViewDate, engageWeekFrom, engageWeekUntil)
) {
renderEngageDayLessons();
} else {
void fetchWeekAndRender();
}
}
function populateChildSelector(
select: HTMLSelectElement,
children: EngageParentChild[],
): void {
select.innerHTML = "";
for (const c of children) {
const opt = document.createElement("option");
opt.value = c.id;
opt.textContent = c.name || `Student ${c.id}`;
select.appendChild(opt);
}
const stored = localStorage.getItem(STORAGE_KEY_STUDENT());
const validStored = stored && children.some((c) => c.id === stored);
engageSelectedStudentId = validStored
? stored!
: children[0]?.id ?? null;
if (engageSelectedStudentId) {
select.value = engageSelectedStudentId;
localStorage.setItem(STORAGE_KEY_STUDENT(), engageSelectedStudentId);
}
}
function bindEngageTimetableUi(): void {
engageListenersCleanup?.();
const cleanups: Array<() => void> = [];
const back = document.getElementById("engage-home-timetable-back");
const forward = document.getElementById("engage-home-timetable-forward");
const select = document.getElementById(
"engage-child-selector",
) as HTMLSelectElement | null;
const onBack = () => shiftEngageDay(-1);
const onForward = () => shiftEngageDay(1);
back?.addEventListener("click", onBack);
forward?.addEventListener("click", onForward);
cleanups.push(
() => back?.removeEventListener("click", onBack),
() => forward?.removeEventListener("click", onForward),
);
const onSelectChange = () => {
if (!select) return;
engageSelectedStudentId = select.value;
localStorage.setItem(STORAGE_KEY_STUDENT(), engageSelectedStudentId);
void fetchWeekAndRender();
};
select?.addEventListener("change", onSelectChange);
cleanups.push(() =>
select?.removeEventListener("change", onSelectChange),
);
engageListenersCleanup = () => {
cleanups.forEach((fn) => fn());
engageListenersCleanup = null;
};
}
/* ——— Notices (duplicated from Learn `LoadHomePage`; fetch uses `/seqta/parent/load/notices`.) ——— */
const ENGAGE_NOTICE_CONTAINER_ID = "engage-notice-container";
const ENGAGE_NOTICES_DATE_ID = "engage-notices-date";
function processEngageNoticeColor(colour: unknown): string | undefined {
if (typeof colour !== "string") return undefined;
const rgb = GetThresholdOfColor(colour);
if (rgb < 100 && settingsState.DarkMode) {
return undefined;
}
return colour;
}
function processEngageNotices(response: any, labelArray: string[]): void {
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
if (!noticeContainer) return;
noticeContainer.innerHTML = "";
const notices = response?.payload;
if (!Array.isArray(notices)) {
const emptyState = document.createElement("div");
emptyState.classList.add("day-empty");
const img = document.createElement("img");
img.src = browser.runtime.getURL(LogoLight);
const text = document.createElement("p");
text.innerText = "No notices for today.";
emptyState.append(img, text);
noticeContainer.append(emptyState);
return;
}
if (!notices.length) {
const emptyState = document.createElement("div");
emptyState.classList.add("day-empty");
const img = document.createElement("img");
img.src = browser.runtime.getURL(LogoLight);
const text = document.createElement("p");
text.innerText = "No notices for today.";
emptyState.append(img, text);
noticeContainer.append(emptyState);
return;
}
const fragment = document.createDocumentFragment();
notices.forEach((notice: any) => {
const shouldInclude =
settingsState.mockNotices ||
labelArray.length === 0 ||
labelArray.includes(JSON.stringify(notice.label));
if (shouldInclude) {
const colour = processEngageNoticeColor(notice.colour);
const noticeElement = createEngageNoticeElement(notice, colour);
fragment.appendChild(noticeElement);
}
});
noticeContainer.appendChild(fragment);
}
function createEngageNoticeElement(
notice: any,
colour: string | undefined,
): Node {
const textPreview =
notice.contents
.replace(/<[^>]*>/g, "")
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/\s+/g, " ")
.trim()
.substring(0, 150) + (notice.contents.length > 150 ? "..." : "");
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const htmlContent = `
<div class="notice-unified-content notice-card-state" data-notice-id="${noticeId}" style="--colour: ${colour || "#8e8e8e"}; position: relative; background: var(--background-primary); cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
${notice.label_title || "General"}
</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn" style="opacity: 0; pointer-events: none;">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${textPreview}</div>
</div>`;
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
element.addEventListener("click", () =>
openEngageNoticeModal(notice, colour, element),
);
return element;
}
function openEngageNoticeModal(
notice: any,
colour: string | undefined,
sourceElement: HTMLElement,
) {
const cleanContent = notice.contents
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " ");
document.getElementById("notice-modal")?.remove();
const sourceRect = sourceElement.getBoundingClientRect();
let scrollY = Math.round(window.scrollY);
let scrollX = Math.round(window.scrollX);
let sourceLeft = sourceRect.left;
let sourceTop = sourceRect.top;
let sourceWidth = sourceRect.width;
let sourceHeight = sourceRect.height;
const modalHtml = `
<div id="notice-modal" class="notice-modal-overlay" style="opacity: 0;">
<div class="notice-modal-transition" style="
position: fixed;
left: ${sourceLeft + scrollX}px;
top: ${sourceTop + scrollY}px;
width: ${sourceWidth}px;
height: ${sourceHeight}px;
transform-origin: center;
z-index: 10001;
">
<div class="notice-modal-content notice-transitioning">
<div class="notice-unified-content notice-card-state">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
${notice.label_title || "General"}
</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
</div>
</div>
</div>
</div>`;
const modal = stringToHTML(modalHtml).firstChild as HTMLElement;
const transitionContainer = modal.querySelector(
".notice-modal-transition",
) as HTMLElement;
const unifiedContent = modal.querySelector(
".notice-unified-content",
) as HTMLElement;
const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement;
document.body.appendChild(modal);
sourceElement.setAttribute("data-transitioning", "true");
sourceElement.style.opacity = "0";
sourceElement.style.transform = "scale(0.95)";
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let targetWidth = Math.round(
Math.min(Math.max(sourceWidth, 800), viewportWidth - 40),
);
const tempMeasureDiv = document.createElement("div");
tempMeasureDiv.style.position = "absolute";
tempMeasureDiv.style.left = "-9999px";
tempMeasureDiv.style.width = targetWidth + "px";
tempMeasureDiv.style.visibility = "hidden";
tempMeasureDiv.innerHTML = `
<div class="notice-unified-content notice-modal-state" style="position: relative; width: 100%; padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1);">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge">${notice.label_title || "General"}</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
</div>
`;
document.body.appendChild(tempMeasureDiv);
const measuredHeight =
tempMeasureDiv.firstElementChild!.getBoundingClientRect().height;
document.body.removeChild(tempMeasureDiv);
let targetHeight = Math.round(
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
);
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
const closeModal = () => {
window.removeEventListener("resize", handleResize);
document.removeEventListener("keydown", handleEscape);
if (!settingsState.animations) {
modal.remove();
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
sourceElement.removeAttribute("data-transitioning");
return;
}
animate(
modal,
{
backgroundColor: ["rgba(0, 0, 0, 0.5)", "rgba(0, 0, 0, 0)"],
backdropFilter: ["blur(4px)", "blur(0px)"],
},
{ duration: 0.2 },
);
animate(
transitionContainer,
{ opacity: [1, 0] },
{ duration: 0.2, delay: 0.3 },
);
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
modal.style.pointerEvents = "none";
animate(
transitionContainer,
{
left: [targetLeft + scrollX, sourceLeft + scrollX],
top: [targetTop, sourceTop + scrollY],
width: [targetWidth, sourceWidth],
height: [targetHeight, sourceHeight],
scale: [1, 1],
},
{
duration: 0.35,
type: "spring",
stiffness: 400,
damping: 35,
},
).finished.then(async () => {
modal.remove();
sourceElement.removeAttribute("data-transitioning");
});
};
closeBtn?.addEventListener("click", closeModal);
modal?.addEventListener("click", (e) => {
if (e.target === modal) {
closeModal();
}
});
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeModal();
document.removeEventListener("keydown", handleEscape);
window.removeEventListener("resize", handleResize);
}
};
document.addEventListener("keydown", handleEscape);
const handleResize = () => {
const newSourceRect = sourceElement.getBoundingClientRect();
const newScrollY = Math.round(window.scrollY);
const newScrollX = Math.round(window.scrollX);
const computedStyle = getComputedStyle(sourceElement);
const transform = computedStyle.transform;
let scaleX = 1,
scaleY = 1;
if (transform && transform !== "none") {
const matrix = transform.match(/matrix.*\((.+)\)/);
if (matrix) {
const values = matrix[1].split(", ");
scaleX = parseFloat(values[0]);
scaleY = parseFloat(values[3]);
}
}
const newSourceWidth = newSourceRect.width / scaleX;
const newSourceHeight = newSourceRect.height / scaleY;
const deltaX = (newSourceWidth - newSourceRect.width) / 2;
const deltaY = (newSourceHeight - newSourceRect.height) / 2;
const newSourceLeft = newSourceRect.left - deltaX;
const newSourceTop = newSourceRect.top - deltaY;
const newViewportWidth = window.innerWidth;
const newViewportHeight = window.innerHeight;
const newTargetWidth = Math.round(
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
);
const currentHeight = unifiedContent.getBoundingClientRect().height;
const newTargetHeight = Math.round(
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
);
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
const newTargetTop =
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
transitionContainer.style.left =
Math.round(newTargetLeft + newScrollX) + "px";
transitionContainer.style.top = Math.round(newTargetTop) + "px";
transitionContainer.style.width = Math.round(newTargetWidth) + "px";
transitionContainer.style.height = Math.round(newTargetHeight) + "px";
sourceLeft = newSourceLeft;
sourceTop = newSourceTop;
sourceWidth = newSourceWidth;
sourceHeight = newSourceHeight;
targetLeft = newTargetLeft;
targetTop = newTargetTop;
targetWidth = newTargetWidth;
targetHeight = newTargetHeight;
scrollY = newScrollY;
scrollX = newScrollX;
};
window.addEventListener("resize", handleResize);
if (settingsState.animations) {
animate(modal, { opacity: [0, 1] }, { duration: 0.2 });
animate(
transitionContainer,
{
left: [sourceLeft + scrollX, targetLeft + scrollX],
top: [sourceTop + scrollY, targetTop],
width: [sourceWidth, targetWidth],
height: [sourceHeight, targetHeight],
scale: [1, 1],
},
{
duration: 0.5,
type: "spring",
stiffness: 280,
damping: 24,
},
);
unifiedContent.classList.remove("notice-card-state");
unifiedContent.classList.add("notice-modal-state");
} else {
modal.style.opacity = "1";
transitionContainer.style.left = Math.round(targetLeft + scrollX) + "px";
transitionContainer.style.top = Math.round(targetTop) + "px";
transitionContainer.style.width = Math.round(targetWidth) + "px";
transitionContainer.style.height = Math.round(targetHeight) + "px";
unifiedContent.classList.remove("notice-card-state");
unifiedContent.classList.add("notice-modal-state");
}
}
async function fetchEngageNoticesFromApi(
date: string,
labelTokens: string[],
): Promise<void> {
try {
const data = settingsState.mockNotices
? getMockNotices()
: await (
await fetch(`${location.origin}/seqta/parent/load/notices`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({ date }),
})
).json();
processEngageNotices(data, labelTokens);
} catch (e) {
console.warn("[BetterSEQTA+] Engage notices request failed:", e);
processEngageNotices({ payload: [] }, labelTokens);
}
}
function bindEngageNoticesDateInput(
labelTokens: string[],
initialDate: string,
): () => void {
const dateControl = document.getElementById(
ENGAGE_NOTICES_DATE_ID,
) as HTMLInputElement | null;
if (!dateControl) {
return () => {};
}
dateControl.value = initialDate;
const debouncedInputChange = debounce((e: Event) => {
void fetchEngageNoticesFromApi(
(e.target as HTMLInputElement).value,
labelTokens,
);
}, 250);
dateControl.addEventListener("input", debouncedInputChange);
return () => dateControl.removeEventListener("input", debouncedInputChange);
}
async function initEngageNoticesUi(todayFormatted: string): Promise<void> {
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
if (!noticeContainer) return;
let labelFilterValues: string[] = [];
try {
const prefsRes = await fetch(`${location.origin}/seqta/parent/load/prefs?`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({ asArray: true, request: "userPrefs" }),
});
const prefs = await prefsRes.json();
const payload = prefs?.payload;
if (Array.isArray(payload)) {
labelFilterValues = payload
.filter((item: { name?: string }) => item.name === "notices.filters")
.map((item: { value?: string }) => item.value)
.filter((v): v is string => typeof v === "string");
}
} catch {
labelFilterValues = [];
}
const labelTokens =
labelFilterValues.length > 0
? String(labelFilterValues[0]).split(" ").filter(Boolean)
: [];
const dateControl = document.getElementById(ENGAGE_NOTICES_DATE_ID);
if (dateControl) {
(dateControl as HTMLInputElement).value = todayFormatted;
}
await fetchEngageNoticesFromApi(todayFormatted, labelTokens);
const cleanup = bindEngageNoticesDateInput(labelTokens, todayFormatted);
engageMergeNoticeCleanup(cleanup);
noticeContainer.classList.remove("loading");
}
function engageMergeNoticeCleanup(noticeCleanup: () => void): void {
const prev = engageListenersCleanup;
engageListenersCleanup = () => {
prev?.();
noticeCleanup();
};
}
function showEngageTimetableError(message: string): void {
const dayContainer = document.getElementById("engage-day-container");
if (!dayContainer) return;
dayContainer.classList.remove("loading");
dayContainer.innerHTML = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
<p>${message}</p>
</div>`;
}
function showEngageNoticesSectionError(message: string): void {
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
if (!noticeContainer) return;
noticeContainer.classList.remove("loading");
noticeContainer.innerHTML = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
<p>${message}</p>
</div>`;
}
/** SEQTA Engage parent home: child timetable (today view) using parent APIs. */
export async function loadEngageHomePage(): Promise<void> {
updateEngageHomeMenuActive(true);
document.title = "Home ― SEQTA Engage";
let main: HTMLElement;
try {
/* Engage mounts `#main` after React hydrates; a single rAF often loses the race on cold load. */
main = (await waitForElm("#main", true, 100, 200)) as HTMLElement;
} catch {
console.warn(
"[BetterSEQTA+] Engage home: timed out waiting for #main (shell not ready).",
);
return;
}
engageListenersCleanup?.();
engageViewDate = new Date();
main.innerHTML = "";
/* `stringToHTML` returns `document.body`; use firstElementChild so we don't append a whitespace text node (which would drop #engage-home-container and break queries). */
const engageHomeBody = stringToHTML(/* html */ `
<div class="home-root" id="engage-home-root">
<div class="home-container" id="engage-home-container">
<div class="border timetable-container">
<div class="home-subtitle">
<div class="engage-timetable-title-cluster">
<h2 id="engage-home-lesson-subtitle">Today's Lessons</h2>
<select id="engage-child-selector" class="engage-child-select" aria-label="Student"></select>
</div>
<div class="timetable-arrows">
<svg width="24" height="24" viewBox="0 0 24 24" style="transform: scale(-1,1)" id="engage-home-timetable-back">
<g style="fill: currentcolor;"><path d="M8.578 16.359l4.594-4.594-4.594-4.594 1.406-1.406 6 6-6 6z"></path></g>
</svg>
<svg width="24" height="24" viewBox="0 0 24 24" id="engage-home-timetable-forward">
<g style="fill: currentcolor;"><path d="M8.578 16.359l4.594-4.594-4.594-4.594 1.406-1.406 6 6-6 6z"></path></g>
</svg>
</div>
</div>
<div class="day-container loading" id="engage-day-container"></div>
</div>
<div class="border notices-container">
<div style="display: flex; justify-content: space-between">
<h2 class="home-subtitle">Notices</h2>
<input type="date" id="engage-notices-date" />
</div>
<div class="notice-container upcoming-items loading" id="engage-notice-container"></div>
</div>
</div>
</div>
`);
const engageHomeRoot = engageHomeBody.firstElementChild as HTMLElement | null;
if (engageHomeRoot) {
main.appendChild(engageHomeRoot);
} else {
console.error(
"[BetterSEQTA+] Engage home: parsed markup had no root element (check DOMPurify / stringToHTML).",
);
return;
}
bindEngageTimetableUi();
setEngageTimetableSubtitle();
const select = document.getElementById(
"engage-child-selector",
) as HTMLSelectElement | null;
const todayFormatted = toISODate(new Date());
let children: EngageParentChild[];
try {
try {
children = await fetchEngageParentChildren();
} catch (e) {
console.error("[BetterSEQTA+] Engage parent child list failed:", e);
showEngageTimetableError("Could not load students for this account.");
return;
}
if (!select) {
showEngageTimetableError("Could not initialize the home view.");
showEngageNoticesSectionError("Could not initialize notices.");
return;
}
if (children.length === 0) {
select.disabled = true;
showEngageTimetableError("No linked students found.");
return;
}
populateChildSelector(select, children);
if (!engageSelectedStudentId) {
showEngageTimetableError("No student selected.");
return;
}
await fetchWeekAndRender();
} finally {
await initEngageNoticesUi(todayFormatted);
}
}
@@ -0,0 +1,87 @@
const TIMETABLE_URL = "/seqta/parent/load/timetable";
export interface EngageParentChild {
name: string;
id: string;
}
export interface EngageParentTimetableItem {
date: string;
period: string;
code: string;
description: string;
staff: string;
type: string;
room: string;
from: string;
until: string;
programmeID?: number;
metaID?: number;
assessments?: unknown[];
}
export function toISODate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** MondaySunday range (inclusive) containing `date`, as YYYY-MM-DD. */
export function weekRangeContaining(date: Date): { from: string; until: string } {
const local = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const dow = local.getDay();
const diff = dow === 0 ? -6 : 1 - dow;
local.setDate(local.getDate() + diff);
const monday = local;
const sunday = new Date(monday);
sunday.setDate(sunday.getDate() + 6);
return { from: toISODate(monday), until: toISODate(sunday) };
}
function isInWeekRange(
isoDay: string,
weekFrom: string,
weekUntil: string,
): boolean {
return isoDay >= weekFrom && isoDay <= weekUntil;
}
export function isDateInCachedWeek(
date: Date,
weekFrom: string,
weekUntil: string,
): boolean {
return isInWeekRange(toISODate(date), weekFrom, weekUntil);
}
async function postParentTimetable(body: object): Promise<any> {
const res = await fetch(`${location.origin}${TIMETABLE_URL}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify(body),
});
return res.json();
}
export async function fetchEngageParentChildren(): Promise<EngageParentChild[]> {
const data = await postParentTimetable({ list: true });
const raw = data?.payload;
if (!Array.isArray(raw)) return [];
return raw.map((row: { name?: string; id?: string | number }) => ({
name: String(row?.name ?? ""),
id: String(row?.id ?? ""),
}));
}
export async function fetchEngageParentTimetableWeek(
from: string,
until: string,
studentId: string,
): Promise<EngageParentTimetableItem[]> {
const student = /^\d+$/.test(studentId) ? Number(studentId) : studentId;
const data = await postParentTimetable({ from, until, student });
const items = data?.payload?.items;
return Array.isArray(items) ? items : [];
}
@@ -0,0 +1,71 @@
import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState";
import { openPopup } from "./PopupManager";
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
/** Same hosting pattern as the What's New update video (GitHub raw). */
const BS_CLOUD_DEMO_VIDEO_URL =
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/bsclouddemo.webm";
export function shouldShowBsCloudAutoSyncAnnouncement(): boolean {
return !settingsState.bsCloudAutoSyncAnnouncementShown;
}
/**
* One-time announcement for BetterSEQTA Cloud automatic settings sync (after other startup popups).
* Video layout matches {@link OpenWhatsNewPopup} (`whatsnewImgContainer` / `whatsnewImg`).
*/
export function showBsCloudAutoSyncAnnouncement(onDismissed?: () => void) {
if (document.getElementById("whatsnewbk")) {
onDismissed?.();
return;
}
if (!shouldShowBsCloudAutoSyncAnnouncement()) {
onDismissed?.();
return;
}
const header = stringToHTML(
/* html */
`<div class="whatsnewHeader bsCloudAutoSyncAnnouncementHeader">
<h1>BetterSEQTA Cloud</h1>
</div>`,
).firstChild as HTMLElement;
const imageContainer = document.createElement("div");
imageContainer.classList.add("whatsnewImgContainer");
const video = document.createElement("video");
const source = document.createElement("source");
source.setAttribute("src", BS_CLOUD_DEMO_VIDEO_URL);
source.setAttribute("type", "video/webm");
video.autoplay = true;
video.muted = true;
video.loop = true;
video.appendChild(source);
video.classList.add("whatsnewImg");
imageContainer.appendChild(video);
attachPopupMediaFullscreen(video);
const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer privacyStatement" style="height: 50%; overflow-y: auto; font-size: 1.2rem; line-height: 1.6;">
<p>
<strong class="bsCloudAccent">BetterSEQTA Cloud</strong> can keep your BetterSEQTA+ settings backed up and in
sync across browsers. Optional <strong>automatic settings sync</strong> runs when you are signed in (passwords
and tokens are never included).
</p>
<p>
Close this dialog when you are done. We will not show this announcement again.
</p>
<p class="bsCloudAutoSyncSignupCallout">Sign up in BetterSEQTA settings</p>
</div>
`).firstChild as HTMLElement;
settingsState.bsCloudAutoSyncAnnouncementShown = true;
openPopup({
header,
content: [imageContainer, text],
afterClose: onDismissed,
});
}
@@ -0,0 +1,55 @@
import { settingsState } from "../listeners/SettingsState";
import { animate as motionAnimate } from "motion";
export function shouldShowEngageParentsAnnouncement(): boolean {
return !settingsState.engageParentsAnnouncementShown;
}
/**
* Non-blocking bottom-right toast announcing SEQTA Engage support. Shown once.
*/
export function showEngageParentsToast() {
if (!shouldShowEngageParentsAnnouncement()) return;
settingsState.engageParentsAnnouncementShown = true;
const toast = document.createElement("div");
toast.className = "bsplus-toast engageParentsToast";
toast.innerHTML = /* html */ `
<button class="bsplus-toast-close" aria-label="Dismiss">&times;</button>
<div class="bsplus-toast-content">
<p class="bsplus-toast-eyebrow">SEQTA Engage support</p>
<strong>BetterSEQTA+ now supports <span class="seqtaEngageAccent">SEQTA Engage</span></strong>
<p>Buy your mum a BetterSEQTA Plus! Parents now get themes, a cleaner home page, and all the Plus polish on SEQTA Engage.</p>
</div>
`;
toast.style.opacity = "0";
document.getElementById("container")?.append(toast);
if (settingsState.animations) {
(motionAnimate as any)(
toast,
{ opacity: [0, 1], y: [40, 0] },
{ duration: 0.35, easing: [0.22, 0.03, 0.26, 1] },
);
} else {
toast.style.opacity = "1";
}
const dismiss = () => {
if (settingsState.animations) {
(motionAnimate as any)(
toast,
{ opacity: [1, 0], y: [0, 40] },
{ duration: 0.2, easing: [0.22, 0.03, 0.26, 1] },
).then(() => toast.remove());
} else {
toast.remove();
}
};
toast.querySelector(".bsplus-toast-close")!.addEventListener("click", dismiss);
setTimeout(dismiss, 10000);
}
@@ -1,13 +1,31 @@
import stringToHTML from "../stringToHTML"; import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState"; import { settingsState } from "../listeners/SettingsState";
import { openPopup } from "./PopupManager"; import { openPopup } from "./PopupManager";
import { attachPopupMediaFullscreenIfPresent } from "./attachPopupMediaFullscreen";
export function showPrivacyNotification() { const PRIVACY_STATEMENT_VERSION = "2025-12-19";
const lastUpdated = "2025-12-19";
if (document.getElementById("whatsnewbk")) return; export function shouldShowPrivacyNotification(): boolean {
if (settingsState.privacyStatementShown) return; if (settingsState.privacyStatementShown) return false;
if (settingsState.privacyStatementLastUpdated && new Date(settingsState.privacyStatementLastUpdated) > new Date(lastUpdated)) return; if (
settingsState.privacyStatementLastUpdated &&
new Date(settingsState.privacyStatementLastUpdated) >
new Date(PRIVACY_STATEMENT_VERSION)
) {
return false;
}
return true;
}
export function showPrivacyNotification(onDismissed?: () => void) {
if (document.getElementById("whatsnewbk")) {
onDismissed?.();
return;
}
if (!shouldShowPrivacyNotification()) {
onDismissed?.();
return;
}
const header = stringToHTML( const header = stringToHTML(
/* html */ /* html */
@@ -42,11 +60,14 @@ export function showPrivacyNotification() {
</div> </div>
`).firstChild as HTMLElement; `).firstChild as HTMLElement;
attachPopupMediaFullscreenIfPresent(text, "img.aboutImg");
settingsState.privacyStatementLastUpdated = "2025-12-20"; settingsState.privacyStatementLastUpdated = "2025-12-20";
settingsState.privacyStatementShown = true; settingsState.privacyStatementShown = true;
openPopup({ openPopup({
header, header,
content: [text], content: [text],
afterClose: onDismissed,
}); });
} }
@@ -0,0 +1,218 @@
import browser from "webextension-polyfill";
import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState";
import { closePopup } from "./PopupManager";
import { getApiBase } from "../DevApiBase";
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
import { cloudAuth } from "../CloudAuth";
/**
* Server response shape from `/api/theme-of-the-month/current`.
* Hero image is resolved client-side via the theme store API when `theme_id` is set.
*/
export interface ThemeOfTheMonthEntry {
id: string;
month: string;
title: string;
description: string;
cover_image: string | null;
theme_id: string | null;
theme: { id: string; name: string; slug: string } | null;
created_at: number;
updated_at: number;
}
/**
* Fetches the current month's Theme of the Month entry from the API.
* Returns `null` when no entry is configured for this month, or when the
* request fails (we never want a network problem to block other startup
* popups).
*/
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
try {
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
cache: "no-store",
});
if (!res.ok) return null;
const text = await res.text();
if (!text) return null;
const data = JSON.parse(text);
if (!data || typeof data !== "object" || !data.id) return null;
return data as ThemeOfTheMonthEntry;
} catch (err) {
console.warn("[ThemeOfTheMonth] Failed to fetch current entry:", err);
return null;
}
}
/** True when we have a new monthly entry the user hasn't dismissed yet. */
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
return settingsState.themeOfTheMonthLastSeenId !== entry.id;
}
function escapeHTML(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatMonthLabel(month: string): string {
const [yyyy, mm] = month.split("-");
if (!yyyy || !mm) return month;
const date = new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, 1);
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
}
/** Same priority as the theme store: marquee, then cover/banner. */
function heroUrlFromStoreTheme(theme: {
marqueeImage?: string | null;
coverImage?: string | null;
}): string | null {
const url = (theme.marqueeImage || theme.coverImage || "").trim();
return url || null;
}
/**
* Loads hero image for a store theme via the background script (same path as
* {@link ThemeSelector} / theme store detail fetches).
*/
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
try {
const token = await cloudAuth.getStoredToken();
const res = (await browser.runtime.sendMessage({
type: "fetchThemeDetails",
themeId,
token: token ?? undefined,
})) as { success?: boolean; data?: { theme?: { marqueeImage?: string; coverImage?: string } } };
if (!res?.success || !res?.data?.theme) return null;
return heroUrlFromStoreTheme(res.data.theme);
} catch (err) {
console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err);
return null;
}
}
/** Linked theme store image, else optional admin-uploaded cover. */
async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise<string | null> {
const themeId = entry.theme_id ?? entry.theme?.id;
if (themeId) {
const fromStore = await fetchThemeStoreHeroImage(themeId);
if (fromStore) return fromStore;
}
const fallback = entry.cover_image?.trim();
return fallback || null;
}
function closeThemeOfTheMonthCard(
card: HTMLElement,
onDismissed?: () => void,
markSeen = true,
) {
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
if (markSeen) {
const entryId = card.dataset.entryId;
if (entryId) settingsState.themeOfTheMonthLastSeenId = entryId;
}
card.classList.add("themeOfTheMonthCardClosing");
window.setTimeout(() => {
card.remove();
onDismissed?.();
}, 180);
}
/**
* Renders the Theme of the Month announcement card.
*/
export async function OpenThemeOfTheMonthPopup(
entry: ThemeOfTheMonthEntry,
onDismissed?: () => void,
) {
document.getElementById("theme-of-the-month-card")?.remove();
const monthLabel = formatMonthLabel(entry.month);
const heroUrl = await resolvePopupHeroImageUrl(entry);
const description = escapeHTML(entry.description).replace(/\n/g, " ");
const linkedThemeId = entry.theme_id ?? entry.theme?.id;
const card = stringToHTML(/* html */ `
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
<button type="button" class="themeOfTheMonthCardClose" aria-label="Close Theme of the Month">×</button>
${
heroUrl
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
: ""
}
<div class="themeOfTheMonthCardBody">
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
<h2>${escapeHTML(entry.title)}</h2>
<p class="themeOfTheMonthCardDescription">${description}</p>
<div class="themeOfTheMonthCardActions">
${
linkedThemeId
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
: ""
}
<button type="button" class="themeOfTheMonthCardSecondary">Don't show again</button>
</div>
</div>
</aside>
`).firstChild as HTMLElement;
card.dataset.entryId = entry.id;
const autoCloseTimeout = window.setTimeout(() => {
closeThemeOfTheMonthCard(card, onDismissed);
}, 12000);
const dismiss = (markSeen = true) => {
window.clearTimeout(autoCloseTimeout);
closeThemeOfTheMonthCard(card, onDismissed, markSeen);
};
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true });
card.querySelector(".themeOfTheMonthCardClose")?.addEventListener("click", () => {
dismiss();
});
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
dismiss();
openThemeStoreWithHighlight(linkedThemeId!);
});
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDisabled = true;
dismiss();
});
document.body.appendChild(card);
}
/**
* Dev helper: fetch the current month's entry and show the popup immediately,
* even if the user has already dismissed it this month.
*/
export async function showThemeOfTheMonthPopupNow(): Promise<void> {
const entry = await fetchThemeOfTheMonth();
if (!entry) {
alert(
"No Theme of the Month entry for the current month (UTC). Create one in the website admin, or check your dev API base URL.",
);
return;
}
settingsState.themeOfTheMonthLastSeenId = undefined;
if (document.getElementById("whatsnewbk")) {
await closePopup();
await new Promise((resolve) => setTimeout(resolve, 150));
}
await OpenThemeOfTheMonthPopup(entry);
}
+43 -2
View File
@@ -2,8 +2,9 @@ import stringToHTML from "../stringToHTML";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import kofi from "@/resources/kofi.png?base64"; import kofi from "@/resources/kofi.png?base64";
import { openPopup } from "./PopupManager"; import { openPopup } from "./PopupManager";
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
export function OpenWhatsNewPopup() { export function OpenWhatsNewPopup(onDismissed?: () => void) {
const header = stringToHTML( const header = stringToHTML(
/* html */ /* html */
`<div class="whatsnewHeader"> `<div class="whatsnewHeader">
@@ -20,7 +21,7 @@ export function OpenWhatsNewPopup() {
source.setAttribute( source.setAttribute(
"src", "src",
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/update-video.mp4", "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/update-video.webm",
); );
video.autoplay = true; video.autoplay = true;
video.muted = true; video.muted = true;
@@ -28,9 +29,47 @@ export function OpenWhatsNewPopup() {
video.appendChild(source); video.appendChild(source);
video.classList.add("whatsnewImg"); video.classList.add("whatsnewImg");
imageContainer.appendChild(video); imageContainer.appendChild(video);
attachPopupMediaFullscreen(video);
const text = stringToHTML(/* html */ ` const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;"> <div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
<h1>3.6.5 - Theme of the Month, custom message folders & assessment weighting overrides</h1>
<li>Added Theme of the Month a monthly featured theme popup with a link to view it in the theme store.</li>
<li>Added custom message folders for organising direct DM's with drag to reorder.</li>
<li>Added the ability to override/add weightings to assessments (on assessment page).</li>
<li>Fixed custom room and teacher names not showing in the timetable popup.</li>
<li>Fixed assessment averages treating N/A weightings incorrectly in subject average calculations.</li>
<li>Fixed the display of weightings that could not automatically be discovered.</li>
<li>Fixed the formatting of the weighting tag that was broken due to a SEQTA update.</li>
<h1>3.6.4 - Theme flavours and fixes, Upcoming Assements improvement</h1>
<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 flavours for multiple colour variations of the same theme.</li>
<h1>3.6.3 - Assessment overview fix</h1>
<li>Fixed assessments overview failing to load.</li>
<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>Optional automatic cloud sync if signed in (on by default).</li>
<li>Option to use cloud profile photo as the local SEQTA profile picture</li>
<li>Firefox: fixed the extension settings popup.</li>
<li>SEQTA Engage: Added BetterSEQTA Plus support for SEQTA Engage for Parents.</li>
<li>Added smooth transitions to adaptive themes (on by default)</li>
<li>Added adaptive theme variables to custom themes (try it out with the Windows XP theme)</li>
<li>Fixed today's lessons on the homepage misbehaving in developer mode.</li>
<li>Reduced overlap between BetterSEQTA subject averages and SEQTA's built-in averages UI.</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>Fix timetable edit plugin not working correctly.</li>
<h1>3.5.3 - Adaptive theme updates</h1>
<li>Fixed adaptive theming on current-year course and assessment pages.</li>
<h1>3.5.2 - PDF & store compliance</h1>
<li>Put PDF.js with the extension so assessment weighting stays compatible with Chrome Web Store rules</li>
<h1>3.5.1 - QR & session link fix</h1> <h1>3.5.1 - QR & session link fix</h1>
<li>Fixed DesQTA Connect Mobile App QR generation on Chrome</li> <li>Fixed DesQTA Connect Mobile App QR generation on Chrome</li>
@@ -346,5 +385,7 @@ export function OpenWhatsNewPopup() {
openPopup({ openPopup({
header, header,
content: [imageContainer, text, footer], content: [imageContainer, text, footer],
afterClose: onDismissed,
clearJustUpdated: true,
}); });
} }
+20
View File
@@ -4,6 +4,13 @@ import { animate as motionAnimate, stagger } from "motion";
type AnimationTarget = string | Element | Element[] | NodeList | null; type AnimationTarget = string | Element | Element[] | NodeList | null;
let isClosing = false; let isClosing = false;
let pendingAfterClose: (() => void) | undefined;
function invokeAfterClose() {
const fn = pendingAfterClose;
pendingAfterClose = undefined;
fn?.();
}
export async function closePopup() { export async function closePopup() {
if (isClosing) return; if (isClosing) return;
@@ -16,12 +23,14 @@ export async function closePopup() {
if (!background || !popup) { if (!background || !popup) {
isClosing = false; isClosing = false;
invokeAfterClose();
return; return;
} }
if (!settingsState.animations) { if (!settingsState.animations) {
background.remove(); background.remove();
isClosing = false; isClosing = false;
invokeAfterClose();
return; return;
} }
@@ -33,19 +42,28 @@ export async function closePopup() {
background.remove(); background.remove();
isClosing = false; isClosing = false;
invokeAfterClose();
} }
interface OpenPopupOptions { interface OpenPopupOptions {
header?: Node | null; header?: Node | null;
content?: (Node | null | undefined)[]; content?: (Node | null | undefined)[];
animateSelector?: AnimationTarget; animateSelector?: AnimationTarget;
/** Called once after this popup is fully closed (including skip-animation path). */
afterClose?: () => void;
/** When true, clears the post-update flag when this popup opens (What's New only). */
clearJustUpdated?: boolean;
} }
export function openPopup({ export function openPopup({
header, header,
content = [], content = [],
animateSelector = ".whatsnewTextContainer *", animateSelector = ".whatsnewTextContainer *",
afterClose,
clearJustUpdated = false,
}: OpenPopupOptions = {}) { }: OpenPopupOptions = {}) {
pendingAfterClose = afterClose;
const background = document.createElement("div"); const background = document.createElement("div");
background.id = "whatsnewbk"; background.id = "whatsnewbk";
background.classList.add("whatsnewBackground"); background.classList.add("whatsnewBackground");
@@ -88,7 +106,9 @@ export function openPopup({
} }
} }
if (clearJustUpdated) {
delete settingsState.justupdated; delete settingsState.justupdated;
}
background.addEventListener("click", (event) => { background.addEventListener("click", (event) => {
if (event.target === background) void closePopup(); if (event.target === background) void closePopup();
@@ -0,0 +1,52 @@
import { settingsState } from "../listeners/SettingsState";
import { OpenWhatsNewPopup } from "./OpenWhatsNewPopup";
import {
shouldShowEngageParentsAnnouncement,
showEngageParentsToast,
} from "./OpenEngageParentsAnnouncement";
import {
fetchThemeOfTheMonth,
OpenThemeOfTheMonthPopup,
shouldShowThemeOfTheMonth,
} from "./OpenThemeOfTheMonthPopup";
import { syncApiBaseToBackground } from "../DevApiBase";
type QueueStep = (goNext: () => void) => void;
/**
* Runs startup modals in order: What's New (if the extension just updated),
* Theme of the Month (when a new monthly entry hasn't been seen), then shows
* the SEQTA Engage toast (once, non-blocking).
*/
export async function runStartupPopupQueue() {
// Make sure the background script knows about any dev-mode API override
// before we start firing requests.
syncApiBaseToBackground();
const steps: QueueStep[] = [];
if (settingsState.justupdated) {
steps.push((goNext) => OpenWhatsNewPopup(goNext));
}
// Fetch the Theme of the Month before queueing so we don't show an empty
// popup if the network or server is unavailable.
const themeOfTheMonth = await fetchThemeOfTheMonth();
if (shouldShowThemeOfTheMonth(themeOfTheMonth)) {
steps.push((goNext) => {
void OpenThemeOfTheMonthPopup(themeOfTheMonth!, goNext);
});
}
function runNext() {
const step = steps.shift();
if (step) step(runNext);
else {
if (shouldShowEngageParentsAnnouncement()) {
showEngageParentsToast();
}
}
}
runNext();
}
@@ -0,0 +1,158 @@
/**
* Makes popup hero images/videos open a padded overlay (not browser fullscreen) on click.
* Escape or backdrop click dismisses it. Clicks use stopPropagation so the
* parent SEQTA popup does not close.
*/
import { settingsState } from "../listeners/SettingsState";
const FULLSCREENABLE_CLASS = "popup-media-fullscreenable";
const OVERLAY_VISIBLE_CLASS = "bsplus-popup-media-overlay-backdrop--visible";
const OVERLAY_ANIM_MS = 280;
function isImageOrVideo(el: Element): el is HTMLImageElement | HTMLVideoElement {
return el instanceof HTMLImageElement || el instanceof HTMLVideoElement;
}
export function attachPopupMediaFullscreen(el: HTMLImageElement | HTMLVideoElement) {
el.classList.add(FULLSCREENABLE_CLASS);
el.setAttribute("tabindex", "0");
el.setAttribute("role", "button");
el.setAttribute("aria-label", "View larger");
el.title = "Click to view larger";
const open = (e: Event) => {
e.preventDefault();
e.stopPropagation();
openMediaOverlayViewer(el);
};
el.addEventListener("click", open);
el.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
open(e);
}
});
}
function openMediaOverlayViewer(source: HTMLImageElement | HTMLVideoElement) {
const backdrop = document.createElement("div");
backdrop.id = "bsplus-popup-media-overlay";
backdrop.className = "bsplus-popup-media-overlay-backdrop";
const inner = document.createElement("div");
inner.className = "bsplus-popup-media-overlay-inner";
const slot = document.createElement("div");
slot.className = "bsplus-popup-media-overlay-slot";
let media: HTMLImageElement | HTMLVideoElement;
if (source instanceof HTMLVideoElement) {
const v = source;
const nv = document.createElement("video");
nv.classList.add("bsplus-popup-media-overlay-media");
nv.controls = true;
nv.playsInline = true;
nv.loop = v.loop;
nv.muted = v.muted;
nv.volume = v.volume;
for (const s of v.querySelectorAll("source")) {
const ns = document.createElement("source");
ns.src = (s as HTMLSourceElement).src;
const t = (s as HTMLSourceElement).type;
if (t) ns.type = t;
nv.appendChild(ns);
}
nv.addEventListener(
"loadeddata",
() => {
try {
nv.currentTime = v.currentTime;
} catch {
/* ignore */
}
void nv.play().catch(() => {});
},
{ once: true },
);
v.pause();
nv.load();
media = nv;
} else {
const img = document.createElement("img");
img.classList.add("bsplus-popup-media-overlay-media");
img.src = source.currentSrc || source.src;
img.alt = source.alt || "";
media = img;
}
media.addEventListener("click", (e) => e.stopPropagation());
slot.appendChild(media);
inner.append(slot);
backdrop.appendChild(inner);
document.body.append(backdrop);
if (!settingsState.animations) {
backdrop.classList.add("bsplus-popup-media-overlay--instant");
backdrop.classList.add(OVERLAY_VISIBLE_CLASS);
} else {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
backdrop.classList.add(OVERLAY_VISIBLE_CLASS);
});
});
}
inner.addEventListener("click", (e) => e.stopPropagation());
let done = false;
const removeOverlay = () => {
if (source instanceof HTMLVideoElement && media instanceof HTMLVideoElement) {
try {
source.currentTime = media.currentTime;
} catch {
/* ignore */
}
void source.play().catch(() => {});
}
backdrop.remove();
};
const close = () => {
if (done) return;
done = true;
document.removeEventListener("keydown", onDocKey, true);
if (!settingsState.animations) {
removeOverlay();
return;
}
backdrop.classList.remove(OVERLAY_VISIBLE_CLASS);
window.setTimeout(removeOverlay, OVERLAY_ANIM_MS);
};
const onDocKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
ev.stopPropagation();
close();
}
};
document.addEventListener("keydown", onDocKey, true);
backdrop.addEventListener("click", () => {
close();
});
}
export function attachPopupMediaFullscreenIfPresent(
root: ParentNode,
selector: string,
) {
const el = root.querySelector(selector);
if (el && isImageOrVideo(el)) {
attachPopupMediaFullscreen(el);
}
}
+8 -3
View File
@@ -2,12 +2,17 @@ import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
/** /**
* Parses the current page from window.location.hash. * Parses the current page from window.location.hash.
* Returns { programme, metaclass } for /courses/SEMESTER/X:Y or /assessments/SEMESTER/X:Y, or null. * Supports both old and current URL formats, e.g.
* e.g. #?page=/courses/2023S/4804:11066 or #?page=/assessments/2023S/4621:10772 * /courses/SEMESTER/X:Y and /courses/X:Y
* /assessments/SEMESTER/X:Y and /assessments/X:Y
* e.g. #?page=/courses/2023S/4804:11066,
* #?page=/courses/4804:11066,
* #?page=/assessments/2023S/4621:10772,
* #?page=/assessments/4621:10772
*/ */
function parsePageContext(): { programme: number; metaclass: number } | null { function parsePageContext(): { programme: number; metaclass: number } | null {
const hash = window.location.hash || ""; const hash = window.location.hash || "";
const match = hash.match(/[?&]page=\/(courses|assessments)\/[^/]+\/(\d+):(\d+)/); const match = hash.match(/[?&]page=\/(courses|assessments)\/(?:[^/]+\/)?(\d+):(\d+)/);
if (!match) return null; if (!match) return null;
const programme = parseInt(match[2], 10); const programme = parseInt(match[2], 10);
const metaclass = parseInt(match[3], 10); const metaclass = parseInt(match[3], 10);
@@ -0,0 +1,128 @@
import { animate } from "motion";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { waitForElm } from "@/seqta/utils/waitForElm";
/**
* Finds the SEQTA notifications dropdown panel (the list container next to the bell).
*/
function findNotificationPanel(): HTMLElement | null {
const wrapper = document.querySelector(".connectedNotificationsWrapper");
if (!wrapper) return null;
const flat = wrapper.querySelector<HTMLElement>(":scope > div > button + div");
if (flat) return flat;
const notifBlock = wrapper.querySelector("[class*='notifications__notifications___']");
if (notifBlock?.nextElementSibling instanceof HTMLElement) {
return notifBlock.nextElementSibling;
}
const list = wrapper.querySelector<HTMLElement>("[class*='notifications__list___']");
if (list) return list;
return null;
}
function isPanelVisible(el: HTMLElement): boolean {
return (
el.getClientRects().length > 0 && getComputedStyle(el).visibility !== "hidden"
);
}
let lastVisible = false;
/** Invalidates in-flight open animations when the panel closes or reopens. */
let motionGeneration = 0;
function runOpenAnimation(panel: HTMLElement) {
const myGen = ++motionGeneration;
panel.classList.add("bsplus-notifications-panel");
if (!settingsState.animations) {
panel.style.opacity = "1";
panel.style.transform = "scale(1)";
return;
}
panel.style.opacity = "0";
panel.style.transform = "scale(0)";
requestAnimationFrame(() => {
if (myGen !== motionGeneration) return;
animate(0, 1, {
onUpdate: (progress) => {
panel.style.opacity = String(progress);
panel.style.transform = `scale(${progress})`;
},
type: "spring",
stiffness: 280,
damping: 20,
});
});
}
function clearPanelMotionStyles(panel: HTMLElement) {
motionGeneration++;
panel.style.opacity = "";
panel.style.transform = "";
}
/**
* Spring open / fade close for the native SEQTA notifications dropdown, matching ExtensionPopup.
*/
export function attachNotificationsPanelAnimation() {
void setupNotificationsPanelAnimation();
}
async function setupNotificationsPanelAnimation() {
try {
await waitForElm(".connectedNotificationsWrapper", true, 100, 60);
} catch {
return;
}
const wrapper = document.querySelector(".connectedNotificationsWrapper");
if (!wrapper) return;
const sync = () => {
const panel = findNotificationPanel();
// When SEQTA removes the dropdown from the DOM on close, we must reset
// lastVisible — otherwise the next open still looks "already visible" and skips animation.
if (!panel) {
if (lastVisible) {
lastVisible = false;
motionGeneration++;
}
return;
}
const visible = isPanelVisible(panel);
if (visible === lastVisible) return;
if (visible) {
runOpenAnimation(panel);
} else {
clearPanelMotionStyles(panel);
}
lastVisible = visible;
};
const observer = new MutationObserver(() => {
sync();
});
observer.observe(wrapper, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["style", "class"],
});
document.addEventListener(
"click",
() => {
requestAnimationFrame(() => requestAnimationFrame(sync));
},
true,
);
sync();
}
+279
View File
@@ -0,0 +1,279 @@
import browser from "webextension-polyfill";
/** Matches the contract in docs/CLOUD_SETTINGS_SYNC_SERVER.md */
export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1;
/**
* Client-only: last known remote `updated_at` for BS+ settings (from summary or sync responses).
* Never uploaded; preserved on restore; used to decide when to pull a newer cloud backup.
*/
export const BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY =
"bsplus_cloud_settings_known_remote_updated_at";
/**
* Written by the service worker after applying a cloud settings envelope; the SEQTA pages
* ThemeManager reads and clears it (SW cannot share localforage/IndexedDB with the page).
*/
export const BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY =
"bsplus_pending_theme_ensure_after_cloud";
/**
* Never uploaded to the cloud backup (OAuth and legacy keys).
* IndexedDB (e.g. Global Searchs `betterseqta-index` database) is not part of
* `chrome.storage.local` and is never included in this payload.
*/
export const KEYS_OMITTED_FROM_CLOUD_UPLOAD = [
"bsplus_token",
"bsplus_refresh_token",
"bsplus_client_id",
"bsplus_user",
"cloudAccessToken",
"cloudUsername",
] as const;
/**
* Device-only caches / school-related data: never uploaded, never applied from a
* cloud snapshot (local values are kept on restore).
*/
export const SENSITIVE_DEVICE_STORAGE_KEYS_EXACT = [
"plugin.assessments-average.storage.assessments",
"plugin.assessments-average.storage.weightings",
] as const;
/** e.g. any future `plugin.global-search.storage.*` keys in chrome.storage */
export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = ["plugin.global-search.storage."] as const;
const CLIENT_ONLY_CLOUD_KEYS_EXACT = [
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
"bsplus_lastCloudPoll",
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
] as const;
/** After restoring from cloud, keep local session so the user stays signed in. */
const AUTH_KEYS_TO_PRESERVE = [
"bsplus_token",
"bsplus_refresh_token",
"bsplus_client_id",
"bsplus_user",
] as const;
const OMIT_FROM_UPLOAD_EXACT = new Set<string>([
...KEYS_OMITTED_FROM_CLOUD_UPLOAD,
...SENSITIVE_DEVICE_STORAGE_KEYS_EXACT,
...CLIENT_ONLY_CLOUD_KEYS_EXACT,
]);
/** True if a storage key is part of the upload payload (and should trigger auto-upload when changed). */
export function isKeyIncludedInCloudUploadPayload(key: string): boolean {
return !shouldOmitKeyFromCloudPayload(key);
}
function shouldOmitKeyFromCloudPayload(key: string): boolean {
if (OMIT_FROM_UPLOAD_EXACT.has(key)) return true;
for (const prefix of SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES) {
if (key.startsWith(prefix)) return true;
}
return false;
}
function isSensitiveDeviceKey(key: string): boolean {
if ((SENSITIVE_DEVICE_STORAGE_KEYS_EXACT as readonly string[]).includes(key)) return true;
for (const prefix of SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES) {
if (key.startsWith(prefix)) return true;
}
return false;
}
/** Auth + device-only caches + client-only cloud metadata to keep when merging a downloaded snapshot. */
function collectLocalKeysToPreserve(local: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const k of AUTH_KEYS_TO_PRESERVE) {
if (local[k] !== undefined) out[k] = local[k];
}
for (const k of CLIENT_ONLY_CLOUD_KEYS_EXACT) {
if (local[k] !== undefined) out[k] = local[k];
}
for (const [k, v] of Object.entries(local)) {
if (isSensitiveDeviceKey(k)) out[k] = v;
}
return out;
}
/** Remove keys that must never come from the server blob (defense in depth). */
function stripExcludedKeysFromRemoteData(remote: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(remote)) {
if (shouldOmitKeyFromCloudPayload(k)) continue;
out[k] = v;
}
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>): {
schemaVersion: number;
themeId: string;
data: Record<string, unknown>;
} {
const filtered: Record<string, unknown> = {};
for (const [k, v] of Object.entries(all)) {
if (shouldOmitKeyFromCloudPayload(k)) continue;
filtered[k] = v;
}
const data = migrateLegacyToPluginSettings(filtered);
const themeId = normalizeThemeIdForSync(all.selectedTheme);
return {
schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
themeId,
data,
};
}
export async function getSnapshotForUpload(): Promise<{
schemaVersion: number;
themeId: string;
data: Record<string, unknown>;
}> {
const all = await browser.storage.local.get();
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> {
if (!iso || typeof iso !== "string") return;
await browser.storage.local.set({ [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY]: iso });
}
/**
* Migrate legacy storage keys to plugin settings format.
* Only applies migrations for keys present in the data; does not overwrite
* existing plugin settings if the legacy key is absent.
*/
function migrateLegacyToPluginSettings(data: Record<string, unknown>): Record<string, unknown> {
const result = { ...data };
function ensurePluginSettings(pluginId: string): Record<string, unknown> {
const key = `plugin.${pluginId}.settings`;
if (!result[key] || typeof result[key] !== "object") {
result[key] = {};
}
return result[key] as Record<string, unknown>;
}
// animatedbk -> plugin.animated-background.settings.enabled
if ("animatedbk" in result) {
const settings = ensurePluginSettings("animated-background");
if (settings.enabled === undefined) {
settings.enabled = !!result.animatedbk;
}
delete result.animatedbk;
}
// bksliderinput -> plugin.animated-background.settings.speed
// Legacy: string "0"-"100", New: float 0.1-2.0
if ("bksliderinput" in result) {
const settings = ensurePluginSettings("animated-background");
if (settings.speed === undefined) {
const legacy = parseFloat(String(result.bksliderinput));
if (!isNaN(legacy)) {
settings.speed = Math.round((0.1 + (legacy / 100) * 1.9) * 100) / 100;
}
}
delete result.bksliderinput;
}
// assessmentsAverage -> plugin.assessments-average.settings.enabled
if ("assessmentsAverage" in result) {
const settings = ensurePluginSettings("assessments-average");
if (settings.enabled === undefined) {
settings.enabled = !!result.assessmentsAverage;
}
delete result.assessmentsAverage;
}
// lettergrade -> plugin.assessments-average.settings.lettergrade
if ("lettergrade" in result) {
const settings = ensurePluginSettings("assessments-average");
if (settings.lettergrade === undefined) {
settings.lettergrade = !!result.lettergrade;
}
delete result.lettergrade;
}
// notificationCollector -> plugin.notificationCollector.settings.enabled
if ("notificationCollector" in result && typeof result.notificationCollector === "boolean") {
const settings = ensurePluginSettings("notificationCollector");
if (settings.enabled === undefined) {
settings.enabled = result.notificationCollector;
}
delete result.notificationCollector;
}
return result;
}
/**
* Apply the downloaded cloud snapshot by setting each key individually,
* preserving auth keys and device-only sensitive caches.
* Legacy keys are automatically migrated to plugin settings format.
*/
export async function applyDownloadedEnvelope(envelope: unknown): Promise<void> {
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 {
throw new Error("Invalid cloud settings payload");
}
const migrated = migrateLegacyToPluginSettings(remoteFlat);
const remoteSanitized = stripExcludedKeysFromRemoteData(migrated);
await browser.storage.local.set(remoteSanitized);
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Learn-style hash routes on Engage: `#?page=/home` `"home"`.
* Falls back to the legacy path segment used by classic Learn routing.
*/
export function getEngageRoutePage(): string | undefined {
const hash = window.location.hash.replace(/^#/, "");
if (hash) {
const qs = hash.startsWith("?") ? hash : `?${hash}`;
const params = new URLSearchParams(qs);
const page = params.get("page");
if (page?.startsWith("/")) {
const segment = page.replace(/^\//, "").split("/")[0];
return segment || undefined;
}
}
return window.location.href.split("/")[4];
}
+4
View File
@@ -0,0 +1,4 @@
/** SEQTA Engage (React) uses a different shell from classic SEQTA Learn. */
export function isSeqtaEngageExperience(): boolean {
return document.title.includes("SEQTA Engage");
}
+5 -2
View File
@@ -6,13 +6,13 @@ import {
OpenMenuOptions, OpenMenuOptions,
} from "@/seqta/utils/Openers/OpenMenuOptions"; } from "@/seqta/utils/Openers/OpenMenuOptions";
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
import { import {
CloseThemeCreator, CloseThemeCreator,
OpenThemeCreator, OpenThemeCreator,
} from "@/plugins/built-in/themes/ThemeCreator"; } from "@/plugins/built-in/themes/ThemeCreator";
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate"; import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent"; import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
@@ -34,7 +34,10 @@ export class MessageHandler {
case "UpdateThemePreview": case "UpdateThemePreview":
if (request?.save == true) { if (request?.save == true) {
const save = async () => { const save = async () => {
await themeManager.saveTheme(request.body); await themeManager.saveTheme({
...request.body,
userEdited: true,
});
if (request.body.enableTheme) { if (request.body.enableTheme) {
await themeManager.setTheme(request.body.id); await themeManager.setTheme(request.body.id);
} }
@@ -17,6 +17,10 @@ export class StorageChangeHandler {
settingsState.register("selectedColor", () => void updateAllColors()); settingsState.register("selectedColor", () => void updateAllColors());
settingsState.register("adaptiveThemeColour", () => void updateAllColors()); settingsState.register("adaptiveThemeColour", () => void updateAllColors());
settingsState.register("adaptiveThemeGradient", () => void updateAllColors()); settingsState.register("adaptiveThemeGradient", () => void updateAllColors());
settingsState.register("adaptiveThemeColourTransition", () =>
void updateAllColors(),
);
settingsState.register("selectedTheme", () => void updateAllColors());
settingsState.register("DarkMode", this.handleDarkModeChange.bind(this)); settingsState.register("DarkMode", this.handleDarkModeChange.bind(this));
settingsState.register("onoff", this.handleOnOffChange.bind(this)); settingsState.register("onoff", this.handleOnOffChange.bind(this));
settingsState.register("shortcuts", this.handleShortcutsChange.bind(this)); settingsState.register("shortcuts", this.handleShortcutsChange.bind(this));
@@ -0,0 +1,39 @@
import { OpenStorePage } from "@/seqta/ui/renderStore";
/**
* Module-level handoff for "open the theme store and highlight this theme".
*
* The store page is mounted lazily inside a Shadow DOM the first time it
* opens, so a `CustomEvent` listener would have to be wired up before mount
* (causing a race). Using a shared cell keeps the producer (popup button) and
* consumer (store `onMount`) decoupled without that timing constraint.
*
* The store reads & clears this on mount via {@link consumePendingHighlightThemeId}.
*/
let pendingHighlightThemeId: string | null = null;
/** Read and clear the pending theme id (called by the store on mount). */
export function consumePendingHighlightThemeId(): string | null {
const id = pendingHighlightThemeId;
pendingHighlightThemeId = null;
return id;
}
/**
* Opens the theme store and asks it to focus / highlight the given theme.
* If the store is already mounted we dispatch a DOM event so it can react
* without remounting; otherwise the store consumes the pending id on mount.
*/
export function openThemeStoreWithHighlight(themeId: string): void {
pendingHighlightThemeId = themeId;
const existing = document.getElementById("store");
if (existing) {
window.dispatchEvent(
new CustomEvent("bsplus:highlight-theme", { detail: { themeId } }),
);
return;
}
OpenStorePage();
}
+10 -9
View File
@@ -9,10 +9,11 @@ import { renderSettingsIfNeeded } from "./Adders/AddExtensionSettings";
import { delay } from "./delay"; import { delay } from "./delay";
export function setupSettingsButton() { export function setupSettingsButton() {
var AddedSettings = document.getElementById("AddedSettings"); const AddedSettings = document.getElementById("AddedSettings");
var extensionPopup = document.getElementById("ExtensionPopup"); const extensionPopup = document.getElementById("ExtensionPopup");
if (!AddedSettings || !extensionPopup) return;
AddedSettings!.addEventListener("click", async () => { AddedSettings.addEventListener("click", async () => {
if (SettingsClicked) { if (SettingsClicked) {
closeExtensionPopup(extensionPopup as HTMLElement); closeExtensionPopup(extensionPopup as HTMLElement);
} else { } else {
@@ -23,20 +24,20 @@ export function setupSettingsButton() {
if (settingsState.animations) { if (settingsState.animations) {
animate(0, 1, { animate(0, 1, {
onUpdate: (progress) => { onUpdate: (progress) => {
extensionPopup!.style.opacity = progress.toString(); extensionPopup.style.opacity = progress.toString();
extensionPopup!.style.transform = `scale(${progress})`; extensionPopup.style.transform = `scale(${progress})`;
}, },
type: "spring", type: "spring",
stiffness: 280, stiffness: 280,
damping: 20, damping: 20,
}); });
} else { } else {
extensionPopup!.style.opacity = "1"; extensionPopup.style.opacity = "1";
extensionPopup!.style.transform = "scale(1)"; extensionPopup.style.transform = "scale(1)";
extensionPopup!.style.transition = extensionPopup.style.transition =
"opacity 0s linear, transform 0s linear"; "opacity 0s linear, transform 0s linear";
} }
extensionPopup!.classList.remove("hide"); extensionPopup.classList.remove("hide");
changeSettingsClicked(true); changeSettingsClicked(true);
} }
}); });
+28
View File
@@ -12,7 +12,20 @@ export type CustomTheme = {
hideThemeName: boolean; hideThemeName: boolean;
webURL?: string; webURL?: string;
selectedColor?: string; selectedColor?: string;
/**
* When true, the theme forces light/dark via `forceDark` (`false` = light, `true` = dark).
* When false/omitted, use legacy rule: `forceDark !== undefined` still means "force" for old JSON.
*/
forceTheme?: boolean;
forceDark?: boolean; forceDark?: boolean;
/** True if installed from the BetterSEQTA theme store (not file import). */
installedFromStore?: boolean;
/** Server `updated_at` (Unix seconds) when this copy was installed or last auto-updated. */
storeSyncedAtSec?: number;
/** User saved edits in theme creator or popup; blocks store auto-update. */
userEdited?: boolean;
/** CSS custom property names (e.g. `--my-accent`) that receive the same value as `--better-main` when adaptive colours apply. */
adaptiveCssVariables?: string[];
}; };
export type LoadedCustomTheme = CustomTheme & { export type LoadedCustomTheme = CustomTheme & {
@@ -37,3 +50,18 @@ export type ThemeList = {
themes: CustomTheme[]; themes: CustomTheme[];
selectedTheme: string; selectedTheme: string;
}; };
/** Whether the theme forces appearance (light vs dark). */
export function shouldForceThemeAppearance(theme: {
forceTheme?: boolean;
forceDark?: boolean;
}): boolean {
if (theme.forceTheme === true) return true;
if (theme.forceTheme === false) return false;
return theme.forceDark !== undefined;
}
/** Resolved forced dark mode when forcing is active. */
export function getForcedDarkMode(theme: { forceDark?: boolean }): boolean {
return theme.forceDark === true;
}
+11
View File
@@ -32,6 +32,14 @@ export interface SettingsState {
justupdated?: boolean; justupdated?: boolean;
privacyStatementShown?: boolean; privacyStatementShown?: boolean;
privacyStatementLastUpdated?: string; privacyStatementLastUpdated?: string;
/** One-time announcement: SEQTA Engage support for parents (dismissed popup queue). */
engageParentsAnnouncementShown?: boolean;
/** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */
bsCloudAutoSyncAnnouncementShown?: boolean;
/** ID of the last Theme of the Month entry shown to the user (shows once per new entry). */
themeOfTheMonthLastSeenId?: string;
/** Permanently disables Theme of the Month startup prompts. */
themeOfTheMonthDisabled?: boolean;
timeFormat?: string; timeFormat?: string;
animations: boolean; animations: boolean;
defaultPage: string; defaultPage: string;
@@ -43,6 +51,7 @@ export interface SettingsState {
iconOnlySidebar?: boolean; iconOnlySidebar?: boolean;
adaptiveThemeColour?: boolean; adaptiveThemeColour?: boolean;
adaptiveThemeGradient?: boolean; adaptiveThemeGradient?: boolean;
adaptiveThemeColourTransition?: boolean;
// depreciated keys // depreciated keys
animatedbk: boolean; animatedbk: boolean;
@@ -56,6 +65,8 @@ export interface SettingsState {
bsplus_token?: string; bsplus_token?: string;
bsplus_refresh_token?: string; bsplus_refresh_token?: string;
bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; admin_level?: number }; bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; admin_level?: number };
/** When not `false`, automatic cloud settings sync is enabled (default-on). */
autoCloudSettingsSync?: boolean;
} }
interface ToggleItem { interface ToggleItem {
+10 -5
View File
@@ -6,6 +6,7 @@ import InlineWorkerPlugin from "./lib/inlineWorker";
import { base64Loader } from "./lib/base64loader"; import { base64Loader } from "./lib/base64loader";
import type { BuildTarget } from "./lib/types"; import type { BuildTarget } from "./lib/types";
import ClosePlugin from "./lib/closePlugin"; import ClosePlugin from "./lib/closePlugin";
import { firefoxStripFunctionProbe } from "./lib/firefoxStripFunctionProbe";
import million from "million/compiler"; import million from "million/compiler";
@@ -23,6 +24,9 @@ const targets: BuildTarget[] = [chrome, brave, edge, firefox, opera, safari];
const mode = process.env.MODE || "chrome"; // Check the environment variable to determine which build type to use. const mode = process.env.MODE || "chrome"; // Check the environment variable to determine which build type to use.
//const sourcemap = (process.env.SOURCEMAP === "true") || false; // Check whether we want sourcemaps. //const sourcemap = (process.env.SOURCEMAP === "true") || false; // Check whether we want sourcemaps.
/** Million's compiler can emit `new Function()`, which Firefox extension pages block (strict CSP, no unsafe-eval). */
const useMillion = mode.toLowerCase() !== "firefox";
export default defineConfig(({ command }) => ({ export default defineConfig(({ command }) => ({
plugins: [ plugins: [
base64Loader, base64Loader,
@@ -30,7 +34,7 @@ export default defineConfig(({ command }) => ({
svelte({ svelte({
emitCss: false, emitCss: false,
}), }),
million.vite({ auto: true }), ...(useMillion ? [million.vite({ auto: true })] : []),
crx({ crx({
manifest: manifest:
targets.find((t) => t.browser === mode.toLowerCase())?.manifest ?? targets.find((t) => t.browser === mode.toLowerCase())?.manifest ??
@@ -38,7 +42,7 @@ export default defineConfig(({ command }) => ({
browser: mode.toLowerCase() === "firefox" ? "firefox" : "chrome", browser: mode.toLowerCase() === "firefox" ? "firefox" : "chrome",
}), }),
touchGlobalCSSPlugin(), touchGlobalCSSPlugin(),
...(command === "build" ? [ClosePlugin()] : []), ...(command === "build" ? [ClosePlugin(), firefoxStripFunctionProbe()] : []),
], ],
root: resolve(__dirname, "./src"), root: resolve(__dirname, "./src"),
resolve: { resolve: {
@@ -56,9 +60,7 @@ export default defineConfig(({ command }) => ({
}, },
css: { css: {
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {},
api: "modern",
},
}, },
}, },
optimizeDeps: { optimizeDeps: {
@@ -84,6 +86,9 @@ export default defineConfig(({ command }) => ({
settings: join(__dirname, "src", "interface", "index.html"), settings: join(__dirname, "src", "interface", "index.html"),
pageState: join(__dirname, "src", "pageState.js"), pageState: join(__dirname, "src", "pageState.js"),
}, },
output: {
assetFileNames: "assets/[name]-[hash][extname]",
},
onwarn(warning, warn) { onwarn(warning, warn) {
if (warning.code === "FILE_NAME_CONFLICT") return; if (warning.code === "FILE_NAME_CONFLICT") return;
warn(warning); warn(warning);