Compare commits

...

103 Commits

Author SHA1 Message Date
AdenMGB 6c08bea5f7 feat: start secuirity thingo 2026-05-08 17:18:23 +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
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
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
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
AdenMGB d692f60291 fix: fix qr code to use safer methoed & bump ver 2026-03-25 08:48:47 +10:30
Jones Jankovic a0367be686 feat: Group settings better and make their descriptions more consistent (#405)
* Reorder and make description mroe consistent of settings

* Make soft gradient description consistent

* Consistent description for music plugin descriptionns

* Update src/plugins/built-in/profilePicture/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/backgroundMusic/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/backgroundMusic/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/backgroundMusic/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/backgroundMusic/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/backgroundMusic/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/backgroundMusic/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/backgroundMusic/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/profilePicture/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Formatting

* Formatting

* Formatting

* Formatting

* Formatting

* Prettier ignore

* Formatting

* format

* Update src/plugins/built-in/timetableEdit/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/timetableEdit/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Surely thats it

* Finally

* Add back stuff

* fix: fix formatting

---------

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Co-authored-by: Aden Lindsay <lindsaya542@gmail.com>
2026-03-24 12:22:53 +10:30
Aden Lindsay aa6b15aa1b Merge pull request #410 from Jaxx7594/qr
Fix: QR code gen issues on Firefox
2026-03-21 09:10:44 +10:30
Jaxon Lewis-Wilson 08342c3873 Fix: QR code in embedded settings (firefox)
Firefox has an issue where a backdrop filter within a shadow dom breaks the drawing of the popup. This disables the filter only in the embedded menu to avoid this issue altogether.
2026-03-20 19:43:40 +08:00
Aden Lindsay 6527a33e38 Merge pull request #407 from Jaxx7594/background
Fix: Animated background race condition
2026-03-20 08:05:58 +10:30
Jaxon Lewis-Wilson 43f125e45d Fix: QR code gen in extension settings (firefox) 2026-03-19 23:24:51 +08:00
Jaxon Lewis-Wilson 49cc1e26c0 Fix: Animated background race condition 2026-03-19 23:09:53 +08:00
SethBurkart123 809a82f31d fix: update publish-extension 2026-03-18 13:01:55 +11:00
98 changed files with 9130 additions and 953 deletions
+8 -6
View File
@@ -6,11 +6,9 @@ pnpm-lock.yaml
yarn.lock yarn.lock
bun.lock bun.lock
.parcel-cache # PDF.js extension assets (copied by postinstall from pdfjs-dist)
.env src/public/resources/pdfjs/pdf.worker.min.mjs
.env.submit src/public/resources/pdfjs/pdf.legacy.min.mjs
dependency-graph.svg
# Build # Build
extension.zip extension.zip
@@ -20,5 +18,9 @@ betterseqtaplus-safari/
.million/ .million/
.vscode/ .vscode/
**/.DS_Store **/.DS_Store
.parcel-cache
.env
.env.submit
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
+173 -29
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,13 +13,14 @@
"@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",
"@types/color": "^4.2.0", "@types/color": "^4.2.0",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"@types/qrcode": "^1.5.6",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.3", "@types/webextension-polyfill": "^0.12.3",
@@ -45,22 +46,23 @@
"motion": "^12.4.12", "motion": "^12.4.12",
"pdfjs-dist": "^5.4.530", "pdfjs-dist": "^5.4.530",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"qrcode": "^1.5.4",
"react": "17", "react": "17",
"react-best-gradient-color-picker": "3.0.11", "react-best-gradient-color-picker": "3.0.11",
"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",
@@ -71,7 +73,7 @@
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"process": "^0.11.10", "process": "^0.11.10",
"publish-browser-extension": "^4.0.3", "publish-browser-extension": "^4.0.4",
"sass": "^1.85.1", "sass": "^1.85.1",
"sass-loader": "^16.0.5", "sass-loader": "^16.0.5",
"semver": "^7.7.1", "semver": "^7.7.1",
@@ -125,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=="],
@@ -145,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=="],
@@ -291,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=="],
@@ -347,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=="],
@@ -407,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=="],
@@ -449,6 +493,8 @@
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], "@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "3.1.3" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="], "@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "3.1.3" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
"@types/react-dom": ["@types/react-dom@19.1.8", "", { "peerDependencies": { "@types/react": "19.1.12" } }, "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ=="], "@types/react-dom": ["@types/react-dom@19.1.8", "", { "peerDependencies": { "@types/react": "19.1.12" } }, "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ=="],
@@ -523,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=="],
@@ -579,6 +625,8 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001737", "", {}, "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw=="], "caniuse-lite": ["caniuse-lite@1.0.30001737", "", {}, "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw=="],
@@ -603,7 +651,7 @@
"cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "4.2.3", "strip-ansi": "6.0.1", "wrap-ansi": "7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -625,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=="],
@@ -667,6 +715,8 @@
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
@@ -685,10 +735,14 @@
"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=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "2.3.0", "domhandler": "5.0.3", "entities": "4.5.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "2.3.0", "domhandler": "5.0.3", "entities": "4.5.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
@@ -763,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=="],
@@ -777,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=="],
@@ -985,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=="],
@@ -1115,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=="],
@@ -1133,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=="],
@@ -1157,6 +1239,8 @@
"p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="], "p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "3.1.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "3.1.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
@@ -1199,6 +1283,8 @@
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "4.2.0", "read-cache": "1.0.0", "resolve": "1.22.10" }, "peerDependencies": { "postcss": "8.5.6" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "4.2.0", "read-cache": "1.0.0", "resolve": "1.22.10" }, "peerDependencies": { "postcss": "8.5.6" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
@@ -1229,12 +1315,14 @@
"protobufjs": ["protobufjs@6.11.4", "", { "dependencies": { "@protobufjs/aspromise": "1.1.2", "@protobufjs/base64": "1.1.2", "@protobufjs/codegen": "2.0.4", "@protobufjs/eventemitter": "1.1.0", "@protobufjs/fetch": "1.1.0", "@protobufjs/float": "1.0.2", "@protobufjs/inquire": "1.1.0", "@protobufjs/path": "1.1.2", "@protobufjs/pool": "1.1.0", "@protobufjs/utf8": "1.1.0", "@types/long": "4.0.2", "@types/node": "24.3.0", "long": "4.0.0" }, "bin": { "pbjs": "bin/pbjs", "pbts": "bin/pbts" } }, "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw=="], "protobufjs": ["protobufjs@6.11.4", "", { "dependencies": { "@protobufjs/aspromise": "1.1.2", "@protobufjs/base64": "1.1.2", "@protobufjs/codegen": "2.0.4", "@protobufjs/eventemitter": "1.1.0", "@protobufjs/fetch": "1.1.0", "@protobufjs/float": "1.0.2", "@protobufjs/inquire": "1.1.0", "@protobufjs/path": "1.1.2", "@protobufjs/pool": "1.1.0", "@protobufjs/utf8": "1.1.0", "@types/long": "4.0.2", "@types/node": "24.3.0", "long": "4.0.0" }, "bin": { "pbjs": "bin/pbjs", "pbts": "bin/pbts" } }, "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw=="],
"publish-browser-extension": ["publish-browser-extension@4.0.3", "", { "dependencies": { "cac": "^6.7.14", "consola": "^3.4.2", "dotenv": "^17.2.4", "form-data-encoder": "^4.1.0", "formdata-node": "^6.0.3", "jsonwebtoken": "^9.0.3", "listr2": "^10.1.0", "ofetch": "^1.5.1", "zod": "3.25.76 || ^4.3.6" }, "bin": { "publish-extension": "bin/publish-extension.mjs" } }, "sha512-yhzn+0z0tOYSsouEVCn6BHd3PPEc6KKplEVDEmxCOAMXC0C7NROEiJcmWm5LGGgqw3TqBvPxiink1juPrEbMqA=="], "publish-browser-extension": ["publish-browser-extension@4.0.4", "", { "dependencies": { "cac": "^6.7.14", "consola": "^3.4.2", "dotenv": "^17.2.4", "form-data-encoder": "^4.1.0", "formdata-node": "^6.0.3", "jsonwebtoken": "^9.0.3", "listr2": "^10.1.0", "ofetch": "^1.5.1", "zod": "3.25.76 || ^4.3.6" }, "bin": { "publish-extension": "bin/publish-extension.mjs" } }, "sha512-QMQbWL0FWgBfnkJ6w8HOJoIPaWLE7vTpewM4ae2vLs7SrD4eKdAk+SxOzqAICwbhEPuaLAOA+XkT9sZS5R0PmA=="],
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "1.4.5", "once": "1.4.0" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "1.4.5", "once": "1.4.0" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="], "punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@@ -1265,6 +1353,8 @@
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "2.16.1", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "2.16.1", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@@ -1275,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=="],
@@ -1301,6 +1393,8 @@
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "4.2.3", "detect-libc": "2.0.4", "node-addon-api": "6.1.0", "prebuild-install": "7.1.3", "semver": "7.7.2", "simple-get": "4.0.1", "tar-fs": "3.1.0", "tunnel-agent": "0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="], "sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "4.2.3", "detect-libc": "2.0.4", "node-addon-api": "6.1.0", "prebuild-install": "7.1.3", "semver": "7.7.2", "simple-get": "4.0.1", "tar-fs": "3.1.0", "tunnel-agent": "0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -1377,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=="],
@@ -1403,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=="],
@@ -1457,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=="],
@@ -1465,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=="],
@@ -1489,6 +1583,8 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
@@ -1503,15 +1599,15 @@
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"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=="], "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="], "yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="],
@@ -1533,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=="],
@@ -1569,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=="],
@@ -1593,10 +1693,14 @@
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"concurrently/rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], "concurrently/rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"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=="],
@@ -1609,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=="],
@@ -1691,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=="],
@@ -1701,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=="],
@@ -1713,8 +1825,18 @@
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"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=="],
@@ -1745,6 +1867,12 @@
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"concurrently/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "4.2.3", "strip-ansi": "6.0.1", "wrap-ansi": "7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"concurrently/yargs/y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"concurrently/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"listr-update-renderer/chalk/ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="], "listr-update-renderer/chalk/ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="],
@@ -1833,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=="],
@@ -1845,12 +1977,18 @@
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@microsoft/api-extractor/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "@microsoft/api-extractor/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"@rushstack/node-core-library/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "@rushstack/node-core-library/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="], "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
"concurrently/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"concurrently/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"listr-update-renderer/cli-truncate/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@1.0.0", "", { "dependencies": { "number-is-nan": "1.0.1" } }, "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw=="], "listr-update-renderer/cli-truncate/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@1.0.0", "", { "dependencies": { "number-is-nan": "1.0.1" } }, "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw=="],
"listr-update-renderer/log-update/wrap-ansi/string-width": ["string-width@2.1.1", "", { "dependencies": { "is-fullwidth-code-point": "2.0.0", "strip-ansi": "4.0.0" } }, "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw=="], "listr-update-renderer/log-update/wrap-ansi/string-width": ["string-width@2.1.1", "", { "dependencies": { "is-fullwidth-code-point": "2.0.0", "strip-ansi": "4.0.0" } }, "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw=="],
@@ -1981,6 +2119,10 @@
"vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], "vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"concurrently/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"listr-update-renderer/log-update/wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="], "listr-update-renderer/log-update/wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
"listr-update-renderer/log-update/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@3.0.1", "", {}, "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw=="], "listr-update-renderer/log-update/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@3.0.1", "", {}, "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw=="],
@@ -1990,5 +2132,7 @@
"log-update/wrap-ansi/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="], "log-update/wrap-ansi/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
"pkg-install/execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="], "pkg-install/execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="],
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
} }
} }
+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;
}
},
};
}
+18 -12
View File
@@ -1,10 +1,11 @@
{ {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"version": "3.5.0", "version": "3.6.4",
"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",
@@ -18,7 +19,8 @@
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg", "dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes", "release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
"publish": "bun lib/publish.js --b", "publish": "bun lib/publish.js --b",
"zip": "bedframe zip" "zip": "bedframe zip",
"test": "vitest run"
}, },
"targets": { "targets": {
"prod": { "prod": {
@@ -31,14 +33,15 @@
"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/jsdom": "^28.0.1",
"@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",
@@ -46,18 +49,20 @@
"dependency-cruiser": "^17.0.1", "dependency-cruiser": "^17.0.1",
"eslint": "^9.33.0", "eslint": "^9.33.0",
"glob": "^11.0.1", "glob": "^11.0.1",
"jsdom": "^29.1.1",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"process": "^0.11.10", "process": "^0.11.10",
"publish-browser-extension": "^4.0.3", "publish-browser-extension": "^4.0.4",
"sass": "^1.85.1", "sass": "^1.85.1",
"sass-loader": "^16.0.5", "sass-loader": "^16.0.5",
"semver": "^7.7.1", "semver": "^7.7.1",
"tailwindcss": "3", "tailwindcss": "3",
"url": "^0.11.4" "url": "^0.11.4",
"vitest": "^4.1.5"
}, },
"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 +70,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",
@@ -91,6 +96,7 @@
"flexsearch": "^0.8.147", "flexsearch": "^0.8.147",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"idb": "^8.0.2", "idb": "^8.0.2",
"jspdf": "^4.2.1",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mathjs": "^14.4.0", "mathjs": "^14.4.0",
@@ -104,10 +110,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"),
);
+36 -1
View File
@@ -11,6 +11,30 @@ import { main } from "@/seqta/main";
import { delay } from "./seqta/utils/delay"; import { delay } from "./seqta/utils/delay";
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle"; import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
function registerFetchSeqtaAppLinkListener() {
browser.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (request?.type !== "fetchSeqtaAppLink") return false;
void (async () => {
try {
const res = await fetch(`${location.origin}/seqta/student/load/profile`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({}),
});
const data = await res.json();
const statusOk = data?.status === "200" || data?.status === 200;
const raw = data?.payload?.app_link;
const appLink = typeof raw === "string" && raw.length > 0 ? raw : null;
sendResponse({ appLink: statusOk ? appLink : null });
} catch {
sendResponse({ appLink: null });
}
})();
return true;
});
}
export let MenuOptionsOpen = false; export let MenuOptionsOpen = false;
var IsSEQTAPage = false; var IsSEQTAPage = false;
@@ -26,10 +50,21 @@ 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();
const documentLoadStyle = document.createElement("style"); const documentLoadStyle = document.createElement("style");
documentLoadStyle.textContent = documentLoadCSS; documentLoadStyle.textContent = documentLoadCSS;
document.head.appendChild(documentLoadStyle); document.head.appendChild(documentLoadStyle);
+183 -19
View File
@@ -1,12 +1,22 @@
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";
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);
} }
} }
@@ -124,6 +134,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 +225,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) {
@@ -167,8 +296,19 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean
return true; return true;
} }
/** Handler for a message type; receives request and sendResponse callback */ /** Handler for a message type; receives request, sendResponse, and optional sender (for tab routing) */
type MessageHandler = { (request: any, sendResponse: MessageSender): boolean | void }; type MessageHandler = {
(request: any, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender): boolean | void;
};
function isSeqtaOrigin(origin: string): boolean {
try {
const u = new URL(origin);
return u.hostname.includes("seqta") || u.hostname.endsWith(".edu.au");
} catch {
return false;
}
}
const MESSAGE_HANDLERS: Record<string, MessageHandler> = { const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
reloadTabs: () => reloadSeqtaPages(), reloadTabs: () => reloadSeqtaPages(),
@@ -198,31 +338,51 @@ 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,
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender) => { cloudSettingsUpload: handleCloudSettingsUpload,
cloudSettingsDownload: handleCloudSettingsDownload,
cloudSettingsPoll: () => {
void runCloudSettingsPoll();
return false;
},
cloudSettingsRequestDebouncedUpload: () => {
requestCloudSettingsDebouncedUpload();
return false;
},
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
(async () => { (async () => {
try { try {
let baseUrl = req.baseUrl; let tabId = sender?.tab?.id;
if (!baseUrl) { let originForCheck: string | undefined = req.baseUrl;
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
if (tabId == null) {
const tabs = await browser.tabs.query({ active: true, lastFocusedWindow: true });
const tab = tabs[0]; const tab = tabs[0];
if (!tab?.url) { if (!tab?.id || !tab.url) {
sendResponse({ session: null }); sendResponse({ appLink: null });
return; return;
} }
baseUrl = new URL(tab.url).origin; tabId = tab.id;
if (!originForCheck) originForCheck = new URL(tab.url).origin;
} else if (!originForCheck && sender?.tab?.url) {
originForCheck = new URL(sender.tab.url).origin;
} }
const cookies = await browser.cookies.getAll({ url: baseUrl });
const jsession = cookies.find((c) => c.name === "JSESSIONID"); if (!originForCheck || !isSeqtaOrigin(originForCheck)) {
if (jsession?.value) { sendResponse({ appLink: null });
sendResponse({ session: { baseUrl, jsessionId: jsession.value } }); return;
} else {
sendResponse({ session: null });
} }
const reply = (await browser.tabs.sendMessage(tabId, { type: "fetchSeqtaAppLink" })) as
| { appLink?: string | null }
| undefined;
const appLink = typeof reply?.appLink === "string" && reply.appLink.length > 0 ? reply.appLink : null;
sendResponse({ appLink });
} catch (err) { } catch (err) {
console.error("[Background] getSeqtaSession error:", err); console.error("[Background] getSeqtaSession error:", err);
sendResponse({ session: null }); sendResponse({ appLink: null });
} }
})(); })();
return true; return true;
@@ -231,10 +391,10 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
browser.runtime.onMessage.addListener( browser.runtime.onMessage.addListener(
// @ts-ignore - OnMessageListener expects literal true for async, we return boolean // @ts-ignore - OnMessageListener expects literal true for async, we return boolean
(request: any, _: any, sendResponse: MessageSender) => { (request: any, sender: browser.Runtime.MessageSender, sendResponse: MessageSender) => {
const handler = MESSAGE_HANDLERS[request.type]; const handler = MESSAGE_HANDLERS[request.type];
if (handler) { if (handler) {
const result = handler(request, sendResponse); const result = handler(request, sendResponse, sender);
return result === true; return result === true;
} }
console.log("Unknown request type"); console.log("Unknown request type");
@@ -308,6 +468,8 @@ function getDefaultValues(): SettingsState {
iconOnlySidebar: false, iconOnlySidebar: false,
adaptiveThemeColour: false, adaptiveThemeColour: false,
adaptiveThemeGradient: false, adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true,
autoCloudSettingsSync: true,
}; };
} }
@@ -325,3 +487,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);
}
+307 -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,138 @@ 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);
}
.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 +4372,41 @@ h2.home-subtitle {
font-size: 20px; font-size: 20px;
font-weight: 400; font-weight: 400;
} }
.bsplus-toast {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 10000;
display: flex;
align-items: flex-start;
gap: 12px;
max-width: 380px;
padding: 16px 18px;
border-radius: 12px;
background: var(--background-secondary, #fff);
color: var(--text-primary, #1a1a1a);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
font-size: 0.9rem;
line-height: 1.5;
}
.bsplus-toast-content p {
margin: 6px 0 0;
opacity: 0.8;
font-size: 0.85rem;
}
.bsplus-toast-close {
flex-shrink: 0;
background: none;
border: none;
color: var(--text-primary, #1a1a1a);
font-size: 1.3rem;
cursor: pointer;
padding: 0 2px;
line-height: 1;
opacity: 0.5;
transition: opacity 0.15s;
}
.bsplus-toast-close:hover {
opacity: 1;
}
+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}
@@ -4,15 +4,20 @@
import QRCode from "qrcode"; import QRCode from "qrcode";
import { portal } from "../utils/portal"; import { portal } from "../utils/portal";
const DEEPLINK_PREFIX = "desqta://connect/";
let showQrModal = $state(false); let showQrModal = $state(false);
let qrDataUrl = $state<string | null>(null); let qrDataUrl = $state<string | null>(null);
let deeplink = $state<string | null>(null); let appLink = $state<string | null>(null);
let errorMessage = $state<string | null>(null); let errorMessage = $state<string | null>(null);
let isLoading = $state(false); let isLoading = $state(false);
let isStandalone = $state(false); let isStandalone = $state(false);
function isExtensionPage(): boolean {
return (
window.location.protocol === "chrome-extension:" ||
window.location.protocol === "moz-extension:"
);
}
function isSeqtaUrl(url: string): boolean { function isSeqtaUrl(url: string): boolean {
try { try {
const u = new URL(url); const u = new URL(url);
@@ -31,30 +36,21 @@
} }
} }
function buildDesqtaConnectPayload(baseUrl: string, jsessionId: string): string { async function getAppLink(): Promise<string | null> {
const payload = JSON.stringify({ u: baseUrl, s: jsessionId });
const base64 = btoa(unescape(encodeURIComponent(payload)));
const encoded = encodeURIComponent(base64);
return `${DEEPLINK_PREFIX}${encoded}`;
}
async function getSession(): Promise<{ baseUrl: string; jsessionId: string } | null> {
let baseUrl: string | undefined; let baseUrl: string | undefined;
if (window.location.protocol === "chrome-extension:") { if (isExtensionPage()) {
// Extension popup: background will get URL from active tab
baseUrl = undefined; baseUrl = undefined;
} else { } else {
// In-page (settings inside SEQTA): pass current page URL (cookies API not available in content scripts)
baseUrl = normalizeBaseUrl(window.location.href); baseUrl = normalizeBaseUrl(window.location.href);
if (!isSeqtaUrl(baseUrl)) return null; if (!isSeqtaUrl(baseUrl)) return null;
} }
const { session } = (await browser.runtime.sendMessage({ const { appLink: link } = (await browser.runtime.sendMessage({
type: "getSeqtaSession", type: "getSeqtaSession",
baseUrl, baseUrl,
})) as { session: { baseUrl: string; jsessionId: string } | null }; })) as { appLink: string | null };
return session ?? null; return link ?? null;
} }
async function generateQrCode() { async function generateQrCode() {
@@ -63,10 +59,10 @@
isLoading = true; isLoading = true;
try { try {
isStandalone = window.location.protocol === "chrome-extension:"; isStandalone = isExtensionPage();
const session = await getSession(); const link = await getAppLink();
if (!session) { if (!link) {
if (isStandalone) { if (isStandalone) {
errorMessage = errorMessage =
"Open SEQTA Learn in a tab and log in, then open settings from that tab to generate a QR code."; "Open SEQTA Learn in a tab and log in, then open settings from that tab to generate a QR code.";
@@ -76,9 +72,8 @@
return; return;
} }
const link = buildDesqtaConnectPayload(session.baseUrl, session.jsessionId);
const dataUrl = await QRCode.toDataURL(link, { width: 256, margin: 2 }); const dataUrl = await QRCode.toDataURL(link, { width: 256, margin: 2 });
deeplink = link; appLink = link;
qrDataUrl = dataUrl; qrDataUrl = dataUrl;
showQrModal = true; showQrModal = true;
} catch (err) { } catch (err) {
@@ -92,12 +87,12 @@
function closeModal() { function closeModal() {
showQrModal = false; showQrModal = false;
qrDataUrl = null; qrDataUrl = null;
deeplink = null; appLink = null;
errorMessage = null; errorMessage = null;
} }
function openInDesqta() { function openAppLink() {
if (deeplink) window.location.href = deeplink; if (appLink) window.location.href = appLink;
} }
function downloadQrImage() { function downloadQrImage() {
@@ -125,7 +120,7 @@
{#if showQrModal && qrDataUrl} {#if showQrModal && qrDataUrl}
<div <div
use:portal use:portal
class="fixed cursor-auto inset-0 z-[10000] flex justify-center items-center bg-black/50 backdrop-blur-sm" class="fixed cursor-auto inset-0 z-[10000] flex justify-center items-center bg-black/50 {isStandalone ? 'backdrop-blur-sm' : ''}"
role="button" role="button"
tabindex="-1" tabindex="-1"
onclick={(e) => { onclick={(e) => {
@@ -153,12 +148,12 @@
</button> </button>
</div> </div>
<div class="flex justify-center p-4 bg-white rounded-xl dark:bg-zinc-900"> <div class="flex justify-center p-4 bg-white rounded-xl dark:bg-zinc-900">
<img src={qrDataUrl} alt="DesQTA QR Code" class="w-64 h-64" /> <img src={qrDataUrl} alt="SEQTA Learn app link QR code" class="w-64 h-64" />
</div> </div>
<div class="flex flex-col gap-2 mt-4"> <div class="flex flex-col gap-2 mt-4">
<button <button
type="button" type="button"
onclick={openInDesqta} onclick={openAppLink}
class="px-4 py-2.5 w-full text-sm font-medium text-white bg-indigo-600 rounded-lg transition-colors dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600"> class="px-4 py-2.5 w-full text-sm font-medium text-white bg-indigo-600 rounded-lg transition-colors dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
Sign into DesQTA Desktop Sign into DesQTA Desktop
</button> </button>
@@ -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>
{: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>
</div> </div>
</div> {/key}
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if} {/if}
</div>
</div>
@@ -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;
+112 -48
View File
@@ -11,8 +11,12 @@
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 { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup" import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
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 +56,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 +103,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) }
@@ -115,33 +139,14 @@
{#each [ {#each [
{ {
title: "Connect Mobile App", title: "Connect Mobile App",
description: "Link your SEQTA session to DesQTA — the modern desktop and mobile app for SEQTA Learn.", description: "Link your SEQTA session to DesQTA — the modern desktop and mobile app for SEQTA Learn",
id: 0, id: 0,
Component: ConnectMobileApp, Component: ConnectMobileApp,
props: {} props: {}
}, },
{
title: "Transparency Effects",
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)",
id: 1,
Component: Switch,
props: {
state: $settingsState.transparencyEffects,
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
}
},
{
title: "Custom Theme Colour",
description: "Customise the overall theme colour of SEQTA Learn.",
id: 4,
Component: PickerSwatch,
props: {
onClick: showColourPicker
}
},
{ {
title: "Edit Sidebar Layout", title: "Edit Sidebar Layout",
description: "Customise the sidebar layout.", description: "Reorder pages on the sidebar",
id: 5, id: 5,
Component: Button, Component: Button,
props: { props: {
@@ -149,9 +154,18 @@
text: "Edit" text: "Edit"
} }
}, },
{
title: "Custom Theme Colour",
description: "Customise the overall theme colour of SEQTA Learn",
id: 4,
Component: PickerSwatch,
props: {
onClick: showColourPicker
}
},
{ {
title: "Icon Only Sidebar", title: "Icon Only Sidebar",
description: "Show only icons in the sidebar for a compact layout.", description: "Show only icons in the sidebar for a compact layout",
id: 14, id: 14,
Component: Switch, Component: Switch,
props: { props: {
@@ -161,7 +175,7 @@
}, },
{ {
title: "Animations", title: "Animations",
description: "Enables animations on certain pages.", description: "Enable animations on certain pages",
id: 6, id: 6,
Component: Switch, Component: Switch,
props: { props: {
@@ -180,27 +194,37 @@
} }
}, },
{ {
title: "Default Page", title: "Transparency Effects",
description: "The page to load when SEQTA Learn is opened.", description: "Enable transparency effects on certain elements, such as blur (May impact battery life)",
id: 10, id: 1,
Component: Select, Component: Switch,
props: { props: {
state: $settingsState.defaultPage, state: $settingsState.transparencyEffects,
onChange: (value: string) => settingsState.defaultPage = value, onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
options: [
{ value: 'home', label: 'Home' },
{ value: 'dashboard', label: 'Dashboard' },
{ value: 'timetable', label: 'Timetable' },
{ value: 'welcome', label: 'Welcome' },
{ value: 'messages', label: 'Messages' },
{ value: 'documents', label: 'Documents' },
{ value: 'reports', label: 'Reports' },
]
} }
}, },
{
title: "Default Page",
description: "Choose which page loads first when you open SEQTA",
id: 10,
Component: Select,
props: {
state: $settingsState.defaultPage ?? "home",
onChange: (value: string) => (settingsState.defaultPage = value),
options: [
{ value: "home", label: "Home" },
{ value: "dashboard", label: "Dashboard" },
{ value: "timetable", label: "Timetable" },
{ value: "welcome", label: "Welcome" },
{ value: "messages", label: "Messages" },
{ value: "documents", label: "Documents" },
{ value: "reports", label: "Reports" },
],
},
},
{ {
title: "News Feed Source", title: "News Feed Source",
description: "Choose sources of your news feed.", description: "Choose the sources for your news feed",
id: 11, id: 11,
Component: Select, Component: Select,
props: { props: {
@@ -230,7 +254,7 @@
<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">Adaptive Theme Colour</h2> <h2 class="text-sm font-bold">Adaptive Theme Colour</h2>
<p class="text-xs">Change the theme colour based on the current class (e.g. when viewing a course or assessments page).</p> <p class="text-xs">Change the theme colour based on the current class (e.g. when viewing a course or assessments page)</p>
</div> </div>
<div> <div>
<Switch <Switch
@@ -243,7 +267,7 @@
<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="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"> <div class="pr-4">
<h2 class="text-sm font-bold">Soft Gradient</h2> <h2 class="text-sm font-bold">Soft Gradient</h2>
<p class="text-xs">Use a soft gradient instead of a solid colour when viewing a class.</p> <p class="text-xs">Use a soft gradient instead of a solid colour when viewing a class</p>
</div> </div>
<div> <div>
<Switch <Switch
@@ -252,6 +276,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,9 +318,9 @@
async () => { async () => {
await updatePluginSetting(plugin.pluginId, 'enabled', true); await updatePluginSetting(plugin.pluginId, 'enabled', true);
}, },
() => { () => {},
// Do nothing on cancel "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;
} }
@@ -297,8 +333,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>
@@ -361,6 +397,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({
@@ -428,6 +483,15 @@
/> />
</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">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> </div>
{/if} {/if}
</div> </div>
+66 -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,7 @@
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'
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn); let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
@@ -25,7 +27,12 @@
// State variables // State variables
let searchTerm = $state(''); let searchTerm = $state('');
let themes = $state<Theme[]>([]); let themes = $state<Theme[]>([]);
let coverThemes = $state<Theme[]>([]);
/** Grid/search/cover: hides flat-listed slaves when API sends them */
let listThemes = $derived(visibleStoreThemes(themes));
/** Cover marquee slides (master + flavour imagery for top masters) */
let coverSlides = $derived(buildCoverSlidesForThemes(listThemes.slice(0, 3)));
let loading = $state(true); let loading = $state(true);
let darkMode = $state(false); let darkMode = $state(false);
let displayTheme = $state<Theme | null>(null); let displayTheme = $state<Theme | null>(null);
@@ -34,6 +41,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 +60,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 +113,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) {
@@ -116,11 +131,37 @@
darkMode = $settingsState.DarkMode; darkMode = $settingsState.DarkMode;
}); });
// 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 +200,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 +240,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),
};
}
+7 -2
View File
@@ -15,7 +15,7 @@
"64": "resources/icons/icon-64.png" "64": "resources/icons/icon-64.png"
} }
}, },
"permissions": ["tabs", "notifications", "storage", "cookies"], "permissions": ["tabs", "notifications", "storage"],
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"], "host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
"background": { "background": {
"service_worker": "background.ts" "service_worker": "background.ts"
@@ -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": ["*://*/*"]
} }
] ]
@@ -6,6 +6,7 @@ import {
Setting, Setting,
} from "@/plugins/core/settingsHelpers"; } from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import { waitForElm } from "@/seqta/utils/waitForElm";
const settings = defineSettings({ const settings = defineSettings({
speed: numberSetting({ speed: numberSetting({
@@ -35,13 +36,10 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
settings: instance.settings, settings: instance.settings,
run: async (api) => { run: async (api) => {
// Create the background elements const [container, menu] = await Promise.all([
const container = document.getElementById("container"); waitForElm("#container", true),
const menu = document.getElementById("menu"); waitForElm("#menu", true),
]);
if (!container || !menu) {
return () => {};
}
const backgrounds = [ const backgrounds = [
{ classes: ["bg"] }, { classes: ["bg"] },
@@ -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);
@@ -7,7 +7,7 @@ import localforage from "localforage";
const settings = defineSettings({ const settings = defineSettings({
uploader: componentSetting({ uploader: componentSetting({
title: "Background Music", title: "Background Music",
description: "Upload a .wav or .mp3 audio file to play in the background.", description: "Upload a .wav or .mp3 audio file to play in the background",
component: BackgroundMusicSetting, component: BackgroundMusicSetting,
}), }),
volume: numberSetting({ volume: numberSetting({
@@ -99,7 +99,7 @@ async function startPlayback(volume: number): Promise<void> {
const backgroundMusicPlugin: Plugin<typeof settings> = { const backgroundMusicPlugin: Plugin<typeof settings> = {
id: "background-music", id: "background-music",
name: "Background Music", name: "Background Music",
description: "Play your own music in the background while SEQTA is open.", description: "Play your own music in the background while SEQTA is open",
version: "1.0.0", version: "1.0.0",
settings, settings,
styles, styles,
@@ -142,7 +142,6 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
if (!currentAudio) return; if (!currentAudio) return;
const pauseOnHidden = (api.settings as any).pauseOnHidden ?? true; const pauseOnHidden = (api.settings as any).pauseOnHidden ?? true;
if (!pauseOnHidden) return; if (!pauseOnHidden) return;
if (document.visibilityState === "hidden") { if (document.visibilityState === "hidden") {
if (visibilityResumeTimeout !== null) { if (visibilityResumeTimeout !== null) {
clearTimeout(visibilityResumeTimeout); clearTimeout(visibilityResumeTimeout);
@@ -183,5 +182,3 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
}; };
export default backgroundMusicPlugin; export default backgroundMusicPlugin;
@@ -0,0 +1,246 @@
/**
* BetterSEQTA Security core XSS-focused protections for HTML rendered from SEQTA APIs.
*
* Execution vs detection: SEQTA loads message bodies in same-origin `iframe.userHTML`.
* Scripts may run during parse before our scan completes. We set `sandbox="allow-same-origin"`
* (without `allow-scripts`) on those iframes so script execution is suppressed while we can
* still read `contentDocument` for scanning and existing theme/CSS injection.
*
* The warning UI is mounted on document.body (fixed layer aligned to the reading pane) so
* React replacing `.uiFrameWrapper` / iframe siblings does not destroy it.
*/
import type { Plugin } from "../../core/types";
import {
analyzeHtmlThreats,
type ThreatAnalysis,
} from "@/seqta/security/analyzeHtmlThreats";
import {
mountBlockedContentUi,
SECURITY_MESSAGE_OVERLAY_CLASS,
} from "@/seqta/security/blockedContentUi";
import { eventManager } from "@/seqta/utils/listeners/EventManager";
const USER_HTML_IFRAME_EVENT = "bssSecurityUserHtmlIframe";
const userHtmlIframeLoadHooked = new WeakSet<HTMLIFrameElement>();
/** Tear down body overlay + listeners for this iframe (safe navigation or cleanup). */
const messageOverlayCleanups = new WeakMap<HTMLIFrameElement, () => void>();
function teardownMessageSecurityOverlay(iframe: HTMLIFrameElement): void {
const fn = messageOverlayCleanups.get(iframe);
if (fn) {
fn();
messageOverlayCleanups.delete(iframe);
}
}
function applyMessageIframeSandbox(iframe: HTMLIFrameElement): void {
if (iframe.dataset.bssUserHtmlSandbox === "1") return;
iframe.dataset.bssUserHtmlSandbox = "1";
iframe.setAttribute("sandbox", "allow-same-origin");
}
function wipeIframeDocument(iframe: HTMLIFrameElement): void {
try {
const d = iframe.contentDocument;
if (!d) return;
d.open();
d.write(
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body></body></html>",
);
d.close();
} catch {
/* ignore */
}
}
/**
* After we replace a malicious document, the iframe fires `load` again with this blank shell.
* That pass must not tear down the blocker UI or the iframe would recover for one frame.
*/
function isPostWipeBlankDocument(doc: Document): boolean {
const body = doc.body;
if (!body || body.childElementCount > 0) return false;
if ((body.textContent ?? "").trim().length > 0) return false;
const meta = doc.head?.querySelector('meta[charset="utf-8"]');
if (!meta) return false;
return doc.documentElement.outerHTML.length < 800;
}
/**
* Full-screen body layer positioned over the reading pane so SEQTA/React can replace iframe
* markup without removing this node.
*/
function mountBodyAnchoredMessageOverlay(
iframe: HTMLIFrameElement,
anchor: HTMLElement,
opts: {
analysis: ThreatAnalysis;
rawSnippet: string;
contextTitle?: string;
},
): void {
teardownMessageSecurityOverlay(iframe);
const shell = document.createElement("div");
shell.className = SECURITY_MESSAGE_OVERLAY_CLASS;
Object.assign(shell.style, {
position: "fixed",
zIndex: "2147483646",
overflow: "hidden",
pointerEvents: "auto",
boxSizing: "border-box",
padding: "12px",
background: "rgba(24,24,27,0.35)",
});
const inner = document.createElement("div");
Object.assign(inner.style, {
width: "100%",
height: "100%",
boxSizing: "border-box",
});
shell.appendChild(inner);
let raf = 0;
const syncRect = (): void => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
if (!iframe.isConnected) {
teardownMessageSecurityOverlay(iframe);
return;
}
if (!anchor.isConnected) {
teardownMessageSecurityOverlay(iframe);
return;
}
const r = anchor.getBoundingClientRect();
const pad = 10;
const left = Math.max(8, r.left - pad);
const top = Math.max(8, r.top - pad);
const width = Math.min(window.innerWidth - left - 8, r.width + pad * 2);
const height = Math.min(window.innerHeight - top - 8, r.height + pad * 2);
shell.style.left = `${left}px`;
shell.style.top = `${top}px`;
shell.style.width = `${Math.max(0, width)}px`;
shell.style.height = `${Math.max(0, height)}px`;
});
};
syncRect();
document.body.appendChild(shell);
const ro = new ResizeObserver(syncRect);
ro.observe(anchor);
window.addEventListener("resize", syncRect);
window.addEventListener("scroll", syncRect, true);
const unmountPanel = mountBlockedContentUi(inner, {
surface: "message",
analysis: opts.analysis,
rawSnippet: opts.rawSnippet,
contextTitle: opts.contextTitle,
rootOverlay: true,
});
const cleanup = (): void => {
cancelAnimationFrame(raf);
ro.disconnect();
window.removeEventListener("resize", syncRect);
window.removeEventListener("scroll", syncRect, true);
unmountPanel();
shell.remove();
};
messageOverlayCleanups.set(iframe, cleanup);
}
function handleUserHtmlIframeLoaded(iframe: HTMLIFrameElement): void {
let idoc: Document | null = null;
try {
idoc = iframe.contentDocument;
} catch {
return;
}
if (!idoc?.documentElement) return;
const wrapper =
iframe.closest(".uiFrameWrapper") ??
iframe.closest(".iframeWrapper") ??
iframe.parentElement;
if (!wrapper) return;
if (
iframe.dataset.bssAwaitingWipeLoad === "1" &&
isPostWipeBlankDocument(idoc)
) {
iframe.dataset.bssAwaitingWipeLoad = "";
return;
}
iframe.dataset.bssAwaitingWipeLoad = "";
teardownMessageSecurityOverlay(iframe);
iframe.style.visibility = "";
iframe.style.height = "";
iframe.style.minHeight = "";
const html = idoc.documentElement.outerHTML;
const analysis = analyzeHtmlThreats(html);
if (!analysis.blocked) return;
const pane = iframe.closest('[class*="ReadingPane__ReadingPane"]');
const anchor = (pane ?? wrapper) as HTMLElement;
iframe.dataset.bssAwaitingWipeLoad = "1";
wipeIframeDocument(iframe);
iframe.style.visibility = "hidden";
iframe.style.height = "0";
iframe.style.minHeight = "0";
const subject = pane
?.querySelector('[class*="Message__subject___"]')
?.textContent?.trim();
mountBodyAnchoredMessageOverlay(iframe, anchor, {
analysis,
rawSnippet: html.slice(0, 50_000),
contextTitle: subject,
});
}
const betterSeqtaSecurityPlugin: Plugin = {
id: "better-seqta-security",
name: "BetterSEQTA Security",
description:
"Blocks risky HTML in messages and notices and surfaces administrator-ready incident reports.",
version: "1.0.0",
settings: {},
run: () => {
const { unregister } = eventManager.register(
USER_HTML_IFRAME_EVENT,
{
elementType: "iframe",
customCheck: (element) => element.classList.contains("userHTML"),
},
(element) => {
const iframe = element as HTMLIFrameElement;
if (userHtmlIframeLoadHooked.has(iframe)) return;
userHtmlIframeLoadHooked.add(iframe);
applyMessageIframeSandbox(iframe);
const onLoad = () => handleUserHtmlIframeLoaded(iframe);
iframe.addEventListener("load", onLoad);
queueMicrotask(onLoad);
},
);
return unregister;
},
};
export default betterSeqtaSecurityPlugin;
@@ -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,748 @@
import type { Plugin } from "../../core/types";
import { booleanSetting } from "@/plugins/core/settingsHelpers";
import { waitForElm } from "@/seqta/utils/waitForElm";
import styles from "./styles.css?inline";
const messageFoldersSettings = {
showTagsInAllMessages: booleanSetting({
default: true,
title: "Show folder tags in All Messages",
description:
"When off, folder tags are not shown on the message list until you select a folder.",
}),
hideFolderedMessagesInAll: booleanSetting({
default: true,
title: "Hide foldered messages in All Messages",
description:
"When on, messages assigned to a custom folder are hidden from the inbox until you open that folder.",
}),
} as const;
interface Folder {
id: string;
name: string;
color: string;
}
interface MessageFoldersStorage {
folders: Folder[];
messageAssignments: Record<string, string[]>;
}
const FOLDER_COLORS = [
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b",
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316",
];
const FOLDER_ICON_SVG = `<svg style="width:24px;height:24px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const PLUS_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`;
const CHECK_SVG_WHITE = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#fff" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>`;
const CLOSE_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>`;
const EDIT_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>`;
const TRASH_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`;
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFoldersStorage> = {
id: "messageFolders",
name: "Message Folders",
description: "Organize direct messages into custom folders",
version: "1.0.0",
settings: messageFoldersSettings,
disableToggle: true,
defaultEnabled: true,
run: async (api) => {
const styleEl = document.createElement("style");
styleEl.textContent = styles;
document.head.appendChild(styleEl);
await api.storage.loaded;
if (!api.storage.folders) api.storage.folders = [];
if (!api.storage.messageAssignments) api.storage.messageAssignments = {};
let activeFolderId: string | null = null;
let messageListObserver: MutationObserver | null = null;
let sidebarObserver: MutationObserver | null = null;
let actionsObserver: MutationObserver | null = null;
let openDropdown: HTMLElement | null = null;
let dropdownCloseHandler: ((e: MouseEvent) => void) | null = null;
const unregisters: Array<{ unregister: () => void }> = [];
// ── Storage accessors ──
const getFolders = (): Folder[] => api.storage.folders ?? [];
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
const saveFolders = (folders: Folder[]) => {
api.storage.folders = [...folders];
};
const saveAssignments = (assignments: Record<string, string[]>) => {
api.storage.messageAssignments = { ...assignments };
};
const getMessageFolderIds = (messageId: string): string[] => {
const assignments = getAssignments();
const ids: string[] = [];
for (const [folderId, msgIds] of Object.entries(assignments)) {
if (msgIds.includes(messageId)) ids.push(folderId);
}
return ids;
};
const toggleMessageInFolder = (messageId: string, folderId: string) => {
const assignments = getAssignments();
if (!assignments[folderId]) assignments[folderId] = [];
const idx = assignments[folderId].indexOf(messageId);
if (idx >= 0) {
assignments[folderId].splice(idx, 1);
} else {
assignments[folderId].push(messageId);
}
saveAssignments(assignments);
};
const getFolderMessageCount = (folderId: string): number => {
return (getAssignments()[folderId] ?? []).length;
};
const restoreSubjectPlain = (subject: Element) => {
subject.querySelector(".bsplus-msg-badges")?.remove();
const textWrap = subject.querySelector(".bsplus-subject-text");
if (textWrap) {
subject.textContent = textWrap.textContent ?? "";
}
};
const isMessageInAnyCustomFolder = (messageId: string): boolean => {
for (const msgIds of Object.values(getAssignments())) {
if (msgIds.includes(messageId)) return true;
}
return false;
};
const shouldShowBadgesInList = (): boolean => {
return api.settings.showTagsInAllMessages || activeFolderId !== null;
};
// ── Confirm modal ──
const showConfirmModal = (
title: string,
message: string,
onConfirm: () => void,
) => {
const overlay = document.createElement("div");
overlay.className = "bsplus-modal-overlay";
const modal = document.createElement("div");
modal.className = "bsplus-modal";
modal.innerHTML = `
<h3>${title}</h3>
<p>${message}</p>
<div class="bsplus-modal-actions">
<button class="bsplus-modal-btn-cancel">Cancel</button>
<button class="bsplus-modal-btn-danger">Delete</button>
</div>
`;
overlay.appendChild(modal);
const remove = () => {
overlay.remove();
document.removeEventListener("keydown", onKey);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") remove();
};
overlay.addEventListener("click", (e) => {
if (e.target === overlay) remove();
});
modal.querySelector(".bsplus-modal-btn-cancel")!.addEventListener("click", remove);
modal.querySelector(".bsplus-modal-btn-danger")!.addEventListener("click", () => {
onConfirm();
remove();
});
document.body.appendChild(overlay);
document.addEventListener("keydown", onKey);
};
// ── Sidebar folder UI ──
const renderSidebarFolders = () => {
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (!sidebar) return;
const ol = sidebar.querySelector("ol");
if (!ol) return;
let section = ol.querySelector(".bsplus-folders-section");
if (!section) {
section = document.createElement("div");
section.className = "bsplus-folders-section";
ol.appendChild(section);
}
const folders = getFolders();
const existingInput = section.querySelector(".bsplus-folder-input");
const existingColors = section.querySelector(".bsplus-folder-colors");
section.innerHTML = "";
// Header
const header = document.createElement("div");
header.className = "bsplus-folders-header";
const label = document.createElement("span");
label.textContent = "Folders";
header.appendChild(label);
const addBtn = document.createElement("button");
addBtn.className = "bsplus-folders-add-btn";
addBtn.title = "New folder";
addBtn.innerHTML = PLUS_SVG;
addBtn.addEventListener("click", (e) => {
e.stopPropagation();
showNewFolderInput(section!);
});
header.appendChild(addBtn);
section.appendChild(header);
// "All Messages" item
const allItem = document.createElement("div");
allItem.className = `bsplus-folder-item${activeFolderId === null ? " bsplus-folder-active" : ""}`;
allItem.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" style="fill: currentcolor; opacity: 0.5; flex-shrink: 0;"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
<span class="bsplus-folder-name">All Messages</span>
`;
allItem.addEventListener("click", () => {
activeFolderId = null;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
});
section.appendChild(allItem);
// Folder items
for (const folder of folders) {
const item = document.createElement("div");
item.className = `bsplus-folder-item${activeFolderId === folder.id ? " bsplus-folder-active" : ""}`;
item.dataset.folderId = folder.id;
const dot = document.createElement("div");
dot.className = "bsplus-folder-dot";
dot.style.background = folder.color;
item.appendChild(dot);
const name = document.createElement("span");
name.className = "bsplus-folder-name";
name.textContent = folder.name;
item.appendChild(name);
const actions = document.createElement("div");
actions.className = "bsplus-folder-actions";
const editBtn = document.createElement("button");
editBtn.className = "bsplus-folder-action-btn";
editBtn.title = "Rename";
editBtn.innerHTML = EDIT_SVG;
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
showEditFolderInput(section!, folder);
});
actions.appendChild(editBtn);
const deleteBtn = document.createElement("button");
deleteBtn.className = "bsplus-folder-action-btn";
deleteBtn.title = "Delete";
deleteBtn.innerHTML = TRASH_SVG;
deleteBtn.addEventListener("click", (e) => {
e.stopPropagation();
showConfirmModal(
"Delete folder",
`Remove "${folder.name}"? Messages won't be deleted.`,
() => {
const folders = getFolders().filter((f) => f.id !== folder.id);
saveFolders(folders);
const assignments = getAssignments();
delete assignments[folder.id];
saveAssignments(assignments);
if (activeFolderId === folder.id) activeFolderId = null;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
},
);
});
actions.appendChild(deleteBtn);
item.appendChild(actions);
const count = document.createElement("span");
count.className = "bsplus-folder-count";
const c = getFolderMessageCount(folder.id);
count.textContent = c > 0 ? String(c) : "";
item.appendChild(count);
item.addEventListener("click", () => {
activeFolderId = folder.id;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
});
section.appendChild(item);
}
// Restore input if it was open
if (existingInput || existingColors) {
// Don't restore let user re-trigger
}
};
const showNewFolderInput = (container: Element, editFolder?: Folder) => {
const existing = container.querySelector(".bsplus-folder-input");
if (existing) existing.remove();
container.querySelector(".bsplus-folder-colors")?.remove();
let selectedColor = editFolder?.color ?? FOLDER_COLORS[Math.floor(Math.random() * FOLDER_COLORS.length)];
const row = document.createElement("div");
row.className = "bsplus-folder-input";
const input = document.createElement("input");
input.type = "text";
input.placeholder = editFolder ? "Rename folder…" : "Folder name…";
input.value = editFolder?.name ?? "";
input.maxLength = 30;
const confirmBtn = document.createElement("button");
confirmBtn.className = "bsplus-folder-input-confirm";
confirmBtn.innerHTML = CHECK_SVG_WHITE;
const cancelBtn = document.createElement("button");
cancelBtn.className = "bsplus-folder-input-cancel";
cancelBtn.innerHTML = CLOSE_SVG;
row.appendChild(input);
row.appendChild(confirmBtn);
row.appendChild(cancelBtn);
// Color picker
const colorRow = document.createElement("div");
colorRow.className = "bsplus-folder-colors";
for (const color of FOLDER_COLORS) {
const swatch = document.createElement("button");
swatch.className = `bsplus-folder-color-opt${color === selectedColor ? " bsplus-color-selected" : ""}`;
swatch.style.background = color;
swatch.addEventListener("click", (e) => {
e.stopPropagation();
selectedColor = color;
colorRow.querySelectorAll(".bsplus-folder-color-opt").forEach((s) =>
s.classList.toggle("bsplus-color-selected", (s as HTMLElement).style.background === color),
);
});
colorRow.appendChild(swatch);
}
const confirm = () => {
const name = input.value.trim();
if (!name) return;
if (editFolder) {
const folders = getFolders().map((f) =>
f.id === editFolder.id ? { ...f, name, color: selectedColor } : f,
);
saveFolders(folders);
} else {
const folder: Folder = { id: generateId(), name, color: selectedColor };
saveFolders([...getFolders(), folder]);
}
applyBadges();
renderSidebarFolders();
};
confirmBtn.addEventListener("click", (e) => {
e.stopPropagation();
confirm();
});
cancelBtn.addEventListener("click", (e) => {
e.stopPropagation();
renderSidebarFolders();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") confirm();
if (e.key === "Escape") renderSidebarFolders();
});
container.appendChild(row);
container.appendChild(colorRow);
requestAnimationFrame(() => input.focus());
};
const showEditFolderInput = (container: Element, folder: Folder) => {
showNewFolderInput(container, folder);
};
// ── Intercept native sidebar clicks to clear folder filter ──
const attachNativeSidebarListeners = () => {
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (!sidebar) return;
const ol = sidebar.querySelector("ol");
if (!ol) return;
ol.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest(".bsplus-folders-section")) return;
const li = target.closest("li");
if (li && ol.contains(li)) {
if (activeFolderId !== null) {
activeFolderId = null;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
}
}
});
};
// ── "Add to folder" button in message action bar ──
const injectFolderButton = (actionsBar: Element) => {
if (actionsBar.querySelector(".bsplus-folder-btn")) return;
const wrapper = document.createElement("div");
wrapper.className = "bsplus-folder-btn";
wrapper.style.position = "relative";
wrapper.style.display = "inline-block";
const btn = document.createElement("button");
const btnClasses = actionsBar.querySelector("button")?.className ?? "";
btn.className = btnClasses;
btn.title = "Add to folder";
btn.innerHTML = FOLDER_ICON_SVG;
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
closeDropdown();
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
const messageId = selectedMsg?.getAttribute("data-message");
if (!messageId) return;
showFolderDropdown(wrapper, messageId);
});
wrapper.appendChild(btn);
const moreMenu = actionsBar.querySelector("[class*='MenuButton__Menu___']");
if (moreMenu) {
actionsBar.insertBefore(wrapper, moreMenu);
} else {
actionsBar.appendChild(wrapper);
}
};
const showFolderDropdown = (anchor: HTMLElement, messageId: string) => {
const dropdown = document.createElement("div");
dropdown.className = "bsplus-folder-dropdown";
const folders = getFolders();
const currentFolderIds = getMessageFolderIds(messageId);
if (folders.length === 0) {
const empty = document.createElement("div");
empty.className = "bsplus-folder-dropdown-empty";
empty.textContent = "No folders yet";
dropdown.appendChild(empty);
} else {
for (const folder of folders) {
const isChecked = currentFolderIds.includes(folder.id);
const item = document.createElement("button");
item.className = `bsplus-folder-dropdown-item${isChecked ? " bsplus-checked" : ""}`;
const check = document.createElement("div");
check.className = "bsplus-folder-dropdown-check";
check.style.borderColor = isChecked ? folder.color : "";
check.style.background = isChecked ? folder.color : "";
check.innerHTML = CHECK_SVG_WHITE;
const dot = document.createElement("div");
dot.className = "bsplus-folder-dot";
dot.style.background = folder.color;
const name = document.createElement("span");
name.textContent = folder.name;
item.appendChild(check);
item.appendChild(dot);
item.appendChild(name);
item.addEventListener("click", (e) => {
e.stopPropagation();
toggleMessageInFolder(messageId, folder.id);
const nowChecked = getMessageFolderIds(messageId).includes(folder.id);
item.classList.toggle("bsplus-checked", nowChecked);
check.style.borderColor = nowChecked ? folder.color : "";
check.style.background = nowChecked ? folder.color : "";
applyBadges();
applyFolderFilter();
renderSidebarFolders();
});
dropdown.appendChild(item);
}
}
anchor.appendChild(dropdown);
openDropdown = dropdown;
dropdownCloseHandler = (e: MouseEvent) => {
if (!dropdown.contains(e.target as Node) && !anchor.contains(e.target as Node)) {
closeDropdown();
}
};
setTimeout(() => {
document.addEventListener("click", dropdownCloseHandler!, true);
}, 0);
};
const closeDropdown = () => {
if (openDropdown) {
openDropdown.remove();
openDropdown = null;
}
if (dropdownCloseHandler) {
document.removeEventListener("click", dropdownCloseHandler, true);
dropdownCloseHandler = null;
}
};
// ── Message badges ──
const applyBadges = () => {
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
if (!shouldShowBadgesInList()) {
for (const li of messageItems) {
const subject = li.querySelector("[class*='MessageList__subject___']");
if (subject && (subject.querySelector(".bsplus-msg-badges") || subject.querySelector(".bsplus-subject-text"))) {
restoreSubjectPlain(subject);
} else {
li.querySelector(".bsplus-msg-badges")?.remove();
}
}
return;
}
const folders = getFolders();
const assignments = getAssignments();
for (const li of messageItems) {
const msgId = li.getAttribute("data-message");
if (!msgId) continue;
let badgeContainer = li.querySelector(".bsplus-msg-badges") as HTMLElement | null;
const folderIds = [];
for (const [fId, mIds] of Object.entries(assignments)) {
if (mIds.includes(msgId)) folderIds.push(fId);
}
if (folderIds.length === 0) {
badgeContainer?.remove();
continue;
}
if (!badgeContainer) {
badgeContainer = document.createElement("div");
badgeContainer.className = "bsplus-msg-badges";
const subject = li.querySelector("[class*='MessageList__subject___']");
if (subject) {
if (!subject.querySelector(".bsplus-subject-text")) {
const textWrap = document.createElement("span");
textWrap.className = "bsplus-subject-text";
textWrap.textContent = subject.textContent;
subject.textContent = "";
subject.appendChild(textWrap);
}
subject.appendChild(badgeContainer);
} else {
li.appendChild(badgeContainer);
}
}
badgeContainer.innerHTML = "";
for (const fId of folderIds) {
const folder = folders.find((f) => f.id === fId);
if (!folder) continue;
const badge = document.createElement("span");
badge.className = "bsplus-msg-badge";
badge.style.background = folder.color;
badge.textContent = folder.name;
badge.title = `Filter by "${folder.name}"`;
badge.addEventListener("click", (e) => {
e.stopPropagation();
activeFolderId = folder.id;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
});
badgeContainer.appendChild(badge);
}
}
};
// ── Folder filtering ──
const applyFolderFilter = () => {
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
const moreBtn = document.querySelector("[class*='MessageList__MessageList___'] ol > button");
if (activeFolderId === null) {
if (api.settings.hideFolderedMessagesInAll) {
for (const li of messageItems) {
const msgId = li.getAttribute("data-message");
if (msgId && isMessageInAnyCustomFolder(msgId)) {
li.classList.add("bsplus-folder-hidden");
} else {
li.classList.remove("bsplus-folder-hidden");
}
}
} else {
for (const li of messageItems) {
li.classList.remove("bsplus-folder-hidden");
}
}
if (moreBtn) (moreBtn as HTMLElement).classList.remove("bsplus-folder-hidden");
return;
}
const folderMsgIds = getAssignments()[activeFolderId] ?? [];
for (const li of messageItems) {
const msgId = li.getAttribute("data-message");
if (msgId && folderMsgIds.includes(msgId)) {
li.classList.remove("bsplus-folder-hidden");
} else {
li.classList.add("bsplus-folder-hidden");
}
}
if (moreBtn) (moreBtn as HTMLElement).classList.add("bsplus-folder-hidden");
};
// ── Observers ──
const setupMessageListObserver = () => {
const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol");
if (!messageList || messageListObserver) return;
messageListObserver = new MutationObserver(() => {
applyBadges();
applyFolderFilter();
});
messageListObserver.observe(messageList, { childList: true, subtree: false });
};
const setupActionsObserver = () => {
if (actionsObserver) return;
const target = document.querySelector("[class*='Viewer__Viewer___']") ?? document.querySelector("div.messages");
if (!target) return;
actionsObserver = new MutationObserver(() => {
const actionsBar = document.querySelector("[class*='Message__actions___']");
if (actionsBar && !actionsBar.querySelector(".bsplus-folder-btn")) {
injectFolderButton(actionsBar);
}
});
actionsObserver.observe(target, { childList: true, subtree: true });
};
// ── Main page handler ──
const handleMessagesPage = async () => {
await waitForElm("[class*='Viewer__sidebar___'] ol", true, 50, 100);
renderSidebarFolders();
attachNativeSidebarListeners();
await waitForElm("[class*='MessageList__MessageList___'] ol", true, 50, 100);
applyBadges();
applyFolderFilter();
setupMessageListObserver();
// The actions bar only exists when a message is selected/open,
// so we observe the whole viewer for it to appear dynamically
setupActionsObserver();
// If a message is already selected, inject immediately
const actionsBar = document.querySelector("[class*='Message__actions___']");
if (actionsBar) injectFolderButton(actionsBar);
// Re-observe the sidebar for SEQTA re-renders
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (sidebar && !sidebarObserver) {
sidebarObserver = new MutationObserver(() => {
const ol = sidebar.querySelector("ol");
if (ol && !ol.querySelector(".bsplus-folders-section")) {
renderSidebarFolders();
attachNativeSidebarListeners();
}
});
sidebarObserver.observe(sidebar, { childList: true, subtree: true });
}
};
// ── Lifecycle ──
const mountUnsub = api.seqta.onMount("div.messages", handleMessagesPage);
unregisters.push(mountUnsub);
unregisters.push(
api.settings.onChange("showTagsInAllMessages", () => {
applyBadges();
}),
);
unregisters.push(
api.settings.onChange("hideFolderedMessagesInAll", () => {
applyFolderFilter();
}),
);
return () => {
for (const u of unregisters) u.unregister();
messageListObserver?.disconnect();
sidebarObserver?.disconnect();
actionsObserver?.disconnect();
closeDropdown();
styleEl.remove();
document.querySelectorAll(".bsplus-folders-section").forEach((el) => el.remove());
document.querySelectorAll(".bsplus-folder-btn").forEach((el) => el.remove());
document.querySelectorAll(".bsplus-msg-badges").forEach((el) => el.remove());
document.querySelectorAll("[class*='MessageList__subject___']").forEach((subject) => {
if (subject.querySelector(".bsplus-subject-text")) {
restoreSubjectPlain(subject);
}
});
document.querySelectorAll(".bsplus-folder-hidden").forEach((el) =>
el.classList.remove("bsplus-folder-hidden"),
);
document.querySelectorAll(".bsplus-modal-overlay").forEach((el) => el.remove());
};
},
};
export default messageFoldersPlugin;
@@ -0,0 +1,491 @@
/* ── Sidebar folder section ── */
.bsplus-folders-section {
border-top: 1px solid var(--background-secondary, rgba(128, 128, 128, 0.2));
margin-top: 4px;
padding-top: 4px;
}
.bsplus-folders-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px 2px;
user-select: none;
}
.bsplus-folders-header span {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-primary, #666);
opacity: 0.5;
}
.bsplus-folders-add-btn {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 20px !important;
height: 20px !important;
min-width: 0 !important;
border: none !important;
background: transparent !important;
opacity: 0.5;
cursor: pointer;
border-radius: 4px !important;
padding: 0 !important;
margin: 0 !important;
transition: all 0.2s ease;
text-align: center !important;
}
.bsplus-folders-add-btn:hover {
opacity: 1;
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
}
/* ── Folder list items ── */
.bsplus-folder-item {
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
transition: background 0.15s ease;
position: relative;
gap: 8px;
user-select: none;
}
.bsplus-folder-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
}
.bsplus-folder-item.bsplus-folder-active {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.12));
}
.bsplus-folder-item.bsplus-folder-active::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--better-main, #007bff);
border-radius: 0 2px 2px 0;
}
.bsplus-folder-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.bsplus-folder-name {
font-size: 13px;
color: var(--text-primary, #333);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bsplus-folder-count {
font-size: 11px;
color: var(--text-primary, #999);
opacity: 0.5;
flex-shrink: 0;
}
.bsplus-folder-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.bsplus-folder-item:hover .bsplus-folder-actions {
opacity: 1;
}
.bsplus-folder-action-btn {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 20px !important;
height: 20px !important;
min-width: 0 !important;
border: none !important;
background: transparent !important;
opacity: 0.6;
cursor: pointer;
border-radius: 4px !important;
padding: 0 !important;
margin: 0 !important;
transition: all 0.15s ease;
}
.bsplus-folder-action-btn:hover {
opacity: 1;
background: var(--background-secondary, rgba(128, 128, 128, 0.15)) !important;
}
/* ── Inline folder name input ── */
.bsplus-folder-input {
display: flex;
align-items: center;
padding: 4px 12px;
gap: 6px;
}
.bsplus-folder-input input {
flex: 1;
min-width: 0;
padding: 4px 8px;
font-size: 13px;
border: 1px solid var(--background-secondary, #ccc);
border-radius: 6px;
background: var(--background-secondary, #f5f5f5);
color: var(--text-primary, #333);
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.bsplus-folder-input input:focus {
border-color: var(--better-main, #007bff);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2);
}
.bsplus-folder-input-confirm,
.bsplus-folder-input-cancel {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 24px !important;
height: 24px !important;
min-width: 0 !important;
border: none !important;
border-radius: 4px !important;
cursor: pointer;
padding: 0 !important;
margin: 0 !important;
transition: all 0.15s ease;
}
.bsplus-folder-input-confirm {
background: var(--better-main, #007bff) !important;
}
.bsplus-folder-input-confirm:hover {
transform: scale(1.1);
}
.bsplus-folder-input-cancel {
background: transparent !important;
opacity: 0.6;
}
.bsplus-folder-input-cancel:hover {
opacity: 1;
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
}
/* ── Color picker row ── */
.bsplus-folder-colors {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
padding: 4px 12px 6px;
max-width: 120px;
}
.bsplus-folder-color-opt {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.2s ease,
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1);
padding: 0;
background: none;
box-sizing: border-box;
}
.bsplus-folder-color-opt:hover {
transform: scale(1.25);
box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.15);
}
.bsplus-folder-color-opt.bsplus-color-selected {
border-color: var(--text-primary, #333);
transform: scale(1.15);
box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.2);
}
.bsplus-folder-color-opt.bsplus-color-selected:hover {
transform: scale(1.25);
}
/* ── "Add to folder" button in message actions bar ── */
.bsplus-folder-btn {
position: relative;
}
.bsplus-folder-btn svg {
fill: currentColor;
}
/* ── Folder dropdown ── */
.bsplus-folder-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 180px;
background: var(--background-primary, #fff);
border: 1px solid var(--background-secondary, #e0e0e0);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 1000;
overflow: hidden;
animation: bsplus-dropdown-in 0.15s ease-out;
}
@keyframes bsplus-dropdown-in {
from {
opacity: 0;
transform: translateY(-4px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.bsplus-folder-dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.1s ease;
border: none;
background: transparent;
width: 100%;
text-align: left;
color: var(--text-primary, #333);
font-size: 13px;
}
.bsplus-folder-dropdown-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
}
.bsplus-folder-dropdown-check {
width: 16px;
height: 16px;
border: 2px solid var(--background-secondary, #ccc);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s ease;
}
.bsplus-folder-dropdown-item.bsplus-checked .bsplus-folder-dropdown-check {
background: var(--better-main, #007bff);
border-color: var(--better-main, #007bff);
}
.bsplus-folder-dropdown-check svg {
width: 10px;
height: 10px;
color: white;
opacity: 0;
transition: opacity 0.1s ease;
}
.bsplus-folder-dropdown-item.bsplus-checked .bsplus-folder-dropdown-check svg {
opacity: 1;
}
.bsplus-folder-dropdown-empty {
padding: 12px;
text-align: center;
font-size: 12px;
color: var(--text-primary, #999);
opacity: 0.5;
}
/* ── Let primary column use available space instead of being clipped ── */
[class*='MessageList__primary___'] {
flex: 1 1 0% !important;
min-width: 0 !important;
overflow: hidden !important;
}
/* ── Make subject line a flex row so badges sit inline ── */
[class*='MessageList__subject___'] {
display: flex !important;
align-items: center;
gap: 6px;
min-width: 0 !important;
overflow: hidden !important;
}
/* ── Subject text truncates to make room for badges ── */
.bsplus-subject-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1 1 auto;
}
/* ── Shrink the secondary column to its content ── */
[class*='MessageList__secondary___'] {
flex: 0 0 auto !important;
width: auto !important;
min-width: 0 !important;
max-width: 200px !important;
}
/* ── Constrain the flags/attachment icon column ── */
[class*='MessageList__flags___'] {
width: 24px !important;
min-width: 0 !important;
flex-shrink: 0 !important;
}
/* ── Message list folder badges ── */
.bsplus-msg-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
margin-left: auto;
}
.bsplus-msg-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
border-radius: 8px;
font-size: 10px;
font-weight: 500;
line-height: 1.4;
color: white;
white-space: nowrap;
cursor: pointer;
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.bsplus-msg-badge:hover {
opacity: 0.85;
transform: scale(1.05);
}
/* ── Folder filtering (hide messages not in active folder) ── */
.bsplus-folder-hidden {
display: none !important;
}
/* ── Delete confirmation modal ── */
@keyframes bsplus-modal-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes bsplus-modal-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.bsplus-modal-overlay {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
animation: bsplus-modal-overlay-in 0.2s ease-out forwards;
}
.bsplus-modal {
padding: 1rem 1.5rem;
margin: 0 1rem;
min-width: 16rem;
max-width: 22rem;
width: 100%;
box-sizing: border-box;
background: var(--background-primary, #fff);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid var(--background-secondary, #e0e0e0);
animation: bsplus-modal-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.bsplus-modal h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #333);
}
.bsplus-modal p {
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--text-primary, #666);
opacity: 0.8;
}
.bsplus-modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.bsplus-modal-actions button {
padding: 0.4rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.bsplus-modal-btn-cancel {
background: transparent;
border: 1px solid var(--background-secondary, #ccc);
color: var(--text-primary, #333);
}
.bsplus-modal-btn-cancel:hover {
background: var(--background-secondary, rgba(128, 128, 128, 0.1));
}
.bsplus-modal-btn-danger {
background: #e53e3e;
border: none;
color: white;
}
.bsplus-modal-btn-danger:hover {
background: #c53030;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35);
}
@@ -1,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 -13
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",
@@ -13,12 +23,11 @@ const settings = defineSettings({
}), }),
}); });
const profilePicturePlugin: Plugin<typeof settings> = { 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,
@@ -37,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;
@@ -53,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);
@@ -66,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);
@@ -83,4 +113,3 @@ const profilePicturePlugin: Plugin<typeof settings> = {
}; };
export default profilePicturePlugin; export default profilePicturePlugin;
+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();
}, },
}; };
+275 -41
View File
@@ -1,8 +1,21 @@
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 { updateAllColors } from "@/seqta/ui/colors/Manager";
import {
clearCustomThemeAdaptiveCssVariables,
setCustomThemeAdaptiveCssVariables,
} from "@/seqta/ui/colors/customThemeAdaptiveBindings";
type ThemeContent = { type ThemeContent = {
id: string; id: string;
@@ -13,8 +26,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 +51,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 +167,33 @@ export class ThemeManager {
} }
} }
/**
* After cloud restore, IndexedDB/theme storage is only reachable from page context (not MV3 SW).
* Background sets BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY; we fetch the store JSON here before setTheme().
* The resolved id matches cloud sync **`themeId` / `selectedTheme`**: it may be a standard theme uuid or a
* flavour (slave) variant id **`downloadAndInstallStoreTheme`** is the same code path as the theme store installer.
*/
public async prepareThemeAfterCloudSync(): Promise<void> {
try {
const snap = await browser.storage.local.get(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
const pending = snap[BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY];
if (pending === undefined) return;
await browser.storage.local.remove(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
if (typeof pending !== "string") return;
const id = pending.trim();
if (!id) return;
const existing = (await localforage.getItem(id)) as CustomTheme | null;
if (existing) return;
await this.downloadAndInstallStoreTheme({ id, name: id });
} catch (e) {
console.warn("[ThemeManager] prepareThemeAfterCloudSync:", e);
}
}
/** /**
* Initialize the theme system and restore previous state * Initialize the theme system and restore previous state
*/ */
@@ -175,6 +227,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 +263,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 +294,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 +325,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 +345,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 +403,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 +440,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 +495,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);
@@ -496,39 +570,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 +633,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 +670,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 +684,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 +706,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 +835,9 @@ export class ThemeManager {
isEditable, isEditable,
selectedColor, selectedColor,
allowBackgrounds, allowBackgrounds,
installedFromStore,
storeSyncedAtSec,
userEdited,
...themeBasics ...themeBasics
} = theme; } = theme;
@@ -651,7 +875,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 +921,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 +996,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 +1045,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 +1076,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") {
+13 -3
View File
@@ -271,7 +271,9 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
}; };
const syncQuickbarFromDOM = () => { const syncQuickbarFromDOM = () => {
const quickbar = document.querySelector(".timetablepage .quickbar.visible"); const quickbar = document.querySelector(
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
);
if (quickbar && quickbar.getAttribute("data-type") === "class") { if (quickbar && quickbar.getAttribute("data-type") === "class") {
const titleEl = quickbar.querySelector(".title"); const titleEl = quickbar.querySelector(".title");
const roomEl = quickbar.querySelector(".meta .room"); const roomEl = quickbar.querySelector(".meta .room");
@@ -287,7 +289,9 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
if (!timetablePage || quickbarObserver) return; if (!timetablePage || quickbarObserver) return;
quickbarObserver = new MutationObserver(() => { quickbarObserver = new MutationObserver(() => {
const quickbar = document.querySelector(".timetablepage .quickbar.visible"); const quickbar = document.querySelector(
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
);
if (quickbar?.getAttribute("data-type") === "class") { if (quickbar?.getAttribute("data-type") === "class") {
addEditButtonToQuickbar(quickbar as HTMLElement); addEditButtonToQuickbar(quickbar as HTMLElement);
} }
@@ -302,7 +306,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();
+4
View File
@@ -10,6 +10,8 @@ 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 betterSeqtaSecurityPlugin from "./built-in/betterSeqtaSecurity";
//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)
@@ -20,6 +22,7 @@ const pluginManager = PluginManager.getInstance();
// Register built-in plugins // Register built-in plugins
pluginManager.registerPlugin(themesPlugin); pluginManager.registerPlugin(themesPlugin);
pluginManager.registerPlugin(betterSeqtaSecurityPlugin);
pluginManager.registerPlugin(animatedBackgroundPlugin); pluginManager.registerPlugin(animatedBackgroundPlugin);
pluginManager.registerPlugin(assessmentsAveragePlugin); pluginManager.registerPlugin(assessmentsAveragePlugin);
pluginManager.registerPlugin(notificationCollectorPlugin); pluginManager.registerPlugin(notificationCollectorPlugin);
@@ -28,6 +31,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) 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),
@@ -410,14 +459,21 @@ async function handleDocuments(node: Element): Promise<void> {
}, },
); );
} }
} catch {
// ignore
}
}
async function handleReports(node: Element): Promise<void> { async function handleReports(node: Element): Promise<void> {
if (!(node instanceof HTMLElement)) return; if (!(node instanceof HTMLElement)) return;
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 }),
@@ -426,6 +482,10 @@ async function handleReports(node: Element): Promise<void> {
}, },
); );
} }
} catch {
// ignore
}
}
function CheckNoticeTextColour(notice: any) { function CheckNoticeTextColour(notice: any) {
eventManager.register( eventManager.register(
@@ -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.
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { analyzeHtmlThreats } from "./analyzeHtmlThreats";
describe("analyzeHtmlThreats", () => {
it("does not flag benign HTML", () => {
const r = analyzeHtmlThreats("<p>Hello <strong>world</strong></p>");
expect(r.blocked).toBe(false);
expect(r.findings).toHaveLength(0);
});
it("flags script tags", () => {
const r = analyzeHtmlThreats('<p>x</p><script>alert(1)</script>');
expect(r.blocked).toBe(true);
expect(r.findings.some((f) => f.kind === "script_tag")).toBe(true);
});
it("flags javascript: URLs", () => {
const r = analyzeHtmlThreats('<a href="javascript:void(0)">click</a>');
expect(r.blocked).toBe(true);
expect(r.findings.some((f) => f.kind === "dangerous_url_scheme")).toBe(
true,
);
});
it("flags inline event handlers", () => {
const r = analyzeHtmlThreats('<img src="https://example.com/x.png" onerror="alert(1)">');
expect(r.blocked).toBe(true);
expect(
r.findings.some((f) => f.kind === "inline_event_handler"),
).toBe(true);
});
it("allows data:image/png sources", () => {
const r = analyzeHtmlThreats(
'<img alt="i" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==">',
);
expect(r.blocked).toBe(false);
});
it("flags data:text/html", () => {
const r = analyzeHtmlThreats(
'<iframe src="data:text/html,%3Cscript%3E%3C/script%3E"></iframe>',
);
expect(r.blocked).toBe(true);
});
});
+190
View File
@@ -0,0 +1,190 @@
import DOMPurify from "dompurify";
export interface ThreatFinding {
kind: string;
detail: string;
}
export interface ThreatAnalysis {
blocked: boolean;
findings: ThreatFinding[];
}
const INLINE_HANDLER_RE = /^on[a-z]+$/i;
const DANGEROUS_SCHEME_RE =
/^\s*(javascript|vbscript|about\s*:|file\s*:)/i;
/** Inline data URIs except common raster images (emails often embed PNG/JPEG). */
function isDangerousDataUri(url: string): boolean {
const v = url.trim().toLowerCase();
if (!v.startsWith("data:")) return false;
if (/^data:image\/(png|jpe?g|gif|webp|bmp)([;,]|$)/i.test(v)) return false;
return true;
}
/** Patterns inside executable contexts (script bodies). */
const SCRIPT_TEXT_SUSPICIOUS =
/\beval\s*\(|new\s+Function\s*\(|document\s*\.\s*write|\.execScript\s*\(/i;
function addFinding(
findings: ThreatFinding[],
kind: string,
detail: string,
): void {
if (findings.some((f) => f.kind === kind && f.detail === detail)) return;
findings.push({ kind, detail });
}
function inspectUrlAttr(attrName: string, value: string): ThreatFinding[] {
const out: ThreatFinding[] = [];
const v = value.trim();
if (!v) return out;
if (DANGEROUS_SCHEME_RE.test(v) || isDangerousDataUri(v)) {
out.push({
kind: "dangerous_url_scheme",
detail: `${attrName}="${v.slice(0, 120)}${v.length > 120 ? "…" : ""}"`,
});
}
return out;
}
function walkElement(el: Element, findings: ThreatFinding[]): void {
const tag = el.tagName.toLowerCase();
if (tag === "script") {
const src = el.getAttribute("src")?.trim() ?? "";
if (
src &&
(DANGEROUS_SCHEME_RE.test(src) || isDangerousDataUri(src))
) {
addFinding(findings, "script_src", `script src="${src.slice(0, 160)}"`);
} else if (!src && el.textContent && SCRIPT_TEXT_SUSPICIOUS.test(el.textContent)) {
addFinding(
findings,
"script_pattern",
"Inline script contains suspicious patterns (eval/new Function/document.write).",
);
} else {
addFinding(findings, "script_tag", "A script element is present in HTML.");
}
return;
}
if (tag === "meta") {
const httpEquiv = el.getAttribute("http-equiv")?.toLowerCase();
if (httpEquiv === "refresh") {
addFinding(
findings,
"meta_refresh",
'meta http-equiv="refresh" can redirect or execute unexpectedly.',
);
}
}
if (tag === "iframe" || tag === "frame") {
const src = el.getAttribute("src")?.trim() ?? "";
const srcdoc = el.getAttribute("srcdoc") ?? "";
findings.push(...inspectUrlAttr("iframe[src]", src));
if (srcdoc.length > 0) {
addFinding(
findings,
"iframe_srcdoc",
"iframe srcdoc may embed arbitrary markup; nested analysis follows.",
);
nestedAnalyze(srcdoc, findings, 2);
}
}
if (tag === "object" || tag === "embed") {
const url =
el.getAttribute("data") ?? el.getAttribute("src") ?? "";
findings.push(...inspectUrlAttr(`${tag}[src/data]`, url));
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name;
if (INLINE_HANDLER_RE.test(name)) {
addFinding(
findings,
"inline_event_handler",
`${tag}.${name}`,
);
}
const val = attr.value ?? "";
if (
name === "href" ||
name === "src" ||
name === "action" ||
name === "formaction" ||
name === "poster" ||
name === "data"
) {
findings.push(...inspectUrlAttr(`${tag}[${name}]`, val));
}
if (name === "style" && /\burl\s*\(\s*["']?\s*javascript:/i.test(val)) {
addFinding(findings, "css_javascript_url", `${tag} style contains javascript: URL.`);
}
}
for (const child of Array.from(el.children)) {
walkElement(child, findings);
}
}
function nestedAnalyze(fragment: string, findings: ThreatFinding[], depth: number): void {
if (depth <= 0) return;
let doc: Document;
try {
doc = new DOMParser().parseFromString(fragment, "text/html");
} catch {
return;
}
walkElement(doc.documentElement, findings);
}
/**
* High-confidence HTML threat signals for user-generated / API HTML (messages, notices).
*
* Note: This runs after load for iframes in many cases; pairing with iframe `sandbox`
* (see BetterSEQTA Security plugin) is required for reliable script blocking see plugin comments.
*/
export function analyzeHtmlThreats(html: string): ThreatAnalysis {
const findings: ThreatFinding[] = [];
if (!html || !html.trim()) {
return { blocked: false, findings: [] };
}
let doc: Document;
try {
doc = new DOMParser().parseFromString(html, "text/html");
} catch {
return { blocked: false, findings: [] };
}
walkElement(doc.documentElement, findings);
/** SEQTA home modal path uses DOMPurify with onclick allowed; flag removal under stricter rules. */
const permissive = DOMPurify.sanitize(html, {
ADD_ATTR: ["onclick"],
ALLOWED_URI_REGEXP:
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|chrome-extension):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
});
const strict = DOMPurify.sanitize(html, {
ALLOW_DATA_ATTR: false,
ALLOW_UNKNOWN_PROTOCOLS: false,
});
if (strict !== permissive) {
addFinding(
findings,
"dompurify_delta",
"Content was altered under strict sanitization (likely inline handlers or risky markup).",
);
}
return {
blocked: findings.length > 0,
findings,
};
}
+234
View File
@@ -0,0 +1,234 @@
import type { ThreatAnalysis } from "./analyzeHtmlThreats";
import {
buildIncidentReport,
copyIncidentReport,
downloadIncidentReportPdf,
formatIncidentReportPlainText,
openIncidentReportEmail,
type IncidentReport,
} from "./incidentReport";
/** Mounted by BetterSEQTA Security on messages / notices when HTML is blocked. */
export const SECURITY_BLOCK_HOST_CLASS = "bss-security-block-host";
/** Body-fixed layer for message pane (survives React replacing iframe wrappers). */
export const SECURITY_MESSAGE_OVERLAY_CLASS = "bss-security-message-overlay";
const HOST_CLASS = SECURITY_BLOCK_HOST_CLASS;
const REPORT_LAYER_CLASS = "bss-security-report-layer";
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
style: Partial<CSSStyleDeclaration>,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
Object.assign(node.style, style);
if (text !== undefined) node.textContent = text;
return node;
}
function button(
label: string,
onClick: () => void,
): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = label;
Object.assign(btn.style, {
padding: "10px 18px",
borderRadius: "8px",
border: "1px solid rgba(255,255,255,0.25)",
background: "rgba(220,38,38,0.35)",
color: "#fff",
cursor: "pointer",
fontSize: "14px",
fontWeight: "600",
transition: "background 0.2s, transform 0.2s",
} as CSSStyleDeclaration);
btn.addEventListener("mouseenter", () => {
btn.style.background = "rgba(220,38,38,0.55)";
});
btn.addEventListener("mouseleave", () => {
btn.style.background = "rgba(220,38,38,0.35)";
});
btn.addEventListener("click", onClick);
return btn;
}
function removeExistingReportLayer(doc: Document): void {
doc.querySelectorAll(`.${REPORT_LAYER_CLASS}`).forEach((n) => n.remove());
}
export interface MountBlockedContentOptions {
surface: "message" | "notice";
analysis: ThreatAnalysis;
rawSnippet?: string;
contextTitle?: string;
contextSubtitle?: string;
hostDocument?: Document;
/**
* Panel lives on document.body (fixed layer). Omits `position: relative` so the caller can pin position/size.
*/
rootOverlay?: boolean;
}
export function mountBlockedContentUi(
container: HTMLElement,
options: MountBlockedContentOptions,
): () => void {
const doc = options.hostDocument ?? document;
container.innerHTML = "";
container.classList.add(HOST_CLASS);
const basePanel: Partial<CSSStyleDeclaration> = {
boxSizing: "border-box",
minHeight: options.rootOverlay ? "min(100%, 260px)" : "220px",
padding: "24px",
borderRadius: "12px",
background:
"linear-gradient(145deg, rgba(30,30,35,0.98), rgba(20,20,24,0.98))",
border: "1px solid rgba(239,68,68,0.45)",
color: "#f4f4f5",
fontFamily: "system-ui, Segoe UI, Roboto, sans-serif",
lineHeight: "1.5",
};
if (options.rootOverlay) {
Object.assign(container.style, {
...basePanel,
height: "100%",
overflow: "auto",
});
} else {
Object.assign(container.style, {
...basePanel,
position: "relative",
});
}
const title = el("h2", {
margin: "0 0 12px 0",
fontSize: "20px",
fontWeight: "700",
color: "#fff",
}, "BetterSEQTA Security");
const lead = el("p", {
margin: "0 0 12px 0",
fontSize: "15px",
color: "#e4e4e7",
});
lead.textContent =
"This content was not shown because BetterSEQTA+ detected potentially malicious HTML (for example scripts or dangerous links). This helps protect your account from cross-site scripting.";
const admin = el("p", {
margin: "0 0 20px 0",
fontSize: "14px",
color: "#a1a1aa",
});
admin.textContent =
"Contact your school SEQTA or IT administrator so they can remove or fix the message or notice at source.";
const actions = el("div", {
display: "flex",
flexWrap: "wrap",
gap: "12px",
alignItems: "center",
});
let latestReport: IncidentReport | null = null;
const openReport = async () => {
latestReport = await buildIncidentReport({
surface: options.surface,
analysis: options.analysis,
rawSnippet: options.rawSnippet,
contextTitle: options.contextTitle,
contextSubtitle: options.contextSubtitle,
});
removeExistingReportLayer(doc);
const layer = doc.createElement("div");
layer.className = REPORT_LAYER_CLASS;
Object.assign(layer.style, {
position: "fixed",
inset: "0",
zIndex: "2147483647",
background: "rgba(0,0,0,0.55)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
boxSizing: "border-box",
});
const panel = doc.createElement("div");
Object.assign(panel.style, {
maxWidth: "640px",
width: "100%",
maxHeight: "85vh",
overflow: "auto",
background: "#18181b",
color: "#fafafa",
borderRadius: "12px",
border: "1px solid #3f3f46",
padding: "24px",
boxShadow: "0 25px 50px rgba(0,0,0,0.45)",
});
const pre = doc.createElement("pre");
pre.style.whiteSpace = "pre-wrap";
pre.style.wordBreak = "break-word";
pre.style.fontSize = "12px";
pre.style.lineHeight = "1.45";
pre.style.margin = "0 0 16px 0";
pre.textContent = formatIncidentReportPlainText(latestReport);
const row = doc.createElement("div");
row.style.display = "flex";
row.style.flexWrap = "wrap";
row.style.gap = "10px";
const closeLayer = () => layer.remove();
row.appendChild(
button("Close", closeLayer),
);
row.appendChild(
button("Copy report", async () => {
if (!latestReport) return;
await copyIncidentReport(latestReport);
}),
);
row.appendChild(
button("Download PDF", () => {
if (!latestReport) return;
downloadIncidentReportPdf(latestReport);
}),
);
row.appendChild(
button("Email", () => {
if (!latestReport) return;
openIncidentReportEmail(latestReport);
}),
);
panel.appendChild(pre);
panel.appendChild(row);
layer.appendChild(panel);
layer.addEventListener("click", (e) => {
if (e.target === layer) closeLayer();
});
doc.body.appendChild(layer);
};
actions.appendChild(button("View report", () => void openReport()));
container.appendChild(title);
container.appendChild(lead);
container.appendChild(admin);
container.appendChild(actions);
return () => {
container.innerHTML = "";
container.classList.remove(HOST_CLASS);
removeExistingReportLayer(doc);
};
}
+156
View File
@@ -0,0 +1,156 @@
import { jsPDF } from "jspdf";
import browser from "webextension-polyfill";
import type { ThreatAnalysis, ThreatFinding } from "./analyzeHtmlThreats";
export type ThreatSurface = "message" | "notice";
export interface IncidentReport {
generatedAtIso: string;
surface: ThreatSurface;
extensionVersion: string;
pageUrl: string;
contextTitle?: string;
contextSubtitle?: string;
analysis: ThreatAnalysis;
contentFingerprint?: string;
}
async function sha256Hex(text: string): Promise<string> {
const enc = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest("SHA-256", enc);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function getExtensionVersion(): string {
try {
return browser.runtime?.getManifest?.()?.version ?? "unknown";
} catch {
return "unknown";
}
}
export async function buildIncidentReport(opts: {
surface: ThreatSurface;
analysis: ThreatAnalysis;
rawSnippet?: string;
contextTitle?: string;
contextSubtitle?: string;
extensionVersion?: string;
}): Promise<IncidentReport> {
let fingerprint: string | undefined;
if (opts.rawSnippet?.trim()) {
try {
if (typeof crypto !== "undefined" && crypto.subtle) {
fingerprint = await sha256Hex(opts.rawSnippet.slice(0, 50_000));
}
} catch {
fingerprint = undefined;
}
}
const version = opts.extensionVersion ?? getExtensionVersion();
return {
generatedAtIso: new Date().toISOString(),
surface: opts.surface,
extensionVersion: version,
pageUrl:
typeof window !== "undefined" ? window.location.href : "",
contextTitle: opts.contextTitle,
contextSubtitle: opts.contextSubtitle,
analysis: opts.analysis,
contentFingerprint: fingerprint,
};
}
function formatFindings(findings: ThreatFinding[]): string {
return findings.map((f, i) => `${i + 1}. [${f.kind}] ${f.detail}`).join("\n");
}
export function formatIncidentReportPlainText(report: IncidentReport): string {
const lines = [
"BetterSEQTA+ Security — incident report",
"=====================================",
"",
`Generated (UTC): ${report.generatedAtIso}`,
`Surface: ${report.surface}`,
`Extension version: ${report.extensionVersion}`,
`Page URL: ${report.pageUrl}`,
];
if (report.contextTitle) lines.push(`Title / subject: ${report.contextTitle}`);
if (report.contextSubtitle) lines.push(`Detail: ${report.contextSubtitle}`);
if (report.contentFingerprint) {
lines.push(`Content SHA-256 (truncated input): ${report.contentFingerprint}`);
}
lines.push("", "Findings:", formatFindings(report.analysis.findings), "");
lines.push(
"Next steps:",
"- Contact your school SEQTA / IT administrator and ask them to remove or sanitise the malicious content at source.",
"- Attach this report (PDF or pasted text) when reporting.",
);
return lines.join("\n");
}
export async function copyIncidentReport(report: IncidentReport): Promise<void> {
const text = formatIncidentReportPlainText(report);
await navigator.clipboard.writeText(text);
}
export function downloadIncidentReportPdf(report: IncidentReport): void {
const doc = new jsPDF({ unit: "pt", format: "a4" });
const margin = 48;
let y = margin;
const lineHeight = 14;
const pageHeight = doc.internal.pageSize.getHeight();
const maxWidth = doc.internal.pageSize.getWidth() - margin * 2;
const pushLines = (text: string, bold = false) => {
doc.setFont("helvetica", bold ? "bold" : "normal");
const wrapped = doc.splitTextToSize(text, maxWidth) as string[];
for (const line of wrapped) {
if (y > pageHeight - margin) {
doc.addPage();
y = margin;
}
doc.text(line, margin, y);
y += lineHeight;
}
};
pushLines("BetterSEQTA+ Security — incident report", true);
pushLines(`Generated (UTC): ${report.generatedAtIso}`);
pushLines(`Surface: ${report.surface}`);
pushLines(`Extension version: ${report.extensionVersion}`);
pushLines(`Page URL: ${report.pageUrl}`);
if (report.contextTitle) pushLines(`Title / subject: ${report.contextTitle}`);
if (report.contextSubtitle) pushLines(`Detail: ${report.contextSubtitle}`);
if (report.contentFingerprint) {
pushLines(`Content SHA-256 (truncated input): ${report.contentFingerprint}`);
}
pushLines("");
pushLines("Findings:", true);
for (const f of report.analysis.findings) {
pushLines(`• [${f.kind}] ${f.detail}`);
}
pushLines("");
pushLines("Next steps:", true);
pushLines(
"Contact your school SEQTA / IT administrator and ask them to remove or sanitise the malicious content at source. Attach this PDF when reporting.",
);
doc.save(`betterseqta-security-report-${report.surface}-${Date.now()}.pdf`);
}
export function openIncidentReportEmail(report: IncidentReport): void {
const subject = encodeURIComponent(
"SEQTA: suspected malicious HTML blocked by BetterSEQTA+ Security",
);
const body = encodeURIComponent(
formatIncidentReportPlainText(report).slice(0, 1800) +
"\n\n[If truncated: use Copy in the report dialog for the full text.]",
);
window.location.href = `mailto:?subject=${subject}&body=${body}`;
}
+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 {
@@ -0,0 +1,864 @@
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";
import { analyzeHtmlThreats } from "@/seqta/security/analyzeHtmlThreats";
import { mountBlockedContentUi } from "@/seqta/security/blockedContentUi";
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(/ +/, " ");
const threatAnalysis = analyzeHtmlThreats(cleanContent);
const noticeBlocked = threatAnalysis.blocked;
const noticeBodyInner = noticeBlocked
? `<div class="notice-content-body bss-security-notice-mount"></div>`
: `<div class="notice-content-body">${cleanContent}</div>`;
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>
${noticeBodyInner}
</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);
if (noticeBlocked) {
const mountEl = modal.querySelector(
".bss-security-notice-mount",
) as HTMLElement | null;
if (mountEl) {
mountBlockedContentUi(mountEl, {
surface: "notice",
analysis: threatAnalysis,
rawSnippet: cleanContent.slice(0, 50_000),
contextTitle: String(notice.title ?? ""),
contextSubtitle: [notice.staff, notice.label_title]
.filter(Boolean)
.join(" · "),
});
}
}
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>
${
noticeBlocked
? `<div class="notice-content-body bss-security-notice-mount" style="min-height:280px;"></div>`
: `<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);
}
}
+31 -2
View File
@@ -13,6 +13,8 @@ import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments"; import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent"; import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip"; import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip";
import { analyzeHtmlThreats } from "@/seqta/security/analyzeHtmlThreats";
import { mountBlockedContentUi } from "@/seqta/security/blockedContentUi";
let LessonInterval: any; let LessonInterval: any;
let currentSelectedDate = new Date(); let currentSelectedDate = new Date();
@@ -384,6 +386,12 @@ function openNoticeModal(
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "") .replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " "); .replace(/ +/, " ");
const threatAnalysis = analyzeHtmlThreats(cleanContent);
const noticeBlocked = threatAnalysis.blocked;
const noticeBodyInner = noticeBlocked
? `<div class="notice-content-body bss-security-notice-mount"></div>`
: `<div class="notice-content-body">${cleanContent}</div>`;
document.getElementById("notice-modal")?.remove(); document.getElementById("notice-modal")?.remove();
const sourceRect = sourceElement.getBoundingClientRect(); const sourceRect = sourceElement.getBoundingClientRect();
@@ -417,7 +425,7 @@ function openNoticeModal(
<button class="notice-close-btn">&times;</button> <button class="notice-close-btn">&times;</button>
</div> </div>
<h2 class="notice-content-title">${notice.title}</h2> <h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div> ${noticeBodyInner}
</div> </div>
</div> </div>
</div> </div>
@@ -434,6 +442,23 @@ function openNoticeModal(
document.body.appendChild(modal); document.body.appendChild(modal);
if (noticeBlocked) {
const mountEl = modal.querySelector(
".bss-security-notice-mount",
) as HTMLElement | null;
if (mountEl) {
mountBlockedContentUi(mountEl, {
surface: "notice",
analysis: threatAnalysis,
rawSnippet: cleanContent.slice(0, 50_000),
contextTitle: String(notice.title ?? ""),
contextSubtitle: [notice.staff, notice.label_title]
.filter(Boolean)
.join(" · "),
});
}
}
sourceElement.setAttribute("data-transitioning", "true"); sourceElement.setAttribute("data-transitioning", "true");
sourceElement.style.opacity = "0"; sourceElement.style.opacity = "0";
sourceElement.style.transform = "scale(0.95)"; sourceElement.style.transform = "scale(0.95)";
@@ -459,7 +484,11 @@ function openNoticeModal(
<button class="notice-close-btn">&times;</button> <button class="notice-close-btn">&times;</button>
</div> </div>
<h2 class="notice-content-title">${notice.title}</h2> <h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div> ${
noticeBlocked
? `<div class="notice-content-body bss-security-notice-mount" style="min-height:280px;"></div>`
: `<div class="notice-content-body">${cleanContent}</div>`
}
</div> </div>
`; `;
document.body.appendChild(tempMeasureDiv); document.body.appendChild(tempMeasureDiv);
@@ -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,54 @@
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";
toast.innerHTML = /* html */ `
<div class="bsplus-toast-content">
<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>
<button class="bsplus-toast-close" aria-label="Dismiss">&times;</button>
`;
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,
}); });
} }
+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,10 +29,48 @@ 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 - Assessment weighting override & fixes</h1>
<li>Added the ability to override/add weightings to assessments (on assessment page).</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>
<li>Fixed DesQTA Connect Mobile App QR generation on Chrome</li>
<h1>3.5.0 - Adaptive Theme, Timetable Editor & More</h1> <h1>3.5.0 - Adaptive Theme, Timetable Editor & More</h1>
<li>Added adaptive theme colour</li> <li>Added adaptive theme colour</li>
<li>Added optional soft gradient for adaptive theme when viewing a class</li> <li>Added optional soft gradient for adaptive theme when viewing a class</li>
@@ -343,5 +382,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,32 @@
import { settingsState } from "../listeners/SettingsState";
import { OpenWhatsNewPopup } from "./OpenWhatsNewPopup";
import {
shouldShowEngageParentsAnnouncement,
showEngageParentsToast,
} from "./OpenEngageParentsAnnouncement";
type QueueStep = (goNext: () => void) => void;
/**
* Runs startup modals in order: What's New (if the extension just updated),
* then shows the SEQTA Engage toast (once, non-blocking).
*/
export function runStartupPopupQueue() {
const steps: QueueStep[] = [];
if (settingsState.justupdated) {
steps.push((goNext) => OpenWhatsNewPopup(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));
+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;
}
+7
View File
@@ -32,6 +32,10 @@ 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;
timeFormat?: string; timeFormat?: string;
animations: boolean; animations: boolean;
defaultPage: string; defaultPage: string;
@@ -43,6 +47,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 +61,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);
+15
View File
@@ -0,0 +1,15 @@
import path from "path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
include: ["src/**/*.test.ts"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});