mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 11:44:40 +00:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa1e1a925e | |||
| 608fc96c4e | |||
| 23ccac4836 | |||
| a875f35f1a | |||
| e2cf9afbf9 | |||
| 3f493ac716 | |||
| e64ef7f95c | |||
| c118b5b8dd | |||
| 6535ec6932 | |||
| fba5d09c75 | |||
| b88d29967d | |||
| 1b87d20a27 | |||
| 7bd3158b05 | |||
| 7a4fa1e5bf | |||
| 01cd5d1428 | |||
| fcc856e798 | |||
| a0038ac871 | |||
| 49824e9eab | |||
| 01eeb18638 | |||
| 3702443ece | |||
| 87ba75ff41 | |||
| f9406fb469 | |||
| 690792fd62 | |||
| f6ac112329 | |||
| ec68cec0ca | |||
| e2270602a3 | |||
| 8b1e5b2ee7 | |||
| 44a029057a | |||
| 249d1c1b4a | |||
| 6748b15024 | |||
| 8b26d07865 | |||
| 55d96a5e8f | |||
| 133a5197aa | |||
| c0fa1576f3 | |||
| 48fbcde6ae | |||
| 89f50f774f | |||
| 1d9b8f3747 | |||
| 0a5359df72 | |||
| 2e9a643a8c | |||
| 39d0b60024 | |||
| c7a6bf051c | |||
| ea4a2c1ff0 | |||
| 71b7c9eb64 | |||
| 0cac3022f5 | |||
| 8b16a21d48 | |||
| 2085ebe189 | |||
| 01c657d247 | |||
| 1f26fb26d7 | |||
| dbd8e2be8e | |||
| 6b5f00add0 | |||
| 1f5eef2fb1 | |||
| 1e6e57ddcd | |||
| e18853ba3a | |||
| 140cd66c9b | |||
| 5684857456 | |||
| 24fee7a743 | |||
| cef99b7278 | |||
| aad5bcd97e | |||
| 423aaa6b84 | |||
| 97a1226eaf | |||
| 72cab5905e | |||
| 088b745600 | |||
| 8801d5a435 | |||
| b63a3d95f3 | |||
| eca8327420 | |||
| 05cf380e86 | |||
| 73f005d645 | |||
| f2fa9c39a9 | |||
| 783ff65fb5 | |||
| 3e7ea3bc03 | |||
| 398029eecd | |||
| a55cb84a69 | |||
| 94d54f65bf | |||
| 8123c5dd33 | |||
| ac1ee702ae | |||
| e657152e3f | |||
| f667ff9e9b | |||
| 3c613f4938 | |||
| 04843a90fe |
+10
@@ -6,6 +6,15 @@ pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
|
||||
# PDF.js extension assets (copied by postinstall from pdfjs-dist)
|
||||
src/public/resources/pdfjs/pdf.worker.min.mjs
|
||||
src/public/resources/pdfjs/pdf.legacy.min.mjs
|
||||
|
||||
# Rust / wasm
|
||||
/target/
|
||||
# wasm-pack output (regenerate with `npm run build:wasm`)
|
||||
src/wasm/pkg/
|
||||
|
||||
# Build
|
||||
extension.zip
|
||||
build/
|
||||
@@ -19,3 +28,4 @@ betterseqtaplus-safari/
|
||||
.env
|
||||
.env.submit
|
||||
dependency-graph.svg
|
||||
|
||||
|
||||
+5
-5
@@ -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:
|
||||
|
||||
- **📖 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.
|
||||
- **🏗️ Understand the codebase** with our [Architecture Guide](./docs/ARCHITECTURE.md)
|
||||
- **🔧 Having issues?** Check our [Troubleshooting Guide](./docs/TROUBLESHOOTING.md)
|
||||
- **📖 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 the [architecture guide](https://docs.betterseqta.org/architecture/)
|
||||
- **🔧 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!
|
||||
|
||||
@@ -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:
|
||||
|
||||
- [Creating Your First Plugin](./docs/plugins/creating-plugins.md)
|
||||
- [Plugin API Reference](./docs/advanced/plugin-api.md)
|
||||
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
|
||||
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
|
||||
Generated
+246
@@ -0,0 +1,246 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "betterseqta-wasm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"csscolorparser",
|
||||
"percent-encoding",
|
||||
"regex",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "csscolorparser"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fda6aace1fbef3aa217b27f4c8d7d071ef2a70a5ca51050b1f17d40299d3f16"
|
||||
dependencies = [
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["wasm/betterseqta-wasm"]
|
||||
@@ -16,17 +16,15 @@
|
||||
<img src="https://img.shields.io/chrome-web-store/rating/afdgaoaclhkhemfkkkonemoapeinchel" />
|
||||
</div>
|
||||
|
||||
## Table of contents
|
||||
## 📚 Documentation
|
||||
All documentation has been moved to the [official docs site](https://docs.betterseqta.org):
|
||||
|
||||
- [Features](#features)
|
||||
- [Creating Custom Themes](#creating-custom-themes)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Running Development](#running-development)
|
||||
- [Building for production](#building-for-production)
|
||||
- [Folder Structure](#folder-structure)
|
||||
- [Contributors](#contributors)
|
||||
- [Credits](#credits)
|
||||
- [Star History](#star-history)
|
||||
Includes:
|
||||
- Getting started
|
||||
- Development setup
|
||||
- Architecture
|
||||
- Plugin system
|
||||
- Theme creation
|
||||
|
||||
## Features
|
||||
|
||||
@@ -50,64 +48,32 @@
|
||||
|
||||
## 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 :)
|
||||
|
||||
## 🚀 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)
|
||||
- **🏗️ 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)
|
||||
## ⚡ Quick Start
|
||||
|
||||
We have lots of https://github.com/BetterSEQTA/BetterSEQTA-Plus/labels/good%20first%20issue labels perfect for beginners!
|
||||
|
||||
## Quick Development Setup
|
||||
|
||||
**1. Fork & Clone**
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/BetterSEQTA-Plus
|
||||
git clone https://github.com/YOUR_USERNAME_FORKED_WITH/BetterSEQTA-Plus
|
||||
cd BetterSEQTA-Plus
|
||||
```
|
||||
|
||||
**2. Install & Run**
|
||||
```bash
|
||||
npm install --legacy-peer-deps
|
||||
npm run dev
|
||||
```
|
||||
````
|
||||
|
||||
**3. Load in Browser**
|
||||
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.
|
||||
Then load `dist` in `chrome://extensions` (Developer Mode → Load unpacked).
|
||||
|
||||
📚 **Need more details?** Check our [detailed setup guide](./docs/GETTING_STARTED_CONTRIBUTING.md#your-first-30-minutes)
|
||||
|
||||
### Building for Production
|
||||
|
||||
```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.
|
||||
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)
|
||||
|
||||
## Contributors
|
||||
|
||||
@@ -115,11 +81,14 @@ The folder structure is as follows:
|
||||
<img src="https://contrib.rocks/image?repo=betterseqta/betterseqta-plus" />
|
||||
</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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"": {
|
||||
"name": "betterseqtaplus",
|
||||
"dependencies": {
|
||||
"@bedframe/core": "^0.0.46",
|
||||
"@bedframe/core": "^0.1.0",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/commands": "^6.8.0",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@@ -13,7 +13,7 @@
|
||||
"@codemirror/search": "^6.5.10",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.36.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"@types/chrome": "^0.1.4",
|
||||
@@ -52,17 +52,17 @@
|
||||
"react-dom": "17",
|
||||
"rss-parser": "^3.13.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"svelte": "^5.22.6",
|
||||
"svelte": "^5.46.4",
|
||||
"typescript": "^5.8.2",
|
||||
"uuid": "^11.1.0",
|
||||
"vite": "^6.2.1",
|
||||
"vite": "^8.0.5",
|
||||
"webextension-polyfill": "^0.12.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-transform-runtime": "^7.26.9",
|
||||
"@babel/runtime": "^7.26.9",
|
||||
"@bedframe/cli": "^0.0.95",
|
||||
"@crxjs/vite-plugin": "^2.2.0",
|
||||
"@bedframe/cli": "^0.1.2",
|
||||
"@crxjs/vite-plugin": "^2.4.0",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
@@ -127,9 +127,9 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
|
||||
"@codemirror/view": ["@codemirror/view@6.38.1", "", { "dependencies": { "@codemirror/state": "6.5.2", "crelt": "1.0.6", "style-mod": "4.1.2", "w3c-keyname": "2.2.8" } }, "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -293,12 +299,16 @@
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
||||
|
||||
"@napi-rs/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.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=="],
|
||||
|
||||
"@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-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
|
||||
@@ -349,6 +359,38 @@
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||
|
||||
"@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/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="],
|
||||
@@ -409,14 +451,14 @@
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
|
||||
|
||||
"@sveltejs/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-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=="],
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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/chai": ["@types/chai@4.3.20", "", {}, "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ=="],
|
||||
@@ -527,7 +569,7 @@
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -631,7 +673,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -693,6 +735,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
|
||||
@@ -773,7 +817,7 @@
|
||||
|
||||
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -787,7 +831,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -995,6 +1039,30 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
@@ -1125,6 +1193,8 @@
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-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=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
@@ -1143,6 +1213,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
@@ -1293,6 +1365,8 @@
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "2.2.0", "xml2js": "0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
|
||||
@@ -1397,7 +1471,7 @@
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1423,7 +1497,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1477,7 +1551,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1485,7 +1559,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1555,7 +1629,9 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -1591,6 +1667,8 @@
|
||||
|
||||
"@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/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||
@@ -1621,6 +1699,8 @@
|
||||
|
||||
"concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"dependency-cruiser/commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -1633,6 +1713,8 @@
|
||||
|
||||
"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/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
|
||||
@@ -1715,9 +1797,13 @@
|
||||
|
||||
"tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1725,6 +1811,8 @@
|
||||
|
||||
"vite-plugin-dts/@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "1.0.8", "estree-walker": "2.0.2", "picomatch": "4.0.3" }, "optionalDependencies": { "rollup": "4.49.0" } }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="],
|
||||
|
||||
"vite-plugin-dts/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/vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.6", "rollup": "4.49.0" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "sass": "1.91.0" }, "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="],
|
||||
@@ -1741,6 +1829,14 @@
|
||||
|
||||
"z-schema/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
@@ -1865,6 +1961,10 @@
|
||||
|
||||
"vite-plugin-dts/@rollup/pluginutils/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
||||
|
||||
"vite-plugin-dts/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/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=="],
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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!
|
||||
|
||||
## Table of Contents
|
||||
@@ -221,7 +223,7 @@ console.log(settingsState.[the setting name])
|
||||
Ready to contribute? Here's what to do next:
|
||||
|
||||
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
|
||||
4. **Join our Discord**: Get help from the community!
|
||||
|
||||
|
||||
@@ -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 extension’s `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 device’s 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 caller’s 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 caller’s 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`.
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
|
||||
## Table of Contents
|
||||
@@ -222,7 +224,7 @@ git push origin your-branch-name
|
||||
|
||||
### 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
|
||||
3. **Ask in Discord** - Our community is super helpful
|
||||
4. **Create an issue** - If you found a bug or need help
|
||||
|
||||
+21
-21
@@ -1,30 +1,36 @@
|
||||
# BetterSEQTA+ Documentation
|
||||
|
||||
🚧 DOCS UNDER CONSTRUCTION! 🚧
|
||||
|
||||
Welcome to the BetterSEQTA+ documentation! This documentation will help you understand how BetterSEQTA+ works and how to extend it with plugins and new features.
|
||||
**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.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
### Getting Started
|
||||
|
||||
- [Project Overview](./README.md) - This file
|
||||
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+
|
||||
- [Getting Started Contributing](./GETTING_STARTED_CONTRIBUTING.md) - **Start here!** Complete beginner-friendly guide
|
||||
- [Architecture Guide](./ARCHITECTURE.md) - How BetterSEQTA+ works under the hood
|
||||
- [Contributing Guide](../CONTRIBUTING.md) - Official contribution guidelines
|
||||
- [Troubleshooting](./TROUBLESHOOTING.md) - Common issues and solutions
|
||||
- [Documentation home](https://docs.betterseqta.org/)
|
||||
- [Installation](https://docs.betterseqta.org/install/)
|
||||
- [Contributing](https://docs.betterseqta.org/contributing/)
|
||||
- [Architecture](https://docs.betterseqta.org/architecture/)
|
||||
- [Contribution guidelines (repository)](../CONTRIBUTING.md)
|
||||
- [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
|
||||
- [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs
|
||||
- [Features](https://docs.betterseqta.org/features/)
|
||||
- [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
|
||||
|
||||
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.
|
||||
|
||||
@@ -36,19 +42,13 @@ BetterSEQTA+ is built around several core concepts:
|
||||
|
||||
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
|
||||
- [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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+.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
|
||||
## Table of Contents
|
||||
@@ -57,7 +59,7 @@ Key points:
|
||||
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
- [Creating Your First Plugin](./plugins/creating-plugins.md)
|
||||
- [Plugin API Reference](./advanced/plugin-api.md)
|
||||
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
|
||||
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
|
||||
|
||||
## Recognition
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
|
||||
## Prerequisites
|
||||
@@ -178,5 +180,5 @@ bun run dev
|
||||
|
||||
Now that you have BetterSEQTA+ installed, you can:
|
||||
|
||||
- [Getting Started with Plugins](./plugins/getting-started.md)
|
||||
- [Contribute to the project](../CONTRIBUTING.md)
|
||||
- [Plugins](https://docs.betterseqta.org/plugins/)
|
||||
- [Contribute to the project](https://docs.betterseqta.org/contributing/) · [Repository CONTRIBUTING.md](../CONTRIBUTING.md)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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!
|
||||
|
||||
## What This Example Does
|
||||
@@ -328,8 +330,8 @@ Once you've got this working:
|
||||
## Need Help?
|
||||
|
||||
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
|
||||
- 📚 Read our [Plugin Development Guide](./README.md)
|
||||
- 🐛 Check the [Troubleshooting Guide](../TROUBLESHOOTING.md)
|
||||
- 📚 Read the [plugin documentation](https://docs.betterseqta.org/plugins/)
|
||||
- 🐛 Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
|
||||
- 📝 Open an issue on GitHub
|
||||
|
||||
Happy coding! 🎉
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
|
||||
## 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
|
||||
- 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.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+16
-9
@@ -1,10 +1,17 @@
|
||||
{
|
||||
"name": "betterseqtaplus",
|
||||
"version": "3.5.2",
|
||||
"version": "3.6.4",
|
||||
"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",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/copy-pdfjs-assets.mjs",
|
||||
"build:wasm": "npx --yes wasm-pack@0.13.1 build wasm/betterseqta-wasm --target web --out-dir ../../src/wasm/pkg --release && node scripts/rm-wasm-pkg-gitignore.mjs",
|
||||
"prebuild": "npm run build:wasm",
|
||||
"prebuild:chrome": "npm run build:wasm",
|
||||
"prebuild:firefox": "npm run build:wasm",
|
||||
"prebuild:safari": "npm run build:wasm",
|
||||
"prebuild:dev": "npm run build:wasm",
|
||||
"autoaudit": "npm audit && npm audit fix && npm run build",
|
||||
"dev": "cross-env MODE=chrome vite dev",
|
||||
"dev:firefox": "cross-env MODE=firefox vite build --watch",
|
||||
@@ -31,14 +38,14 @@
|
||||
"author": {
|
||||
"name": "SethBurkart123",
|
||||
"email": "betterseqta.plus@gmail.com",
|
||||
"url": "https://github.com/BetterSEQTA/BetterSEQTA-plus"
|
||||
"url": "https://github.com/BetterSEQTA/BetterSEQTA-Plus"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@babel/plugin-transform-runtime": "^7.26.9",
|
||||
"@babel/runtime": "^7.26.9",
|
||||
"@bedframe/cli": "^0.0.95",
|
||||
"@crxjs/vite-plugin": "^2.2.0",
|
||||
"@bedframe/cli": "^0.1.2",
|
||||
"@crxjs/vite-plugin": "^2.4.0",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
@@ -57,7 +64,7 @@
|
||||
"url": "^0.11.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bedframe/core": "^0.0.46",
|
||||
"@bedframe/core": "^0.1.0",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/commands": "^6.8.0",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@@ -65,7 +72,7 @@
|
||||
"@codemirror/search": "^6.5.10",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.36.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"@types/chrome": "^0.1.4",
|
||||
@@ -104,10 +111,10 @@
|
||||
"react-dom": "17",
|
||||
"rss-parser": "^3.13.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"svelte": "^5.22.6",
|
||||
"svelte": "^5.46.4",
|
||||
"typescript": "^5.8.2",
|
||||
"uuid": "^11.1.0",
|
||||
"vite": "^6.2.1",
|
||||
"vite": "^8.0.5",
|
||||
"webextension-polyfill": "^0.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = join(fileURLToPath(new URL("..", import.meta.url)));
|
||||
try {
|
||||
unlinkSync(join(root, "src", "wasm", "pkg", ".gitignore"));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
+61
-11
@@ -10,6 +10,12 @@ import * as plugins from "@/plugins";
|
||||
import { main } from "@/seqta/main";
|
||||
import { delay } from "./seqta/utils/delay";
|
||||
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
||||
import {
|
||||
childTextHasSeqtaCopyright,
|
||||
initBetterseqtaWasm,
|
||||
isBetterseqtaWasmReady,
|
||||
titleIsSeqtaLearnOrEngage,
|
||||
} from "@/wasm/init";
|
||||
|
||||
function registerFetchSeqtaAppLinkListener() {
|
||||
browser.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||
@@ -35,30 +41,74 @@ function registerFetchSeqtaAppLinkListener() {
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleDeferredPluginInitialization() {
|
||||
const start = () => {
|
||||
void plugins.initializePlugins().catch((error) => {
|
||||
console.error("[BetterSEQTA+] Deferred plugin initialization failed:", error);
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
|
||||
const idle = window.requestIdleCallback as (
|
||||
callback: IdleRequestCallback,
|
||||
options?: IdleRequestOptions,
|
||||
) => number;
|
||||
idle(() => start(), { timeout: 1200 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for browsers without requestIdleCallback.
|
||||
setTimeout(start, 0);
|
||||
}
|
||||
|
||||
export let MenuOptionsOpen = false;
|
||||
|
||||
var IsSEQTAPage = false;
|
||||
let hasSEQTAText = false;
|
||||
|
||||
function childTextLooksLikeSeqtaCopyright(text: string): boolean {
|
||||
return text.includes("Copyright (c) SEQTA Software");
|
||||
}
|
||||
|
||||
function titleLooksLikeSeqtaLearnOrEngage(title: string): boolean {
|
||||
return title.includes("SEQTA Learn") || title.includes("SEQTA Engage");
|
||||
}
|
||||
|
||||
// This check is placed outside of the document load event due to issues with EP (https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/84)
|
||||
if (document.childNodes[1]) {
|
||||
void bootstrap();
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
try {
|
||||
await initBetterseqtaWasm();
|
||||
} catch (e) {
|
||||
console.warn("[BetterSEQTA+] WASM init failed, using JS fallbacks:", e);
|
||||
}
|
||||
|
||||
const childText = document.childNodes[1]?.textContent;
|
||||
hasSEQTAText =
|
||||
document.childNodes[1].textContent?.includes(
|
||||
"Copyright (c) SEQTA Software",
|
||||
) ?? false;
|
||||
init();
|
||||
typeof childText === "string" &&
|
||||
(isBetterseqtaWasmReady()
|
||||
? childTextHasSeqtaCopyright(childText)
|
||||
: childTextLooksLikeSeqtaCopyright(childText));
|
||||
|
||||
await init();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (
|
||||
hasSEQTAText &&
|
||||
(document.title.includes("SEQTA Learn") ||
|
||||
document.title.includes("SEQTA Engage")) &&
|
||||
!IsSEQTAPage
|
||||
) {
|
||||
const titleOk = isBetterseqtaWasmReady()
|
||||
? titleIsSeqtaLearnOrEngage(document.title)
|
||||
: titleLooksLikeSeqtaLearnOrEngage(document.title);
|
||||
|
||||
if (hasSEQTAText && titleOk && !IsSEQTAPage) {
|
||||
IsSEQTAPage = true;
|
||||
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");
|
||||
@@ -103,7 +153,7 @@ async function init() {
|
||||
plugins.Monofile();
|
||||
|
||||
if (settingsState.onoff) {
|
||||
await plugins.initializePlugins();
|
||||
scheduleDeferredPluginInitialization();
|
||||
}
|
||||
|
||||
if (settingsState.devMode) {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import type { SettingsState } from "@/types/storage";
|
||||
import { fetchNews } from "./background/news";
|
||||
import {
|
||||
initCloudSettingsAutoSync,
|
||||
performCloudSettingsDownloadWithRetry,
|
||||
performCloudSettingsUploadWithRetry,
|
||||
requestCloudSettingsDebouncedUpload,
|
||||
runCloudSettingsPoll,
|
||||
} from "./background/cloudSettingsAutoSync";
|
||||
|
||||
function reloadSeqtaPages() {
|
||||
const result = browser.tabs.query({});
|
||||
@@ -127,6 +134,74 @@ function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
|
||||
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 {
|
||||
const { refresh_token, client_id } = request;
|
||||
if (!refresh_token || !client_id) {
|
||||
@@ -150,6 +225,57 @@ function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean
|
||||
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 {
|
||||
const { themeId, token, action } = request;
|
||||
if (!themeId || !token) {
|
||||
@@ -212,8 +338,19 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
||||
fetchFromUrl: handleFetchFromUrl,
|
||||
cloudReserveClient: handleCloudReserveClient,
|
||||
cloudLogin: handleCloudLogin,
|
||||
cloudStartLogin: handleCloudStartLogin,
|
||||
cloudRefresh: handleCloudRefresh,
|
||||
cloudFavorite: handleCloudFavorite,
|
||||
cloudSettingsUpload: handleCloudSettingsUpload,
|
||||
cloudSettingsDownload: handleCloudSettingsDownload,
|
||||
cloudSettingsPoll: () => {
|
||||
void runCloudSettingsPoll();
|
||||
return false;
|
||||
},
|
||||
cloudSettingsRequestDebouncedUpload: () => {
|
||||
requestCloudSettingsDebouncedUpload();
|
||||
return false;
|
||||
},
|
||||
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -331,6 +468,8 @@ function getDefaultValues(): SettingsState {
|
||||
iconOnlySidebar: false,
|
||||
adaptiveThemeColour: false,
|
||||
adaptiveThemeGradient: false,
|
||||
adaptiveThemeColourTransition: true,
|
||||
autoCloudSettingsSync: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -348,3 +487,5 @@ browser.runtime.onInstalled.addListener(function (event) {
|
||||
browser.storage.local.set({ justupdated: true });
|
||||
}
|
||||
});
|
||||
|
||||
initCloudSettingsAutoSync({ reloadSeqtaPages });
|
||||
|
||||
@@ -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
@@ -1,3 +1,4 @@
|
||||
|
||||
@use "sass:meta";
|
||||
@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;
|
||||
}
|
||||
|
||||
/* 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___"] {
|
||||
-ms-overflow-style: none !important;
|
||||
@@ -1628,6 +1654,13 @@ html.transparencyEffects
|
||||
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 {
|
||||
color: #ffffff !important;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
@@ -2577,7 +2610,7 @@ body {
|
||||
|
||||
[class*="MessageList__unread___"] {
|
||||
position: relative;
|
||||
background: rgb(228 225 225);
|
||||
background: var(--background-secondary, rgb(228 225 225));
|
||||
}
|
||||
|
||||
.dark [class*="MessageList__unread___"] {
|
||||
@@ -2703,7 +2736,7 @@ body {
|
||||
[class*="MessageList__MessageList___"]
|
||||
> ol
|
||||
> li[class*="MessageList__selected___"] {
|
||||
background: rgb(228 225 225);
|
||||
background: var(--background-secondary, rgb(228 225 225));
|
||||
color: var(--text-primary);
|
||||
box-shadow: none !important;
|
||||
position: relative;
|
||||
@@ -3296,6 +3329,88 @@ div.day-empty {
|
||||
position: absolute;
|
||||
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 Learn’s 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 {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -3417,6 +3532,26 @@ div.day-empty {
|
||||
font-size: 1em;
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -3545,6 +3680,138 @@ div.day-empty {
|
||||
object-fit: cover;
|
||||
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 {
|
||||
0% {
|
||||
@@ -4105,3 +4372,41 @@ h2.home-subtitle {
|
||||
font-size: 20px;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
<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>
|
||||
|
||||
<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}
|
||||
</button>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { animate } from "motion";
|
||||
import { delay } from "@/seqta/utils/delay.ts";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
|
||||
const { hidePanel } = $props<{
|
||||
hidePanel: () => void;
|
||||
}>();
|
||||
|
||||
let cloudState = $state(cloudAuth.state);
|
||||
let background = $state<HTMLDivElement | null>(null);
|
||||
let content = $state<HTMLDivElement | null>(null);
|
||||
let loginError = $state<string | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
const unsub = cloudAuth.subscribe((s) => {
|
||||
cloudState = s;
|
||||
});
|
||||
|
||||
if (background && content) {
|
||||
animate(
|
||||
background,
|
||||
{ opacity: [0, 1] },
|
||||
{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }
|
||||
);
|
||||
animate(
|
||||
content,
|
||||
{ scale: [0.4, 1], opacity: [0, 1] },
|
||||
{ type: "spring", stiffness: 400, damping: 30 }
|
||||
);
|
||||
}
|
||||
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closePanel();
|
||||
};
|
||||
document.addEventListener("keydown", handleEscapeKey);
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
document.removeEventListener("keydown", handleEscapeKey);
|
||||
};
|
||||
});
|
||||
|
||||
async function closePanel() {
|
||||
if (!background || !content) return;
|
||||
animate(
|
||||
content,
|
||||
{ scale: [1, 0.4], opacity: [1, 0] },
|
||||
{ type: "spring", stiffness: 400, damping: 30 }
|
||||
);
|
||||
animate(
|
||||
background,
|
||||
{ opacity: [1, 0] },
|
||||
{ ease: [0.4, 0, 0.2, 1] }
|
||||
);
|
||||
await delay(400);
|
||||
hidePanel();
|
||||
}
|
||||
|
||||
function handleBackgroundClick(event: MouseEvent) {
|
||||
if (event.target === background) closePanel();
|
||||
}
|
||||
|
||||
async function handleSignIn() {
|
||||
loginError = null;
|
||||
const result = await cloudAuth.startLogin();
|
||||
if (result.success) {
|
||||
closePanel();
|
||||
} else {
|
||||
loginError = result.error ?? "Failed to open login page";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await cloudAuth.logout();
|
||||
}
|
||||
|
||||
function getInitials(): string {
|
||||
const u = cloudState.user;
|
||||
if (!u) return "?";
|
||||
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
|
||||
if (u.username) return u.username.slice(0, 2).toUpperCase();
|
||||
if (u.email) return u.email.slice(0, 2).toUpperCase();
|
||||
return "?";
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={background}
|
||||
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50"
|
||||
onclick={handleBackgroundClick}
|
||||
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }}
|
||||
>
|
||||
<div
|
||||
bind:this={content}
|
||||
class="p-5 w-[320px] bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
|
||||
>
|
||||
<h3 class="text-lg font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
|
||||
<p class="mt-0.5 text-sm text-zinc-500 dark:text-zinc-400">Account & sync</p>
|
||||
|
||||
<div class="mt-4">
|
||||
{#if cloudState.isLoggedIn}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if cloudState.user?.pfpUrl}
|
||||
<img
|
||||
src={cloudState.user.pfpUrl}
|
||||
alt=""
|
||||
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
|
||||
{getInitials()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-zinc-900 dark:text-white truncate">
|
||||
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
|
||||
</p>
|
||||
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
|
||||
<p class="text-xs text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleLogout}
|
||||
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Sign in to sync settings across devices, use your cloud profile picture, and more.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSignIn}
|
||||
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
|
||||
>
|
||||
Sign in with BetterSEQTA Cloud
|
||||
</button>
|
||||
{#if loginError}
|
||||
<p class="text-xs text-red-600 dark:text-red-400">{loginError}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
|
||||
Opens accounts.betterseqta.org in a new tab
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import browser from "webextension-polyfill";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import Button from "./Button.svelte";
|
||||
import Switch from "./Switch.svelte";
|
||||
|
||||
let { showDisclaimer } = $props<{
|
||||
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
|
||||
}>();
|
||||
|
||||
let cloudState = $state(cloudAuth.state);
|
||||
let busy = $state(false);
|
||||
let statusMessage = $state<string | null>(null);
|
||||
let statusError = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const unsub = cloudAuth.subscribe((s) => {
|
||||
cloudState = s;
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
|
||||
async function upload() {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
if (!token) return;
|
||||
busy = true;
|
||||
statusError = null;
|
||||
statusMessage = null;
|
||||
try {
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: "cloudSettingsUpload",
|
||||
token,
|
||||
})) as { success?: boolean; error?: string };
|
||||
if (res?.success) {
|
||||
statusMessage = "Settings uploaded.";
|
||||
} else {
|
||||
statusError = res?.error ?? "Upload failed";
|
||||
}
|
||||
} catch (e) {
|
||||
statusError = e instanceof Error ? e.message : "Upload failed";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function promptDownload() {
|
||||
showDisclaimer(confirmDownload, () => {});
|
||||
}
|
||||
|
||||
async function confirmDownload() {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
if (!token) return;
|
||||
busy = true;
|
||||
statusError = null;
|
||||
statusMessage = null;
|
||||
try {
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: "cloudSettingsDownload",
|
||||
token,
|
||||
})) as { success?: boolean; error?: string; notFound?: boolean };
|
||||
if (res?.success) {
|
||||
statusMessage = "Settings restored.";
|
||||
} else {
|
||||
statusError = res?.error ?? "Download failed";
|
||||
}
|
||||
} catch (e) {
|
||||
statusError = e instanceof Error ? e.message : "Download failed";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if cloudState.isLoggedIn}
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold text-zinc-800 dark:text-zinc-100">Automatic sync</p>
|
||||
<p class="text-[10px] text-zinc-500 dark:text-zinc-400">Syncs settings when SEQTA loads and when you make changes</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<Switch
|
||||
state={$settingsState.autoCloudSettingsSync !== false}
|
||||
onChange={(isOn: boolean) => (settingsState.autoCloudSettingsSync = isOn)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
text={busy ? "Please wait\u2026" : "Upload"}
|
||||
onClick={upload}
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button
|
||||
text={busy ? "Please wait\u2026" : "Download"}
|
||||
onClick={promptDownload}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if statusMessage}
|
||||
<p class="text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
|
||||
{/if}
|
||||
{#if statusError}
|
||||
<p class="text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||
Passwords and tokens are never synced.
|
||||
<a
|
||||
href="https://betterseqta.org/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 rounded-sm"
|
||||
>Privacy policy</a>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,29 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
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 modalElement: HTMLElement;
|
||||
|
||||
onMount(() => {
|
||||
return cloudAuth.subscribe((s) => {
|
||||
if (s.isLoggedIn) onClose();
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
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() {
|
||||
onClose();
|
||||
if (document.getElementById("ExtensionPopup")) {
|
||||
closeExtensionPopup();
|
||||
} else {
|
||||
window.close();
|
||||
}
|
||||
async function handleSignIn() {
|
||||
await cloudAuth.startLogin();
|
||||
}
|
||||
</script>
|
||||
|
||||
<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) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
@@ -37,7 +43,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
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()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -45,32 +51,28 @@
|
||||
Sign in to favorite themes
|
||||
</h2>
|
||||
|
||||
<p class="mb-6 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.
|
||||
<p class="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Sign in to the Theme Store to save favorites across devices, or create an account to get started.
|
||||
</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
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
OK
|
||||
</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
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import { onMount } from "svelte";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
|
||||
let username = $state("");
|
||||
let password = $state("");
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let { alwaysShowUserName = false, onClick = undefined } = $props<{
|
||||
alwaysShowUserName?: boolean;
|
||||
onClick?: () => void;
|
||||
}>();
|
||||
|
||||
let cloudState = $state(cloudAuth.state);
|
||||
let open = $state(false);
|
||||
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() {
|
||||
await cloudAuth.logout();
|
||||
open = false;
|
||||
}
|
||||
|
||||
async function handleSignIn() {
|
||||
await cloudAuth.startLogin();
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleButtonClick() {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
} else {
|
||||
open = !open;
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(): string {
|
||||
const u = cloudState.user;
|
||||
if (!u) return "?";
|
||||
@@ -74,31 +67,35 @@
|
||||
<div class="relative flex items-center" bind:this={dropdownEl}>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (open = !open)}
|
||||
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"
|
||||
onclick={handleButtonClick}
|
||||
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.user?.pfpUrl}
|
||||
<img
|
||||
src={cloudState.user.pfpUrl}
|
||||
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}
|
||||
<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()}
|
||||
</div>
|
||||
{/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"}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xl font-IconFamily" aria-hidden="true">{'\ued53'}</span>
|
||||
<span class="text-base font-medium">Sign in</span>
|
||||
<span class="text-sm font-IconFamily" aria-hidden="true">{'\ued53'}</span>
|
||||
<span class="text-[0.75rem]">Sign in</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
{#if !onClick && open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
@@ -142,58 +139,21 @@
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-4 text-base text-zinc-600 dark:text-zinc-400">
|
||||
Sign in to favorite themes. Your favorites sync across devices when logged in.
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Sign in to sync favorites across devices.
|
||||
</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
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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"
|
||||
type="button"
|
||||
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 transition-colors duration-200"
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
Sign in with BetterSEQTA Cloud
|
||||
</button>
|
||||
<a
|
||||
href="https://accounts.betterseqta.org/register"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
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 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();
|
||||
|
||||
const options = { loop: true };
|
||||
@@ -12,8 +15,8 @@
|
||||
Autoplay({
|
||||
delay: 5000,
|
||||
stopOnInteraction: false,
|
||||
stopOnMouseEnter: true
|
||||
})
|
||||
stopOnMouseEnter: true,
|
||||
}),
|
||||
];
|
||||
|
||||
function onInit(event: CustomEvent) {
|
||||
@@ -26,7 +29,7 @@
|
||||
const slideNext = () => emblaApi?.scrollNext();
|
||||
</script>
|
||||
|
||||
{#if coverThemes.length > 0}
|
||||
{#if slides.length > 0}
|
||||
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
|
||||
<div
|
||||
class="w-full aspect-[5/1] max-h-[500px]"
|
||||
@@ -34,33 +37,69 @@
|
||||
onemblaInit={onInit}
|
||||
>
|
||||
<div class="flex">
|
||||
{#each coverThemes as theme}
|
||||
{#each slides as slide (slide.imageUrl + slide.title + (slide.subtitle ?? ''))}
|
||||
<div
|
||||
class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
|
||||
onclick={() => setDisplayTheme(theme)}
|
||||
onkeydown={(e) => {
|
||||
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" />
|
||||
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
|
||||
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
|
||||
<p class='text-lg text-white'>{theme.description}</p>
|
||||
<img src={slide.imageUrl} alt="" class="object-cover w-full h-full" />
|
||||
{#if slide.badgeFeatured === true}
|
||||
<div class="absolute top-4 left-4 z-[2] 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>
|
||||
<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>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation buttons -->
|
||||
<div class='flex absolute right-2 bottom-2 z-10 gap-2'>
|
||||
<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'>
|
||||
<div class="flex absolute right-2 bottom-2 z-10 gap-2">
|
||||
<button
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
|
||||
</svg>
|
||||
</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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
|
||||
@@ -1,17 +1,55 @@
|
||||
<script lang="ts">
|
||||
import type { Theme } from '@/interface/types/Theme'
|
||||
import {
|
||||
masterGridDisplayDownloadCount,
|
||||
gridCardPreviewImageUrls,
|
||||
} from '@/interface/utils/themeStoreFlavours'
|
||||
import { fade } from 'svelte/transition';
|
||||
import { onMount } from 'svelte';
|
||||
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte';
|
||||
|
||||
let { theme, onClick, toggleFavorite, isLoggedIn } = $props<{
|
||||
import emblaCarouselSvelte from 'embla-carousel-svelte';
|
||||
import Autoplay from 'embla-carousel-autoplay';
|
||||
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn, allStoreThemeRows } = $props<{
|
||||
theme: Theme;
|
||||
onClick: () => void;
|
||||
toggleFavorite: (theme: Theme) => void;
|
||||
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 doesn’t briefly mount carousel */
|
||||
let allowSlideAutoplay = $state(!prefersReducedMotion());
|
||||
|
||||
const gridEmblaKey = $derived(gridRotatorUrls.join('|'));
|
||||
|
||||
const gridEmblaOptions = $derived({ loop: gridRotatorUrls.length > 1 });
|
||||
|
||||
const gridEmblaPlugins = $derived.by(() => {
|
||||
if (!allowSlideAutoplay || gridRotatorUrls.length <= 1) return [];
|
||||
return [
|
||||
Autoplay({
|
||||
delay: 2000,
|
||||
stopOnInteraction: false,
|
||||
stopOnMouseEnter: true,
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
let menuOpen = $state(false);
|
||||
let showSignInModal = $state(false);
|
||||
let menuRef: HTMLDivElement;
|
||||
|
||||
onMount(() => {
|
||||
@@ -34,14 +72,36 @@
|
||||
if (isLoggedIn) {
|
||||
toggleFavorite(theme);
|
||||
} else {
|
||||
showSignInModal = true;
|
||||
onRequestSignIn?.();
|
||||
}
|
||||
menuOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={handleCardClick}>
|
||||
<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>
|
||||
<div
|
||||
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 -->
|
||||
<div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}>
|
||||
<button
|
||||
@@ -83,12 +143,15 @@
|
||||
</div>
|
||||
<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>
|
||||
{#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">
|
||||
<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">
|
||||
<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>
|
||||
{(theme.download_count ?? 0).toLocaleString()}
|
||||
{displayDownloadCount.toLocaleString()}
|
||||
</span>
|
||||
<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">
|
||||
@@ -99,12 +162,40 @@
|
||||
</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='w-full'>
|
||||
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
|
||||
{#if gridRotatorUrls.length === 0}
|
||||
<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>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showSignInModal}
|
||||
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -2,12 +2,23 @@
|
||||
import type { Theme } from '@/interface/types/Theme'
|
||||
import ThemeCard from './ThemeCard.svelte';
|
||||
|
||||
let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn } = $props<{
|
||||
let {
|
||||
themes,
|
||||
searchTerm,
|
||||
setDisplayTheme,
|
||||
toggleFavorite,
|
||||
isLoggedIn,
|
||||
onRequestSignIn,
|
||||
allStoreThemeRows,
|
||||
} = $props<{
|
||||
themes: Theme[];
|
||||
searchTerm: string;
|
||||
setDisplayTheme: (theme: Theme) => void;
|
||||
toggleFavorite: (theme: Theme) => void;
|
||||
isLoggedIn: boolean;
|
||||
onRequestSignIn?: () => void;
|
||||
/** Raw API list (includes `slave` rows) for master download aggregation */
|
||||
allStoreThemeRows?: Theme[];
|
||||
}>();
|
||||
|
||||
let filteredThemes = $derived(themes.filter((theme: Theme) =>
|
||||
@@ -23,12 +34,14 @@
|
||||
onClick={() => setDisplayTheme(theme)}
|
||||
{toggleFavorite}
|
||||
{isLoggedIn}
|
||||
{onRequestSignIn}
|
||||
{allStoreThemeRows}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if filteredThemes.length !== 0}
|
||||
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='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">
|
||||
<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 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-xl font-bold text-center transition-all duration-500 dark:text-white">
|
||||
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">
|
||||
<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>
|
||||
<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!
|
||||
</a>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -85,7 +85,7 @@
|
||||
try {
|
||||
const result = JSON.parse(event.target?.result as string);
|
||||
tempTheme = result;
|
||||
await themeManager.installTheme(result);
|
||||
await themeManager.installTheme(result, { fromStore: false });
|
||||
await fetchThemes();
|
||||
} catch (error) {
|
||||
console.error('Error parsing file:', error);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
|
||||
|
||||
import ColourPicker from "../components/ColourPicker.svelte";
|
||||
import CloudPanel from "../components/CloudPanel.svelte";
|
||||
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
||||
import { settingsPopup } from "../hooks/SettingsPopup";
|
||||
|
||||
@@ -22,6 +23,8 @@
|
||||
let settingsActiveTab = $state(0);
|
||||
let showDisclaimerModal = $state(false);
|
||||
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
|
||||
let disclaimerTitle = $state("Confirm");
|
||||
let disclaimerMessage = $state("");
|
||||
|
||||
const handleDevModeToggle = () => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -66,15 +69,23 @@
|
||||
|
||||
let { standalone } = $props<{ standalone?: boolean }>();
|
||||
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 };
|
||||
disclaimerTitle = title ?? "Confirm";
|
||||
disclaimerMessage = message ?? "";
|
||||
showDisclaimerModal = true;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
settingsPopup.addListener(() => {
|
||||
showColourPicker = false;
|
||||
showCloudPanel = false;
|
||||
});
|
||||
|
||||
if (standalone) {
|
||||
@@ -282,7 +293,7 @@
|
||||
{
|
||||
title: "Settings",
|
||||
Content: Settings,
|
||||
props: { showColourPicker: openColourPicker, showDisclaimer },
|
||||
props: { showColourPicker: openColourPicker, showDisclaimer, showCloudPanel: openCloudPanel },
|
||||
},
|
||||
{ title: "Shortcuts", Content: Shortcuts },
|
||||
{ title: "Themes", Content: Theme },
|
||||
@@ -297,19 +308,20 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showCloudPanel}
|
||||
<CloudPanel
|
||||
hidePanel={() => {
|
||||
showCloudPanel = false;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showDisclaimerModal && disclaimerCallbacks}
|
||||
<DisclaimerModal
|
||||
title="Assessment Averages Disclaimer"
|
||||
message="This feature calculates a simple average of your assessment grades. It does not take into account:
|
||||
• 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?"
|
||||
title={disclaimerTitle}
|
||||
message={disclaimerMessage}
|
||||
onConfirm={() => {
|
||||
disclaimerCallbacks?.onConfirm();
|
||||
showDisclaimerModal = false;
|
||||
|
||||
@@ -11,8 +11,12 @@
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
|
||||
import PickerSwatch from "@/interface/components/PickerSwatch.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 { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
||||
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
|
||||
|
||||
import { getAllPluginSettings } from "@/plugins"
|
||||
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
|
||||
@@ -52,6 +56,12 @@
|
||||
const pluginSettings = getAllPluginSettings() as Plugin[];
|
||||
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() {
|
||||
for (const plugin of pluginSettings) {
|
||||
if (Object.keys(plugin.settings).length === 0) continue;
|
||||
@@ -93,10 +103,24 @@
|
||||
loadPluginSettings();
|
||||
})
|
||||
|
||||
const { showColourPicker, showDisclaimer } = $props<{
|
||||
const { showColourPicker, showDisclaimer, showCloudPanel } = $props<{
|
||||
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>
|
||||
|
||||
{#snippet Setting({ title, description, Component, props }: SettingsList) }
|
||||
@@ -181,22 +205,22 @@
|
||||
},
|
||||
{
|
||||
title: "Default Page",
|
||||
description: "The page to load when SEQTA Learn is opened",
|
||||
description: "Choose which page loads first when you open SEQTA",
|
||||
id: 10,
|
||||
Component: Select,
|
||||
props: {
|
||||
state: $settingsState.defaultPage,
|
||||
onChange: (value: string) => settingsState.defaultPage = value,
|
||||
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' },
|
||||
]
|
||||
}
|
||||
{ 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",
|
||||
@@ -252,6 +276,18 @@
|
||||
/>
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,8 +318,9 @@
|
||||
async () => {
|
||||
await updatePluginSetting(plugin.pluginId, 'enabled', true);
|
||||
},
|
||||
() => {
|
||||
}
|
||||
() => {},
|
||||
"Assessment Averages Disclaimer",
|
||||
"This feature calculates a simple average of your assessment grades. It does not take into account:\n• Assessment weightings\n• Different grading scales\n• Other factors used in official reports\n\nThe displayed average may be inaccurate compared to your actual marks found in reports.\n\nDo you want to enable this feature?"
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -296,8 +333,8 @@
|
||||
|
||||
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
|
||||
{#each Object.entries(plugin.settings) as [key, setting]}
|
||||
<!-- Skip the 'enabled' setting if it's part of the settings object -->
|
||||
{#if key !== 'enabled'}
|
||||
<!-- Skip the 'enabled' setting and hide cloud-only settings when not signed in -->
|
||||
{#if key !== 'enabled' && !(key === 'useCloudPfp' && !cloudState.isLoggedIn)}
|
||||
<div class="flex justify-between items-center px-4 py-3">
|
||||
<div class="pr-4">
|
||||
<h2 class="text-sm font-bold">{setting.title || key}</h2>
|
||||
@@ -360,6 +397,25 @@
|
||||
</div>
|
||||
{/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>
|
||||
|
||||
{@render Setting({
|
||||
@@ -427,6 +483,15 @@
|
||||
/>
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import SkeletonLoader from '../components/SkeletonLoader.svelte';
|
||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||
import type { Theme } from '../types/Theme'
|
||||
import { visibleStoreThemes, buildCoverSlidesForThemes } from '@/interface/utils/themeStoreFlavours'
|
||||
import browser from 'webextension-polyfill'
|
||||
import ThemeModal from '../components/store/ThemeModal.svelte'
|
||||
import Header from '../components/store/Header.svelte'
|
||||
@@ -16,6 +17,7 @@
|
||||
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
|
||||
import Backgrounds from '../components/store/Backgrounds.svelte'
|
||||
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
||||
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
|
||||
|
||||
const themeManager = ThemeManager.getInstance();
|
||||
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
|
||||
@@ -25,7 +27,12 @@
|
||||
// State variables
|
||||
let searchTerm = $state('');
|
||||
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 darkMode = $state(false);
|
||||
let displayTheme = $state<Theme | null>(null);
|
||||
@@ -34,6 +41,7 @@
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
let selectedBackground = $state<string | null>(null);
|
||||
let showSignInOverlay = $state(false);
|
||||
|
||||
const fetchCurrentThemes = async () => {
|
||||
const themes = await themeManager.getAvailableThemes();
|
||||
@@ -52,6 +60,17 @@
|
||||
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 token = await cloudAuth.getStoredToken();
|
||||
if (!token) return;
|
||||
@@ -94,11 +113,7 @@
|
||||
if (!data?.success || !data?.data?.themes) {
|
||||
throw new Error(data?.error || 'Failed to fetch themes');
|
||||
}
|
||||
themes = data.data.themes;
|
||||
|
||||
// Shuffle for cover themes
|
||||
const shuffled = [...themes].sort(() => 0.5 - Math.random());
|
||||
coverThemes = shuffled.slice(0, 3);
|
||||
themes = [...data.data.themes].sort(compareStoreThemes);
|
||||
|
||||
loading = false;
|
||||
} catch (err) {
|
||||
@@ -116,11 +131,37 @@
|
||||
darkMode = $settingsState.DarkMode;
|
||||
});
|
||||
|
||||
// Filter themes based on search term
|
||||
let filteredThemes = $derived(themes.filter(theme =>
|
||||
// Filter themes (list is already featured-first, then newest; filter preserves order)
|
||||
let filteredThemes = $derived(
|
||||
listThemes.filter(
|
||||
(theme) =>
|
||||
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(() => {
|
||||
loadBackground();
|
||||
@@ -159,42 +200,37 @@
|
||||
<!-- Themes Tab Content -->
|
||||
{#if activeTab === 'themes'}
|
||||
{#if searchTerm === ''}
|
||||
<CoverSwiper {coverThemes} {setDisplayTheme} />
|
||||
<CoverSwiper slides={coverSlides} {setDisplayTheme} />
|
||||
{/if}
|
||||
|
||||
<!-- ThemeGrid to display filtered themes -->
|
||||
<ThemeGrid
|
||||
themes={filteredThemes}
|
||||
allStoreThemeRows={themes}
|
||||
{searchTerm}
|
||||
{setDisplayTheme}
|
||||
{toggleFavorite}
|
||||
isLoggedIn={cloudLoggedIn}
|
||||
onRequestSignIn={() => (showSignInOverlay = true)}
|
||||
/>
|
||||
|
||||
{#if displayTheme}
|
||||
<ThemeModal
|
||||
currentThemes={currentThemes}
|
||||
allThemes={themes}
|
||||
allThemes={listThemes}
|
||||
allStoreThemeRows={themes}
|
||||
theme={displayTheme}
|
||||
{displayTheme}
|
||||
{setDisplayTheme}
|
||||
{toggleFavorite}
|
||||
isLoggedIn={cloudLoggedIn}
|
||||
onInstall={async () => {
|
||||
if (displayTheme) {
|
||||
await themeManager.downloadTheme(displayTheme);
|
||||
await themeManager.setTheme(displayTheme.id);
|
||||
themeUpdates.triggerUpdate();
|
||||
await fetchCurrentThemes();
|
||||
}
|
||||
onRequestSignIn={() => (showSignInOverlay = true)}
|
||||
onInstall={async (themeId: string) => {
|
||||
if (displayTheme) await installThemeFromStore(themeId, displayTheme);
|
||||
}}
|
||||
onRemove={async () => {
|
||||
if (displayTheme?.id) {
|
||||
console.debug('deleting theme', displayTheme.id);
|
||||
await themeManager.deleteTheme(displayTheme.id);
|
||||
themeUpdates.triggerUpdate();
|
||||
await fetchCurrentThemes();
|
||||
}
|
||||
onRemove={async (themeId: string) => {
|
||||
console.debug('deleting theme', themeId);
|
||||
await removeThemeFromStore(themeId);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
@@ -204,4 +240,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showSignInOverlay}
|
||||
<SignInToFavoriteModal onClose={() => (showSignInOverlay = false)} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -4,7 +4,10 @@
|
||||
import { slide } 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'
|
||||
|
||||
@@ -21,9 +24,9 @@
|
||||
handleImageVariableChange,
|
||||
handleCoverImageUpload
|
||||
} 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 { themeUpdates } from '../hooks/ThemeUpdates'
|
||||
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
||||
|
||||
const { themeID } = $props<{ themeID: string }>()
|
||||
const themeManager = ThemeManager.getInstance();
|
||||
@@ -40,7 +43,9 @@
|
||||
coverImage: null,
|
||||
isEditable: true,
|
||||
hideThemeName: false,
|
||||
forceDark: undefined
|
||||
forceTheme: undefined,
|
||||
forceDark: undefined,
|
||||
adaptiveCssVariables: [],
|
||||
})
|
||||
let closedAccordions = $state<string[]>([])
|
||||
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
|
||||
} else {
|
||||
themeLoaded = true
|
||||
@@ -114,6 +125,14 @@
|
||||
blob: image.blob
|
||||
}))
|
||||
themeClone.coverImage = theme.coverImage
|
||||
themeClone.userEdited = true
|
||||
|
||||
if (shouldForceThemeAppearance(themeClone)) {
|
||||
themeClone.forceTheme = true;
|
||||
} else {
|
||||
themeClone.forceTheme = false;
|
||||
themeClone.forceDark = undefined;
|
||||
}
|
||||
|
||||
themeManager.clearPreview();
|
||||
await themeManager.saveTheme(themeClone);
|
||||
@@ -270,7 +289,7 @@
|
||||
|
||||
|
||||
<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='underline'>
|
||||
Need help? Check out the docs!
|
||||
@@ -317,6 +336,27 @@
|
||||
|
||||
<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 "Adaptive theme colour" 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 --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 [
|
||||
{
|
||||
type: 'switch',
|
||||
@@ -332,21 +372,28 @@
|
||||
title: 'Force Theme',
|
||||
description: 'Force users to use either dark or light mode',
|
||||
props: {
|
||||
state: theme.forceDark !== undefined,
|
||||
onChange: (value: boolean) => theme = { ...theme, forceDark: value ? false : undefined }
|
||||
state: shouldForceThemeAppearance(theme),
|
||||
onChange: (value: boolean) => {
|
||||
if (value) {
|
||||
theme = { ...theme, forceTheme: true, forceDark: false };
|
||||
} else {
|
||||
theme = { ...theme, forceTheme: false, forceDark: undefined };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'conditional',
|
||||
props: {
|
||||
condition: theme.forceDark !== undefined,
|
||||
condition: shouldForceThemeAppearance(theme),
|
||||
children: {
|
||||
type: 'lightDarkToggle',
|
||||
title: 'Mode',
|
||||
description: 'Choose whether to force light or dark mode',
|
||||
props: {
|
||||
state: theme.forceDark === true,
|
||||
onChange: (value: boolean) => theme = { ...theme, forceDark: value }
|
||||
onChange: (value: boolean) =>
|
||||
(theme = { ...theme, forceDark: value, forceTheme: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -8,4 +22,29 @@ export type Theme = {
|
||||
is_favorited?: boolean;
|
||||
favorite_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;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Theme, ThemeCoverSlide, ThemeFlavour } from "@/interface/types/Theme";
|
||||
|
||||
export function isHiddenStoreTheme(theme: Theme): boolean {
|
||||
return theme.theme_role === "slave";
|
||||
}
|
||||
|
||||
/** Grid and search: omit slave rows (when API sends a flattened list). */
|
||||
export function visibleStoreThemes(themes: Theme[]): Theme[] {
|
||||
return themes.filter((t) => !isHiddenStoreTheme(t));
|
||||
}
|
||||
|
||||
function marqueeOrCoverUrl(t: { marqueeImage?: string; coverImage: string }): string {
|
||||
return t.marqueeImage || t.coverImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds slides for CoverSwiper: for each top-N master, first master image then each flavour image.
|
||||
*/
|
||||
export function buildCoverSlidesForThemes(topThemes: Theme[]): ThemeCoverSlide[] {
|
||||
const slides: ThemeCoverSlide[] = [];
|
||||
for (const theme of topThemes) {
|
||||
const flavours = theme.flavours ?? [];
|
||||
if (flavours.length === 0) {
|
||||
slides.push({
|
||||
imageUrl: marqueeOrCoverUrl(theme),
|
||||
title: theme.name,
|
||||
subtitle: theme.author ? `By ${theme.author}` : undefined,
|
||||
openTheme: theme,
|
||||
badgeFeatured: theme.featured === true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
slides.push({
|
||||
imageUrl: marqueeOrCoverUrl(theme),
|
||||
title: theme.name,
|
||||
subtitle: theme.author ? `By ${theme.author}` : undefined,
|
||||
openTheme: theme,
|
||||
badgeFeatured: theme.featured === true,
|
||||
});
|
||||
for (const f of flavours) {
|
||||
slides.push({
|
||||
imageUrl: f.marquee_image || f.cover_image,
|
||||
title: theme.name,
|
||||
subtitle: f.name,
|
||||
openTheme: theme,
|
||||
badgeFeatured: theme.featured === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return slides;
|
||||
}
|
||||
|
||||
export type ModalHeroSlide = { imageUrl: string; caption: string };
|
||||
|
||||
/** Preview image for carousel + flavour picker (matches hero slide order after master slide). */
|
||||
export function flavourCarouselImageUrl(f: ThemeFlavour): string {
|
||||
const u = (f.marquee_image || f.cover_image || "").trim();
|
||||
return u;
|
||||
}
|
||||
|
||||
/** Preview image for master variant tile (modal hero slide 0). */
|
||||
export function masterCarouselImageUrl(t: Theme): string {
|
||||
return (marqueeOrCoverUrl(t) || "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered preview URLs for the store grid card rotator: master first, then each variant.
|
||||
* Uses nested `flavours` when present; otherwise flat `slave` rows (same order as modal when possible).
|
||||
*/
|
||||
export function gridCardPreviewImageUrls(theme: Theme, allStoreRows?: Theme[]): string[] {
|
||||
const urls: string[] = [];
|
||||
const push = (raw: string) => {
|
||||
const u = raw.trim();
|
||||
if (u && !urls.includes(u)) urls.push(u);
|
||||
};
|
||||
|
||||
push(marqueeOrCoverUrl(theme) || "");
|
||||
|
||||
const flavours = theme.flavours ?? [];
|
||||
if (flavours.length > 0) {
|
||||
for (const f of flavours) {
|
||||
push(flavourCarouselImageUrl(f));
|
||||
}
|
||||
return urls.length > 0 ? urls : [(theme.coverImage || "").trim()].filter(Boolean);
|
||||
}
|
||||
|
||||
if (allStoreRows) {
|
||||
const slaves = allStoreRows
|
||||
.filter((t) => t.theme_role === "slave" && t.master_id === theme.id)
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
for (const s of slaves) {
|
||||
push((marqueeOrCoverUrl(s) || "").trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (urls.length > 0) return urls;
|
||||
const fallback = (theme.coverImage || "").trim();
|
||||
return fallback ? [fallback] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads shown on the grid card for a master row: master's count plus each slave
|
||||
* (flat `theme_role === "slave"` with `master_id`) and any flavour-only `download_count`
|
||||
* when there is no matching flat slave id (nested-only API shape).
|
||||
*/
|
||||
export function masterGridDisplayDownloadCount(master: Theme, allStoreRows: Theme[]): number {
|
||||
let total = master.download_count ?? 0;
|
||||
const slaveRows = allStoreRows.filter(
|
||||
(t) => t.theme_role === "slave" && t.master_id === master.id,
|
||||
);
|
||||
const countedIds = new Set(slaveRows.map((r) => r.id));
|
||||
for (const r of slaveRows) {
|
||||
total += r.download_count ?? 0;
|
||||
}
|
||||
for (const f of master.flavours ?? []) {
|
||||
if (countedIds.has(f.id)) continue;
|
||||
total += f.download_count ?? 0;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Modal hero: master first, then each flavour (plan order). */
|
||||
export function buildModalHeroSlides(theme: Theme): ModalHeroSlide[] {
|
||||
const slides: ModalHeroSlide[] = [];
|
||||
slides.push({
|
||||
imageUrl: marqueeOrCoverUrl(theme),
|
||||
caption: theme.name,
|
||||
});
|
||||
const flavours = theme.flavours ?? [];
|
||||
for (const f of flavours) {
|
||||
slides.push({
|
||||
imageUrl: f.marquee_image || f.cover_image,
|
||||
caption: `${theme.name} — ${f.name}`,
|
||||
});
|
||||
}
|
||||
return slides;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative luminance 0–1 for rgba/rgb/hex-ish strings; fallback 0.5 → white text
|
||||
*/
|
||||
export function pickContrastingTextColor(backgroundCss: string): "#ffffff" | "#0a0a0a" {
|
||||
const s = backgroundCss.trim();
|
||||
const rgba = s.match(
|
||||
/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i,
|
||||
);
|
||||
if (rgba) {
|
||||
const r = Number(rgba[1]) / 255;
|
||||
const g = Number(rgba[2]) / 255;
|
||||
const b = Number(rgba[3]) / 255;
|
||||
const a = rgba[4] !== undefined ? Number(rgba[4]) : 1;
|
||||
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
const effective = lum * a + 0.05 * (1 - a);
|
||||
return effective > 0.45 ? "#0a0a0a" : "#ffffff";
|
||||
}
|
||||
const hex = s.match(/^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
|
||||
if (hex) {
|
||||
const r = parseInt(hex[1], 16) / 255;
|
||||
const g = parseInt(hex[2], 16) / 255;
|
||||
const b = parseInt(hex[3], 16) / 255;
|
||||
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
return lum > 0.45 ? "#0a0a0a" : "#ffffff";
|
||||
}
|
||||
return "#ffffff";
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import * as pdfjs from "pdfjs-dist";
|
||||
import browser from "webextension-polyfill";
|
||||
import pdfWorkerHref from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
import pdfLegacyHref from "pdfjs-dist/legacy/build/pdf.min.mjs?url";
|
||||
|
||||
function extensionAssetUrl(viteAssetHref: string): string {
|
||||
const path = viteAssetHref.replace(/^\/+/, "");
|
||||
return browser.runtime.getURL(path);
|
||||
/** 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;
|
||||
@@ -13,14 +14,14 @@ let workerConfigured = false;
|
||||
/** Required before pdfjs spawns a worker (content-script / extension isolate). */
|
||||
export function ensurePdfjsWorker(): void {
|
||||
if (workerConfigured) return;
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = extensionAssetUrl(pdfWorkerHref);
|
||||
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(pdfLegacyHref),
|
||||
worker: extensionAssetUrl(pdfWorkerHref),
|
||||
lib: extensionAssetUrl(PDF_LEGACY_RESOURCE),
|
||||
worker: extensionAssetUrl(PDF_WORKER_RESOURCE),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"service_worker": "background.ts"
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org"
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
@@ -33,6 +33,7 @@
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": [
|
||||
"assets/*",
|
||||
"resources/icons/*",
|
||||
"resources/update-image.webp",
|
||||
"resources/pdfjs/pdf.worker.min.mjs",
|
||||
|
||||
@@ -5,9 +5,57 @@ import {
|
||||
getPdfjsPageContextUrls,
|
||||
} from "@/lib/pdfjsExtension.ts";
|
||||
import * as pdfjs from "pdfjs-dist";
|
||||
import {
|
||||
escapeJsForInlineScript,
|
||||
escapeJsSingleQuotedString,
|
||||
extractWeightFromPdfText,
|
||||
isBetterseqtaWasmReady,
|
||||
isFirefoxUserAgent,
|
||||
parseGradeToPercent,
|
||||
} from "@/wasm/init";
|
||||
|
||||
ensurePdfjsWorker();
|
||||
|
||||
function escJsSingleQuoted(s: string): string {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return escapeJsSingleQuotedString(s);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
function escJsInlineUrl(s: string): string {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return escapeJsForInlineScript(s);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function detectFirefox(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const ua = navigator.userAgent;
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return isFirefoxUserAgent(ua);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const u = ua.toLowerCase();
|
||||
return (
|
||||
u.indexOf("firefox") > -1 &&
|
||||
!u.includes("seamonkey") &&
|
||||
!u.includes("waterfox")
|
||||
);
|
||||
}
|
||||
|
||||
export async function initStorage(api: any) {
|
||||
await api.storage.loaded;
|
||||
|
||||
@@ -63,7 +111,7 @@ export const letterToNumber: Record<string, number> = {
|
||||
F: 0,
|
||||
};
|
||||
|
||||
function parseGrade(text: string): number {
|
||||
function parseGradeTs(text: string): number {
|
||||
const str = text.trim().toUpperCase();
|
||||
if (str.includes("/")) {
|
||||
const [raw, max] = str.split("/").map((n) => parseFloat(n));
|
||||
@@ -75,6 +123,17 @@ function parseGrade(text: string): number {
|
||||
return letterToNumber[str] ?? 0;
|
||||
}
|
||||
|
||||
function parseGrade(text: string): number {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return parseGradeToPercent(text);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return parseGradeTs(text);
|
||||
}
|
||||
|
||||
function createWeightLabel(
|
||||
assessmentItem: Element,
|
||||
weighting: string | undefined,
|
||||
@@ -114,28 +173,28 @@ function createWeightLabel(
|
||||
: "N/A";
|
||||
}
|
||||
|
||||
statsContainer.style.position = "relative";
|
||||
weightLabel.style.position = "absolute";
|
||||
weightLabel.style.right = "0";
|
||||
weightLabel.style.top = "50%";
|
||||
weightLabel.style.transform = "translateY(-50%)";
|
||||
// Stack weight under Max/native stats — absolute right:0 overlapped the max column (#414).
|
||||
statsContainer.style.display = "flex";
|
||||
statsContainer.style.flexDirection = "column";
|
||||
statsContainer.style.alignItems = "flex-end";
|
||||
statsContainer.style.gap = "2px";
|
||||
statsContainer.style.justifyContent = "center";
|
||||
|
||||
weightLabel.style.position = "relative";
|
||||
weightLabel.style.inset = "unset";
|
||||
weightLabel.style.transform = "none";
|
||||
|
||||
statsContainer.appendChild(weightLabel);
|
||||
}
|
||||
|
||||
export const isFirefox =
|
||||
navigator.userAgent.toLowerCase().indexOf("firefox") > -1 &&
|
||||
!navigator.userAgent.toLowerCase().includes("seamonkey") &&
|
||||
!navigator.userAgent.toLowerCase().includes("waterfox");
|
||||
|
||||
async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
||||
const isBlobUrl = url.startsWith("blob:");
|
||||
|
||||
if (isBlobUrl || isFirefox) {
|
||||
if (isBlobUrl || detectFirefox()) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`;
|
||||
const escapedUrl = url.replace(/'/g, "\\'");
|
||||
const escapedUrl = escJsSingleQuoted(url);
|
||||
|
||||
script.textContent = `
|
||||
(function() {
|
||||
@@ -222,10 +281,8 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
||||
|
||||
export async function extractPDFText(url: string): Promise<string> {
|
||||
try {
|
||||
if (isFirefox) {
|
||||
if (detectFirefox()) {
|
||||
const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls();
|
||||
const escJsSingleQuoted = (s: string) =>
|
||||
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
|
||||
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
|
||||
|
||||
@@ -233,10 +290,7 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
const script = document.createElement("script");
|
||||
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
|
||||
|
||||
const escapedUrl = url
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/"/g, '\\"');
|
||||
const escapedUrl = escJsInlineUrl(url);
|
||||
|
||||
script.textContent = `
|
||||
(function() {
|
||||
@@ -483,7 +537,7 @@ async function handleWeightings(mark: any, api: any) {
|
||||
text = await extractPDFText(pdfUrl);
|
||||
} catch (error: any) {
|
||||
if (
|
||||
isFirefox &&
|
||||
detectFirefox() &&
|
||||
(error?.message?.includes("blob") ||
|
||||
error?.message?.includes("Security") ||
|
||||
error?.message?.includes("CSP"))
|
||||
@@ -495,11 +549,22 @@ async function handleWeightings(mark: any, api: any) {
|
||||
}
|
||||
}
|
||||
|
||||
let weight: string | undefined;
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
weight = extractWeightFromPdfText(text);
|
||||
} catch {
|
||||
weight = undefined;
|
||||
}
|
||||
}
|
||||
if (weight === undefined) {
|
||||
const match = text.match(/weight:\s*(\d+\.?\d*)/i);
|
||||
weight = match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
api.storage.weightings = {
|
||||
...api.storage.weightings,
|
||||
[assessmentID]: match ? match[1] : "N/A",
|
||||
[assessmentID]: weight ?? "N/A",
|
||||
};
|
||||
} catch (error: any) {
|
||||
api.storage.weightings = {
|
||||
|
||||
@@ -28,9 +28,17 @@ async function fetchJSON(url: string, body: any) {
|
||||
|
||||
async function loadSubjects() {
|
||||
const res = await fetchJSON("/seqta/student/load/subjects?", {});
|
||||
return res.payload
|
||||
.filter((s: any) => s.active === 1)
|
||||
const activeGroup = res.payload.find((s: any) => s.active === 1);
|
||||
const activeYear = activeGroup?.year;
|
||||
const allSubjects = res.payload
|
||||
.filter((s: any) => s.year === activeYear)
|
||||
.flatMap((s: any) => s.subjects);
|
||||
const seen = new Set<string>();
|
||||
return allSubjects.filter((s: Subject) => {
|
||||
if (seen.has(s.code)) return false;
|
||||
seen.add(s.code);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPrefs(student: number) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Plugin } from "../../core/types";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { getAssessmentsData } from "./api";
|
||||
import { renderErrorState, renderSkeletonLoader } from "./ui";
|
||||
import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
|
||||
import styles from "./styles.css?inline";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
|
||||
const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
id: "assessments-overview",
|
||||
@@ -16,6 +17,8 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
styles,
|
||||
|
||||
run: async () => {
|
||||
if (isSeqtaEngageExperience()) return;
|
||||
|
||||
const menu = (await waitForElm(
|
||||
'[data-key="assessments"] > .sub > ul',
|
||||
true,
|
||||
@@ -65,7 +68,6 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
|
||||
try {
|
||||
const data = await getAssessmentsData();
|
||||
const { renderGrid } = await import("./ui");
|
||||
renderGrid(container, data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load assessments:", err);
|
||||
|
||||
@@ -120,7 +120,6 @@ export default defineLazyPlugin({
|
||||
settings,
|
||||
disableToggle: true,
|
||||
defaultEnabled: false,
|
||||
beta: true,
|
||||
styles: styles,
|
||||
|
||||
// Lazy loader - only imports the heavy plugin when actually needed
|
||||
|
||||
@@ -145,7 +145,6 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
||||
settings: settingsInstance.settings,
|
||||
disableToggle: true,
|
||||
defaultEnabled: false,
|
||||
beta: true,
|
||||
styles: styles,
|
||||
|
||||
run: async (api) => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import {
|
||||
isBetterseqtaWasmReady,
|
||||
isFirefoxUserAgent,
|
||||
} from "@/wasm/init";
|
||||
|
||||
/**
|
||||
* Detects if the current browser is Firefox
|
||||
@@ -11,6 +15,13 @@ export function isFirefox(): boolean {
|
||||
}
|
||||
// Fallback: check user agent
|
||||
if (typeof navigator !== "undefined") {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return isFirefoxUserAgent(navigator.userAgent);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return navigator.userAgent.toLowerCase().includes("firefox");
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -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 { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
|
||||
interface NotificationCollectorStorage {
|
||||
lastNotificationCount: number;
|
||||
@@ -15,6 +16,10 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
|
||||
disableToggle: true,
|
||||
|
||||
run: async (api) => {
|
||||
if (isSeqtaEngageExperience()) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let pollInterval: number | null = null;
|
||||
let isVisible = !document.hidden;
|
||||
let baseInterval = 30000; // 30 seconds
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
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 { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import styles from "./styles.css?inline";
|
||||
import localforage from "localforage";
|
||||
|
||||
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({
|
||||
title: "Profile Picture",
|
||||
description: "Upload or remove your custom profile image",
|
||||
@@ -17,7 +27,7 @@ const profilePicturePlugin: Plugin<typeof settings> = {
|
||||
id: "profile-picture",
|
||||
name: "Custom Profile Picture",
|
||||
description: "Use your own image in place of the profile icon",
|
||||
version: "1.1.0",
|
||||
version: "1.2.0",
|
||||
settings: settings,
|
||||
disableToggle: true,
|
||||
defaultEnabled: false,
|
||||
@@ -36,14 +46,12 @@ const profilePicturePlugin: Plugin<typeof settings> = {
|
||||
let img: HTMLImageElement | null = null;
|
||||
let currentBlobUrl: string | undefined;
|
||||
|
||||
// Setup localforage instance
|
||||
const store = localforage.createInstance({
|
||||
name: "profile-picture-store",
|
||||
storeName: "profilePicture",
|
||||
});
|
||||
|
||||
async function updateImageFromStore() {
|
||||
// Remove old image if present
|
||||
async function applyProfileImage() {
|
||||
if (img) {
|
||||
img.remove();
|
||||
img = null;
|
||||
@@ -52,6 +60,19 @@ const profilePicturePlugin: Plugin<typeof settings> = {
|
||||
URL.revokeObjectURL(currentBlobUrl);
|
||||
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");
|
||||
if (blob && blob instanceof Blob) {
|
||||
currentBlobUrl = URL.createObjectURL(blob);
|
||||
@@ -65,15 +86,25 @@ const profilePicturePlugin: Plugin<typeof settings> = {
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
await updateImageFromStore();
|
||||
await applyProfileImage();
|
||||
|
||||
// Listen for profile picture updates
|
||||
const handler = () => { updateImageFromStore(); };
|
||||
window.addEventListener('profile-picture-updated', handler);
|
||||
const onLocalPictureUpdated = () => {
|
||||
void applyProfileImage();
|
||||
};
|
||||
window.addEventListener("profile-picture-updated", onLocalPictureUpdated);
|
||||
|
||||
const cloudUnsub = cloudAuth.subscribe(() => {
|
||||
void applyProfileImage();
|
||||
});
|
||||
|
||||
const useCloudUnreg = api.settings.onChange("useCloudPfp", () => {
|
||||
void applyProfileImage();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("profile-picture-updated", handler);
|
||||
useCloudUnreg.unregister();
|
||||
cloudUnsub();
|
||||
window.removeEventListener("profile-picture-updated", onLocalPictureUpdated);
|
||||
if (img) img.remove();
|
||||
if (svg) svg.style.display = "";
|
||||
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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 { unmount } from "svelte";
|
||||
import themeCreator from "@/interface/pages/themeCreator.svelte";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
|
||||
let themeCreatorSvelteApp: any = null;
|
||||
const themeManager = ThemeManager.getInstance();
|
||||
|
||||
/**
|
||||
* 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.addEventListener("click", () => {
|
||||
CloseThemeCreator();
|
||||
themeManager.clearPreview();
|
||||
ThemeManager.getInstance().clearPreview();
|
||||
});
|
||||
|
||||
document.body.appendChild(closeButton);
|
||||
|
||||
@@ -10,6 +10,7 @@ const themesPlugin: Plugin = {
|
||||
|
||||
run: async (_) => {
|
||||
const themeManager = ThemeManager.getInstance();
|
||||
await themeManager.prepareThemeAfterCloudSync();
|
||||
await themeManager.initialize();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import localforage from "localforage";
|
||||
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 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";
|
||||
import {
|
||||
decodeBase64,
|
||||
isBetterseqtaWasmReady,
|
||||
stripDataUrlBase64Payload,
|
||||
} from "@/wasm/init";
|
||||
|
||||
type ThemeContent = {
|
||||
id: string;
|
||||
@@ -13,8 +31,19 @@ type ThemeContent = {
|
||||
CanChangeColour?: boolean;
|
||||
CustomCSS?: string;
|
||||
hideThemeName?: boolean;
|
||||
forceTheme?: 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 {
|
||||
@@ -27,6 +56,7 @@ export class ThemeManager {
|
||||
private originalPreviewTheme: boolean | null = null;
|
||||
private imageUrlCache: Map<string, string> = new Map();
|
||||
private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 };
|
||||
private storeUpdateCheckRunning = false;
|
||||
|
||||
private constructor() {
|
||||
console.debug("[ThemeManager] Initializing...");
|
||||
@@ -142,6 +172,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
|
||||
*/
|
||||
@@ -175,6 +232,8 @@ export class ThemeManager {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error during initialization:", error);
|
||||
} finally {
|
||||
void this.checkStoreThemeUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +268,7 @@ export class ThemeManager {
|
||||
console.debug("[ThemeManager] Storing original settings");
|
||||
settingsState.originalSelectedColor = settingsState.selectedColor;
|
||||
|
||||
if (theme.forceDark) {
|
||||
if (shouldForceThemeAppearance(theme)) {
|
||||
settingsState.originalDarkMode = settingsState.DarkMode;
|
||||
}
|
||||
}
|
||||
@@ -240,6 +299,7 @@ export class ThemeManager {
|
||||
this.currentTheme = theme;
|
||||
settingsState.selectedTheme = themeId;
|
||||
}
|
||||
void updateAllColors();
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error setting theme:", error);
|
||||
}
|
||||
@@ -270,9 +330,10 @@ export class ThemeManager {
|
||||
}
|
||||
|
||||
// Apply theme settings
|
||||
if (theme.forceDark !== undefined) {
|
||||
console.debug("[ThemeManager] Setting dark mode:", theme.forceDark);
|
||||
settingsState.DarkMode = theme.forceDark;
|
||||
if (shouldForceThemeAppearance(theme)) {
|
||||
const dark = getForcedDarkMode(theme);
|
||||
console.debug("[ThemeManager] Setting dark mode:", dark);
|
||||
settingsState.DarkMode = dark;
|
||||
}
|
||||
|
||||
// Use the stored selected color if available, otherwise use the default
|
||||
@@ -289,6 +350,8 @@ export class ThemeManager {
|
||||
);
|
||||
settingsState.selectedColor = theme.defaultColour;
|
||||
}
|
||||
|
||||
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error applying theme:", error);
|
||||
}
|
||||
@@ -345,9 +408,18 @@ export class ThemeManager {
|
||||
|
||||
if (this.currentTheme) {
|
||||
// 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, {
|
||||
...this.currentTheme,
|
||||
selectedColor: settingsState.selectedColor,
|
||||
selectedColor,
|
||||
...(markUserEditedForColor ? { userEdited: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -373,6 +445,7 @@ export class ThemeManager {
|
||||
if (clearSelectedTheme) {
|
||||
settingsState.selectedTheme = "";
|
||||
}
|
||||
clearCustomThemeAdaptiveCssVariables();
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error removing theme:", error);
|
||||
}
|
||||
@@ -427,18 +500,24 @@ export class ThemeManager {
|
||||
public async saveTheme(theme: LoadedCustomTheme): Promise<void> {
|
||||
console.debug("[ThemeManager] Saving theme:", theme.name);
|
||||
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
|
||||
| string[]
|
||||
| null;
|
||||
|
||||
if (themeIds) {
|
||||
if (!themeIds.includes(theme.id)) {
|
||||
themeIds.push(theme.id);
|
||||
if (!themeIds.includes(toSave.id)) {
|
||||
themeIds.push(toSave.id);
|
||||
await localforage.setItem("customThemes", themeIds);
|
||||
}
|
||||
} else {
|
||||
await localforage.setItem("customThemes", [theme.id]);
|
||||
await localforage.setItem("customThemes", [toSave.id]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error saving theme:", error);
|
||||
@@ -496,39 +575,62 @@ export class ThemeManager {
|
||||
description?: string;
|
||||
coverImage?: string;
|
||||
theme_json_url?: string;
|
||||
updated_at?: number;
|
||||
}): Promise<void> {
|
||||
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
|
||||
try {
|
||||
if (!themeContent.id) return;
|
||||
|
||||
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);
|
||||
await this.downloadAndInstallStoreTheme(themeContent);
|
||||
} catch (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
|
||||
*/
|
||||
public async installTheme(themeData: ThemeContent): Promise<void> {
|
||||
public async installTheme(
|
||||
themeData: ThemeContent,
|
||||
meta?: InstallThemeMeta,
|
||||
): Promise<void> {
|
||||
console.debug("[ThemeManager] Installing theme:", themeData.name);
|
||||
try {
|
||||
// Validate required fields
|
||||
@@ -536,6 +638,9 @@ export class ThemeManager {
|
||||
throw new Error("Theme is missing required fields (id or name)");
|
||||
}
|
||||
|
||||
const fromStore = meta?.fromStore ?? false;
|
||||
const serverUpdatedAtSec = meta?.serverUpdatedAtSec;
|
||||
|
||||
// Handle cover image (optional)
|
||||
let coverImageBlob = null;
|
||||
if (themeData.coverImage) {
|
||||
@@ -570,7 +675,6 @@ export class ThemeManager {
|
||||
})
|
||||
.filter((img) => img !== null) ?? [];
|
||||
|
||||
// Create theme with defaults for optional fields
|
||||
const theme: LoadedCustomTheme = {
|
||||
id: themeData.id,
|
||||
name: themeData.name,
|
||||
@@ -585,6 +689,19 @@ export class ThemeManager {
|
||||
isEditable: false,
|
||||
hideThemeName: themeData.hideThemeName ?? false,
|
||||
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);
|
||||
@@ -594,6 +711,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
|
||||
*/
|
||||
@@ -614,6 +840,9 @@ export class ThemeManager {
|
||||
isEditable,
|
||||
selectedColor,
|
||||
allowBackgrounds,
|
||||
installedFromStore,
|
||||
storeSyncedAtSec,
|
||||
userEdited,
|
||||
...themeBasics
|
||||
} = theme;
|
||||
|
||||
@@ -651,7 +880,7 @@ export class ThemeManager {
|
||||
public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
|
||||
console.debug("[ThemeManager] Previewing theme:", theme.name);
|
||||
try {
|
||||
const { CustomCSS, CustomImages, defaultColour, forceDark } = theme;
|
||||
const { CustomCSS, CustomImages, defaultColour } = theme;
|
||||
|
||||
// Store original settings only if this is a new theme
|
||||
if (!theme.webURL) {
|
||||
@@ -697,13 +926,16 @@ export class ThemeManager {
|
||||
this.previousImageVariableNames = newImageVariableNames;
|
||||
|
||||
// Apply theme settings
|
||||
if (forceDark !== undefined) {
|
||||
settingsState.DarkMode = forceDark;
|
||||
if (shouldForceThemeAppearance(theme)) {
|
||||
settingsState.DarkMode = getForcedDarkMode(theme);
|
||||
}
|
||||
|
||||
if (defaultColour) {
|
||||
settingsState.selectedColor = defaultColour;
|
||||
}
|
||||
|
||||
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
|
||||
void updateAllColors();
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error previewing theme:", error);
|
||||
}
|
||||
@@ -769,15 +1001,18 @@ export class ThemeManager {
|
||||
this.previousImageVariableNames = newImageVariableNames;
|
||||
}
|
||||
|
||||
// Always apply dark mode setting
|
||||
if (theme.forceDark !== undefined) {
|
||||
settingsState.DarkMode = theme.forceDark;
|
||||
// Always apply dark mode setting when theme forces appearance
|
||||
if (shouldForceThemeAppearance(theme as CustomTheme)) {
|
||||
settingsState.DarkMode = getForcedDarkMode(theme as CustomTheme);
|
||||
}
|
||||
|
||||
// Only apply color if this is a new theme
|
||||
if (!theme.webURL && theme.defaultColour) {
|
||||
settingsState.selectedColor = theme.defaultColour;
|
||||
}
|
||||
|
||||
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
|
||||
void updateAllColors();
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error updating theme preview:", error);
|
||||
}
|
||||
@@ -815,6 +1050,8 @@ export class ThemeManager {
|
||||
this.previewStyleElement = null;
|
||||
}
|
||||
|
||||
clearCustomThemeAdaptiveCssVariables();
|
||||
|
||||
// Restore original settings
|
||||
const storedColor = localStorage.getItem("originalPreviewColor");
|
||||
|
||||
@@ -844,6 +1081,8 @@ export class ThemeManager {
|
||||
settingsState.DarkMode = this.originalPreviewTheme;
|
||||
this.originalPreviewTheme = null;
|
||||
}
|
||||
|
||||
void updateAllColors();
|
||||
} catch (error) {
|
||||
console.error("[ThemeManager] Error clearing preview:", error);
|
||||
}
|
||||
@@ -853,8 +1092,15 @@ export class ThemeManager {
|
||||
private stripBase64Prefix(base64String: string): string {
|
||||
if (!base64String) return "";
|
||||
|
||||
const prefixRegex = /^data:[^;]+;base64,/;
|
||||
try {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return stripDataUrlBase64Payload(base64String);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const prefixRegex = /^data:[^;]+;base64,/;
|
||||
return prefixRegex.test(base64String)
|
||||
? base64String.replace(prefixRegex, "")
|
||||
: base64String;
|
||||
@@ -866,6 +1112,16 @@ export class ThemeManager {
|
||||
|
||||
private base64ToBlob(base64: string): Blob {
|
||||
try {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
const bytes = decodeBase64(base64.trim());
|
||||
if (bytes.byteLength > 0) {
|
||||
return new Blob([bytes], { type: "image/png" });
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const byteString = atob(base64);
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
|
||||
@@ -63,7 +63,12 @@ function resetTimetableStyles(): 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
|
||||
if (settingsState.timeFormat == "12") {
|
||||
|
||||
@@ -271,7 +271,9 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
};
|
||||
|
||||
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") {
|
||||
const titleEl = quickbar.querySelector(".title");
|
||||
const roomEl = quickbar.querySelector(".meta .room");
|
||||
@@ -287,7 +289,9 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
if (!timetablePage || quickbarObserver) return;
|
||||
|
||||
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") {
|
||||
addEditButtonToQuickbar(quickbar as HTMLElement);
|
||||
}
|
||||
@@ -302,7 +306,13 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
};
|
||||
|
||||
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();
|
||||
setupQuickbarObserver();
|
||||
syncQuickbarFromDOM();
|
||||
|
||||
@@ -10,6 +10,7 @@ import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
|
||||
import profilePicturePlugin from "./built-in/profilePicture";
|
||||
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
|
||||
import backgroundMusicPlugin from "./built-in/backgroundMusic";
|
||||
//import messageFoldersPlugin from "./built-in/messageFolders";
|
||||
//import testPlugin from './built-in/test';
|
||||
|
||||
// Heavy plugins (lazy-loaded only when enabled)
|
||||
@@ -28,6 +29,7 @@ pluginManager.registerPlugin(timetableEditPlugin);
|
||||
pluginManager.registerPlugin(profilePicturePlugin);
|
||||
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
||||
pluginManager.registerPlugin(backgroundMusicPlugin);
|
||||
//pluginManager.registerPlugin(messageFoldersPlugin);
|
||||
//pluginManager.registerPlugin(testPlugin);
|
||||
|
||||
// Register heavy plugins with lazy loading
|
||||
|
||||
+116
-29
@@ -1,5 +1,4 @@
|
||||
// Third-party libraries
|
||||
import browser from "webextension-polyfill";
|
||||
import { animate, stagger } from "motion";
|
||||
|
||||
// Internal utilities and functions
|
||||
@@ -23,9 +22,14 @@ import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements";
|
||||
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
||||
import loading from "@/seqta/ui/Loading";
|
||||
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 { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
|
||||
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification";
|
||||
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
|
||||
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||
|
||||
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
||||
|
||||
@@ -84,6 +88,7 @@ export function hideSideBar() {
|
||||
}
|
||||
|
||||
let betterSeqtaFinishLoadDone = false;
|
||||
let engageHashListenerAttached = false;
|
||||
|
||||
export async function finishLoad() {
|
||||
if (betterSeqtaFinishLoadDone) return;
|
||||
@@ -100,18 +105,11 @@ export async function finishLoad() {
|
||||
console.error("Error during loading cleanup:", err);
|
||||
}
|
||||
|
||||
// Check and show privacy statement notification (before what's new)
|
||||
if (!document.getElementById("privacy-notification")) {
|
||||
await showPrivacyNotification();
|
||||
}
|
||||
|
||||
if (settingsState.justupdated && !document.getElementById("whatsnewbk") && !document.getElementById("privacy-notification")) {
|
||||
OpenWhatsNewPopup();
|
||||
}
|
||||
runStartupPopupQueue();
|
||||
}
|
||||
|
||||
export function GetCSSElement(file: string) {
|
||||
const cssFile = browser.runtime.getURL(file);
|
||||
const cssFile = extensionAssetUrl(file);
|
||||
const fileref = document.createElement("link");
|
||||
fileref.setAttribute("rel", "stylesheet");
|
||||
fileref.setAttribute("type", "text/css");
|
||||
@@ -208,7 +206,20 @@ function SortMessagePageItems(messagesParentElement: any) {
|
||||
|
||||
async function LoadPageElements(): Promise<void> {
|
||||
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(
|
||||
"messagesAdded",
|
||||
@@ -303,7 +314,24 @@ async function handleNotices(node: Element): 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;
|
||||
}
|
||||
|
||||
@@ -480,24 +508,83 @@ 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() {
|
||||
if (isSeqtaEngageExperience()) {
|
||||
updateIframesWithDarkMode();
|
||||
window.addEventListener(
|
||||
"load",
|
||||
() => removeThemeTagsFromNotices(),
|
||||
{ once: true },
|
||||
);
|
||||
window.addEventListener(
|
||||
"load",
|
||||
() => void finishLoad(),
|
||||
{ once: true },
|
||||
);
|
||||
waitForElm(".login").then(() => void finishLoad());
|
||||
waitForElm(".day-container").then(() => void finishLoad());
|
||||
waitForElm(".code", true, 50).then((elm: any) => {
|
||||
if (!elm.innerText.includes("BetterSEQTA")) void LoadPageElements();
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -779,7 +866,7 @@ function InjectCustomIcons() {
|
||||
style.innerHTML = `
|
||||
@font-face {
|
||||
font-family: 'IconFamily';
|
||||
src: url('${browser.runtime.getURL(IconFamily)}') format('woff');
|
||||
src: url('${extensionAssetUrl(IconFamily)}') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}`;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 653 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
-2
@@ -1,8 +1,8 @@
|
||||
// Third-party libraries
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
// Internal utilities and functions
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||
|
||||
// UI and theme management
|
||||
import pageState from "@/pageState.js?url";
|
||||
@@ -34,7 +34,12 @@ export async function main() {
|
||||
}
|
||||
|
||||
function injectPageState() {
|
||||
const scriptUrl = extensionAssetUrl(pageState);
|
||||
if (!scriptUrl) {
|
||||
console.warn("[BetterSEQTA+] Could not resolve pageState script URL.");
|
||||
return;
|
||||
}
|
||||
const mainScript = document.createElement("script");
|
||||
mainScript.src = browser.runtime.getURL(pageState);
|
||||
mainScript.src = scriptUrl;
|
||||
document.head.appendChild(mainScript);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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 { SendNewsPage } from "@/seqta/utils/SendNewsPage";
|
||||
import { attachNotificationsPanelAnimation } from "@/seqta/utils/attachNotificationsPanelAnimation";
|
||||
import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
|
||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||
import { appendBackgroundToUI } from "./ImageBackgrounds";
|
||||
@@ -44,7 +47,13 @@ export async function getUserInfo() {
|
||||
|
||||
export async function AddBetterSEQTAElements() {
|
||||
if (isSeqtaEngageExperience()) {
|
||||
await waitForElm("#content");
|
||||
addExtensionSettings();
|
||||
if (settingsState.onoff) {
|
||||
await injectEngageHomeButton();
|
||||
}
|
||||
void setupEngageSettingsButton();
|
||||
void addEngageUserInfo();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,6 +90,7 @@ export async function AddBetterSEQTAElements() {
|
||||
addExtensionSettings();
|
||||
await createSettingsButton();
|
||||
setupSettingsButton();
|
||||
attachNotificationsPanelAnimation();
|
||||
}
|
||||
|
||||
function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
|
||||
@@ -263,8 +273,9 @@ function setupEventListeners() {
|
||||
});
|
||||
}
|
||||
|
||||
async function createSettingsButton() {
|
||||
document.getElementById("content")!.append(
|
||||
async function createSettingsButton(parent?: Element) {
|
||||
const target = parent ?? document.getElementById("content")!;
|
||||
target.append(
|
||||
stringToHTML(/* html */ `
|
||||
<button class="addedButton tooltip" id="AddedSettings">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
@@ -276,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() {
|
||||
return settingsState.DarkMode
|
||||
? "Switch to light 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 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 */ `
|
||||
<button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">${settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG}</svg>
|
||||
|
||||
@@ -18,12 +18,9 @@ export class SettingsResizer {
|
||||
if (!iframePopup) return;
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,104 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import Color from "color";
|
||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||
import { lightenAndPaleColor } from "./lightenAndPaleColor";
|
||||
import ColorLuminance from "./ColorLuminance";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
|
||||
import { getCustomThemeAdaptiveCssVariableBindings } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
|
||||
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||
|
||||
import darkLogo from "@/resources/icons/betterseqta-light-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
|
||||
const setCSSVar = (varName: any, value: any) =>
|
||||
document.documentElement.style.setProperty(varName, value);
|
||||
const applyProperties = (props: any) =>
|
||||
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) {
|
||||
if (settingsState.transparencyEffects) {
|
||||
document.documentElement.classList.add("transparencyEffects");
|
||||
@@ -31,11 +115,11 @@ function applyColorsWith(selectedColor: string) {
|
||||
let modeProps = {};
|
||||
modeProps = settingsState.DarkMode
|
||||
? {
|
||||
"--betterseqta-logo": `url(${browser.runtime.getURL(darkLogo)})`,
|
||||
"--betterseqta-logo": `url(${extensionAssetUrl(darkLogo)})`,
|
||||
}
|
||||
: {
|
||||
"--better-pale": lightenAndPaleColor(selectedColor),
|
||||
"--betterseqta-logo": `url(${browser.runtime.getURL(lightLogo)})`,
|
||||
"--betterseqta-logo": `url(${extensionAssetUrl(lightLogo)})`,
|
||||
};
|
||||
|
||||
if (settingsState.DarkMode) {
|
||||
@@ -59,6 +143,39 @@ function applyColorsWith(selectedColor: string) {
|
||||
// Apply all the properties
|
||||
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");
|
||||
|
||||
for (let i = 0; i < alliframes.length; i++) {
|
||||
@@ -89,15 +206,71 @@ export async function updateAllColors() {
|
||||
? settingsState.selectedColor
|
||||
: "#007bff";
|
||||
|
||||
let adaptiveHex: string | null = null;
|
||||
|
||||
if (settingsState.adaptiveThemeColour) {
|
||||
const adaptiveColor = await getAdaptiveColour();
|
||||
if (adaptiveColor) {
|
||||
effectiveColor =
|
||||
settingsState.adaptiveThemeGradient
|
||||
adaptiveHex = adaptiveColor;
|
||||
effectiveColor = settingsState.adaptiveThemeGradient
|
||||
? toSoftGradient(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);
|
||||
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 = [];
|
||||
}
|
||||
@@ -1,38 +1,44 @@
|
||||
import Color from "color";
|
||||
import {
|
||||
colorCssThresholdDistance,
|
||||
isBetterseqtaWasmReady,
|
||||
} from "@/wasm/init";
|
||||
|
||||
export function GetThresholdOfColor(color: any) {
|
||||
if (!color) return 0;
|
||||
// Case-insensitive regular expression for matching RGBA colors
|
||||
const s = typeof color === "string" ? color : String(color);
|
||||
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
const v = colorCssThresholdDistance(s);
|
||||
if (v >= 0 && Number.isFinite(v)) return v;
|
||||
} catch {
|
||||
/* fall through to Color */
|
||||
}
|
||||
}
|
||||
|
||||
const rgbaRegex = /rgba?\(([^)]+)\)/gi;
|
||||
|
||||
// Check if the color string is a gradient (linear or radial)
|
||||
if (color.includes("gradient")) {
|
||||
let gradientThresholds = [];
|
||||
|
||||
// Find and replace all instances of RGBA in the gradient
|
||||
if (s.includes("gradient")) {
|
||||
const gradientThresholds = [];
|
||||
let match;
|
||||
while ((match = rgbaRegex.exec(color)) !== null) {
|
||||
// Extract the individual components (r, g, b, a)
|
||||
while ((match = rgbaRegex.exec(s)) !== null) {
|
||||
const rgbaString = match[1];
|
||||
const [r, g, b] = rgbaString.split(",").map((str) => str.trim());
|
||||
|
||||
// Compute the threshold using your existing algorithm
|
||||
const threshold = Math.sqrt(
|
||||
parseInt(r) ** 2 + parseInt(g) ** 2 + parseInt(b) ** 2,
|
||||
parseInt(r, 10) ** 2 + parseInt(g, 10) ** 2 + parseInt(b, 10) ** 2,
|
||||
);
|
||||
|
||||
// Store the computed threshold
|
||||
gradientThresholds.push(threshold);
|
||||
}
|
||||
|
||||
// Calculate the average threshold
|
||||
const averageThreshold =
|
||||
gradientThresholds.reduce((acc, val) => acc + val, 0) /
|
||||
gradientThresholds.length;
|
||||
|
||||
return averageThreshold;
|
||||
} else {
|
||||
// Handle the color as a simple RGBA (or hex, or whatever the Color library supports)
|
||||
const rgb = Color.rgb(color).object();
|
||||
return Math.sqrt(rgb.r ** 2 + rgb.g ** 2 + rgb.b ** 2);
|
||||
if (gradientThresholds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return (
|
||||
gradientThresholds.reduce((acc, val) => acc + val, 0) /
|
||||
gradientThresholds.length
|
||||
);
|
||||
}
|
||||
|
||||
const rgb = Color.rgb(s).object();
|
||||
return Math.sqrt(rgb.r ** 2 + rgb.g ** 2 + rgb.b ** 2);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ interface ElementConfig {
|
||||
action: (element: Element) => void;
|
||||
/** When true, element is not added to processedElements so the action runs every time (e.g. overwriting container content) */
|
||||
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 {
|
||||
@@ -12,6 +14,12 @@ interface ContentConfig {
|
||||
// Track processed elements to avoid re-randomizing
|
||||
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 {
|
||||
let timeout: NodeJS.Timeout;
|
||||
return function executedFunction(...args: any[]) {
|
||||
@@ -44,19 +52,19 @@ function getRandomDate(): Date {
|
||||
|
||||
const contentConfig: ContentConfig = {
|
||||
lessonTitle: {
|
||||
selector: ".day h2",
|
||||
selector: `.day:not([${MOCK_DAY_ATTR}]) h2`,
|
||||
action: (element) => {
|
||||
element.textContent = getRandomElement(mockData.subjects);
|
||||
},
|
||||
},
|
||||
teacher: {
|
||||
selector: ".day h3:first-of-type",
|
||||
selector: `.day:not([${MOCK_DAY_ATTR}]) h3:first-of-type`,
|
||||
action: (element) => {
|
||||
element.textContent = getRandomElement(mockData.teachers);
|
||||
},
|
||||
},
|
||||
classroom: {
|
||||
selector: ".day h3:last-of-type",
|
||||
selector: `.day:not([${MOCK_DAY_ATTR}]) h3:last-of-type`,
|
||||
action: (element) => {
|
||||
element.textContent = getRandomElement(mockData.classrooms);
|
||||
},
|
||||
@@ -283,13 +291,28 @@ const contentConfig: ContentConfig = {
|
||||
// Home page: replace entire day with mock schedule (care + 7 lessons 8:55–3:15)
|
||||
homeDayContainer: {
|
||||
selector: "#day-container",
|
||||
alwaysRun: true,
|
||||
neverMarkProcessed: true,
|
||||
action: (element) => {
|
||||
const container = element as HTMLElement;
|
||||
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();
|
||||
container.innerHTML = schedule;
|
||||
container.classList.remove("loading");
|
||||
container.dataset.bspMockSchedule = "1";
|
||||
requestAnimationFrame(() => {
|
||||
suppressMockMutations = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -665,7 +688,7 @@ function getMockDaySchedule(): string {
|
||||
return blocks
|
||||
.map(
|
||||
(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>
|
||||
<h3>${b.teacher}</h3>
|
||||
<h3>${b.room}</h3>
|
||||
@@ -758,12 +781,12 @@ const debouncedProcessElements = debounce(processNewElements, 1);
|
||||
|
||||
function processNewElements() {
|
||||
Object.entries(contentConfig).forEach(([_, config]) => {
|
||||
const { selector, action, alwaysRun } = config;
|
||||
const { selector, action, alwaysRun, neverMarkProcessed } = config;
|
||||
const elements = document.querySelectorAll(selector);
|
||||
elements.forEach((element: Element) => {
|
||||
if (alwaysRun || !processedElements.has(element)) {
|
||||
if (alwaysRun || neverMarkProcessed || !processedElements.has(element)) {
|
||||
action(element);
|
||||
if (!alwaysRun) {
|
||||
if (!alwaysRun && !neverMarkProcessed) {
|
||||
processedElements.add(element);
|
||||
}
|
||||
}
|
||||
@@ -772,7 +795,6 @@ function processNewElements() {
|
||||
}
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
let intervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
export default function hideSensitiveContent() {
|
||||
// Initial processing of existing elements
|
||||
@@ -781,6 +803,8 @@ export default function hideSensitiveContent() {
|
||||
// Set up MutationObserver if not already created
|
||||
if (!observer) {
|
||||
observer = new MutationObserver((mutations) => {
|
||||
if (suppressMockMutations) return;
|
||||
|
||||
let shouldProcess = false;
|
||||
|
||||
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) {
|
||||
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;
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -848,8 +881,4 @@ export function stopHidingSensitiveContent() {
|
||||
observer.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
const result = await browser.storage.local.get(STORAGE_KEYS.accessToken);
|
||||
return (result[STORAGE_KEYS.accessToken] as string) ?? null;
|
||||
@@ -108,6 +127,26 @@ class CloudAuthService {
|
||||
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(
|
||||
login: string,
|
||||
password: string
|
||||
@@ -135,6 +174,7 @@ class CloudAuthService {
|
||||
user: result.user ?? null,
|
||||
};
|
||||
this.notify();
|
||||
this.triggerCloudSettingsDownloadAfterLogin(result.access_token);
|
||||
return { success: true };
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
import { animate } from "motion";
|
||||
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 { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||
|
||||
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="${extensionAssetUrl(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 = extensionAssetUrl(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 = extensionAssetUrl(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;">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${textPreview}</div>
|
||||
</div>`;
|
||||
|
||||
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
|
||||
element.addEventListener("click", () =>
|
||||
openEngageNoticeModal(notice, colour, element),
|
||||
);
|
||||
return element;
|
||||
}
|
||||
|
||||
function openEngageNoticeModal(
|
||||
notice: any,
|
||||
colour: string | undefined,
|
||||
sourceElement: HTMLElement,
|
||||
) {
|
||||
const cleanContent = notice.contents
|
||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||
.replace(/ +/, " ");
|
||||
|
||||
document.getElementById("notice-modal")?.remove();
|
||||
|
||||
const sourceRect = sourceElement.getBoundingClientRect();
|
||||
let scrollY = Math.round(window.scrollY);
|
||||
let scrollX = Math.round(window.scrollX);
|
||||
let sourceLeft = sourceRect.left;
|
||||
let sourceTop = sourceRect.top;
|
||||
let sourceWidth = sourceRect.width;
|
||||
let sourceHeight = sourceRect.height;
|
||||
|
||||
const modalHtml = `
|
||||
<div id="notice-modal" class="notice-modal-overlay" style="opacity: 0;">
|
||||
<div class="notice-modal-transition" style="
|
||||
position: fixed;
|
||||
left: ${sourceLeft + scrollX}px;
|
||||
top: ${sourceTop + scrollY}px;
|
||||
width: ${sourceWidth}px;
|
||||
height: ${sourceHeight}px;
|
||||
transform-origin: center;
|
||||
z-index: 10001;
|
||||
">
|
||||
<div class="notice-modal-content notice-transitioning">
|
||||
<div class="notice-unified-content notice-card-state">
|
||||
<div class="notice-header">
|
||||
<div class="notice-badge-row">
|
||||
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
|
||||
${notice.label_title || "General"}
|
||||
</span>
|
||||
<span class="notice-staff">${notice.staff}</span>
|
||||
</div>
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const modal = stringToHTML(modalHtml).firstChild as HTMLElement;
|
||||
const transitionContainer = modal.querySelector(
|
||||
".notice-modal-transition",
|
||||
) as HTMLElement;
|
||||
const unifiedContent = modal.querySelector(
|
||||
".notice-unified-content",
|
||||
) as HTMLElement;
|
||||
const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
sourceElement.setAttribute("data-transitioning", "true");
|
||||
sourceElement.style.opacity = "0";
|
||||
sourceElement.style.transform = "scale(0.95)";
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
let targetWidth = Math.round(
|
||||
Math.min(Math.max(sourceWidth, 800), viewportWidth - 40),
|
||||
);
|
||||
|
||||
const tempMeasureDiv = document.createElement("div");
|
||||
tempMeasureDiv.style.position = "absolute";
|
||||
tempMeasureDiv.style.left = "-9999px";
|
||||
tempMeasureDiv.style.width = targetWidth + "px";
|
||||
tempMeasureDiv.style.visibility = "hidden";
|
||||
tempMeasureDiv.innerHTML = `
|
||||
<div class="notice-unified-content notice-modal-state" style="position: relative; width: 100%; padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1);">
|
||||
<div class="notice-header">
|
||||
<div class="notice-badge-row">
|
||||
<span class="notice-badge">${notice.label_title || "General"}</span>
|
||||
<span class="notice-staff">${notice.staff}</span>
|
||||
</div>
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(tempMeasureDiv);
|
||||
const measuredHeight =
|
||||
tempMeasureDiv.firstElementChild!.getBoundingClientRect().height;
|
||||
document.body.removeChild(tempMeasureDiv);
|
||||
|
||||
let targetHeight = Math.round(
|
||||
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
|
||||
);
|
||||
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
|
||||
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
|
||||
|
||||
const closeModal = () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
|
||||
if (!settingsState.animations) {
|
||||
modal.remove();
|
||||
sourceElement.style.opacity = "1";
|
||||
sourceElement.style.transform = "";
|
||||
sourceElement.removeAttribute("data-transitioning");
|
||||
return;
|
||||
}
|
||||
|
||||
animate(
|
||||
modal,
|
||||
{
|
||||
backgroundColor: ["rgba(0, 0, 0, 0.5)", "rgba(0, 0, 0, 0)"],
|
||||
backdropFilter: ["blur(4px)", "blur(0px)"],
|
||||
},
|
||||
{ duration: 0.2 },
|
||||
);
|
||||
|
||||
animate(
|
||||
transitionContainer,
|
||||
{ opacity: [1, 0] },
|
||||
{ duration: 0.2, delay: 0.3 },
|
||||
);
|
||||
|
||||
sourceElement.style.opacity = "1";
|
||||
sourceElement.style.transform = "";
|
||||
|
||||
modal.style.pointerEvents = "none";
|
||||
|
||||
animate(
|
||||
transitionContainer,
|
||||
{
|
||||
left: [targetLeft + scrollX, sourceLeft + scrollX],
|
||||
top: [targetTop, sourceTop + scrollY],
|
||||
width: [targetWidth, sourceWidth],
|
||||
height: [targetHeight, sourceHeight],
|
||||
scale: [1, 1],
|
||||
},
|
||||
{
|
||||
duration: 0.35,
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 35,
|
||||
},
|
||||
).finished.then(async () => {
|
||||
modal.remove();
|
||||
sourceElement.removeAttribute("data-transitioning");
|
||||
});
|
||||
};
|
||||
|
||||
closeBtn?.addEventListener("click", closeModal);
|
||||
modal?.addEventListener("click", (e) => {
|
||||
if (e.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
closeModal();
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
|
||||
const handleResize = () => {
|
||||
const newSourceRect = sourceElement.getBoundingClientRect();
|
||||
const newScrollY = Math.round(window.scrollY);
|
||||
const newScrollX = Math.round(window.scrollX);
|
||||
|
||||
const computedStyle = getComputedStyle(sourceElement);
|
||||
const transform = computedStyle.transform;
|
||||
let scaleX = 1,
|
||||
scaleY = 1;
|
||||
|
||||
if (transform && transform !== "none") {
|
||||
const matrix = transform.match(/matrix.*\((.+)\)/);
|
||||
if (matrix) {
|
||||
const values = matrix[1].split(", ");
|
||||
scaleX = parseFloat(values[0]);
|
||||
scaleY = parseFloat(values[3]);
|
||||
}
|
||||
}
|
||||
|
||||
const newSourceWidth = newSourceRect.width / scaleX;
|
||||
const newSourceHeight = newSourceRect.height / scaleY;
|
||||
|
||||
const deltaX = (newSourceWidth - newSourceRect.width) / 2;
|
||||
const deltaY = (newSourceHeight - newSourceRect.height) / 2;
|
||||
|
||||
const newSourceLeft = newSourceRect.left - deltaX;
|
||||
const newSourceTop = newSourceRect.top - deltaY;
|
||||
|
||||
const newViewportWidth = window.innerWidth;
|
||||
const newViewportHeight = window.innerHeight;
|
||||
const newTargetWidth = Math.round(
|
||||
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
|
||||
);
|
||||
const currentHeight = unifiedContent.getBoundingClientRect().height;
|
||||
const newTargetHeight = Math.round(
|
||||
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
|
||||
);
|
||||
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
|
||||
const newTargetTop =
|
||||
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
|
||||
|
||||
transitionContainer.style.left =
|
||||
Math.round(newTargetLeft + newScrollX) + "px";
|
||||
transitionContainer.style.top = Math.round(newTargetTop) + "px";
|
||||
transitionContainer.style.width = Math.round(newTargetWidth) + "px";
|
||||
transitionContainer.style.height = Math.round(newTargetHeight) + "px";
|
||||
|
||||
sourceLeft = newSourceLeft;
|
||||
sourceTop = newSourceTop;
|
||||
sourceWidth = newSourceWidth;
|
||||
sourceHeight = newSourceHeight;
|
||||
targetLeft = newTargetLeft;
|
||||
targetTop = newTargetTop;
|
||||
targetWidth = newTargetWidth;
|
||||
targetHeight = newTargetHeight;
|
||||
scrollY = newScrollY;
|
||||
scrollX = newScrollX;
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
if (settingsState.animations) {
|
||||
animate(modal, { opacity: [0, 1] }, { duration: 0.2 });
|
||||
|
||||
animate(
|
||||
transitionContainer,
|
||||
{
|
||||
left: [sourceLeft + scrollX, targetLeft + scrollX],
|
||||
top: [sourceTop + scrollY, targetTop],
|
||||
width: [sourceWidth, targetWidth],
|
||||
height: [sourceHeight, targetHeight],
|
||||
scale: [1, 1],
|
||||
},
|
||||
{
|
||||
duration: 0.5,
|
||||
type: "spring",
|
||||
stiffness: 280,
|
||||
damping: 24,
|
||||
},
|
||||
);
|
||||
|
||||
unifiedContent.classList.remove("notice-card-state");
|
||||
unifiedContent.classList.add("notice-modal-state");
|
||||
} else {
|
||||
modal.style.opacity = "1";
|
||||
transitionContainer.style.left = Math.round(targetLeft + scrollX) + "px";
|
||||
transitionContainer.style.top = Math.round(targetTop) + "px";
|
||||
transitionContainer.style.width = Math.round(targetWidth) + "px";
|
||||
transitionContainer.style.height = Math.round(targetHeight) + "px";
|
||||
unifiedContent.classList.remove("notice-card-state");
|
||||
unifiedContent.classList.add("notice-modal-state");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEngageNoticesFromApi(
|
||||
date: string,
|
||||
labelTokens: string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const data = settingsState.mockNotices
|
||||
? getMockNotices()
|
||||
: await (
|
||||
await fetch(`${location.origin}/seqta/parent/load/notices`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ date }),
|
||||
})
|
||||
).json();
|
||||
|
||||
processEngageNotices(data, labelTokens);
|
||||
} catch (e) {
|
||||
console.warn("[BetterSEQTA+] Engage notices request failed:", e);
|
||||
processEngageNotices({ payload: [] }, labelTokens);
|
||||
}
|
||||
}
|
||||
|
||||
function bindEngageNoticesDateInput(
|
||||
labelTokens: string[],
|
||||
initialDate: string,
|
||||
): () => void {
|
||||
const dateControl = document.getElementById(
|
||||
ENGAGE_NOTICES_DATE_ID,
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
if (!dateControl) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
dateControl.value = initialDate;
|
||||
|
||||
const debouncedInputChange = debounce((e: Event) => {
|
||||
void fetchEngageNoticesFromApi(
|
||||
(e.target as HTMLInputElement).value,
|
||||
labelTokens,
|
||||
);
|
||||
}, 250);
|
||||
|
||||
dateControl.addEventListener("input", debouncedInputChange);
|
||||
|
||||
return () => dateControl.removeEventListener("input", debouncedInputChange);
|
||||
}
|
||||
|
||||
async function initEngageNoticesUi(todayFormatted: string): Promise<void> {
|
||||
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
|
||||
if (!noticeContainer) return;
|
||||
|
||||
let labelFilterValues: string[] = [];
|
||||
try {
|
||||
const prefsRes = await fetch(`${location.origin}/seqta/parent/load/prefs?`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ asArray: true, request: "userPrefs" }),
|
||||
});
|
||||
const prefs = await prefsRes.json();
|
||||
const payload = prefs?.payload;
|
||||
if (Array.isArray(payload)) {
|
||||
labelFilterValues = payload
|
||||
.filter((item: { name?: string }) => item.name === "notices.filters")
|
||||
.map((item: { value?: string }) => item.value)
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
}
|
||||
} catch {
|
||||
labelFilterValues = [];
|
||||
}
|
||||
|
||||
const labelTokens =
|
||||
labelFilterValues.length > 0
|
||||
? String(labelFilterValues[0]).split(" ").filter(Boolean)
|
||||
: [];
|
||||
|
||||
const dateControl = document.getElementById(ENGAGE_NOTICES_DATE_ID);
|
||||
if (dateControl) {
|
||||
(dateControl as HTMLInputElement).value = todayFormatted;
|
||||
}
|
||||
|
||||
await fetchEngageNoticesFromApi(todayFormatted, labelTokens);
|
||||
|
||||
const cleanup = bindEngageNoticesDateInput(labelTokens, todayFormatted);
|
||||
engageMergeNoticeCleanup(cleanup);
|
||||
|
||||
noticeContainer.classList.remove("loading");
|
||||
}
|
||||
|
||||
function engageMergeNoticeCleanup(noticeCleanup: () => void): void {
|
||||
const prev = engageListenersCleanup;
|
||||
engageListenersCleanup = () => {
|
||||
prev?.();
|
||||
noticeCleanup();
|
||||
};
|
||||
}
|
||||
|
||||
function showEngageTimetableError(message: string): void {
|
||||
const dayContainer = document.getElementById("engage-day-container");
|
||||
if (!dayContainer) return;
|
||||
dayContainer.classList.remove("loading");
|
||||
dayContainer.innerHTML = `
|
||||
<div class="day-empty">
|
||||
<img src="${extensionAssetUrl(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="${extensionAssetUrl(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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { animate, stagger } from "motion";
|
||||
import browser from "webextension-polyfill";
|
||||
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
||||
import assessmentsicon from "@/seqta/icons/assessmentsIcon";
|
||||
import coursesicon from "@/seqta/icons/coursesIcon";
|
||||
@@ -13,6 +12,7 @@ import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
|
||||
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
||||
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
||||
import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip";
|
||||
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||
|
||||
let LessonInterval: any;
|
||||
let currentSelectedDate = new Date();
|
||||
@@ -113,7 +113,16 @@ export async function loadHomePage() {
|
||||
callHomeTimetable(TodayFormatted, true);
|
||||
|
||||
const activeClass = classes.find((c: any) => c.hasOwnProperty("active"));
|
||||
const activeSubjects = activeClass?.subjects || [];
|
||||
const activeYear = activeClass?.year;
|
||||
const allSubjectsInYear = classes
|
||||
.filter((c: any) => c.year === activeYear)
|
||||
.flatMap((c: any) => c.subjects || []);
|
||||
const seen = new Set<string>();
|
||||
const activeSubjects = allSubjectsInYear.filter((s: any) => {
|
||||
if (seen.has(s.code)) return false;
|
||||
seen.add(s.code);
|
||||
return true;
|
||||
});
|
||||
const activeSubjectCodes = activeSubjects.map((s: any) => s.code);
|
||||
const currentAssessments = assessments
|
||||
.filter((a: any) => activeSubjectCodes.includes(a.code))
|
||||
@@ -146,7 +155,7 @@ export async function loadHomePage() {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLight);
|
||||
img.src = extensionAssetUrl(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No notices available.";
|
||||
emptyState.append(img, text);
|
||||
@@ -295,7 +304,7 @@ function processNotices(response: any, labelArray: string[]) {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLight);
|
||||
img.src = extensionAssetUrl(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No notices for today.";
|
||||
emptyState.append(img, text);
|
||||
@@ -307,7 +316,7 @@ function processNotices(response: any, labelArray: string[]) {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLight);
|
||||
img.src = extensionAssetUrl(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No notices for today.";
|
||||
emptyState.append(img, text);
|
||||
@@ -726,7 +735,7 @@ function callHomeTimetable(date: string, change?: any) {
|
||||
const dummyDay = document.createElement("div");
|
||||
dummyDay.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLight);
|
||||
img.src = extensionAssetUrl(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No lessons available.";
|
||||
dummyDay.append(img, text);
|
||||
@@ -978,7 +987,7 @@ async function CreateUpcomingSection(assessments: any, activeSubjects: any) {
|
||||
if (assessments.length === 0) {
|
||||
upcomingitemcontainer!.innerHTML = `
|
||||
<div class="day-empty">
|
||||
<img src="${browser.runtime.getURL(LogoLight)}" />
|
||||
<img src="${extensionAssetUrl(LogoLight)}" />
|
||||
<p>No assessments available.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
/** Monday–Sunday 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">×</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 { settingsState } from "../listeners/SettingsState";
|
||||
import { openPopup } from "./PopupManager";
|
||||
import { attachPopupMediaFullscreenIfPresent } from "./attachPopupMediaFullscreen";
|
||||
|
||||
export function showPrivacyNotification() {
|
||||
const lastUpdated = "2025-12-19";
|
||||
const PRIVACY_STATEMENT_VERSION = "2025-12-19";
|
||||
|
||||
if (document.getElementById("whatsnewbk")) return;
|
||||
if (settingsState.privacyStatementShown) return;
|
||||
if (settingsState.privacyStatementLastUpdated && new Date(settingsState.privacyStatementLastUpdated) > new Date(lastUpdated)) return;
|
||||
export function shouldShowPrivacyNotification(): boolean {
|
||||
if (settingsState.privacyStatementShown) return false;
|
||||
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(
|
||||
/* html */
|
||||
@@ -42,11 +60,14 @@ export function showPrivacyNotification() {
|
||||
</div>
|
||||
`).firstChild as HTMLElement;
|
||||
|
||||
attachPopupMediaFullscreenIfPresent(text, "img.aboutImg");
|
||||
|
||||
settingsState.privacyStatementLastUpdated = "2025-12-20";
|
||||
settingsState.privacyStatementShown = true;
|
||||
|
||||
openPopup({
|
||||
header,
|
||||
content: [text],
|
||||
afterClose: onDismissed,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import stringToHTML from "../stringToHTML";
|
||||
import browser from "webextension-polyfill";
|
||||
import kofi from "@/resources/kofi.png?base64";
|
||||
import { openPopup } from "./PopupManager";
|
||||
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
|
||||
|
||||
export function OpenWhatsNewPopup() {
|
||||
export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
||||
const header = stringToHTML(
|
||||
/* html */
|
||||
`<div class="whatsnewHeader">
|
||||
@@ -20,7 +21,7 @@ export function OpenWhatsNewPopup() {
|
||||
|
||||
source.setAttribute(
|
||||
"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.muted = true;
|
||||
@@ -28,10 +29,38 @@ export function OpenWhatsNewPopup() {
|
||||
video.appendChild(source);
|
||||
video.classList.add("whatsnewImg");
|
||||
imageContainer.appendChild(video);
|
||||
attachPopupMediaFullscreen(video);
|
||||
|
||||
const text = stringToHTML(/* html */ `
|
||||
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
@@ -349,5 +378,7 @@ export function OpenWhatsNewPopup() {
|
||||
openPopup({
|
||||
header,
|
||||
content: [imageContainer, text, footer],
|
||||
afterClose: onDismissed,
|
||||
clearJustUpdated: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@ import { animate as motionAnimate, stagger } from "motion";
|
||||
type AnimationTarget = string | Element | Element[] | NodeList | null;
|
||||
|
||||
let isClosing = false;
|
||||
let pendingAfterClose: (() => void) | undefined;
|
||||
|
||||
function invokeAfterClose() {
|
||||
const fn = pendingAfterClose;
|
||||
pendingAfterClose = undefined;
|
||||
fn?.();
|
||||
}
|
||||
|
||||
export async function closePopup() {
|
||||
if (isClosing) return;
|
||||
@@ -16,12 +23,14 @@ export async function closePopup() {
|
||||
|
||||
if (!background || !popup) {
|
||||
isClosing = false;
|
||||
invokeAfterClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settingsState.animations) {
|
||||
background.remove();
|
||||
isClosing = false;
|
||||
invokeAfterClose();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,19 +42,28 @@ export async function closePopup() {
|
||||
|
||||
background.remove();
|
||||
isClosing = false;
|
||||
invokeAfterClose();
|
||||
}
|
||||
|
||||
interface OpenPopupOptions {
|
||||
header?: Node | null;
|
||||
content?: (Node | null | undefined)[];
|
||||
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({
|
||||
header,
|
||||
content = [],
|
||||
animateSelector = ".whatsnewTextContainer *",
|
||||
afterClose,
|
||||
clearJustUpdated = false,
|
||||
}: OpenPopupOptions = {}) {
|
||||
pendingAfterClose = afterClose;
|
||||
|
||||
const background = document.createElement("div");
|
||||
background.id = "whatsnewbk";
|
||||
background.classList.add("whatsnewBackground");
|
||||
@@ -88,7 +106,9 @@ export function openPopup({
|
||||
}
|
||||
}
|
||||
|
||||
if (clearJustUpdated) {
|
||||
delete settingsState.justupdated;
|
||||
}
|
||||
|
||||
background.addEventListener("click", (event) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { settingsState } from "./listeners/SettingsState";
|
||||
import browser from "webextension-polyfill";
|
||||
import LogoLightOutline from "@/resources/icons/betterseqta-light-outline.png";
|
||||
import { animate, stagger } from "motion";
|
||||
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||
|
||||
export async function SendNewsPage() {
|
||||
console.info("[BetterSEQTA+] Started Loading News Page");
|
||||
@@ -58,7 +59,7 @@ export async function SendNewsPage() {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLightOutline);
|
||||
img.src = extensionAssetUrl(LogoLightOutline);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No news articles available right now.";
|
||||
emptyState.append(img, text);
|
||||
@@ -79,7 +80,7 @@ export async function SendNewsPage() {
|
||||
|
||||
if (article.urlToImage == "null" || article.urlToImage == null) {
|
||||
articleimage.style.cssText = `
|
||||
background-image: url(${browser.runtime.getURL(LogoLightOutline)});
|
||||
background-image: url(${extensionAssetUrl(LogoLightOutline)});
|
||||
width: 20%;
|
||||
margin: 0 7.5%;
|
||||
`;
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
|
||||
import {
|
||||
isBetterseqtaWasmReady,
|
||||
normalizeSeqtaSubjectHexColour,
|
||||
parseSeqtaCoursesAssessmentsPageJson,
|
||||
} from "@/wasm/init";
|
||||
|
||||
/**
|
||||
* Parses the current page from window.location.hash.
|
||||
* Returns { programme, metaclass } for /courses/SEMESTER/X:Y or /assessments/SEMESTER/X:Y, or null.
|
||||
* e.g. #?page=/courses/2023S/4804:11066 or #?page=/assessments/2023S/4621:10772
|
||||
* Supports both old and current URL formats, e.g.
|
||||
* /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 {
|
||||
const hash = window.location.hash || "";
|
||||
const match = hash.match(/[?&]page=\/(courses|assessments)\/[^/]+\/(\d+):(\d+)/);
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
const json = parseSeqtaCoursesAssessmentsPageJson(hash);
|
||||
if (json) {
|
||||
const o = JSON.parse(json) as { programme: number; metaclass: number };
|
||||
if (
|
||||
typeof o.programme === "number" &&
|
||||
typeof o.metaclass === "number" &&
|
||||
!Number.isNaN(o.programme) &&
|
||||
!Number.isNaN(o.metaclass)
|
||||
) {
|
||||
return { programme: o.programme, metaclass: o.metaclass };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const match = hash.match(/[?&]page=\/(courses|assessments)\/(?:[^/]+\/)?(\d+):(\d+)/);
|
||||
if (!match) return null;
|
||||
const programme = parseInt(match[2], 10);
|
||||
const metaclass = parseInt(match[3], 10);
|
||||
@@ -107,6 +135,14 @@ export async function getAdaptiveColour(): Promise<string | null> {
|
||||
const colour = await getSubjectColour(subjectCode, userId);
|
||||
if (!colour || typeof colour !== "string") return null;
|
||||
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
const normalized = normalizeSeqtaSubjectHexColour(colour);
|
||||
if (normalized) return normalized;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
// Basic hex validation
|
||||
if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(colour)) return colour;
|
||||
if (/^[0-9A-Fa-f]{6}$/.test(colour)) return `#${colour}`;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
const base64ToBlob = (base64: string, contentType: string = ""): Blob => {
|
||||
import { decodeBase64, isBetterseqtaWasmReady } from "@/wasm/init";
|
||||
|
||||
const base64ToBlobTs = (base64: string, contentType: string = ""): Blob => {
|
||||
const byteCharacters = atob(base64);
|
||||
const byteArrays: Uint8Array[] = [];
|
||||
|
||||
@@ -14,4 +16,15 @@ const base64ToBlob = (base64: string, contentType: string = ""): Blob => {
|
||||
return new Blob(byteArrays, { type: contentType });
|
||||
};
|
||||
|
||||
const base64ToBlob = (base64: string, contentType: string = ""): Blob => {
|
||||
const trimmed = base64.trim();
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
const bytes = decodeBase64(trimmed);
|
||||
if (bytes.byteLength > 0 || trimmed.length === 0) {
|
||||
return new Blob([bytes], { type: contentType });
|
||||
}
|
||||
}
|
||||
return base64ToBlobTs(trimmed, contentType);
|
||||
};
|
||||
|
||||
export default base64ToBlob;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const blobToBase64 = (blob: Blob) => {
|
||||
import { encodeDataUrl, isBetterseqtaWasmReady } from "@/wasm/init";
|
||||
|
||||
function readAsDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
@@ -8,4 +10,21 @@ export const blobToBase64 = (blob: Blob) => {
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export const blobToBase64 = (blob: Blob) => {
|
||||
return (async () => {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
const buf = await blob.arrayBuffer();
|
||||
return encodeDataUrl(
|
||||
blob.type || "application/octet-stream",
|
||||
new Uint8Array(buf),
|
||||
);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return readAsDataUrl(blob);
|
||||
})();
|
||||
};
|
||||
|
||||
@@ -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 page’s
|
||||
* ThemeManager reads and clears it (SW cannot share localforage/IndexedDB with the page).
|
||||
*/
|
||||
export const BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY =
|
||||
"bsplus_pending_theme_ensure_after_cloud";
|
||||
|
||||
/**
|
||||
* Never uploaded to the cloud backup (OAuth and legacy keys).
|
||||
* IndexedDB (e.g. Global Search’s `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);
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
export function convertTo12HourFormat(
|
||||
import {
|
||||
convertTo12HourFormatWasm,
|
||||
isBetterseqtaWasmReady,
|
||||
} from "@/wasm/init";
|
||||
|
||||
function convertTo12HourFormatTs(
|
||||
time: string,
|
||||
noMinutes: boolean = false,
|
||||
): string {
|
||||
@@ -19,3 +24,18 @@ export function convertTo12HourFormat(
|
||||
|
||||
return `${hoursStr}${noMinutes ? "" : `:${minutes.toString().padStart(2, "0")}`}${period}`;
|
||||
}
|
||||
|
||||
/** 12-hour time label; Rust/WASM when initialized, else TypeScript. */
|
||||
export function convertTo12HourFormat(
|
||||
time: string,
|
||||
noMinutes: boolean = false,
|
||||
): string {
|
||||
if (!isBetterseqtaWasmReady()) {
|
||||
return convertTo12HourFormatTs(time, noMinutes);
|
||||
}
|
||||
try {
|
||||
return convertTo12HourFormatWasm(time, noMinutes);
|
||||
} catch {
|
||||
return convertTo12HourFormatTs(time, noMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
isBetterseqtaWasmReady,
|
||||
parseEngageRoutePage,
|
||||
} from "@/wasm/init";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return parseEngageRoutePage(
|
||||
window.location.hash,
|
||||
window.location.href,
|
||||
);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
function hasExtensionRuntimeGetUrl(): boolean {
|
||||
const runtime = (globalThis as any)?.chrome?.runtime;
|
||||
return (
|
||||
!!runtime &&
|
||||
typeof runtime.getURL === "function" &&
|
||||
typeof runtime.id === "string" &&
|
||||
runtime.id.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an extension asset URL safely across content-script and page contexts.
|
||||
* Falls back to the provided path when extension runtime APIs are unavailable.
|
||||
*/
|
||||
export function extensionAssetUrl(path: string): string {
|
||||
if (!path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Already fully-resolved URL.
|
||||
if (
|
||||
path.startsWith("chrome-extension://") ||
|
||||
path.startsWith("moz-extension://") ||
|
||||
path.startsWith("http://") ||
|
||||
path.startsWith("https://") ||
|
||||
path.startsWith("data:") ||
|
||||
path.startsWith("blob:")
|
||||
) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (hasExtensionRuntimeGetUrl()) {
|
||||
return (globalThis as any).chrome.runtime.getURL(path.replace(/^\/+/, ""));
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import {
|
||||
decodeBase64,
|
||||
isBetterseqtaWasmReady,
|
||||
stripDataUrlBase64Payload,
|
||||
} from "@/wasm/init";
|
||||
|
||||
export function base64toblobURL(base64: string) {
|
||||
// Extract base64 data from the data URI
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
const payload = stripDataUrlBase64Payload(base64);
|
||||
const bytes = decodeBase64(payload.trim());
|
||||
if (bytes.byteLength > 0) {
|
||||
const blob = new Blob([bytes], { type: "image/png" });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
|
||||
const base64Index = base64.indexOf(",") + 1;
|
||||
const imageBase64 = base64.substring(base64Index);
|
||||
|
||||
// Convert base64 to blob
|
||||
const byteCharacters = atob(imageBase64);
|
||||
const byteNumbers = new Uint8Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
@@ -12,8 +29,5 @@ export function base64toblobURL(base64: string) {
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type: "image/png" });
|
||||
|
||||
// Convert blob to blob URL
|
||||
const imageUrl = URL.createObjectURL(blob);
|
||||
|
||||
return imageUrl;
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { isBetterseqtaWasmReady, titleIsSeqtaEngage } from "@/wasm/init";
|
||||
|
||||
/** SEQTA Engage (React) uses a different shell from classic SEQTA Learn. */
|
||||
export function isSeqtaEngageExperience(): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return titleIsSeqtaEngage(document.title);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return document.title.includes("SEQTA Engage");
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
OpenMenuOptions,
|
||||
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
||||
|
||||
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
|
||||
import {
|
||||
CloseThemeCreator,
|
||||
OpenThemeCreator,
|
||||
} from "@/plugins/built-in/themes/ThemeCreator";
|
||||
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
|
||||
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
|
||||
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
|
||||
|
||||
const themeManager = ThemeManager.getInstance();
|
||||
|
||||
@@ -34,7 +34,10 @@ export class MessageHandler {
|
||||
case "UpdateThemePreview":
|
||||
if (request?.save == true) {
|
||||
const save = async () => {
|
||||
await themeManager.saveTheme(request.body);
|
||||
await themeManager.saveTheme({
|
||||
...request.body,
|
||||
userEdited: true,
|
||||
});
|
||||
if (request.body.enableTheme) {
|
||||
await themeManager.setTheme(request.body.id);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ export class StorageChangeHandler {
|
||||
settingsState.register("selectedColor", () => void updateAllColors());
|
||||
settingsState.register("adaptiveThemeColour", () => 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("onoff", this.handleOnOffChange.bind(this));
|
||||
settingsState.register("shortcuts", this.handleShortcutsChange.bind(this));
|
||||
|
||||
@@ -1,7 +1,41 @@
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import {
|
||||
formatTimetableTimeLabel,
|
||||
formatTimetableTimeRange,
|
||||
isBetterseqtaWasmReady,
|
||||
locationHashIncludesTimetablePage,
|
||||
} from "@/wasm/init";
|
||||
import { convertTo12HourFormat } from "./convertTo12HourFormat";
|
||||
import { waitForElm } from "./waitForElm";
|
||||
|
||||
function timetableLabel12(original: string, noMinutes: boolean): string {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return formatTimetableTimeLabel(original, noMinutes);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return convertTo12HourFormat(original, noMinutes)
|
||||
.toLowerCase()
|
||||
.replace(" ", "");
|
||||
}
|
||||
|
||||
function timetableRange12(original: string): string | undefined {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
const r = formatTimetableTimeRange(original);
|
||||
if (r) return r;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
if (!original.includes("–") && !original.includes("-")) return undefined;
|
||||
const [start, end] = original.split(/[-–]/).map((p) => p.trim());
|
||||
if (!start || !end) return undefined;
|
||||
return `${timetableLabel12(start, false)}–${timetableLabel12(end, false)}`;
|
||||
}
|
||||
|
||||
let timetableObserver: MutationObserver | null = null;
|
||||
let isOnTimetablePage = false;
|
||||
let isInitialized = false;
|
||||
@@ -19,9 +53,7 @@ function updateTimeElements(): void {
|
||||
const original = el.dataset.original;
|
||||
if (!original) return;
|
||||
|
||||
el.textContent = convertTo12HourFormat(original, true)
|
||||
.toLowerCase()
|
||||
.replace(" ", "");
|
||||
el.textContent = timetableLabel12(original, true);
|
||||
});
|
||||
|
||||
const entryTimes = timetablePage.querySelectorAll<HTMLElement>(".entry .times");
|
||||
@@ -30,30 +62,30 @@ function updateTimeElements(): void {
|
||||
const original = el.dataset.original || "";
|
||||
if (!original.includes("–") && !original.includes("-")) return;
|
||||
|
||||
const [start, end] = original.split(/[-–]/).map((p) => p.trim());
|
||||
if (!start || !end) return;
|
||||
|
||||
const start12 = convertTo12HourFormat(start).toLowerCase().replace(" ", "");
|
||||
const end12 = convertTo12HourFormat(end).toLowerCase().replace(" ", "");
|
||||
el.textContent = `${start12}–${end12}`;
|
||||
const ranged = timetableRange12(original);
|
||||
if (ranged) el.textContent = ranged;
|
||||
});
|
||||
|
||||
const quickbarTimes = document.querySelectorAll<HTMLElement>(".quickbar .meta .times");
|
||||
const quickbarTimes = document.querySelectorAll<HTMLElement>(
|
||||
".quickbar .meta .times",
|
||||
);
|
||||
quickbarTimes.forEach((el) => {
|
||||
if (!el.dataset.original) el.dataset.original = el.textContent || "";
|
||||
const original = el.dataset.original || "";
|
||||
|
||||
if (!original.includes("–") && !original.includes("-")) return;
|
||||
const [start, end] = original.split(/[-–]/).map((p) => p.trim());
|
||||
|
||||
if (!start || !end) return;
|
||||
const start12 = convertTo12HourFormat(start).toLowerCase().replace(" ", "");
|
||||
const end12 = convertTo12HourFormat(end).toLowerCase().replace(" ", "");
|
||||
el.textContent = `${start12}–${end12}`;
|
||||
const ranged = timetableRange12(original);
|
||||
if (ranged) el.textContent = ranged;
|
||||
});
|
||||
}
|
||||
|
||||
function checkIfOnTimetablePage(): boolean {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return locationHashIncludesTimetablePage(window.location.hash);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return window.location.hash.includes("page=/timetable");
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,20 @@ export type CustomTheme = {
|
||||
hideThemeName: boolean;
|
||||
webURL?: 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;
|
||||
/** 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 & {
|
||||
@@ -37,3 +50,18 @@ export type ThemeList = {
|
||||
themes: CustomTheme[];
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ export interface SettingsState {
|
||||
justupdated?: boolean;
|
||||
privacyStatementShown?: boolean;
|
||||
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;
|
||||
animations: boolean;
|
||||
defaultPage: string;
|
||||
@@ -43,6 +47,7 @@ export interface SettingsState {
|
||||
iconOnlySidebar?: boolean;
|
||||
adaptiveThemeColour?: boolean;
|
||||
adaptiveThemeGradient?: boolean;
|
||||
adaptiveThemeColourTransition?: boolean;
|
||||
|
||||
// depreciated keys
|
||||
animatedbk: boolean;
|
||||
@@ -56,6 +61,8 @@ export interface SettingsState {
|
||||
bsplus_token?: string;
|
||||
bsplus_refresh_token?: string;
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* wasm-bindgen bundle (`wasm-pack --target web`): call {@link initBetterseqtaWasm}
|
||||
* before using exports that touch the module instance.
|
||||
*/
|
||||
import wasmInit, {
|
||||
childTextHasSeqtaCopyright,
|
||||
colorCssThresholdDistance,
|
||||
convertTo12HourFormat as convertTo12HourFormatWasm,
|
||||
decodeBase64,
|
||||
encodeBase64,
|
||||
encodeDataUrl,
|
||||
escapeJsForInlineScript,
|
||||
escapeJsSingleQuotedString,
|
||||
extensionWasmVersion,
|
||||
extractWeightFromPdfText,
|
||||
formatTimetableTimeLabel,
|
||||
formatTimetableTimeRange,
|
||||
isFirefoxUserAgent,
|
||||
locationHashIncludesTimetablePage,
|
||||
normalizeSeqtaSubjectHexColour,
|
||||
parseEngageRoutePage,
|
||||
parseGradeToPercent,
|
||||
parseSeqtaCoursesAssessmentsPageJson,
|
||||
stripDataUrlBase64Payload,
|
||||
titleIsSeqtaEngage,
|
||||
titleIsSeqtaLearnOrEngage,
|
||||
} from "@/wasm/pkg/betterseqta_wasm.js";
|
||||
|
||||
let wasmReady = false;
|
||||
let wasmInflight: Promise<void> | null = null;
|
||||
|
||||
export async function initBetterseqtaWasm(): Promise<void> {
|
||||
if (wasmReady) return;
|
||||
if (!wasmInflight) {
|
||||
wasmInflight = wasmInit().then(() => {
|
||||
wasmReady = true;
|
||||
});
|
||||
}
|
||||
await wasmInflight;
|
||||
}
|
||||
|
||||
export function isBetterseqtaWasmReady(): boolean {
|
||||
return wasmReady;
|
||||
}
|
||||
|
||||
export {
|
||||
childTextHasSeqtaCopyright,
|
||||
colorCssThresholdDistance,
|
||||
convertTo12HourFormatWasm,
|
||||
decodeBase64,
|
||||
encodeBase64,
|
||||
encodeDataUrl,
|
||||
escapeJsForInlineScript,
|
||||
escapeJsSingleQuotedString,
|
||||
extensionWasmVersion,
|
||||
extractWeightFromPdfText,
|
||||
formatTimetableTimeLabel,
|
||||
formatTimetableTimeRange,
|
||||
isFirefoxUserAgent,
|
||||
locationHashIncludesTimetablePage,
|
||||
normalizeSeqtaSubjectHexColour,
|
||||
parseEngageRoutePage,
|
||||
parseGradeToPercent,
|
||||
parseSeqtaCoursesAssessmentsPageJson,
|
||||
stripDataUrlBase64Payload,
|
||||
titleIsSeqtaEngage,
|
||||
titleIsSeqtaLearnOrEngage,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user