mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b535e87023 | |||
| dd0830d349 | |||
| b4a59330c5 | |||
| 314c555d87 | |||
| 24e38870a9 | |||
| 0878910043 | |||
| ce18412405 | |||
| 4f6c978043 | |||
| 9093553ff1 | |||
| acb2c682f3 | |||
| 3987871a6a | |||
| 9000cb28cd | |||
| 337f85c3cc | |||
| 9e521722f1 | |||
| 2b7c5e17b6 | |||
| 21bc6078d7 | |||
| 6c966a8c38 | |||
| 774be0ceed | |||
| 2356a49fcd | |||
| efbc734d61 | |||
| f5034ca0bc | |||
| a1d95ebd40 | |||
| 943623e0fb | |||
| a23eda1162 | |||
| 3d13202779 | |||
| 9f263c8c02 | |||
| e8d1dadfa7 | |||
| 7a3cbb50cc | |||
| eb49e8d7f1 | |||
| 65cd0a1c4f | |||
| 0007b55c03 | |||
| 93e0a2b123 | |||
| 1a8f025a04 | |||
| f0358bec07 | |||
| 4f6916d8b3 | |||
| fee79e8623 | |||
| 475b865000 | |||
| 304ce2e128 | |||
| 0bc6beb0f1 | |||
| 68173a8b75 | |||
| 7583d0ee47 | |||
| 6c79fe3588 | |||
| c0a8a76105 | |||
| 6ad214bb09 | |||
| b4598668d4 | |||
| 01e679eab6 | |||
| f57bd107b9 | |||
| aa5d193e55 | |||
| da5bc7ab11 | |||
| b0857054eb | |||
| f721bf6609 | |||
| 2aecd63850 | |||
| f35520029f | |||
| 95994fcd3a | |||
| 999f12e958 | |||
| 260afac294 | |||
| 678a958351 | |||
| 608fc96c4e | |||
| 577478ba7e | |||
| 23ccac4836 | |||
| f6472ea9bd | |||
| f3f4491f04 | |||
| c987e4d54e | |||
| cc7f2bc634 | |||
| 189a30a611 | |||
| 710c03f463 | |||
| a875f35f1a | |||
| e2cf9afbf9 | |||
| a1131cf6cd | |||
| 3f493ac716 | |||
| e64ef7f95c | |||
| c118b5b8dd | |||
| 6535ec6932 | |||
| fba5d09c75 | |||
| b88d29967d | |||
| 1b87d20a27 | |||
| 7bd3158b05 | |||
| 7a4fa1e5bf | |||
| f7d9199500 | |||
| 01cd5d1428 | |||
| 5178408f39 | |||
| 0b51db5434 | |||
| 9c47fa38ae | |||
| 5c4d7e1be3 | |||
| acbbac8266 | |||
| fa8f36f3d5 | |||
| fcc856e798 | |||
| 44116edca5 | |||
| 37be31859f | |||
| 10667f17b4 | |||
| 0ca0c7cf43 | |||
| 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 | |||
| 834d585ac7 | |||
| 343fa7ca9f | |||
| e049f34a5e | |||
| d692f60291 | |||
| a0367be686 | |||
| aa6b15aa1b | |||
| 08342c3873 | |||
| 6527a33e38 | |||
| 43f125e45d | |||
| 49cc1e26c0 | |||
| 809a82f31d |
+8
-6
@@ -6,11 +6,9 @@ pnpm-lock.yaml
|
|||||||
yarn.lock
|
yarn.lock
|
||||||
bun.lock
|
bun.lock
|
||||||
|
|
||||||
.parcel-cache
|
# PDF.js extension assets (copied by postinstall from pdfjs-dist)
|
||||||
.env
|
src/public/resources/pdfjs/pdf.worker.min.mjs
|
||||||
.env.submit
|
src/public/resources/pdfjs/pdf.legacy.min.mjs
|
||||||
|
|
||||||
dependency-graph.svg
|
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
extension.zip
|
extension.zip
|
||||||
@@ -20,5 +18,9 @@ betterseqtaplus-safari/
|
|||||||
|
|
||||||
.million/
|
.million/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
|
.parcel-cache
|
||||||
|
.env
|
||||||
|
.env.submit
|
||||||
|
dependency-graph.svg
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -6,9 +6,9 @@ Hey there! 👋 Thanks for your interest in contributing to BetterSEQTA+! We're
|
|||||||
|
|
||||||
**Never contributed to an open source project before?** No worries! We've made it super easy to get started:
|
**Never contributed to an open source project before?** No worries! We've made it super easy to get started:
|
||||||
|
|
||||||
- **📖 Read our [Getting Started Guide](./docs/GETTING_STARTED_CONTRIBUTING.md)** - This walks you through everything step-by-step, from setting up your development environment to making your first pull request.
|
- **📖 Read our [contributing guide](https://docs.betterseqta.org/contributing/)** - This walks you through everything step-by-step, from setting up your development environment to making your first pull request.
|
||||||
- **🏗️ Understand the codebase** with our [Architecture Guide](./docs/ARCHITECTURE.md)
|
- **🏗️ Understand the codebase** with the [architecture guide](https://docs.betterseqta.org/architecture/)
|
||||||
- **🔧 Having issues?** Check our [Troubleshooting Guide](./docs/TROUBLESHOOTING.md)
|
- **🔧 Having issues?** Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
|
||||||
|
|
||||||
We have lots of [`good first issue`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/good%20first%20issue) labels that are perfect for beginners!
|
We have lots of [`good first issue`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/good%20first%20issue) labels that are perfect for beginners!
|
||||||
|
|
||||||
@@ -33,8 +33,8 @@ Join our community channels to discuss the project, get help, and connect with o
|
|||||||
|
|
||||||
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
|
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
|
||||||
|
|
||||||
- [Creating Your First Plugin](./docs/plugins/creating-plugins.md)
|
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
|
||||||
- [Plugin API Reference](./docs/advanced/plugin-api.md)
|
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
|
||||||
|
|
||||||
## Pull Request Process
|
## Pull Request Process
|
||||||
|
|
||||||
|
|||||||
@@ -16,17 +16,15 @@
|
|||||||
<img src="https://img.shields.io/chrome-web-store/rating/afdgaoaclhkhemfkkkonemoapeinchel" />
|
<img src="https://img.shields.io/chrome-web-store/rating/afdgaoaclhkhemfkkkonemoapeinchel" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Table of contents
|
## 📚 Documentation
|
||||||
|
All documentation has been moved to the [official docs site](https://docs.betterseqta.org):
|
||||||
|
|
||||||
- [Features](#features)
|
Includes:
|
||||||
- [Creating Custom Themes](#creating-custom-themes)
|
- Getting started
|
||||||
- [Getting Started](#getting-started)
|
- Development setup
|
||||||
- [Running Development](#running-development)
|
- Architecture
|
||||||
- [Building for production](#building-for-production)
|
- Plugin system
|
||||||
- [Folder Structure](#folder-structure)
|
- Theme creation
|
||||||
- [Contributors](#contributors)
|
|
||||||
- [Credits](#credits)
|
|
||||||
- [Star History](#star-history)
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -50,64 +48,32 @@
|
|||||||
|
|
||||||
## Creating Custom Themes
|
## Creating Custom Themes
|
||||||
|
|
||||||
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://betterseqta.gitbook.io/betterseqta-docs). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
|
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://docs.betterseqta.org/theme-creation/). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
|
||||||
|
|
||||||
Don't worry- if you get stuck feel free to ask around in the [discord](https://discord.gg/YzmbnCDkat). We're open and happy to help out! Happy creating :)
|
Don't worry- if you get stuck feel free to ask around in the [discord](https://discord.gg/YzmbnCDkat). We're open and happy to help out! Happy creating :)
|
||||||
|
|
||||||
## 🚀 Want to Contribute?
|
## 🚀 Contributing
|
||||||
|
**New contributors welcome!**
|
||||||
|
- 📖 Start here: https://docs.betterseqta.org/install/
|
||||||
|
- 🧠 Architecture: https://docs.betterseqta.org/architecture/
|
||||||
|
- 🧩 Plugins: https://docs.betterseqta.org/plugins/
|
||||||
|
- 💬 Discord: https://discord.gg/YzmbnCDkat
|
||||||
|
|
||||||
**New contributors welcome!** 🎉 We've made it easy to get started:
|
|
||||||
|
|
||||||
- **👋 New to the project?** Start with our [Getting Started Guide](./docs/GETTING_STARTED_CONTRIBUTING.md)
|
## ⚡ Quick Start
|
||||||
- **🏗️ Want to understand the code?** Check out our [Architecture Guide](./docs/ARCHITECTURE.md)
|
|
||||||
- **🧩 Interested in plugins?** Read our [Plugin Development Guide](./docs/plugins/README.md)
|
|
||||||
- **🐛 Found a bug?** Open an [issue](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) or fix it yourself!
|
|
||||||
- **💬 Need help?** Join our [Discord community](https://discord.gg/YzmbnCDkat)
|
|
||||||
|
|
||||||
We have lots of https://github.com/BetterSEQTA/BetterSEQTA-Plus/labels/good%20first%20issue labels perfect for beginners!
|
|
||||||
|
|
||||||
## Quick Development Setup
|
|
||||||
|
|
||||||
**1. Fork & Clone**
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/YOUR_USERNAME/BetterSEQTA-Plus
|
git clone https://github.com/YOUR_USERNAME_FORKED_WITH/BetterSEQTA-Plus
|
||||||
cd BetterSEQTA-Plus
|
cd BetterSEQTA-Plus
|
||||||
```
|
|
||||||
|
|
||||||
**2. Install & Run**
|
|
||||||
```bash
|
|
||||||
npm install --legacy-peer-deps
|
npm install --legacy-peer-deps
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
````
|
||||||
|
|
||||||
**3. Load in Browser**
|
Then load `dist` in `chrome://extensions` (Developer Mode → Load unpacked).
|
||||||
1. Go to `chrome://extensions`
|
|
||||||
2. Enable "Developer mode"
|
|
||||||
3. Click "Load unpacked" → Select `dist` folder
|
|
||||||
4. Visit a SEQTA page to see it work! 🎉
|
|
||||||
> [!WARNING]
|
|
||||||
> Whenever you update the extension while not in dev mode, you will need to use the reload button on the extension page.
|
|
||||||
|
|
||||||
📚 **Need more details?** Check our [detailed setup guide](./docs/GETTING_STARTED_CONTRIBUTING.md#your-first-30-minutes)
|
|
||||||
|
|
||||||
### Building for Production
|
Full setup guide:
|
||||||
|
[https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment](https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment)
|
||||||
```bash
|
|
||||||
npm run build # Build for all browsers
|
|
||||||
npm run zip # Package for distribution (requires 7-Zip)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Folder Structure
|
|
||||||
|
|
||||||
The folder structure is as follows:
|
|
||||||
|
|
||||||
- The `src` folder contains source files that are compiled to the build directory.
|
|
||||||
|
|
||||||
- The `src/plugins` folder contains vital loaders required for BetterSEQTA+ functionality.
|
|
||||||
|
|
||||||
- The `src/interface` folder contains source React & Svelte files that are required for the Settings page.
|
|
||||||
|
|
||||||
- The `dist` folder is where the compiled code ends up, this is the folder what you need to load into chrome as an unpacked extension for development.
|
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
@@ -115,11 +81,14 @@ The folder structure is as follows:
|
|||||||
<img src="https://contrib.rocks/image?repo=betterseqta/betterseqta-plus" />
|
<img src="https://contrib.rocks/image?repo=betterseqta/betterseqta-plus" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plus/blob/main/CONTRIBUTING.md)
|
Want to contribute? [Click Here!](https://docs.betterseqta.org/contributing/)
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers.
|
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers.
|
||||||
|
Originally developed by [Nulkem](https://github.com/Nulkem/betterseqta)
|
||||||
|
Ported to Manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68)
|
||||||
|
Maintained by [SethBurkart123](https://github.com/SethBurkart123), [Crazypersonalph](https://github.com/Crazypersonalph) & the rest of the BetterSEQTA team!
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "betterseqtaplus",
|
"name": "betterseqtaplus",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bedframe/core": "^0.0.46",
|
"@bedframe/core": "^0.1.0",
|
||||||
"@codemirror/autocomplete": "^6.18.6",
|
"@codemirror/autocomplete": "^6.18.6",
|
||||||
"@codemirror/commands": "^6.8.0",
|
"@codemirror/commands": "^6.8.0",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
@@ -13,13 +13,14 @@
|
|||||||
"@codemirror/search": "^6.5.10",
|
"@codemirror/search": "^6.5.10",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.36.4",
|
"@codemirror/view": "^6.36.4",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tsconfig/svelte": "^5.0.4",
|
"@tsconfig/svelte": "^5.0.4",
|
||||||
"@types/chrome": "^0.1.4",
|
"@types/chrome": "^0.1.4",
|
||||||
"@types/color": "^4.2.0",
|
"@types/color": "^4.2.0",
|
||||||
"@types/lodash": "^4.17.16",
|
"@types/lodash": "^4.17.16",
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^24.3.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/sortablejs": "^1.15.8",
|
"@types/sortablejs": "^1.15.8",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/webextension-polyfill": "^0.12.3",
|
"@types/webextension-polyfill": "^0.12.3",
|
||||||
@@ -45,22 +46,23 @@
|
|||||||
"motion": "^12.4.12",
|
"motion": "^12.4.12",
|
||||||
"pdfjs-dist": "^5.4.530",
|
"pdfjs-dist": "^5.4.530",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "17",
|
"react": "17",
|
||||||
"react-best-gradient-color-picker": "3.0.11",
|
"react-best-gradient-color-picker": "3.0.11",
|
||||||
"react-dom": "17",
|
"react-dom": "17",
|
||||||
"rss-parser": "^3.13.0",
|
"rss-parser": "^3.13.0",
|
||||||
"sortablejs": "^1.15.6",
|
"sortablejs": "^1.15.6",
|
||||||
"svelte": "^5.22.6",
|
"svelte": "^5.46.4",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vite": "^6.2.1",
|
"vite": "^8.0.5",
|
||||||
"webextension-polyfill": "^0.12.0",
|
"webextension-polyfill": "^0.12.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-transform-runtime": "^7.26.9",
|
"@babel/plugin-transform-runtime": "^7.26.9",
|
||||||
"@babel/runtime": "^7.26.9",
|
"@babel/runtime": "^7.26.9",
|
||||||
"@bedframe/cli": "^0.0.95",
|
"@bedframe/cli": "^0.1.2",
|
||||||
"@crxjs/vite-plugin": "^2.2.0",
|
"@crxjs/vite-plugin": "^2.4.0",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
@@ -71,7 +73,7 @@
|
|||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
"publish-browser-extension": "^4.0.3",
|
"publish-browser-extension": "^4.0.4",
|
||||||
"sass": "^1.85.1",
|
"sass": "^1.85.1",
|
||||||
"sass-loader": "^16.0.5",
|
"sass-loader": "^16.0.5",
|
||||||
"semver": "^7.7.1",
|
"semver": "^7.7.1",
|
||||||
@@ -125,9 +127,9 @@
|
|||||||
|
|
||||||
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
||||||
|
|
||||||
"@bedframe/cli": ["@bedframe/cli@0.0.95", "", { "dependencies": { "@bedframe/core": "0.0.46", "commander": "14.0.0", "execa": "9.6.0", "kolorist": "1.8.0", "listr": "0.14.3", "nanospinner": "1.2.2", "node-fetch": "3.3.2", "pkg-install": "1.0.0", "prompts": "2.4.2", "vite": "6.3.5" }, "peerDependencies": { "concurrently": "8.2.2" }, "bin": { "bedframe": "dist/bedframe.js", "create-bedframe": "dist/create-bedframe.js" } }, "sha512-WSb0HhHCfH7/tS5dDC/HL/VgKrIFGLmI0OesmcwQntrJdHVirtjrDVjcTFG3lC3LB5Ax85P1CY50Gy5aDNc0sQ=="],
|
"@bedframe/cli": ["@bedframe/cli@0.1.2", "", { "dependencies": { "@bedframe/core": "0.1.0", "commander": "^14.0.2", "execa": "^9.6.1", "kolorist": "^1.8.0", "listr": "^0.14.3", "nanospinner": "^1.2.2", "node-fetch": "^3.3.2", "pkg-install": "^1.0.0", "prompts": "^2.4.2", "vite": "^6.4.1" }, "peerDependencies": { "concurrently": "^8.2.1" }, "bin": { "bedframe": "dist/bedframe.js", "create-bedframe": "dist/create-bedframe.js" } }, "sha512-nu0VSfGLhY9f62w+fDRQi2YnfoY9c6u28ZlJ8rH6f57ItLo5TNrZetfw37fYNnh8yK2RSAWU7+6KCkdVm0Fokg=="],
|
||||||
|
|
||||||
"@bedframe/core": ["@bedframe/core@0.0.46", "", { "dependencies": { "@crxjs/vite-plugin": "2.0.2" }, "peerDependencies": { "vite-plugin-dts": "3.9.1", "vite-plugin-externalize-deps": "0.7.0", "vitest": "0.34.6" } }, "sha512-cOshFUrBksWnVQ08chunlvAetwhuytkX7NdH6blNNylYzsgCaLGBbCJ2EZ0d18kimFVNZoODrc+812if5dio/w=="],
|
"@bedframe/core": ["@bedframe/core@0.1.0", "", { "dependencies": { "@crxjs/vite-plugin": "2.3.0" }, "peerDependencies": { "vite-plugin-dts": "^3.7.0", "vite-plugin-externalize-deps": "^0.7.0", "vitest": "^0.34.6" } }, "sha512-bM9vuYG67m9lVTui966AmkoxPPdEHEDOKKjzAWV/Ymgur818fRhMMpblx3+PLs8kTCek1m79fjYKoE8PhqJ22g=="],
|
||||||
|
|
||||||
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "6.11.3", "@codemirror/state": "6.5.2", "@codemirror/view": "6.38.1", "@lezer/common": "1.2.3" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="],
|
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "6.11.3", "@codemirror/state": "6.5.2", "@codemirror/view": "6.38.1", "@lezer/common": "1.2.3" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="],
|
||||||
|
|
||||||
@@ -145,7 +147,13 @@
|
|||||||
|
|
||||||
"@codemirror/view": ["@codemirror/view@6.38.1", "", { "dependencies": { "@codemirror/state": "6.5.2", "crelt": "1.0.6", "style-mod": "4.1.2", "w3c-keyname": "2.2.8" } }, "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ=="],
|
"@codemirror/view": ["@codemirror/view@6.38.1", "", { "dependencies": { "@codemirror/state": "6.5.2", "crelt": "1.0.6", "style-mod": "4.1.2", "w3c-keyname": "2.2.8" } }, "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ=="],
|
||||||
|
|
||||||
"@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.2.0", "", { "dependencies": { "@rollup/pluginutils": "4.2.1", "@webcomponents/custom-elements": "1.6.0", "acorn-walk": "8.3.4", "cheerio": "1.1.2", "convert-source-map": "1.9.0", "debug": "4.4.1", "es-module-lexer": "0.10.5", "fast-glob": "3.3.3", "fs-extra": "10.1.0", "jsesc": "3.1.0", "magic-string": "0.30.18", "pathe": "2.0.3", "picocolors": "1.1.1", "react-refresh": "0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-HpT1GLbUQy42nlpN4sGzFgulacBraMM778s8Q+oPo4cb26DwO9tTwdndlvAS8fe6vEProFXvbdt37objp/0IQA=="],
|
"@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.4.0", "", { "dependencies": { "@rollup/pluginutils": "^4.1.2", "@webcomponents/custom-elements": "^1.5.0", "acorn-walk": "^8.2.0", "convert-source-map": "^1.7.0", "debug": "^4.3.3", "es-module-lexer": "^0.10.0", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "jsesc": "^3.0.2", "magic-string": "^0.30.12", "node-html-parser": "^7.0.2", "pathe": "^2.0.1", "picocolors": "^1.1.1", "react-refresh": "^0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" }, "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bDLdq0W2V1SkMQDJjrcYyjK9/uKtdl4joT7GRImcootCjZdKRiRYt+cv9z8tJoU/tK3o1lX48LTqN7JMsk5AQg=="],
|
||||||
|
|
||||||
|
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||||
|
|
||||||
|
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||||
|
|
||||||
|
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||||
|
|
||||||
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
||||||
|
|
||||||
@@ -291,12 +299,16 @@
|
|||||||
|
|
||||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
||||||
|
|
||||||
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||||
|
|
||||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
|
|
||||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||||
|
|
||||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "1.19.1" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "1.19.1" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||||
|
|
||||||
|
"@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
|
||||||
|
|
||||||
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "1.0.3", "is-glob": "4.0.3", "micromatch": "4.0.8", "node-addon-api": "7.1.1" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
|
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "1.0.3", "is-glob": "4.0.3", "micromatch": "4.0.8", "node-addon-api": "7.1.1" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
|
||||||
|
|
||||||
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
|
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
|
||||||
@@ -347,6 +359,38 @@
|
|||||||
|
|
||||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
|
||||||
|
|
||||||
"@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "2.0.2", "picomatch": "2.3.1" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
|
"@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "2.0.2", "picomatch": "2.3.1" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="],
|
||||||
@@ -407,14 +451,14 @@
|
|||||||
|
|
||||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
|
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
|
||||||
|
|
||||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "4.0.1", "debug": "4.4.1", "deepmerge": "4.3.1", "kleur": "4.1.5", "magic-string": "0.30.18", "vitefu": "1.1.1" }, "peerDependencies": { "svelte": "5.38.6", "vite": "6.3.5" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="],
|
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="],
|
||||||
|
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "5.1.1", "svelte": "5.38.6", "vite": "6.3.5" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
|
|
||||||
|
|
||||||
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.10", "", { "dependencies": { "mini-svg-data-uri": "1.4.4" }, "peerDependencies": { "tailwindcss": "3.4.17" } }, "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw=="],
|
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.10", "", { "dependencies": { "mini-svg-data-uri": "1.4.4" }, "peerDependencies": { "tailwindcss": "3.4.17" } }, "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw=="],
|
||||||
|
|
||||||
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.5", "", {}, "sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ=="],
|
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.5", "", {}, "sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ=="],
|
||||||
|
|
||||||
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
"@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="],
|
"@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="],
|
||||||
|
|
||||||
"@types/chai": ["@types/chai@4.3.20", "", {}, "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ=="],
|
"@types/chai": ["@types/chai@4.3.20", "", {}, "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ=="],
|
||||||
@@ -449,6 +493,8 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
|
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
|
||||||
|
|
||||||
|
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "3.1.3" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
|
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "3.1.3" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
|
||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.1.8", "", { "peerDependencies": { "@types/react": "19.1.12" } }, "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ=="],
|
"@types/react-dom": ["@types/react-dom@19.1.8", "", { "peerDependencies": { "@types/react": "19.1.12" } }, "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ=="],
|
||||||
@@ -523,7 +569,7 @@
|
|||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||||
|
|
||||||
"assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="],
|
"assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="],
|
||||||
|
|
||||||
@@ -579,6 +625,8 @@
|
|||||||
|
|
||||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
|
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||||
|
|
||||||
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001737", "", {}, "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001737", "", {}, "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw=="],
|
||||||
@@ -603,7 +651,7 @@
|
|||||||
|
|
||||||
"cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
|
"cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
|
||||||
|
|
||||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "4.2.3", "strip-ansi": "6.0.1", "wrap-ansi": "7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
@@ -625,7 +673,7 @@
|
|||||||
|
|
||||||
"colors-named-hex": ["colors-named-hex@1.0.2", "", {}, "sha512-k6kq1e1pUCQvSVwIaGFq2l0LrkAPQZWyeuZn1Z8nOiYSEZiKoFj4qx690h2Kd34DFl9Me0gKS6MUwAMBJj8nuA=="],
|
"colors-named-hex": ["colors-named-hex@1.0.2", "", {}, "sha512-k6kq1e1pUCQvSVwIaGFq2l0LrkAPQZWyeuZn1Z8nOiYSEZiKoFj4qx690h2Kd34DFl9Me0gKS6MUwAMBJj8nuA=="],
|
||||||
|
|
||||||
"commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
|
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
"complex.js": ["complex.js@2.4.2", "", {}, "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g=="],
|
"complex.js": ["complex.js@2.4.2", "", {}, "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g=="],
|
||||||
|
|
||||||
@@ -667,6 +715,8 @@
|
|||||||
|
|
||||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||||
|
|
||||||
|
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||||
|
|
||||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||||
|
|
||||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||||
@@ -685,10 +735,14 @@
|
|||||||
|
|
||||||
"detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
"detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
||||||
|
|
||||||
|
"devalue": ["devalue@5.7.1", "", {}, "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA=="],
|
||||||
|
|
||||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||||
|
|
||||||
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
|
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
|
||||||
|
|
||||||
|
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||||
|
|
||||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||||
|
|
||||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "2.3.0", "domhandler": "5.0.3", "entities": "4.5.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "2.3.0", "domhandler": "5.0.3", "entities": "4.5.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
@@ -763,7 +817,7 @@
|
|||||||
|
|
||||||
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
|
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
|
||||||
|
|
||||||
"esrap": ["esrap@2.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA=="],
|
"esrap": ["esrap@2.2.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig=="],
|
||||||
|
|
||||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||||
|
|
||||||
@@ -777,7 +831,7 @@
|
|||||||
|
|
||||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||||
|
|
||||||
"execa": ["execa@9.6.0", "", { "dependencies": { "@sindresorhus/merge-streams": "4.0.0", "cross-spawn": "7.0.6", "figures": "6.1.0", "get-stream": "9.0.1", "human-signals": "8.0.1", "is-plain-obj": "4.1.0", "is-stream": "4.0.1", "npm-run-path": "6.0.0", "pretty-ms": "9.2.0", "signal-exit": "4.1.0", "strip-final-newline": "4.0.0", "yoctocolors": "2.1.2" } }, "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw=="],
|
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||||
|
|
||||||
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||||
|
|
||||||
@@ -985,6 +1039,30 @@
|
|||||||
|
|
||||||
"lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "3.0.6" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
|
"lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "3.0.6" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||||
|
|
||||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||||
|
|
||||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
@@ -1115,6 +1193,8 @@
|
|||||||
|
|
||||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||||
|
|
||||||
|
"node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="],
|
||||||
|
|
||||||
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
|
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
|
||||||
|
|
||||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||||
@@ -1133,6 +1213,8 @@
|
|||||||
|
|
||||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
|
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||||
|
|
||||||
"ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
"ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||||
|
|
||||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
@@ -1157,6 +1239,8 @@
|
|||||||
|
|
||||||
"p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
|
"p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
|
||||||
|
|
||||||
|
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||||
|
|
||||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||||
|
|
||||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "3.1.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "3.1.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||||
@@ -1199,6 +1283,8 @@
|
|||||||
|
|
||||||
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
||||||
|
|
||||||
|
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "4.2.0", "read-cache": "1.0.0", "resolve": "1.22.10" }, "peerDependencies": { "postcss": "8.5.6" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "4.2.0", "read-cache": "1.0.0", "resolve": "1.22.10" }, "peerDependencies": { "postcss": "8.5.6" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||||
@@ -1229,12 +1315,14 @@
|
|||||||
|
|
||||||
"protobufjs": ["protobufjs@6.11.4", "", { "dependencies": { "@protobufjs/aspromise": "1.1.2", "@protobufjs/base64": "1.1.2", "@protobufjs/codegen": "2.0.4", "@protobufjs/eventemitter": "1.1.0", "@protobufjs/fetch": "1.1.0", "@protobufjs/float": "1.0.2", "@protobufjs/inquire": "1.1.0", "@protobufjs/path": "1.1.2", "@protobufjs/pool": "1.1.0", "@protobufjs/utf8": "1.1.0", "@types/long": "4.0.2", "@types/node": "24.3.0", "long": "4.0.0" }, "bin": { "pbjs": "bin/pbjs", "pbts": "bin/pbts" } }, "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw=="],
|
"protobufjs": ["protobufjs@6.11.4", "", { "dependencies": { "@protobufjs/aspromise": "1.1.2", "@protobufjs/base64": "1.1.2", "@protobufjs/codegen": "2.0.4", "@protobufjs/eventemitter": "1.1.0", "@protobufjs/fetch": "1.1.0", "@protobufjs/float": "1.0.2", "@protobufjs/inquire": "1.1.0", "@protobufjs/path": "1.1.2", "@protobufjs/pool": "1.1.0", "@protobufjs/utf8": "1.1.0", "@types/long": "4.0.2", "@types/node": "24.3.0", "long": "4.0.0" }, "bin": { "pbjs": "bin/pbjs", "pbts": "bin/pbts" } }, "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw=="],
|
||||||
|
|
||||||
"publish-browser-extension": ["publish-browser-extension@4.0.3", "", { "dependencies": { "cac": "^6.7.14", "consola": "^3.4.2", "dotenv": "^17.2.4", "form-data-encoder": "^4.1.0", "formdata-node": "^6.0.3", "jsonwebtoken": "^9.0.3", "listr2": "^10.1.0", "ofetch": "^1.5.1", "zod": "3.25.76 || ^4.3.6" }, "bin": { "publish-extension": "bin/publish-extension.mjs" } }, "sha512-yhzn+0z0tOYSsouEVCn6BHd3PPEc6KKplEVDEmxCOAMXC0C7NROEiJcmWm5LGGgqw3TqBvPxiink1juPrEbMqA=="],
|
"publish-browser-extension": ["publish-browser-extension@4.0.4", "", { "dependencies": { "cac": "^6.7.14", "consola": "^3.4.2", "dotenv": "^17.2.4", "form-data-encoder": "^4.1.0", "formdata-node": "^6.0.3", "jsonwebtoken": "^9.0.3", "listr2": "^10.1.0", "ofetch": "^1.5.1", "zod": "3.25.76 || ^4.3.6" }, "bin": { "publish-extension": "bin/publish-extension.mjs" } }, "sha512-QMQbWL0FWgBfnkJ6w8HOJoIPaWLE7vTpewM4ae2vLs7SrD4eKdAk+SxOzqAICwbhEPuaLAOA+XkT9sZS5R0PmA=="],
|
||||||
|
|
||||||
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "1.4.5", "once": "1.4.0" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "1.4.5", "once": "1.4.0" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||||
|
|
||||||
"punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="],
|
"punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="],
|
||||||
|
|
||||||
|
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||||
|
|
||||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||||
|
|
||||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
@@ -1265,6 +1353,8 @@
|
|||||||
|
|
||||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||||
|
|
||||||
|
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||||
|
|
||||||
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "2.16.1", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "2.16.1", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
||||||
|
|
||||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
@@ -1275,6 +1365,8 @@
|
|||||||
|
|
||||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||||
|
|
||||||
|
"rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
|
||||||
|
|
||||||
"rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="],
|
"rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="],
|
||||||
|
|
||||||
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "2.2.0", "xml2js": "0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
|
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "2.2.0", "xml2js": "0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
|
||||||
@@ -1301,6 +1393,8 @@
|
|||||||
|
|
||||||
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
|
|
||||||
|
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||||
|
|
||||||
"sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "4.2.3", "detect-libc": "2.0.4", "node-addon-api": "6.1.0", "prebuild-install": "7.1.3", "semver": "7.7.2", "simple-get": "4.0.1", "tar-fs": "3.1.0", "tunnel-agent": "0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="],
|
"sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "4.2.3", "detect-libc": "2.0.4", "node-addon-api": "6.1.0", "prebuild-install": "7.1.3", "semver": "7.7.2", "simple-get": "4.0.1", "tar-fs": "3.1.0", "tunnel-agent": "0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="],
|
||||||
|
|
||||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
@@ -1377,7 +1471,7 @@
|
|||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
"svelte": ["svelte@5.38.6", "", { "dependencies": { "@jridgewell/remapping": "2.3.5", "@jridgewell/sourcemap-codec": "1.5.5", "@sveltejs/acorn-typescript": "1.0.5", "@types/estree": "1.0.8", "acorn": "8.15.0", "aria-query": "5.3.2", "axobject-query": "4.1.0", "clsx": "2.1.1", "esm-env": "1.2.2", "esrap": "2.1.0", "is-reference": "3.0.3", "locate-character": "3.0.0", "magic-string": "0.30.18", "zimmerframe": "1.1.2" } }, "sha512-ltBPlkvqk3bgCK7/N323atUpP3O3Y+DrGV4dcULrsSn4fZaaNnOmdplNznwfdWclAgvSr5rxjtzn/zJhRm6TKg=="],
|
"svelte": ["svelte@5.55.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ=="],
|
||||||
|
|
||||||
"symbol-observable": ["symbol-observable@1.2.0", "", {}, "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="],
|
"symbol-observable": ["symbol-observable@1.2.0", "", {}, "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="],
|
||||||
|
|
||||||
@@ -1403,7 +1497,7 @@
|
|||||||
|
|
||||||
"tinycolor2": ["tinycolor2@1.4.2", "", {}, "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="],
|
"tinycolor2": ["tinycolor2@1.4.2", "", {}, "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||||
|
|
||||||
"tinypool": ["tinypool@0.7.0", "", {}, "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww=="],
|
"tinypool": ["tinypool@0.7.0", "", {}, "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww=="],
|
||||||
|
|
||||||
@@ -1457,7 +1551,7 @@
|
|||||||
|
|
||||||
"validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="],
|
"validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="],
|
||||||
|
|
||||||
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "0.25.9", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.49.0", "tinyglobby": "0.2.14" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "jiti": "1.21.7", "sass": "1.91.0", "yaml": "2.8.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
|
"vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
|
||||||
|
|
||||||
"vite-node": ["vite-node@0.34.6", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.1", "mlly": "1.8.0", "pathe": "1.1.2", "picocolors": "1.1.1", "vite": "5.4.19" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA=="],
|
"vite-node": ["vite-node@0.34.6", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.1", "mlly": "1.8.0", "pathe": "1.1.2", "picocolors": "1.1.1", "vite": "5.4.19" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA=="],
|
||||||
|
|
||||||
@@ -1465,7 +1559,7 @@
|
|||||||
|
|
||||||
"vite-plugin-externalize-deps": ["vite-plugin-externalize-deps@0.7.0", "", { "peerDependencies": { "vite": "6.3.5" } }, "sha512-do2gPrR79Tm8UKcqsw3RTAtN4YO8GkVRBckWdJWINZ3Qdp3KN9S1oyUZxKszTB/iyg4zdOUweLOeBI8t86QVow=="],
|
"vite-plugin-externalize-deps": ["vite-plugin-externalize-deps@0.7.0", "", { "peerDependencies": { "vite": "6.3.5" } }, "sha512-do2gPrR79Tm8UKcqsw3RTAtN4YO8GkVRBckWdJWINZ3Qdp3KN9S1oyUZxKszTB/iyg4zdOUweLOeBI8t86QVow=="],
|
||||||
|
|
||||||
"vitefu": ["vitefu@1.1.1", "", { "optionalDependencies": { "vite": "6.3.5" } }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||||
|
|
||||||
"vitest": ["vitest@0.34.6", "", { "dependencies": { "@types/chai": "4.3.20", "@types/chai-subset": "1.3.6", "@types/node": "24.3.0", "@vitest/expect": "0.34.6", "@vitest/runner": "0.34.6", "@vitest/snapshot": "0.34.6", "@vitest/spy": "0.34.6", "@vitest/utils": "0.34.6", "acorn": "8.15.0", "acorn-walk": "8.3.4", "cac": "6.7.14", "chai": "4.5.0", "debug": "4.4.1", "local-pkg": "0.4.3", "magic-string": "0.30.18", "pathe": "1.1.2", "picocolors": "1.1.1", "std-env": "3.9.0", "strip-literal": "1.3.0", "tinybench": "2.9.0", "tinypool": "0.7.0", "vite": "5.4.19", "vite-node": "0.34.6", "why-is-node-running": "2.3.0" }, "bin": { "vitest": "vitest.mjs" } }, "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q=="],
|
"vitest": ["vitest@0.34.6", "", { "dependencies": { "@types/chai": "4.3.20", "@types/chai-subset": "1.3.6", "@types/node": "24.3.0", "@vitest/expect": "0.34.6", "@vitest/runner": "0.34.6", "@vitest/snapshot": "0.34.6", "@vitest/spy": "0.34.6", "@vitest/utils": "0.34.6", "acorn": "8.15.0", "acorn-walk": "8.3.4", "cac": "6.7.14", "chai": "4.5.0", "debug": "4.4.1", "local-pkg": "0.4.3", "magic-string": "0.30.18", "pathe": "1.1.2", "picocolors": "1.1.1", "std-env": "3.9.0", "strip-literal": "1.3.0", "tinybench": "2.9.0", "tinypool": "0.7.0", "vite": "5.4.19", "vite-node": "0.34.6", "why-is-node-running": "2.3.0" }, "bin": { "vitest": "vitest.mjs" } }, "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q=="],
|
||||||
|
|
||||||
@@ -1489,6 +1583,8 @@
|
|||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||||
|
|
||||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||||
|
|
||||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||||
@@ -1503,15 +1599,15 @@
|
|||||||
|
|
||||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||||
|
|
||||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||||
|
|
||||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
|
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
|
||||||
|
|
||||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||||
|
|
||||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||||
|
|
||||||
"yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="],
|
"yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="],
|
||||||
|
|
||||||
@@ -1533,7 +1629,9 @@
|
|||||||
|
|
||||||
"@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"@bedframe/core/@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.0.2", "", { "dependencies": { "@rollup/pluginutils": "4.2.1", "@webcomponents/custom-elements": "1.6.0", "acorn-walk": "8.3.4", "cheerio": "1.1.2", "convert-source-map": "1.9.0", "debug": "4.4.1", "es-module-lexer": "0.10.5", "fast-glob": "3.3.3", "fs-extra": "10.1.0", "jsesc": "3.1.0", "magic-string": "0.30.18", "pathe": "2.0.3", "picocolors": "1.1.1", "react-refresh": "0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-BeaVEkCTmna2tzl5DL9nw1kxll1IpIFZ+wbl2+iILz4fNJy1xRD6c1nF8w8/CvrWUuPYTFTpyX9K+A30ISDXHA=="],
|
"@bedframe/cli/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
|
||||||
|
|
||||||
|
"@bedframe/core/@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.3.0", "", { "dependencies": { "@rollup/pluginutils": "^4.1.2", "@webcomponents/custom-elements": "^1.5.0", "acorn-walk": "^8.2.0", "cheerio": "^1.0.0-rc.10", "convert-source-map": "^1.7.0", "debug": "^4.3.3", "es-module-lexer": "^0.10.0", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "jsesc": "^3.0.2", "magic-string": "^0.30.12", "pathe": "^2.0.1", "picocolors": "^1.1.1", "react-refresh": "^0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-+0CNVGS4bB30OoaF1vUsHVwWU1Lm7MxI0XWY9Fd/Ob+ZVTZgEFNqJ1ZC69IVwQsoYhY0sMQLvpLWiFIuDz8htg=="],
|
||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
@@ -1569,6 +1667,8 @@
|
|||||||
|
|
||||||
"@samverschueren/stream-to-observable/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
|
"@samverschueren/stream-to-observable/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
|
||||||
|
|
||||||
|
"@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"@vitest/runner/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
"@vitest/runner/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||||
|
|
||||||
"@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
"@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||||
@@ -1593,10 +1693,14 @@
|
|||||||
|
|
||||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
"cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||||
|
|
||||||
"concurrently/rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
"concurrently/rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||||
|
|
||||||
|
"concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||||
|
|
||||||
|
"dependency-cruiser/commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
|
||||||
|
|
||||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
"eslint/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
"eslint/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||||
@@ -1609,6 +1713,8 @@
|
|||||||
|
|
||||||
"htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
"htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||||
|
|
||||||
|
"lightningcss/detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
|
||||||
|
|
||||||
"listr/is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="],
|
"listr/is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="],
|
||||||
|
|
||||||
"listr/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
|
"listr/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
|
||||||
@@ -1691,9 +1797,13 @@
|
|||||||
|
|
||||||
"tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
"tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||||
|
|
||||||
|
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
"vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
|
"vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
||||||
|
|
||||||
"vite-node/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
"vite-node/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||||
|
|
||||||
@@ -1701,6 +1811,8 @@
|
|||||||
|
|
||||||
"vite-plugin-dts/@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "1.0.8", "estree-walker": "2.0.2", "picomatch": "4.0.3" }, "optionalDependencies": { "rollup": "4.49.0" } }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="],
|
"vite-plugin-dts/@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "1.0.8", "estree-walker": "2.0.2", "picomatch": "4.0.3" }, "optionalDependencies": { "rollup": "4.49.0" } }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="],
|
||||||
|
|
||||||
|
"vite-plugin-dts/vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "0.25.9", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.49.0", "tinyglobby": "0.2.14" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "jiti": "1.21.7", "sass": "1.91.0", "yaml": "2.8.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
|
||||||
|
|
||||||
"vitest/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
"vitest/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||||
|
|
||||||
"vitest/vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.6", "rollup": "4.49.0" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "sass": "1.91.0" }, "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="],
|
"vitest/vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.6", "rollup": "4.49.0" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "sass": "1.91.0" }, "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="],
|
||||||
@@ -1713,8 +1825,18 @@
|
|||||||
|
|
||||||
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
|
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||||
|
|
||||||
"z-schema/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
"z-schema/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
||||||
|
|
||||||
|
"@bedframe/cli/vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
|
"@bedframe/cli/vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
||||||
|
|
||||||
|
"@bedframe/cli/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
||||||
|
|
||||||
|
"@bedframe/core/@crxjs/vite-plugin/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|
||||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||||
@@ -1745,6 +1867,12 @@
|
|||||||
|
|
||||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
|
"concurrently/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "4.2.3", "strip-ansi": "6.0.1", "wrap-ansi": "7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||||
|
|
||||||
|
"concurrently/yargs/y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||||
|
|
||||||
|
"concurrently/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||||
|
|
||||||
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|
||||||
"listr-update-renderer/chalk/ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="],
|
"listr-update-renderer/chalk/ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="],
|
||||||
@@ -1833,6 +1961,10 @@
|
|||||||
|
|
||||||
"vite-plugin-dts/@rollup/pluginutils/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
"vite-plugin-dts/@rollup/pluginutils/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
||||||
|
|
||||||
|
"vite-plugin-dts/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
||||||
|
|
||||||
|
"vite-plugin-dts/vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||||
|
|
||||||
"vitest/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
"vitest/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||||
|
|
||||||
"vitest/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
"vitest/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
||||||
@@ -1845,12 +1977,18 @@
|
|||||||
|
|
||||||
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
|
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
|
||||||
|
|
||||||
|
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
|
|
||||||
"@microsoft/api-extractor/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
"@microsoft/api-extractor/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
"@rushstack/node-core-library/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
"@rushstack/node-core-library/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
|
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
|
||||||
|
|
||||||
|
"concurrently/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
|
"concurrently/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
"listr-update-renderer/cli-truncate/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@1.0.0", "", { "dependencies": { "number-is-nan": "1.0.1" } }, "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw=="],
|
"listr-update-renderer/cli-truncate/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@1.0.0", "", { "dependencies": { "number-is-nan": "1.0.1" } }, "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw=="],
|
||||||
|
|
||||||
"listr-update-renderer/log-update/wrap-ansi/string-width": ["string-width@2.1.1", "", { "dependencies": { "is-fullwidth-code-point": "2.0.0", "strip-ansi": "4.0.0" } }, "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw=="],
|
"listr-update-renderer/log-update/wrap-ansi/string-width": ["string-width@2.1.1", "", { "dependencies": { "is-fullwidth-code-point": "2.0.0", "strip-ansi": "4.0.0" } }, "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw=="],
|
||||||
@@ -1981,6 +2119,10 @@
|
|||||||
|
|
||||||
"vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
"vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||||
|
|
||||||
|
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|
||||||
|
"concurrently/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"listr-update-renderer/log-update/wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
|
"listr-update-renderer/log-update/wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
|
||||||
|
|
||||||
"listr-update-renderer/log-update/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@3.0.1", "", {}, "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw=="],
|
"listr-update-renderer/log-update/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@3.0.1", "", {}, "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw=="],
|
||||||
@@ -1990,5 +2132,7 @@
|
|||||||
"log-update/wrap-ansi/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
|
"log-update/wrap-ansi/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
|
||||||
|
|
||||||
"pkg-install/execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="],
|
"pkg-install/execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="],
|
||||||
|
|
||||||
|
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# BetterSEQTA+ Architecture
|
# BetterSEQTA+ Architecture
|
||||||
|
|
||||||
|
**Published version:** [docs.betterseqta.org/architecture/](https://docs.betterseqta.org/architecture/)
|
||||||
|
|
||||||
Hey there! 👋 New to the codebase and feeling a bit lost? Don't worry - this guide will help you understand how everything fits together!
|
Hey there! 👋 New to the codebase and feeling a bit lost? Don't worry - this guide will help you understand how everything fits together!
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
@@ -221,7 +223,7 @@ console.log(settingsState.[the setting name])
|
|||||||
Ready to contribute? Here's what to do next:
|
Ready to contribute? Here's what to do next:
|
||||||
|
|
||||||
1. **Read the code**: Start with `src/SEQTA.ts` and follow the flow
|
1. **Read the code**: Start with `src/SEQTA.ts` and follow the flow
|
||||||
2. **Try creating a simple plugin**: Follow our [plugin guide](./plugins/README.md)
|
2. **Try creating a simple plugin**: Follow the [plugin documentation](https://docs.betterseqta.org/plugins/)
|
||||||
3. **Look at existing issues**: Check our [GitHub issues](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) for "good first issue" labels
|
3. **Look at existing issues**: Check our [GitHub issues](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) for "good first issue" labels
|
||||||
4. **Join our Discord**: Get help from the community!
|
4. **Join our Discord**: Get help from the community!
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# 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.
|
||||||
|
- **Grade Analytics** — keys under `bsplus.analytics.*` (synced assessment cache and per-school chart preferences).
|
||||||
|
- **`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
|
# Getting Started as a Contributor
|
||||||
|
|
||||||
|
**Published version:** [docs.betterseqta.org/contributing/](https://docs.betterseqta.org/contributing/)
|
||||||
|
|
||||||
Welcome to BetterSEQTA+! 🎉 This guide will walk you through making your first contribution, even if you're completely new to the project.
|
Welcome to BetterSEQTA+! 🎉 This guide will walk you through making your first contribution, even if you're completely new to the project.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
@@ -222,7 +224,7 @@ git push origin your-branch-name
|
|||||||
|
|
||||||
### Stuck? Here's How to Get Unstuck
|
### Stuck? Here's How to Get Unstuck
|
||||||
|
|
||||||
1. **Check the docs** - [Architecture guide](./ARCHITECTURE.md) explains everything
|
1. **Check the docs** - The [architecture guide](https://docs.betterseqta.org/architecture/) explains everything
|
||||||
2. **Search existing issues** - Someone might have had the same problem
|
2. **Search existing issues** - Someone might have had the same problem
|
||||||
3. **Ask in Discord** - Our community is super helpful
|
3. **Ask in Discord** - Our community is super helpful
|
||||||
4. **Create an issue** - If you found a bug or need help
|
4. **Create an issue** - If you found a bug or need help
|
||||||
|
|||||||
+21
-21
@@ -1,30 +1,36 @@
|
|||||||
# BetterSEQTA+ Documentation
|
# BetterSEQTA+ Documentation
|
||||||
|
|
||||||
🚧 DOCS UNDER CONSTRUCTION! 🚧
|
**Canonical documentation lives at [docs.betterseqta.org](https://docs.betterseqta.org/).** The pages below are the same topics; use the site for search, navigation, and the latest updates.
|
||||||
|
|
||||||
Welcome to the BetterSEQTA+ documentation! This documentation will help you understand how BetterSEQTA+ works and how to extend it with plugins and new features.
|
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
### Getting Started
|
### Getting Started
|
||||||
|
|
||||||
- [Project Overview](./README.md) - This file
|
- [Documentation home](https://docs.betterseqta.org/)
|
||||||
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+
|
- [Installation](https://docs.betterseqta.org/install/)
|
||||||
- [Getting Started Contributing](./GETTING_STARTED_CONTRIBUTING.md) - **Start here!** Complete beginner-friendly guide
|
- [Contributing](https://docs.betterseqta.org/contributing/)
|
||||||
- [Architecture Guide](./ARCHITECTURE.md) - How BetterSEQTA+ works under the hood
|
- [Architecture](https://docs.betterseqta.org/architecture/)
|
||||||
- [Contributing Guide](../CONTRIBUTING.md) - Official contribution guidelines
|
- [Contribution guidelines (repository)](../CONTRIBUTING.md)
|
||||||
- [Troubleshooting](./TROUBLESHOOTING.md) - Common issues and solutions
|
- [Troubleshooting](https://docs.betterseqta.org/troubleshooting/)
|
||||||
|
|
||||||
### Plugin System
|
### Features & customization
|
||||||
|
|
||||||
- [Creating Your First Plugin](./plugins/README.md) - A comprehensive, beginner-friendly guide to creating plugins
|
- [Features](https://docs.betterseqta.org/features/)
|
||||||
- [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs
|
- [Themes & customization](https://docs.betterseqta.org/customization/)
|
||||||
|
- [Theme creation](https://docs.betterseqta.org/theme-creation/)
|
||||||
|
|
||||||
|
### Plugin system
|
||||||
|
|
||||||
|
- [Plugins overview](https://docs.betterseqta.org/plugins/)
|
||||||
|
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
|
||||||
|
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
|
||||||
|
- [Example plugin](https://docs.betterseqta.org/example-plugin/)
|
||||||
|
|
||||||
## Core Concepts
|
## Core Concepts
|
||||||
|
|
||||||
BetterSEQTA+ is built around several core concepts:
|
BetterSEQTA+ is built around several core concepts:
|
||||||
|
|
||||||
1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. Check out our [plugin guide](./plugins/README.md) to learn how to create your own!
|
1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. See the [plugins documentation](https://docs.betterseqta.org/plugins/).
|
||||||
|
|
||||||
2. **Type-Safe Settings**: Each plugin can define settings that are type-safe and automatically rendered in the settings UI. The settings system uses TypeScript decorators to make it easy to define settings with proper typing.
|
2. **Type-Safe Settings**: Each plugin can define settings that are type-safe and automatically rendered in the settings UI. The settings system uses TypeScript decorators to make it easy to define settings with proper typing.
|
||||||
|
|
||||||
@@ -36,19 +42,13 @@ BetterSEQTA+ is built around several core concepts:
|
|||||||
|
|
||||||
If you need help with BetterSEQTA+, you can:
|
If you need help with BetterSEQTA+, you can:
|
||||||
|
|
||||||
- [Open an Issue](https://github.com/SeqtaLearning/betterseqta-plus/issues) - Report bugs or request features
|
- [Open an Issue](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) - Report bugs or request features
|
||||||
- [Join the Discord](https://discord.gg/YzmbnCDkat) - Chat with the community
|
- [Join the Discord](https://discord.gg/YzmbnCDkat) - Chat with the community
|
||||||
- [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly
|
- [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly
|
||||||
|
|
||||||
## Contributing to the Documentation
|
## Contributing to the Documentation
|
||||||
|
|
||||||
We welcome contributions to the documentation! If you find something unclear or missing, please open an issue or submit a pull request.
|
We welcome contributions to the documentation. The published site is built from the documentation repository; see [Contributing](https://docs.betterseqta.org/contributing/) for how to help.
|
||||||
|
|
||||||
To contribute to the documentation:
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Make your changes to the documentation files
|
|
||||||
3. Submit a pull request with a clear description of your changes
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Theme Creation Guide
|
# Theme Creation Guide
|
||||||
|
|
||||||
|
**Published version:** [docs.betterseqta.org/theme-creation/](https://docs.betterseqta.org/theme-creation/)
|
||||||
|
|
||||||
This guide covers everything you need to know about creating custom themes for BetterSEQTA+.
|
This guide covers everything you need to know about creating custom themes for BetterSEQTA+.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Troubleshooting Guide
|
# Troubleshooting Guide
|
||||||
|
|
||||||
|
**Published version:** [docs.betterseqta.org/troubleshooting/](https://docs.betterseqta.org/troubleshooting/)
|
||||||
|
|
||||||
Having issues with BetterSEQTA+ development? This guide covers the most common problems and their solutions.
|
Having issues with BetterSEQTA+ development? This guide covers the most common problems and their solutions.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Contributing to BetterSEQTA+
|
# Contributing to BetterSEQTA+
|
||||||
|
|
||||||
|
**Published version:** [docs.betterseqta.org/contributing/](https://docs.betterseqta.org/contributing/)
|
||||||
|
|
||||||
Thank you for your interest in contributing to BetterSEQTA+! This document provides guidelines and instructions for contributing to the project.
|
Thank you for your interest in contributing to BetterSEQTA+! This document provides guidelines and instructions for contributing to the project.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
@@ -57,7 +59,7 @@ Key points:
|
|||||||
|
|
||||||
5. **Install in Chrome/Firefox**
|
5. **Install in Chrome/Firefox**
|
||||||
|
|
||||||
Follow the [installation instructions](./installation.md#development-installation) to load the development version into your browser.
|
Follow the [installation instructions](https://docs.betterseqta.org/install/) to load the development version into your browser.
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
|
|
||||||
@@ -246,8 +248,8 @@ Join our community channels to discuss the project, get help, and connect with o
|
|||||||
|
|
||||||
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
|
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
|
||||||
|
|
||||||
- [Creating Your First Plugin](./plugins/creating-plugins.md)
|
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
|
||||||
- [Plugin API Reference](./advanced/plugin-api.md)
|
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
|
||||||
|
|
||||||
## Recognition
|
## Recognition
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Installing BetterSEQTA+
|
# Installing BetterSEQTA+
|
||||||
|
|
||||||
|
**Published version:** [docs.betterseqta.org/install/](https://docs.betterseqta.org/install/)
|
||||||
|
|
||||||
This guide will walk you through the process of installing and setting up BetterSEQTA+ for development or usage.
|
This guide will walk you through the process of installing and setting up BetterSEQTA+ for development or usage.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
@@ -178,5 +180,5 @@ bun run dev
|
|||||||
|
|
||||||
Now that you have BetterSEQTA+ installed, you can:
|
Now that you have BetterSEQTA+ installed, you can:
|
||||||
|
|
||||||
- [Getting Started with Plugins](./plugins/getting-started.md)
|
- [Plugins](https://docs.betterseqta.org/plugins/)
|
||||||
- [Contribute to the project](../CONTRIBUTING.md)
|
- [Contribute to the project](https://docs.betterseqta.org/contributing/) · [Repository CONTRIBUTING.md](../CONTRIBUTING.md)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Example Plugin Template
|
# Example Plugin Template
|
||||||
|
|
||||||
|
**Published version:** [docs.betterseqta.org/example-plugin/](https://docs.betterseqta.org/example-plugin/)
|
||||||
|
|
||||||
This is a complete, working example of a simple BetterSEQTA+ plugin. You can copy this code and modify it to create your own plugin!
|
This is a complete, working example of a simple BetterSEQTA+ plugin. You can copy this code and modify it to create your own plugin!
|
||||||
|
|
||||||
## What This Example Does
|
## What This Example Does
|
||||||
@@ -328,8 +330,8 @@ Once you've got this working:
|
|||||||
## Need Help?
|
## Need Help?
|
||||||
|
|
||||||
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
|
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
|
||||||
- 📚 Read our [Plugin Development Guide](./README.md)
|
- 📚 Read the [plugin documentation](https://docs.betterseqta.org/plugins/)
|
||||||
- 🐛 Check the [Troubleshooting Guide](../TROUBLESHOOTING.md)
|
- 🐛 Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
|
||||||
- 📝 Open an issue on GitHub
|
- 📝 Open an issue on GitHub
|
||||||
|
|
||||||
Happy coding! 🎉
|
Happy coding! 🎉
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Creating Plugins for BetterSEQTA+
|
# Creating Plugins for BetterSEQTA+
|
||||||
|
|
||||||
|
**Published version:** [docs.betterseqta.org/plugins/](https://docs.betterseqta.org/plugins/) · [Plugin development](https://docs.betterseqta.org/plugin-development/) · [Plugin API](https://docs.betterseqta.org/plugin-api/)
|
||||||
|
|
||||||
Hey there! 👋 So you want to create a plugin for BetterSEQTA+? That's awesome! This guide will walk you through everything you need to know, from the very basics to more advanced features. Don't worry if you're new to this - we'll explain everything step by step.
|
Hey there! 👋 So you want to create a plugin for BetterSEQTA+? That's awesome! This guide will walk you through everything you need to know, from the very basics to more advanced features. Don't worry if you're new to this - we'll explain everything step by step.
|
||||||
|
|
||||||
## What is a Plugin?
|
## What is a Plugin?
|
||||||
@@ -294,4 +296,4 @@ Got stuck? No worries! Here's where you can get help:
|
|||||||
- Check out the built-in plugins in the `src/plugins/built-in` folder
|
- Check out the built-in plugins in the `src/plugins/built-in` folder
|
||||||
- Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues)
|
- Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues)
|
||||||
|
|
||||||
Happy coding and feel free to checkout the api reference [here](./api-reference.md)
|
Happy coding and feel free to check out the [plugin API](https://docs.betterseqta.org/plugin-api/) on the documentation site.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Plugin API Reference
|
# Plugin API Reference
|
||||||
|
|
||||||
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see [Creating Your First Plugin](./README.md).
|
**Published version:** [docs.betterseqta.org/plugin-api/](https://docs.betterseqta.org/plugin-api/)
|
||||||
|
|
||||||
|
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see the [plugins section](https://docs.betterseqta.org/plugins/) at [docs.betterseqta.org](https://docs.betterseqta.org/).
|
||||||
|
|
||||||
## Plugin Structure
|
## Plugin Structure
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+17
-11
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "betterseqtaplus",
|
"name": "betterseqtaplus",
|
||||||
"version": "3.5.0",
|
"version": "3.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!",
|
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
|
||||||
"browserslist": "> 0.5%, last 2 versions, not dead",
|
"browserslist": "> 0.5%, last 2 versions, not dead",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"postinstall": "node scripts/copy-pdfjs-assets.mjs",
|
||||||
"autoaudit": "npm audit && npm audit fix && npm run build",
|
"autoaudit": "npm audit && npm audit fix && npm run build",
|
||||||
"dev": "cross-env MODE=chrome vite dev",
|
"dev": "cross-env MODE=chrome vite dev",
|
||||||
"dev:firefox": "cross-env MODE=firefox vite build --watch",
|
"dev:firefox": "cross-env MODE=firefox vite build --watch",
|
||||||
@@ -31,14 +32,16 @@
|
|||||||
"author": {
|
"author": {
|
||||||
"name": "SethBurkart123",
|
"name": "SethBurkart123",
|
||||||
"email": "betterseqta.plus@gmail.com",
|
"email": "betterseqta.plus@gmail.com",
|
||||||
"url": "https://github.com/BetterSEQTA/BetterSEQTA-plus"
|
"url": "https://github.com/BetterSEQTA/BetterSEQTA-Plus"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-transform-runtime": "^7.26.9",
|
"@babel/plugin-transform-runtime": "^7.26.9",
|
||||||
"@babel/runtime": "^7.26.9",
|
"@babel/runtime": "^7.26.9",
|
||||||
"@bedframe/cli": "^0.0.95",
|
"@bedframe/cli": "^0.1.2",
|
||||||
"@crxjs/vite-plugin": "^2.2.0",
|
"@crxjs/vite-plugin": "^2.4.0",
|
||||||
|
"@types/d3-scale": "^4.0.9",
|
||||||
|
"@types/d3-shape": "^3.1.8",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
@@ -49,7 +52,7 @@
|
|||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
"publish-browser-extension": "^4.0.3",
|
"publish-browser-extension": "^4.0.4",
|
||||||
"sass": "^1.85.1",
|
"sass": "^1.85.1",
|
||||||
"sass-loader": "^16.0.5",
|
"sass-loader": "^16.0.5",
|
||||||
"semver": "^7.7.1",
|
"semver": "^7.7.1",
|
||||||
@@ -57,7 +60,7 @@
|
|||||||
"url": "^0.11.4"
|
"url": "^0.11.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bedframe/core": "^0.0.46",
|
"@bedframe/core": "^0.1.0",
|
||||||
"@codemirror/autocomplete": "^6.18.6",
|
"@codemirror/autocomplete": "^6.18.6",
|
||||||
"@codemirror/commands": "^6.8.0",
|
"@codemirror/commands": "^6.8.0",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
@@ -65,7 +68,7 @@
|
|||||||
"@codemirror/search": "^6.5.10",
|
"@codemirror/search": "^6.5.10",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.36.4",
|
"@codemirror/view": "^6.36.4",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tsconfig/svelte": "^5.0.4",
|
"@tsconfig/svelte": "^5.0.4",
|
||||||
"@types/chrome": "^0.1.4",
|
"@types/chrome": "^0.1.4",
|
||||||
@@ -82,8 +85,10 @@
|
|||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.3",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"color": "^5.0.0",
|
"color": "^5.0.0",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.2.0",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.4",
|
||||||
"embeddia": "^1.2.1",
|
"embeddia": "^1.3.0",
|
||||||
"embla-carousel-autoplay": "^8.5.2",
|
"embla-carousel-autoplay": "^8.5.2",
|
||||||
"embla-carousel-svelte": "^8.5.2",
|
"embla-carousel-svelte": "^8.5.2",
|
||||||
"esbuild": "^0.25.3",
|
"esbuild": "^0.25.3",
|
||||||
@@ -91,6 +96,7 @@
|
|||||||
"flexsearch": "^0.8.147",
|
"flexsearch": "^0.8.147",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"idb": "^8.0.2",
|
"idb": "^8.0.2",
|
||||||
|
"layerchart": "2.0.0-next.27",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mathjs": "^14.4.0",
|
"mathjs": "^14.4.0",
|
||||||
@@ -104,10 +110,10 @@
|
|||||||
"react-dom": "17",
|
"react-dom": "17",
|
||||||
"rss-parser": "^3.13.0",
|
"rss-parser": "^3.13.0",
|
||||||
"sortablejs": "^1.15.6",
|
"sortablejs": "^1.15.6",
|
||||||
"svelte": "^5.22.6",
|
"svelte": "^5.46.4",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vite": "^6.2.1",
|
"vite": "^8.0.5",
|
||||||
"webextension-polyfill": "^0.12.0"
|
"webextension-polyfill": "^0.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { copyFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const pdfjsRoot = join(root, "node_modules", "pdfjs-dist");
|
||||||
|
const outDir = join(root, "src", "public", "resources", "pdfjs");
|
||||||
|
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
copyFileSync(
|
||||||
|
join(pdfjsRoot, "build", "pdf.worker.min.mjs"),
|
||||||
|
join(outDir, "pdf.worker.min.mjs"),
|
||||||
|
);
|
||||||
|
copyFileSync(
|
||||||
|
join(pdfjsRoot, "legacy", "build", "pdf.min.mjs"),
|
||||||
|
join(outDir, "pdf.legacy.min.mjs"),
|
||||||
|
);
|
||||||
+36
-1
@@ -11,6 +11,30 @@ import { main } from "@/seqta/main";
|
|||||||
import { delay } from "./seqta/utils/delay";
|
import { delay } from "./seqta/utils/delay";
|
||||||
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
||||||
|
|
||||||
|
function registerFetchSeqtaAppLinkListener() {
|
||||||
|
browser.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||||
|
if (request?.type !== "fetchSeqtaAppLink") return false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${location.origin}/seqta/student/load/profile`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const statusOk = data?.status === "200" || data?.status === 200;
|
||||||
|
const raw = data?.payload?.app_link;
|
||||||
|
const appLink = typeof raw === "string" && raw.length > 0 ? raw : null;
|
||||||
|
sendResponse({ appLink: statusOk ? appLink : null });
|
||||||
|
} catch {
|
||||||
|
sendResponse({ appLink: null });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export let MenuOptionsOpen = false;
|
export let MenuOptionsOpen = false;
|
||||||
|
|
||||||
var IsSEQTAPage = false;
|
var IsSEQTAPage = false;
|
||||||
@@ -26,10 +50,21 @@ if (document.childNodes[1]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
if (hasSEQTAText && document.title.includes("SEQTA Learn") && !IsSEQTAPage) {
|
if (
|
||||||
|
hasSEQTAText &&
|
||||||
|
(document.title.includes("SEQTA Learn") ||
|
||||||
|
document.title.includes("SEQTA Engage")) &&
|
||||||
|
!IsSEQTAPage
|
||||||
|
) {
|
||||||
IsSEQTAPage = true;
|
IsSEQTAPage = true;
|
||||||
console.info("[BetterSEQTA+] Verified SEQTA Page");
|
console.info("[BetterSEQTA+] Verified SEQTA Page");
|
||||||
|
|
||||||
|
if (typeof window !== "undefined" && window === window.top) {
|
||||||
|
void browser.runtime.sendMessage({ type: "cloudSettingsPoll" }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
registerFetchSeqtaAppLinkListener();
|
||||||
|
|
||||||
const documentLoadStyle = document.createElement("style");
|
const documentLoadStyle = document.createElement("style");
|
||||||
documentLoadStyle.textContent = documentLoadCSS;
|
documentLoadStyle.textContent = documentLoadCSS;
|
||||||
document.head.appendChild(documentLoadStyle);
|
document.head.appendChild(documentLoadStyle);
|
||||||
|
|||||||
+385
-25
@@ -1,12 +1,38 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
import semver from "semver";
|
||||||
import type { SettingsState } from "@/types/storage";
|
import type { SettingsState } from "@/types/storage";
|
||||||
import { fetchNews } from "./background/news";
|
import { fetchNews } from "./background/news";
|
||||||
|
import {
|
||||||
|
initCloudSettingsAutoSync,
|
||||||
|
performCloudSettingsDownloadWithRetry,
|
||||||
|
performCloudSettingsUploadWithRetry,
|
||||||
|
requestCloudSettingsDebouncedUpload,
|
||||||
|
runCloudSettingsPoll,
|
||||||
|
} from "./background/cloudSettingsAutoSync";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session-only dev-mode override of the content API base.
|
||||||
|
*
|
||||||
|
* Stored in a module-level variable (not `chrome.storage`) so it is wiped
|
||||||
|
* automatically when the browser/service-worker process restarts. Content
|
||||||
|
* scripts re-sync this on every page load via `setDevApiBase` so the value
|
||||||
|
* survives transient service-worker terminations within the same browser
|
||||||
|
* session.
|
||||||
|
*/
|
||||||
|
const DEFAULT_API_BASE = "https://betterseqta.org";
|
||||||
|
let DEV_API_BASE: string | null = null;
|
||||||
|
function apiBase(): string {
|
||||||
|
return DEV_API_BASE ?? DEFAULT_API_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
function reloadSeqtaPages() {
|
function reloadSeqtaPages() {
|
||||||
const result = browser.tabs.query({});
|
const result = browser.tabs.query({});
|
||||||
function open(tabs: any) {
|
function open(tabs: any) {
|
||||||
for (let tab of tabs) {
|
for (let tab of tabs) {
|
||||||
if (tab.title.includes("SEQTA Learn")) {
|
if (
|
||||||
|
tab.title?.includes("SEQTA Learn") ||
|
||||||
|
tab.title?.includes("SEQTA Engage")
|
||||||
|
) {
|
||||||
browser.tabs.reload(tab.id);
|
browser.tabs.reload(tab.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,20 +43,68 @@ function reloadSeqtaPages() {
|
|||||||
/** Callback for sending a response back to the message sender */
|
/** Callback for sending a response back to the message sender */
|
||||||
type MessageSender = { (response?: unknown): void };
|
type MessageSender = { (response?: unknown): void };
|
||||||
|
|
||||||
|
/** Accept API + GitHub fallback shapes; always return `{ success, data?: { themes } }`. */
|
||||||
|
function normalizeFetchThemesResponse(json: unknown): {
|
||||||
|
success: boolean;
|
||||||
|
data?: { themes: unknown[] };
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
if (!json || typeof json !== "object") {
|
||||||
|
return { success: false, error: "Invalid themes response" };
|
||||||
|
}
|
||||||
|
const body = json as Record<string, unknown>;
|
||||||
|
if (body.success === false) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: typeof body.error === "string" ? body.error : "Failed to fetch themes",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const data = body.data;
|
||||||
|
let themes: unknown[] | null = null;
|
||||||
|
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||||
|
const nested = (data as Record<string, unknown>).themes;
|
||||||
|
if (Array.isArray(nested)) themes = nested;
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
themes = data;
|
||||||
|
}
|
||||||
|
if (!themes && Array.isArray(body.themes)) {
|
||||||
|
themes = body.themes;
|
||||||
|
}
|
||||||
|
if (!themes) {
|
||||||
|
return { success: false, error: "Themes list missing from response" };
|
||||||
|
}
|
||||||
|
return { success: true, data: { themes } };
|
||||||
|
}
|
||||||
|
|
||||||
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
|
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
|
||||||
const { token } = request;
|
const { token } = request;
|
||||||
const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
|
const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
|
||||||
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
|
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
fetch(apiUrl, { cache: "no-store", headers })
|
fetch(apiUrl, { cache: "no-store", headers })
|
||||||
.then((r) => r.json())
|
.then(async (r) => {
|
||||||
|
const json = await r.json();
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(
|
||||||
|
(json && typeof json === "object" && "error" in json && typeof (json as { error?: string }).error === "string"
|
||||||
|
? (json as { error: string }).error
|
||||||
|
: null) ?? `Themes API HTTP ${r.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalizeFetchThemesResponse(json);
|
||||||
|
})
|
||||||
.then(sendResponse)
|
.then(sendResponse)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
|
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
|
||||||
fetch(githubUrl, { cache: "no-store" })
|
fetch(githubUrl, { cache: "no-store" })
|
||||||
.then((r) => r.json())
|
.then(async (r) => {
|
||||||
.then((data) => sendResponse({ success: true, data: { themes: data.themes ?? [] } }))
|
if (!r.ok) throw new Error(`GitHub fallback HTTP ${r.status}`);
|
||||||
|
const data = await r.json();
|
||||||
|
const themes = Array.isArray(data) ? data : (data?.themes ?? []);
|
||||||
|
return normalizeFetchThemesResponse({ success: true, data: { themes } });
|
||||||
|
})
|
||||||
|
.then(sendResponse)
|
||||||
.catch((fallbackErr) => {
|
.catch((fallbackErr) => {
|
||||||
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
|
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
|
||||||
sendResponse({ success: false, error: fallbackErr?.message });
|
sendResponse({ success: false, error: fallbackErr?.message });
|
||||||
@@ -47,7 +121,7 @@ function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boo
|
|||||||
}
|
}
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers })
|
fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(sendResponse)
|
.then(sendResponse)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -124,6 +198,74 @@ function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCloudStartLogin(request: any, sendResponse: MessageSender): boolean {
|
||||||
|
const { client_id, redirect_uri } = request;
|
||||||
|
if (!client_id || !redirect_uri) {
|
||||||
|
sendResponse({ error: "Missing client_id or redirect_uri" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const authorizeUrl = `https://accounts.betterseqta.org/login?redirect=${encodeURIComponent(`/oauth/authorize?client_id=${client_id}&redirect_uri=${encodeURIComponent(redirect_uri)}`)}`;
|
||||||
|
browser.tabs.create({ url: authorizeUrl }).then(() => {
|
||||||
|
sendResponse({ success: true });
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error("[Background] cloudStartLogin error:", err);
|
||||||
|
sendResponse({ error: err?.message ?? "Failed to open login page" });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CALLBACK_URL_PREFIX = "https://accounts.betterseqta.org/auth/bsplus/callback";
|
||||||
|
|
||||||
|
function initCloudLoginCallbackListener() {
|
||||||
|
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||||
|
if (changeInfo.url && changeInfo.url.startsWith(CALLBACK_URL_PREFIX)) {
|
||||||
|
try {
|
||||||
|
const url = new URL(changeInfo.url);
|
||||||
|
const token = url.searchParams.get("token");
|
||||||
|
const refreshToken = url.searchParams.get("refresh_token");
|
||||||
|
const userId = url.searchParams.get("user_id");
|
||||||
|
|
||||||
|
if (token && refreshToken) {
|
||||||
|
// Store tokens
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await browser.storage.local.set({
|
||||||
|
bsplus_token: token,
|
||||||
|
bsplus_refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch full user info
|
||||||
|
const userRes = await fetch("https://accounts.betterseqta.org/api/auth/me", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (userRes.ok) {
|
||||||
|
const user = await userRes.json();
|
||||||
|
await browser.storage.local.set({ bsplus_user: user });
|
||||||
|
} else if (userId) {
|
||||||
|
await browser.storage.local.set({ bsplus_user: { id: userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger cloud settings download
|
||||||
|
void performCloudSettingsDownloadWithRetry(token).catch((err) => {
|
||||||
|
console.warn("[Background] Cloud settings download after login:", err);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Background] Failed to process login callback:", err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Close the callback tab
|
||||||
|
void browser.tabs.remove(tabId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Background] Error parsing callback URL:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initCloudLoginCallbackListener();
|
||||||
|
|
||||||
function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
|
function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
|
||||||
const { refresh_token, client_id } = request;
|
const { refresh_token, client_id } = request;
|
||||||
if (!refresh_token || !client_id) {
|
if (!refresh_token || !client_id) {
|
||||||
@@ -147,6 +289,57 @@ function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): boolean {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const token = request.token as string | undefined;
|
||||||
|
if (!token) {
|
||||||
|
sendResponse({ success: false, error: "Not authenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await performCloudSettingsUploadWithRetry(token);
|
||||||
|
sendResponse({
|
||||||
|
success: res.success,
|
||||||
|
error: res.error,
|
||||||
|
updated_at: res.updated_at,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Background] cloudSettingsUpload error:", err);
|
||||||
|
sendResponse({
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : "Upload failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): boolean {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const token = request.token as string | undefined;
|
||||||
|
if (!token) {
|
||||||
|
sendResponse({ success: false, error: "Not authenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await performCloudSettingsDownloadWithRetry(token);
|
||||||
|
sendResponse({
|
||||||
|
success: res.success,
|
||||||
|
notFound: res.notFound,
|
||||||
|
error: res.error,
|
||||||
|
updated_at: res.updated_at,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Background] cloudSettingsDownload error:", err);
|
||||||
|
sendResponse({
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : "Download failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
|
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
|
||||||
const { themeId, token, action } = request;
|
const { themeId, token, action } = request;
|
||||||
if (!themeId || !token) {
|
if (!themeId || !token) {
|
||||||
@@ -154,7 +347,7 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const isFavorite = action === "favorite";
|
const isFavorite = action === "favorite";
|
||||||
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, {
|
fetch(`${apiBase()}/api/themes/${themeId}/favorite`, {
|
||||||
method: isFavorite ? "POST" : "DELETE",
|
method: isFavorite ? "POST" : "DELETE",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
@@ -167,11 +360,33 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handler for a message type; receives request and sendResponse callback */
|
/** Handler for a message type; receives request, sendResponse, and optional sender (for tab routing) */
|
||||||
type MessageHandler = { (request: any, sendResponse: MessageSender): boolean | void };
|
type MessageHandler = {
|
||||||
|
(request: any, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender): boolean | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isSeqtaOrigin(origin: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(origin);
|
||||||
|
return u.hostname.includes("seqta") || u.hostname.endsWith(".edu.au");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSetDevApiBase(request: any): boolean {
|
||||||
|
const url = typeof request?.url === "string" ? request.url.trim() : null;
|
||||||
|
if (url && /^https?:\/\//.test(url)) {
|
||||||
|
DEV_API_BASE = url.replace(/\/$/, "");
|
||||||
|
} else {
|
||||||
|
DEV_API_BASE = null;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
||||||
reloadTabs: () => reloadSeqtaPages(),
|
reloadTabs: () => reloadSeqtaPages(),
|
||||||
|
setDevApiBase: handleSetDevApiBase,
|
||||||
extensionPages: (req) => {
|
extensionPages: (req) => {
|
||||||
browser.tabs.query({}).then((tabs) => {
|
browser.tabs.query({}).then((tabs) => {
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
@@ -198,31 +413,51 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
|||||||
fetchFromUrl: handleFetchFromUrl,
|
fetchFromUrl: handleFetchFromUrl,
|
||||||
cloudReserveClient: handleCloudReserveClient,
|
cloudReserveClient: handleCloudReserveClient,
|
||||||
cloudLogin: handleCloudLogin,
|
cloudLogin: handleCloudLogin,
|
||||||
|
cloudStartLogin: handleCloudStartLogin,
|
||||||
cloudRefresh: handleCloudRefresh,
|
cloudRefresh: handleCloudRefresh,
|
||||||
cloudFavorite: handleCloudFavorite,
|
cloudFavorite: handleCloudFavorite,
|
||||||
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender) => {
|
cloudSettingsUpload: handleCloudSettingsUpload,
|
||||||
|
cloudSettingsDownload: handleCloudSettingsDownload,
|
||||||
|
cloudSettingsPoll: () => {
|
||||||
|
void runCloudSettingsPoll();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
cloudSettingsRequestDebouncedUpload: () => {
|
||||||
|
requestCloudSettingsDebouncedUpload();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
let baseUrl = req.baseUrl;
|
let tabId = sender?.tab?.id;
|
||||||
if (!baseUrl) {
|
let originForCheck: string | undefined = req.baseUrl;
|
||||||
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
|
|
||||||
|
if (tabId == null) {
|
||||||
|
const tabs = await browser.tabs.query({ active: true, lastFocusedWindow: true });
|
||||||
const tab = tabs[0];
|
const tab = tabs[0];
|
||||||
if (!tab?.url) {
|
if (!tab?.id || !tab.url) {
|
||||||
sendResponse({ session: null });
|
sendResponse({ appLink: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
baseUrl = new URL(tab.url).origin;
|
tabId = tab.id;
|
||||||
|
if (!originForCheck) originForCheck = new URL(tab.url).origin;
|
||||||
|
} else if (!originForCheck && sender?.tab?.url) {
|
||||||
|
originForCheck = new URL(sender.tab.url).origin;
|
||||||
}
|
}
|
||||||
const cookies = await browser.cookies.getAll({ url: baseUrl });
|
|
||||||
const jsession = cookies.find((c) => c.name === "JSESSIONID");
|
if (!originForCheck || !isSeqtaOrigin(originForCheck)) {
|
||||||
if (jsession?.value) {
|
sendResponse({ appLink: null });
|
||||||
sendResponse({ session: { baseUrl, jsessionId: jsession.value } });
|
return;
|
||||||
} else {
|
|
||||||
sendResponse({ session: null });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reply = (await browser.tabs.sendMessage(tabId, { type: "fetchSeqtaAppLink" })) as
|
||||||
|
| { appLink?: string | null }
|
||||||
|
| undefined;
|
||||||
|
const appLink = typeof reply?.appLink === "string" && reply.appLink.length > 0 ? reply.appLink : null;
|
||||||
|
sendResponse({ appLink });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Background] getSeqtaSession error:", err);
|
console.error("[Background] getSeqtaSession error:", err);
|
||||||
sendResponse({ session: null });
|
sendResponse({ appLink: null });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return true;
|
return true;
|
||||||
@@ -231,10 +466,10 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
|||||||
|
|
||||||
browser.runtime.onMessage.addListener(
|
browser.runtime.onMessage.addListener(
|
||||||
// @ts-ignore - OnMessageListener expects literal true for async, we return boolean
|
// @ts-ignore - OnMessageListener expects literal true for async, we return boolean
|
||||||
(request: any, _: any, sendResponse: MessageSender) => {
|
(request: any, sender: browser.Runtime.MessageSender, sendResponse: MessageSender) => {
|
||||||
const handler = MESSAGE_HANDLERS[request.type];
|
const handler = MESSAGE_HANDLERS[request.type];
|
||||||
if (handler) {
|
if (handler) {
|
||||||
const result = handler(request, sendResponse);
|
const result = handler(request, sendResponse, sender);
|
||||||
return result === true;
|
return result === true;
|
||||||
}
|
}
|
||||||
console.log("Unknown request type");
|
console.log("Unknown request type");
|
||||||
@@ -308,6 +543,9 @@ function getDefaultValues(): SettingsState {
|
|||||||
iconOnlySidebar: false,
|
iconOnlySidebar: false,
|
||||||
adaptiveThemeColour: false,
|
adaptiveThemeColour: false,
|
||||||
adaptiveThemeGradient: false,
|
adaptiveThemeGradient: false,
|
||||||
|
adaptiveThemeColourTransition: true,
|
||||||
|
themeOfTheMonthDisabled: false,
|
||||||
|
autoCloudSettingsSync: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +555,120 @@ function SetStorageValue(object: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One-time migration for 3.6.5: opt upgraders into Global Search + indexing + transparency defaults. */
|
||||||
|
const GLOBAL_SEARCH_PLUGIN_SETTINGS_KEY = "plugin.global-search.settings";
|
||||||
|
const GLOBAL_SEARCH_MIGRATION_VERSION = "3.6.5";
|
||||||
|
|
||||||
|
async function migrateGlobalSearchDefaultsFor365Upgrade(
|
||||||
|
previousVersion: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const currRaw = browser.runtime.getManifest().version;
|
||||||
|
const prev = semver.coerce(previousVersion);
|
||||||
|
const curr = semver.coerce(currRaw);
|
||||||
|
if (
|
||||||
|
prev == null ||
|
||||||
|
curr == null ||
|
||||||
|
semver.lt(curr, GLOBAL_SEARCH_MIGRATION_VERSION) ||
|
||||||
|
!semver.lt(prev, GLOBAL_SEARCH_MIGRATION_VERSION)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const got = await browser.storage.local.get(GLOBAL_SEARCH_PLUGIN_SETTINGS_KEY);
|
||||||
|
const existing = (got[GLOBAL_SEARCH_PLUGIN_SETTINGS_KEY] ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
await browser.storage.local.set({
|
||||||
|
[GLOBAL_SEARCH_PLUGIN_SETTINGS_KEY]: {
|
||||||
|
...existing,
|
||||||
|
enabled: true,
|
||||||
|
transparencyEffects: true,
|
||||||
|
runIndexingOnLoad: true,
|
||||||
|
passiveIndexing: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`[BetterSEQTA+] Migration ${GLOBAL_SEARCH_MIGRATION_VERSION}: Global Search and related settings enabled (from ${previousVersion}).`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[BetterSEQTA+] Global Search 3.6.5 settings migration failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-time reset for 3.6.6: re-enable Theme of the Month for existing users. */
|
||||||
|
const THEME_OF_THE_MONTH_RESET_VERSION = "3.6.6";
|
||||||
|
|
||||||
|
async function resetThemeOfTheMonthDisabledFor366Upgrade(
|
||||||
|
previousVersion: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const currRaw = browser.runtime.getManifest().version;
|
||||||
|
const prev = semver.coerce(previousVersion);
|
||||||
|
const curr = semver.coerce(currRaw);
|
||||||
|
if (
|
||||||
|
prev == null ||
|
||||||
|
curr == null ||
|
||||||
|
semver.lt(curr, THEME_OF_THE_MONTH_RESET_VERSION) ||
|
||||||
|
!semver.lt(prev, THEME_OF_THE_MONTH_RESET_VERSION)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.storage.local.set({
|
||||||
|
themeOfTheMonthDisabled: false,
|
||||||
|
themeOfTheMonthLastSeenId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`[BetterSEQTA+] Migration ${THEME_OF_THE_MONTH_RESET_VERSION}: Theme of the Month re-enabled (from ${previousVersion}).`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
"[BetterSEQTA+] Theme of the Month 3.6.6 reset migration failed:",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 3.7.0: Close no longer marks entries seen — clear legacy dismissal keys. */
|
||||||
|
const THEME_OF_THE_MONTH_RELOAD_VERSION = "3.7.0";
|
||||||
|
|
||||||
|
async function resetThemeOfTheMonthDismissalFor370Upgrade(
|
||||||
|
previousVersion: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const currRaw = browser.runtime.getManifest().version;
|
||||||
|
const prev = semver.coerce(previousVersion);
|
||||||
|
const curr = semver.coerce(currRaw);
|
||||||
|
if (
|
||||||
|
prev == null ||
|
||||||
|
curr == null ||
|
||||||
|
semver.lt(curr, THEME_OF_THE_MONTH_RELOAD_VERSION) ||
|
||||||
|
!semver.lt(prev, THEME_OF_THE_MONTH_RELOAD_VERSION)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.storage.local.set({
|
||||||
|
themeOfTheMonthLastSeenId: undefined,
|
||||||
|
themeOfTheMonthDismissedMonth: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`[BetterSEQTA+] Migration ${THEME_OF_THE_MONTH_RELOAD_VERSION}: Theme of the Month shows again until dismissed for the month (from ${previousVersion}).`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
"[BetterSEQTA+] Theme of the Month 3.7.0 dismissal migration failed:",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
browser.runtime.onInstalled.addListener(function (event) {
|
browser.runtime.onInstalled.addListener(function (event) {
|
||||||
browser.storage.local.remove(["justupdated"]);
|
browser.storage.local.remove(["justupdated"]);
|
||||||
browser.storage.local.remove(["data"]);
|
browser.storage.local.remove(["data"]);
|
||||||
@@ -324,4 +676,12 @@ browser.runtime.onInstalled.addListener(function (event) {
|
|||||||
if (event.reason == "install" || event.reason == "update") {
|
if (event.reason == "install" || event.reason == "update") {
|
||||||
browser.storage.local.set({ justupdated: true });
|
browser.storage.local.set({ justupdated: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.reason === "update" && event.previousVersion) {
|
||||||
|
void migrateGlobalSearchDefaultsFor365Upgrade(event.previousVersion);
|
||||||
|
void resetThemeOfTheMonthDisabledFor366Upgrade(event.previousVersion);
|
||||||
|
void resetThemeOfTheMonthDismissalFor370Upgrade(event.previousVersion);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -37,8 +37,9 @@
|
|||||||
@layer base, override;
|
@layer base, override;
|
||||||
|
|
||||||
@layer override {
|
@layer override {
|
||||||
* {
|
.legacy-root,
|
||||||
font-family: Rubik, sans-serif !important;
|
.legacy-root * {
|
||||||
|
font-family: var(--betterseqta-font-family, Rubik), sans-serif !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconFamily,
|
.iconFamily,
|
||||||
|
|||||||
+666
-3
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
@use "sass:meta";
|
@use "sass:meta";
|
||||||
@import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,600,700");
|
@import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,600,700");
|
||||||
|
|
||||||
@@ -118,7 +119,8 @@ select option {
|
|||||||
#container {
|
#container {
|
||||||
background: var(--auto-background) !important;
|
background: var(--auto-background) !important;
|
||||||
}
|
}
|
||||||
:root * {
|
.legacy-root,
|
||||||
|
.legacy-root * {
|
||||||
font-family: Rubik, sans-serif !important;
|
font-family: Rubik, sans-serif !important;
|
||||||
--theme-fg-parts: white;
|
--theme-fg-parts: white;
|
||||||
}
|
}
|
||||||
@@ -454,6 +456,58 @@ ul.magicDelete > li.deleting {
|
|||||||
top: 71.5px;
|
top: 71.5px;
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Drill-in stack: only the current list + folder header stay clickable.
|
||||||
|
Class is toggled by updateSidebarAccessibility (never touches aria-hidden). */
|
||||||
|
#menu .bsplus-sidebar-offscreen,
|
||||||
|
#menu .bsplus-sidebar-offscreen * {
|
||||||
|
pointer-events: none !important;
|
||||||
|
user-select: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu > ul > .bsplus-sidebar-offscreen:not(.hasChildren.active) {
|
||||||
|
position: absolute !important;
|
||||||
|
left: -10000px !important;
|
||||||
|
width: 1px !important;
|
||||||
|
height: 1px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
clip: rect(0, 0, 0, 0) !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu .sub .bsplus-sidebar-offscreen:not(.hasChildren.active) {
|
||||||
|
visibility: hidden !important;
|
||||||
|
position: absolute !important;
|
||||||
|
left: -10000px !important;
|
||||||
|
width: 1px !important;
|
||||||
|
height: 1px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only the frontmost open .sub panel receives pointer events */
|
||||||
|
#menu .sub {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu li.hasChildren.active > .sub {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu li.hasChildren.active > .sub:has(.hasChildren.active) {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu li.hasChildren.active .hasChildren.active > .sub {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu:has(> ul > li.hasChildren.active) > ul > li:not(.hasChildren.active) {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
#menu section > label {
|
#menu section > label {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -555,6 +609,31 @@ body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) {
|
|||||||
flex-shrink: 0 !important;
|
flex-shrink: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Engage: hide text nodes in labels via font-size trick, restore SVG size */
|
||||||
|
#menu .logo-link li > label,
|
||||||
|
#menu .logo-link section > label {
|
||||||
|
font-size: 0 !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
font-size: initial !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Engage: hide chevron arrows on hasChildren items */
|
||||||
|
#menu .logo-link li > svg {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Engage: hide the name/details in #userActions */
|
||||||
|
#menu #userActions .details {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
[class*="notifications__items___"] {
|
[class*="notifications__items___"] {
|
||||||
-ms-overflow-style: none !important;
|
-ms-overflow-style: none !important;
|
||||||
@@ -1628,6 +1707,13 @@ html.transparencyEffects
|
|||||||
box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.4);
|
box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Smoothed by attachNotificationsPanelAnimation (matches #ExtensionPopup spring) */
|
||||||
|
.bsplus-notifications-panel {
|
||||||
|
transform-origin: top right;
|
||||||
|
will-change: opacity, transform;
|
||||||
|
filter: drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
#menu li.active {
|
#menu li.active {
|
||||||
color: #ffffff !important;
|
color: #ffffff !important;
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: rgba(0, 0, 0, 0.35);
|
||||||
@@ -2293,6 +2379,10 @@ blurred {
|
|||||||
height: 64px;
|
height: 64px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
/* While a drill-in submenu is open, don't steal clicks meant for folder rows. */
|
||||||
|
#menu:has(li.hasChildren.active) > .icon-cover {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.uiSlidePane > .pane > .header button {
|
.uiSlidePane > .pane > .header button {
|
||||||
color: var(--text-color) !important;
|
color: var(--text-color) !important;
|
||||||
}
|
}
|
||||||
@@ -2577,7 +2667,7 @@ body {
|
|||||||
|
|
||||||
[class*="MessageList__unread___"] {
|
[class*="MessageList__unread___"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: rgb(228 225 225);
|
background: var(--background-secondary, rgb(228 225 225));
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark [class*="MessageList__unread___"] {
|
.dark [class*="MessageList__unread___"] {
|
||||||
@@ -2703,7 +2793,7 @@ body {
|
|||||||
[class*="MessageList__MessageList___"]
|
[class*="MessageList__MessageList___"]
|
||||||
> ol
|
> ol
|
||||||
> li[class*="MessageList__selected___"] {
|
> li[class*="MessageList__selected___"] {
|
||||||
background: rgb(228 225 225);
|
background: var(--background-secondary, rgb(228 225 225));
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -3296,6 +3386,88 @@ div.day-empty {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 250px;
|
right: 250px;
|
||||||
}
|
}
|
||||||
|
.engage-titlebar {
|
||||||
|
right: 250px;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Engage parent home: same timetable DOM as Learn; title+student replace the lone h2 — give the cluster 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 {
|
.pagename {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3387,6 +3559,32 @@ div.day-empty {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Text-only popups (privacy notices): body fills remaining height, scrolls inside */
|
||||||
|
.whatsnewContainer.whatsnewContainer--scrollBody {
|
||||||
|
.whatsnewHeader {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: auto;
|
||||||
|
min-height: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .whatsnewTextContainer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: none;
|
||||||
|
width: 90%;
|
||||||
|
margin: 0 auto 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .whatsnewTextContainer.privacyStatement {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
.whatsnewTextContainer.privacyStatement p {
|
.whatsnewTextContainer.privacyStatement p {
|
||||||
margin-bottom: 1.5ex;
|
margin-bottom: 1.5ex;
|
||||||
|
|
||||||
@@ -3417,6 +3615,26 @@ div.day-empty {
|
|||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
.whatsnewHeader.engageParentsAnnouncementHeader {
|
||||||
|
height: auto;
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
.whatsnewHeader.engageParentsAnnouncementHeader h1 {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.whatsnewHeader.engageParentsAnnouncementHeader .engageParentsSubheading {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
.seqtaEngageAccent {
|
||||||
|
color: #ea580c;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.dark .seqtaEngageAccent {
|
||||||
|
color: #fb923c;
|
||||||
|
}
|
||||||
.whatsnewBackground {
|
.whatsnewBackground {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -3545,6 +3763,388 @@ div.day-empty {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
.whatsnewTextContainer .engageParentsPromoWrap {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.28);
|
||||||
|
background: color-mix(in srgb, var(--background-secondary) 88%, var(--text-primary) 12%);
|
||||||
|
}
|
||||||
|
.whatsnewTextContainer .engageParentsPromoWrap .engageParentsPromoImg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
aspect-ratio: unset;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader {
|
||||||
|
height: auto;
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader h1 {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.bsCloudAccent {
|
||||||
|
color: #059669;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.dark .bsCloudAccent {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
.whatsnewTextContainer .bsCloudAutoSyncSignupCallout {
|
||||||
|
margin: 1.5rem 0 0;
|
||||||
|
padding: 1.25rem 1rem 0;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||||
|
font-size: clamp(1.35rem, 3.8vw, 1.85rem);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeOfTheMonthCard {
|
||||||
|
position: fixed;
|
||||||
|
right: max(18px, env(safe-area-inset-right));
|
||||||
|
bottom: max(18px, env(safe-area-inset-bottom));
|
||||||
|
z-index: 48;
|
||||||
|
width: min(360px, calc(100vw - 36px));
|
||||||
|
overflow: visible;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--background-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
|
||||||
|
animation: themeOfTheMonthCardIn 0.24s ease-out;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCard::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: inherit;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardClosing {
|
||||||
|
pointer-events: none;
|
||||||
|
animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardConfirm {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 4;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.16s ease;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardConfirm[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardConfirmVisible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardConfirmInner {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardConfirmInner h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardConfirmInner p {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardConfirmActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardConfirmCancel,
|
||||||
|
.themeOfTheMonthCardConfirmAccept {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 0.5rem 0.85rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: transform 0.15s ease, filter 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardConfirmCancel {
|
||||||
|
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardConfirmAccept {
|
||||||
|
background: var(--better-pri, #6366f1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardConfirmCancel:hover,
|
||||||
|
.themeOfTheMonthCardConfirmAccept:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardConfirmCancel:active,
|
||||||
|
.themeOfTheMonthCardConfirmAccept:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardImage {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardBody {
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardEyebrow {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, var(--better-pri, #6366f1) 82%, var(--text-primary) 18%);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCard h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardDescription {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin: 8px 0 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardActionsStart {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardActionsEnd {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: stretch;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 3px;
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--background-secondary, var(--text-primary)) 28%,
|
||||||
|
var(--background-primary)
|
||||||
|
);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary) 8%, transparent);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardPrimary,
|
||||||
|
.themeOfTheMonthCardSecondary,
|
||||||
|
.themeOfTheMonthCardDontShow {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 0.58rem 0.9rem;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardPrimary {
|
||||||
|
background: var(--better-pri, #6366f1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary,
|
||||||
|
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow {
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 9999px !important;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
filter: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow {
|
||||||
|
color: color-mix(in srgb, var(--text-primary) 58%, transparent);
|
||||||
|
}
|
||||||
|
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary:hover,
|
||||||
|
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary:focus-visible,
|
||||||
|
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow:hover,
|
||||||
|
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow:focus-visible {
|
||||||
|
background: color-mix(in srgb, var(--text-primary) 10%, transparent) !important;
|
||||||
|
border-radius: 9999px !important;
|
||||||
|
filter: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary:active,
|
||||||
|
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow:active {
|
||||||
|
background: color-mix(in srgb, var(--text-primary) 14%, transparent) !important;
|
||||||
|
border-radius: 9999px !important;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardPrimary:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardPrimary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
@keyframes themeOfTheMonthCardIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes themeOfTheMonthCardOut {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px) scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.themeOfTheMonthCard {
|
||||||
|
z-index: 2147483645;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-theme-highlight {
|
||||||
|
animation: bsplusThemeHighlightPulse 1.4s ease-in-out 2;
|
||||||
|
}
|
||||||
|
@keyframes bsplusThemeHighlightPulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--better-pri, #6366f1) 0%, transparent);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 6px color-mix(in srgb, var(--better-pri, #6366f1) 60%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-media-fullscreenable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
.popup-media-fullscreenable:hover {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
.popup-media-fullscreenable:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.popup-media-fullscreenable:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--text-primary) 70%, transparent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2147483646;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: clamp(20px, 4vw, 48px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-inner {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
max-width: min(96vw, 1320px);
|
||||||
|
max-height: calc(100vh - clamp(40px, 10vw, 96px));
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--background-primary);
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.94) translateY(12px);
|
||||||
|
transition:
|
||||||
|
opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1),
|
||||||
|
transform 0.28s cubic-bezier(0.22, 0.03, 0.26, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible
|
||||||
|
.bsplus-popup-media-overlay-inner {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant
|
||||||
|
.bsplus-popup-media-overlay-inner {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-slot {
|
||||||
|
width: 100%;
|
||||||
|
max-height: inherit;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: clamp(16px, 3vw, 28px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-popup-media-overlay-media {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: calc(100vh - clamp(120px, 22vh, 200px));
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% {
|
0% {
|
||||||
@@ -4105,3 +4705,66 @@ h2.home-subtitle {
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bsplus-toast {
|
||||||
|
position: fixed;
|
||||||
|
right: max(18px, env(safe-area-inset-right));
|
||||||
|
bottom: max(18px, env(safe-area-inset-bottom));
|
||||||
|
z-index: 10000;
|
||||||
|
width: min(360px, calc(100vw - 36px));
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--background-primary, #fff);
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.bsplus-toast-eyebrow {
|
||||||
|
margin: 0 0 6px !important;
|
||||||
|
font-size: 0.72rem !important;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, #ea580c 82%, var(--text-primary) 18%);
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
.dark .bsplus-toast-eyebrow {
|
||||||
|
color: color-mix(in srgb, #fb923c 82%, var(--text-primary) 18%);
|
||||||
|
}
|
||||||
|
.bsplus-toast-content strong {
|
||||||
|
display: block;
|
||||||
|
padding-right: 34px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.bsplus-toast-content p:not(.bsplus-toast-eyebrow) {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.bsplus-toast-close {
|
||||||
|
position: absolute !important;
|
||||||
|
top: 4px !important;
|
||||||
|
right: 4px !important;
|
||||||
|
z-index: 2;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
border-radius: 16px !important;
|
||||||
|
background: rgba(0, 0, 0, 0.42);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: filter 0.15s ease;
|
||||||
|
}
|
||||||
|
.bsplus-toast-close:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>();
|
let {
|
||||||
|
onClick,
|
||||||
|
text,
|
||||||
|
disabled = false,
|
||||||
|
} = $props<{
|
||||||
|
onClick: () => void;
|
||||||
|
text: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button onclick={onClick} class='px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg'>
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
class="px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
{text}
|
{text}
|
||||||
</button>
|
</button>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { animate } from "motion";
|
||||||
|
import { delay } from "@/seqta/utils/delay.ts";
|
||||||
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
|
||||||
|
const { hidePanel } = $props<{
|
||||||
|
hidePanel: () => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let cloudState = $state(cloudAuth.state);
|
||||||
|
let background = $state<HTMLDivElement | null>(null);
|
||||||
|
let content = $state<HTMLDivElement | null>(null);
|
||||||
|
let loginError = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const unsub = cloudAuth.subscribe((s) => {
|
||||||
|
cloudState = s;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (background && content) {
|
||||||
|
animate(
|
||||||
|
background,
|
||||||
|
{ opacity: [0, 1] },
|
||||||
|
{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }
|
||||||
|
);
|
||||||
|
animate(
|
||||||
|
content,
|
||||||
|
{ scale: [0.4, 1], opacity: [0, 1] },
|
||||||
|
{ type: "spring", stiffness: 400, damping: 30 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") closePanel();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleEscapeKey);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsub();
|
||||||
|
document.removeEventListener("keydown", handleEscapeKey);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function closePanel() {
|
||||||
|
if (!background || !content) return;
|
||||||
|
animate(
|
||||||
|
content,
|
||||||
|
{ scale: [1, 0.4], opacity: [1, 0] },
|
||||||
|
{ type: "spring", stiffness: 400, damping: 30 }
|
||||||
|
);
|
||||||
|
animate(
|
||||||
|
background,
|
||||||
|
{ opacity: [1, 0] },
|
||||||
|
{ ease: [0.4, 0, 0.2, 1] }
|
||||||
|
);
|
||||||
|
await delay(400);
|
||||||
|
hidePanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackgroundClick(event: MouseEvent) {
|
||||||
|
if (event.target === background) closePanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignIn() {
|
||||||
|
loginError = null;
|
||||||
|
const result = await cloudAuth.startLogin();
|
||||||
|
if (result.success) {
|
||||||
|
closePanel();
|
||||||
|
} else {
|
||||||
|
loginError = result.error ?? "Failed to open login page";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await cloudAuth.logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(): string {
|
||||||
|
const u = cloudState.user;
|
||||||
|
if (!u) return "?";
|
||||||
|
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
|
||||||
|
if (u.username) return u.username.slice(0, 2).toUpperCase();
|
||||||
|
if (u.email) return u.email.slice(0, 2).toUpperCase();
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
bind:this={background}
|
||||||
|
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50"
|
||||||
|
onclick={handleBackgroundClick}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
bind:this={content}
|
||||||
|
class="p-5 w-[320px] bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
<h3 class="text-lg font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
|
||||||
|
<p class="mt-0.5 text-sm text-zinc-500 dark:text-zinc-400">Account & sync</p>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
{#if cloudState.isLoggedIn}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if cloudState.user?.pfpUrl}
|
||||||
|
<img
|
||||||
|
src={cloudState.user.pfpUrl}
|
||||||
|
alt=""
|
||||||
|
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
|
||||||
|
{getInitials()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-zinc-900 dark:text-white truncate">
|
||||||
|
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
|
||||||
|
</p>
|
||||||
|
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
|
||||||
|
<p class="text-xs text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleLogout}
|
||||||
|
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<p class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Sign in to sync settings across devices, use your cloud profile picture, and more.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleSignIn}
|
||||||
|
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Sign in with BetterSEQTA Cloud
|
||||||
|
</button>
|
||||||
|
{#if loginError}
|
||||||
|
<p class="text-xs text-red-600 dark:text-red-400">{loginError}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
|
||||||
|
Opens accounts.betterseqta.org in a new tab
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import browser from "webextension-polyfill";
|
||||||
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
import Button from "./Button.svelte";
|
||||||
|
import Switch from "./Switch.svelte";
|
||||||
|
|
||||||
|
let { showDisclaimer } = $props<{
|
||||||
|
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let cloudState = $state(cloudAuth.state);
|
||||||
|
let busy = $state(false);
|
||||||
|
let statusMessage = $state<string | null>(null);
|
||||||
|
let statusError = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const unsub = cloudAuth.subscribe((s) => {
|
||||||
|
cloudState = s;
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
const token = await cloudAuth.getStoredToken();
|
||||||
|
if (!token) return;
|
||||||
|
busy = true;
|
||||||
|
statusError = null;
|
||||||
|
statusMessage = null;
|
||||||
|
try {
|
||||||
|
const res = (await browser.runtime.sendMessage({
|
||||||
|
type: "cloudSettingsUpload",
|
||||||
|
token,
|
||||||
|
})) as { success?: boolean; error?: string };
|
||||||
|
if (res?.success) {
|
||||||
|
statusMessage = "Settings uploaded.";
|
||||||
|
} else {
|
||||||
|
statusError = res?.error ?? "Upload failed";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusError = e instanceof Error ? e.message : "Upload failed";
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptDownload() {
|
||||||
|
showDisclaimer(confirmDownload, () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDownload() {
|
||||||
|
const token = await cloudAuth.getStoredToken();
|
||||||
|
if (!token) return;
|
||||||
|
busy = true;
|
||||||
|
statusError = null;
|
||||||
|
statusMessage = null;
|
||||||
|
try {
|
||||||
|
const res = (await browser.runtime.sendMessage({
|
||||||
|
type: "cloudSettingsDownload",
|
||||||
|
token,
|
||||||
|
})) as { success?: boolean; error?: string; notFound?: boolean };
|
||||||
|
if (res?.success) {
|
||||||
|
statusMessage = "Settings restored.";
|
||||||
|
} else {
|
||||||
|
statusError = res?.error ?? "Download failed";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusError = e instanceof Error ? e.message : "Download failed";
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if cloudState.isLoggedIn}
|
||||||
|
<div class="flex flex-col gap-2.5">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] font-semibold text-zinc-800 dark:text-zinc-100">Automatic sync</p>
|
||||||
|
<p class="text-[10px] text-zinc-500 dark:text-zinc-400">Syncs settings when SEQTA loads and when you make changes</p>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0">
|
||||||
|
<Switch
|
||||||
|
state={$settingsState.autoCloudSettingsSync !== false}
|
||||||
|
onChange={(isOn: boolean) => (settingsState.autoCloudSettingsSync = isOn)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
text={busy ? "Please wait\u2026" : "Upload"}
|
||||||
|
onClick={upload}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
text={busy ? "Please wait\u2026" : "Download"}
|
||||||
|
onClick={promptDownload}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if statusMessage}
|
||||||
|
<p class="text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
|
||||||
|
{/if}
|
||||||
|
{#if statusError}
|
||||||
|
<p class="text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||||
|
Passwords and tokens are never synced.
|
||||||
|
<a
|
||||||
|
href="https://betterseqta.org/privacy"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 rounded-sm"
|
||||||
|
>Privacy policy</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -4,15 +4,20 @@
|
|||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
import { portal } from "../utils/portal";
|
import { portal } from "../utils/portal";
|
||||||
|
|
||||||
const DEEPLINK_PREFIX = "desqta://connect/";
|
|
||||||
|
|
||||||
let showQrModal = $state(false);
|
let showQrModal = $state(false);
|
||||||
let qrDataUrl = $state<string | null>(null);
|
let qrDataUrl = $state<string | null>(null);
|
||||||
let deeplink = $state<string | null>(null);
|
let appLink = $state<string | null>(null);
|
||||||
let errorMessage = $state<string | null>(null);
|
let errorMessage = $state<string | null>(null);
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let isStandalone = $state(false);
|
let isStandalone = $state(false);
|
||||||
|
|
||||||
|
function isExtensionPage(): boolean {
|
||||||
|
return (
|
||||||
|
window.location.protocol === "chrome-extension:" ||
|
||||||
|
window.location.protocol === "moz-extension:"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isSeqtaUrl(url: string): boolean {
|
function isSeqtaUrl(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
@@ -31,30 +36,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDesqtaConnectPayload(baseUrl: string, jsessionId: string): string {
|
async function getAppLink(): Promise<string | null> {
|
||||||
const payload = JSON.stringify({ u: baseUrl, s: jsessionId });
|
|
||||||
const base64 = btoa(unescape(encodeURIComponent(payload)));
|
|
||||||
const encoded = encodeURIComponent(base64);
|
|
||||||
return `${DEEPLINK_PREFIX}${encoded}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSession(): Promise<{ baseUrl: string; jsessionId: string } | null> {
|
|
||||||
let baseUrl: string | undefined;
|
let baseUrl: string | undefined;
|
||||||
|
|
||||||
if (window.location.protocol === "chrome-extension:") {
|
if (isExtensionPage()) {
|
||||||
// Extension popup: background will get URL from active tab
|
|
||||||
baseUrl = undefined;
|
baseUrl = undefined;
|
||||||
} else {
|
} else {
|
||||||
// In-page (settings inside SEQTA): pass current page URL (cookies API not available in content scripts)
|
|
||||||
baseUrl = normalizeBaseUrl(window.location.href);
|
baseUrl = normalizeBaseUrl(window.location.href);
|
||||||
if (!isSeqtaUrl(baseUrl)) return null;
|
if (!isSeqtaUrl(baseUrl)) return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { session } = (await browser.runtime.sendMessage({
|
const { appLink: link } = (await browser.runtime.sendMessage({
|
||||||
type: "getSeqtaSession",
|
type: "getSeqtaSession",
|
||||||
baseUrl,
|
baseUrl,
|
||||||
})) as { session: { baseUrl: string; jsessionId: string } | null };
|
})) as { appLink: string | null };
|
||||||
return session ?? null;
|
return link ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateQrCode() {
|
async function generateQrCode() {
|
||||||
@@ -63,10 +59,10 @@
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isStandalone = window.location.protocol === "chrome-extension:";
|
isStandalone = isExtensionPage();
|
||||||
const session = await getSession();
|
const link = await getAppLink();
|
||||||
|
|
||||||
if (!session) {
|
if (!link) {
|
||||||
if (isStandalone) {
|
if (isStandalone) {
|
||||||
errorMessage =
|
errorMessage =
|
||||||
"Open SEQTA Learn in a tab and log in, then open settings from that tab to generate a QR code.";
|
"Open SEQTA Learn in a tab and log in, then open settings from that tab to generate a QR code.";
|
||||||
@@ -76,9 +72,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = buildDesqtaConnectPayload(session.baseUrl, session.jsessionId);
|
|
||||||
const dataUrl = await QRCode.toDataURL(link, { width: 256, margin: 2 });
|
const dataUrl = await QRCode.toDataURL(link, { width: 256, margin: 2 });
|
||||||
deeplink = link;
|
appLink = link;
|
||||||
qrDataUrl = dataUrl;
|
qrDataUrl = dataUrl;
|
||||||
showQrModal = true;
|
showQrModal = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -92,12 +87,12 @@
|
|||||||
function closeModal() {
|
function closeModal() {
|
||||||
showQrModal = false;
|
showQrModal = false;
|
||||||
qrDataUrl = null;
|
qrDataUrl = null;
|
||||||
deeplink = null;
|
appLink = null;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openInDesqta() {
|
function openAppLink() {
|
||||||
if (deeplink) window.location.href = deeplink;
|
if (appLink) window.location.href = appLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadQrImage() {
|
function downloadQrImage() {
|
||||||
@@ -125,7 +120,7 @@
|
|||||||
{#if showQrModal && qrDataUrl}
|
{#if showQrModal && qrDataUrl}
|
||||||
<div
|
<div
|
||||||
use:portal
|
use:portal
|
||||||
class="fixed cursor-auto inset-0 z-[10000] flex justify-center items-center bg-black/50 backdrop-blur-sm"
|
class="fixed cursor-auto inset-0 z-[10000] flex justify-center items-center bg-black/50 {isStandalone ? 'backdrop-blur-sm' : ''}"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
@@ -153,12 +148,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center p-4 bg-white rounded-xl dark:bg-zinc-900">
|
<div class="flex justify-center p-4 bg-white rounded-xl dark:bg-zinc-900">
|
||||||
<img src={qrDataUrl} alt="DesQTA QR Code" class="w-64 h-64" />
|
<img src={qrDataUrl} alt="SEQTA Learn app link QR code" class="w-64 h-64" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 mt-4">
|
<div class="flex flex-col gap-2 mt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={openInDesqta}
|
onclick={openAppLink}
|
||||||
class="px-4 py-2.5 w-full text-sm font-medium text-white bg-indigo-600 rounded-lg transition-colors dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
|
class="px-4 py-2.5 w-full text-sm font-medium text-white bg-indigo-600 rounded-lg transition-colors dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
|
||||||
Sign into DesQTA Desktop
|
Sign into DesQTA Desktop
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
import { FONT_PRESETS, DEFAULT_FONT_ID, getFontPreset } from "@/seqta/ui/fonts/presets";
|
||||||
|
import {
|
||||||
|
applySelectedFont,
|
||||||
|
buildFontPreviewCss,
|
||||||
|
ensureFontPickerFontsLoaded,
|
||||||
|
} from "@/seqta/ui/fonts/Manager";
|
||||||
|
import { portal } from "@/interface/utils/portal";
|
||||||
|
import { syncPageThemeToElement } from "@/interface/utils/syncPageTheme";
|
||||||
|
import fontPickerStyles from "./fontPickerModal.css?inline";
|
||||||
|
|
||||||
|
let { hidePicker } = $props<{ hidePicker: () => void }>();
|
||||||
|
|
||||||
|
let rootEl = $state<HTMLElement | null>(null);
|
||||||
|
let selectedId = $state(getFontPreset($settingsState.selectedFont).id);
|
||||||
|
let styleEl: HTMLStyleElement | null = null;
|
||||||
|
|
||||||
|
function selectFont(id: string) {
|
||||||
|
selectedId = id;
|
||||||
|
settingsState.selectedFont = id;
|
||||||
|
applySelectedFont(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetToDefault() {
|
||||||
|
selectFont(DEFAULT_FONT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) hidePicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTheme() {
|
||||||
|
if (rootEl) syncPageThemeToElement(rootEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void ensureFontPickerFontsLoaded();
|
||||||
|
|
||||||
|
styleEl = document.getElementById(
|
||||||
|
"betterseqta-font-picker-styles",
|
||||||
|
) as HTMLStyleElement | null;
|
||||||
|
if (!styleEl) {
|
||||||
|
styleEl = document.createElement("style");
|
||||||
|
styleEl.id = "betterseqta-font-picker-styles";
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
}
|
||||||
|
styleEl.textContent = `${fontPickerStyles}\n${buildFontPreviewCss()}`;
|
||||||
|
|
||||||
|
syncTheme();
|
||||||
|
|
||||||
|
const themeObserver = new MutationObserver(() => syncTheme());
|
||||||
|
themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["style", "class"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") hidePicker();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleEscapeKey);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
themeObserver.disconnect();
|
||||||
|
document.removeEventListener("keydown", handleEscapeKey);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
bind:this={rootEl}
|
||||||
|
use:portal={document.body}
|
||||||
|
class="bsplus-font-picker-overlay bsplus-font-picker-root"
|
||||||
|
onclick={handleBackdropClick}
|
||||||
|
onkeydown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") handleBackdropClick(event as unknown as MouseEvent);
|
||||||
|
}}
|
||||||
|
role="presentation"
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bsplus-font-picker-dialog"
|
||||||
|
onclick={(event) => event.stopPropagation()}
|
||||||
|
onkeydown={(event) => event.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="font-picker-title"
|
||||||
|
>
|
||||||
|
<header class="bsplus-font-picker-header">
|
||||||
|
<div class="bsplus-font-picker-header-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={resetToDefault}
|
||||||
|
disabled={selectedId === DEFAULT_FONT_ID}
|
||||||
|
class="bsplus-font-picker-reset"
|
||||||
|
aria-label="Reset font to default"
|
||||||
|
>
|
||||||
|
Reset to default
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={hidePicker}
|
||||||
|
class="bsplus-font-picker-done"
|
||||||
|
aria-label="Close font picker"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="bsplus-font-picker-header-text">
|
||||||
|
<h2 id="font-picker-title" class="bsplus-font-picker-title">
|
||||||
|
Choose a font
|
||||||
|
</h2>
|
||||||
|
<p class="bsplus-font-picker-desc">
|
||||||
|
Choose a typeface for SEQTA Learn.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="bsplus-font-picker-list">
|
||||||
|
{#each FONT_PRESETS as preset (preset.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectFont(preset.id)}
|
||||||
|
class="bsplus-font-picker-option {selectedId === preset.id ? 'is-selected' : ''}"
|
||||||
|
data-font-id={preset.id}
|
||||||
|
>
|
||||||
|
<div class="bsplus-font-picker-option-head">
|
||||||
|
<span class="bsplus-font-picker-option-name">{preset.name}</span>
|
||||||
|
{#if selectedId === preset.id}
|
||||||
|
<span class="bsplus-font-picker-badge">Selected</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,29 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import { animate } from "motion";
|
import { animate } from "motion";
|
||||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
|
import { onMount } from "svelte";
|
||||||
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
|
||||||
let { onClose } = $props<{ onClose: () => void }>();
|
let { onClose } = $props<{ onClose: () => void }>();
|
||||||
let modalElement: HTMLElement;
|
let modalElement: HTMLElement;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
return cloudAuth.subscribe((s) => {
|
||||||
|
if (s.isLoggedIn) onClose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (modalElement) {
|
if (modalElement) {
|
||||||
animate(modalElement, { scale: [0.9, 1], opacity: [0, 1] }, { type: "spring", stiffness: 300, damping: 25 });
|
animate(
|
||||||
|
modalElement,
|
||||||
|
{ scale: [0.9, 1], opacity: [0, 1] },
|
||||||
|
{ type: "spring", stiffness: 300, damping: 25 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSignIn() {
|
async function handleSignIn() {
|
||||||
onClose();
|
await cloudAuth.startLogin();
|
||||||
if (document.getElementById("ExtensionPopup")) {
|
|
||||||
closeExtensionPopup();
|
|
||||||
} else {
|
|
||||||
window.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex fixed inset-0 z-[10000] justify-center items-center bg-black/50"
|
class="flex fixed inset-0 z-[99999] justify-center items-center bg-black/50"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
@@ -37,7 +43,7 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
bind:this={modalElement}
|
bind:this={modalElement}
|
||||||
class="p-4 mx-4 w-full max-w-md bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white"
|
class="p-4 mx-4 w-full max-w-md max-h-[90vh] overflow-y-auto bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onkeydown={(e) => e.stopPropagation()}
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -45,32 +51,28 @@
|
|||||||
Sign in to favorite themes
|
Sign in to favorite themes
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="mb-6 text-zinc-600 dark:text-zinc-400">
|
<p class="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
Sign in in the Theme Store to save favorites across devices, or create an account to get started.
|
Sign in to the Theme Store to save favorites across devices, or create an account to get started.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 justify-end">
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleSignIn}
|
||||||
|
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Sign in with BetterSEQTA Cloud
|
||||||
|
</button>
|
||||||
|
<p class="mt-2 text-xs text-center text-zinc-400 dark:text-zinc-500">
|
||||||
|
Opens accounts.betterseqta.org in a new tab
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
|
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
OK
|
Close
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href="https://accounts.betterseqta.org/register"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
|
|
||||||
>
|
|
||||||
Create account
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={handleSignIn}
|
|
||||||
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
/* Font picker — analytics design tokens & components */
|
||||||
|
|
||||||
|
.bsplus-font-picker-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background: color-mix(in srgb, #000 52%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-root {
|
||||||
|
--bsplus-analytics-radius: 16px;
|
||||||
|
--bsplus-analytics-radius-sm: 12px;
|
||||||
|
--bsplus-analytics-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--bsplus-analytics-surface: var(--background-primary, #ffffff);
|
||||||
|
--bsplus-analytics-surface-2: var(--background-secondary, #f8fafc);
|
||||||
|
--bsplus-analytics-text: var(--text-primary, #1a1a1a);
|
||||||
|
--bsplus-analytics-muted: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--bsplus-analytics-text) 55%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
--bsplus-analytics-border: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--theme-offset-bg, var(--background-secondary, #e2e8f0)) 78%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
--bsplus-analytics-shadow: 0 5px 16px 6px rgba(0, 0, 0, 0.12);
|
||||||
|
--bsplus-analytics-shadow-hover: 0 8px 24px 8px rgba(0, 0, 0, 0.16);
|
||||||
|
--bsplus-analytics-accent: var(--better-main, #007bff);
|
||||||
|
|
||||||
|
font-family: Rubik, system-ui, sans-serif;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--bsplus-analytics-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-root.dark {
|
||||||
|
--bsplus-analytics-shadow: 0 5px 20px 6px rgba(0, 0, 0, 0.45);
|
||||||
|
--bsplus-analytics-shadow-hover: 0 10px 28px 10px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bsplus-font-picker-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px) scale(0.985);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-dialog {
|
||||||
|
width: min(100%, 22rem);
|
||||||
|
max-height: min(88vh, 820px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: auto;
|
||||||
|
border-radius: var(--bsplus-analytics-radius);
|
||||||
|
background: var(--bsplus-analytics-surface);
|
||||||
|
border: 1px solid var(--bsplus-analytics-border);
|
||||||
|
box-shadow: var(--bsplus-analytics-shadow-hover);
|
||||||
|
animation: bsplus-font-picker-in 0.45s var(--bsplus-analytics-ease) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.bsplus-font-picker-dialog {
|
||||||
|
width: min(92vw, 22rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bsplus-font-picker-dialog {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.85rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 1.15rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--bsplus-analytics-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-header-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-header-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bsplus-analytics-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-desc {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--bsplus-analytics-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-reset {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
border-radius: var(--bsplus-analytics-radius-sm);
|
||||||
|
border: 2px solid var(--bsplus-analytics-border);
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--bsplus-analytics-text);
|
||||||
|
transition:
|
||||||
|
transform 0.2s var(--bsplus-analytics-ease),
|
||||||
|
background 0.2s var(--bsplus-analytics-ease),
|
||||||
|
border-color 0.2s var(--bsplus-analytics-ease),
|
||||||
|
opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-reset:hover:not(:disabled) {
|
||||||
|
transform: scale(1.02);
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--bsplus-analytics-surface-2) 80%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-reset:active:not(:disabled) {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-reset:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px
|
||||||
|
color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-reset:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-done {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.65rem 1.25rem;
|
||||||
|
border-radius: var(--bsplus-analytics-radius-sm);
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bsplus-analytics-accent);
|
||||||
|
color: var(--text-color, #ffffff);
|
||||||
|
box-shadow: 0 2px 8px
|
||||||
|
color-mix(in srgb, var(--bsplus-analytics-accent) 40%, transparent);
|
||||||
|
transition:
|
||||||
|
transform 0.2s var(--bsplus-analytics-ease),
|
||||||
|
box-shadow 0.2s var(--bsplus-analytics-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-done:hover {
|
||||||
|
transform: scale(1.03);
|
||||||
|
box-shadow: 0 4px 14px
|
||||||
|
color-mix(in srgb, var(--bsplus-analytics-accent) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-done:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-done:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px
|
||||||
|
color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 1rem 1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent)
|
||||||
|
transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-list::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-list::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-option {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: var(--bsplus-analytics-radius-sm);
|
||||||
|
border: 1px solid var(--bsplus-analytics-border);
|
||||||
|
background: var(--bsplus-analytics-surface);
|
||||||
|
box-shadow: var(--bsplus-analytics-shadow);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: Rubik, system-ui, sans-serif;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition:
|
||||||
|
transform 0.25s var(--bsplus-analytics-ease),
|
||||||
|
box-shadow 0.25s var(--bsplus-analytics-ease),
|
||||||
|
border-color 0.2s ease,
|
||||||
|
background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-option:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--bsplus-analytics-shadow-hover);
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--bsplus-analytics-surface-2) 55%,
|
||||||
|
var(--bsplus-analytics-surface)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-option:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow:
|
||||||
|
var(--bsplus-analytics-shadow-hover),
|
||||||
|
0 0 0 3px color-mix(in srgb, var(--bsplus-analytics-accent) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-option.is-selected {
|
||||||
|
border-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--bsplus-analytics-accent) 45%,
|
||||||
|
var(--bsplus-analytics-border)
|
||||||
|
);
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--bsplus-analytics-accent) 10%,
|
||||||
|
var(--bsplus-analytics-surface)
|
||||||
|
);
|
||||||
|
box-shadow: 0 4px 16px
|
||||||
|
color-mix(in srgb, var(--bsplus-analytics-accent) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-option-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-root .bsplus-font-picker-option-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bsplus-analytics-text);
|
||||||
|
text-align: left;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-font-picker-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--bsplus-analytics-accent) 18%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
color: var(--bsplus-analytics-accent);
|
||||||
|
}
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
|
||||||
let username = $state("");
|
let { alwaysShowUserName = false, onClick = undefined } = $props<{
|
||||||
let password = $state("");
|
alwaysShowUserName?: boolean;
|
||||||
let loading = $state(false);
|
onClick?: () => void;
|
||||||
let error = $state<string | null>(null);
|
}>();
|
||||||
|
|
||||||
let cloudState = $state(cloudAuth.state);
|
let cloudState = $state(cloudAuth.state);
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let dropdownEl: HTMLElement;
|
let dropdownEl: HTMLElement;
|
||||||
@@ -35,32 +36,24 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleLogin() {
|
|
||||||
if (loading) return;
|
|
||||||
error = null;
|
|
||||||
if (!username.trim() || !password) {
|
|
||||||
error = "Please enter username and password";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading = true;
|
|
||||||
try {
|
|
||||||
const result = await cloudAuth.login(username.trim(), password);
|
|
||||||
if (result.success) {
|
|
||||||
password = "";
|
|
||||||
open = false;
|
|
||||||
} else {
|
|
||||||
error = result.error ?? "Login failed";
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await cloudAuth.logout();
|
await cloudAuth.logout();
|
||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSignIn() {
|
||||||
|
await cloudAuth.startLogin();
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleButtonClick() {
|
||||||
|
if (onClick) {
|
||||||
|
onClick();
|
||||||
|
} else {
|
||||||
|
open = !open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getInitials(): string {
|
function getInitials(): string {
|
||||||
const u = cloudState.user;
|
const u = cloudState.user;
|
||||||
if (!u) return "?";
|
if (!u) return "?";
|
||||||
@@ -74,31 +67,35 @@
|
|||||||
<div class="relative flex items-center" bind:this={dropdownEl}>
|
<div class="relative flex items-center" bind:this={dropdownEl}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (open = !open)}
|
onclick={handleButtonClick}
|
||||||
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-100/80 dark:bg-zinc-700/80 hover:bg-zinc-200/80 dark:hover:bg-zinc-600/80 transition-colors duration-200 text-base font-medium text-zinc-900 dark:text-white"
|
class="flex items-center gap-2 px-3 py-1.5 text-[0.75rem] rounded-lg shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white transition-colors duration-200"
|
||||||
>
|
>
|
||||||
{#if cloudState.isLoggedIn}
|
{#if cloudState.isLoggedIn}
|
||||||
{#if cloudState.user?.pfpUrl}
|
{#if cloudState.user?.pfpUrl}
|
||||||
<img
|
<img
|
||||||
src={cloudState.user.pfpUrl}
|
src={cloudState.user.pfpUrl}
|
||||||
alt=""
|
alt=""
|
||||||
class="w-8 h-8 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-sm">
|
<div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
|
||||||
{getInitials()}
|
{getInitials()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="hidden max-w-24 truncate sm:inline text-base">
|
<span
|
||||||
|
class={alwaysShowUserName
|
||||||
|
? "inline max-w-[10rem] truncate text-[0.75rem]"
|
||||||
|
: "hidden max-w-24 truncate sm:inline text-[0.75rem]"}
|
||||||
|
>
|
||||||
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
|
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-xl font-IconFamily" aria-hidden="true">{'\ued53'}</span>
|
<span class="text-sm font-IconFamily" aria-hidden="true">{'\ued53'}</span>
|
||||||
<span class="text-base font-medium">Sign in</span>
|
<span class="text-[0.75rem]">Sign in</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if open}
|
{#if !onClick && open}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
@@ -142,58 +139,21 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mb-4 text-base text-zinc-600 dark:text-zinc-400">
|
<div class="flex flex-col gap-3">
|
||||||
Sign in to favorite themes. Your favorites sync across devices when logged in.
|
<p class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
</p>
|
Sign in to sync favorites across devices.
|
||||||
<form
|
</p>
|
||||||
class="flex flex-col gap-3"
|
|
||||||
autocomplete="off"
|
|
||||||
onsubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleLogin();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="betterseqta-cloud-username"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="Email or username"
|
|
||||||
bind:value={username}
|
|
||||||
disabled={loading}
|
|
||||||
readonly
|
|
||||||
onfocus={(e) => e.currentTarget.removeAttribute('readonly')}
|
|
||||||
class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="betterseqta-cloud-password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Password"
|
|
||||||
bind:value={password}
|
|
||||||
disabled={loading}
|
|
||||||
readonly
|
|
||||||
onfocus={(e) => e.currentTarget.removeAttribute('readonly')}
|
|
||||||
class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
|
|
||||||
/>
|
|
||||||
{#if error}
|
|
||||||
<p class="text-base text-red-600 dark:text-red-400">{error}</p>
|
|
||||||
{/if}
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={loading}
|
onclick={handleSignIn}
|
||||||
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200"
|
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
{loading ? "Signing in..." : "Sign in"}
|
Sign in with BetterSEQTA Cloud
|
||||||
</button>
|
</button>
|
||||||
<a
|
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
|
||||||
href="https://accounts.betterseqta.org/register"
|
Opens accounts.betterseqta.org in a new tab
|
||||||
target="_blank"
|
</p>
|
||||||
rel="noopener noreferrer"
|
</div>
|
||||||
class="inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
|
|
||||||
>
|
|
||||||
Create account
|
|
||||||
</a>
|
|
||||||
</form>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
|
||||||
|
let {
|
||||||
|
introText,
|
||||||
|
onSuccess,
|
||||||
|
compact = false,
|
||||||
|
} = $props<{
|
||||||
|
introText?: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
/** Smaller padding/text for overlays (e.g. SignInToFavoriteModal) */
|
||||||
|
compact?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let username = $state("");
|
||||||
|
let password = $state("");
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
const inputClass = $derived(
|
||||||
|
compact
|
||||||
|
? "w-full px-4 py-2 text-sm rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
|
||||||
|
: "w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200",
|
||||||
|
);
|
||||||
|
|
||||||
|
const btnClass = $derived(
|
||||||
|
compact
|
||||||
|
? "w-full px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200"
|
||||||
|
: "w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200",
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkClass = $derived(
|
||||||
|
compact
|
||||||
|
? "inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
|
||||||
|
: "inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200",
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (loading) return;
|
||||||
|
error = null;
|
||||||
|
if (!username.trim() || !password) {
|
||||||
|
error = "Please enter username and password";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const result = await cloudAuth.login(username.trim(), password);
|
||||||
|
if (result.success) {
|
||||||
|
password = "";
|
||||||
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
error = result.error ?? "Login failed";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if introText}
|
||||||
|
<p
|
||||||
|
class="mb-4 text-zinc-600 dark:text-zinc-400 {compact ? 'text-sm' : 'text-base'}"
|
||||||
|
>
|
||||||
|
{introText}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<form
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
autocomplete="off"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleLogin();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="betterseqta-cloud-username"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Email or username"
|
||||||
|
bind:value={username}
|
||||||
|
disabled={loading}
|
||||||
|
readonly
|
||||||
|
onfocus={(e) => e.currentTarget.removeAttribute("readonly")}
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="betterseqta-cloud-password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="Password"
|
||||||
|
bind:value={password}
|
||||||
|
disabled={loading}
|
||||||
|
readonly
|
||||||
|
onfocus={(e) => e.currentTarget.removeAttribute("readonly")}
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
{#if error}
|
||||||
|
<p class="text-red-600 dark:text-red-400 {compact ? 'text-sm' : 'text-base'}">{error}</p>
|
||||||
|
{/if}
|
||||||
|
<button type="submit" disabled={loading} class={btnClass}>
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="https://accounts.betterseqta.org/register"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class={linkClass}
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
@@ -1,20 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { Theme } from '@/interface/types/Theme';
|
import type { ThemeCoverSlide } from '@/interface/types/Theme';
|
||||||
import emblaCarouselSvelte from 'embla-carousel-svelte';
|
import emblaCarouselSvelte from 'embla-carousel-svelte';
|
||||||
import Autoplay from 'embla-carousel-autoplay';
|
import Autoplay from 'embla-carousel-autoplay';
|
||||||
|
|
||||||
let { coverThemes, setDisplayTheme } = $props<{ coverThemes: Theme[], setDisplayTheme: (theme: Theme) => void }>();
|
let { slides, setDisplayTheme } = $props<{
|
||||||
|
slides: ThemeCoverSlide[];
|
||||||
|
setDisplayTheme: (theme: import('@/interface/types/Theme').Theme) => void;
|
||||||
|
}>();
|
||||||
let emblaApi = $state();
|
let emblaApi = $state();
|
||||||
|
|
||||||
const options = { loop: true };
|
const options = $derived({ loop: slides.length > 1 });
|
||||||
const plugins = [
|
const plugins = $derived(
|
||||||
Autoplay({
|
slides.length > 1
|
||||||
delay: 5000,
|
? [
|
||||||
stopOnInteraction: false,
|
Autoplay({
|
||||||
stopOnMouseEnter: true
|
delay: 5000,
|
||||||
})
|
stopOnInteraction: false,
|
||||||
];
|
stopOnMouseEnter: true,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
function onInit(event: CustomEvent) {
|
function onInit(event: CustomEvent) {
|
||||||
emblaApi = event.detail;
|
emblaApi = event.detail;
|
||||||
@@ -26,7 +33,7 @@
|
|||||||
const slideNext = () => emblaApi?.scrollNext();
|
const slideNext = () => emblaApi?.scrollNext();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if coverThemes.length > 0}
|
{#if slides.length > 0}
|
||||||
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
|
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
|
||||||
<div
|
<div
|
||||||
class="w-full aspect-[5/1] max-h-[500px]"
|
class="w-full aspect-[5/1] max-h-[500px]"
|
||||||
@@ -34,33 +41,69 @@
|
|||||||
onemblaInit={onInit}
|
onemblaInit={onInit}
|
||||||
>
|
>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
{#each coverThemes as theme}
|
{#each slides as slide (slide.imageUrl + slide.title + (slide.subtitle ?? ''))}
|
||||||
<div
|
<div
|
||||||
class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip"
|
class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
|
onkeydown={(e) => {
|
||||||
onclick={() => setDisplayTheme(theme)}
|
if (e.key === 'Enter') setDisplayTheme(slide.openTheme);
|
||||||
|
}}
|
||||||
|
onclick={() => setDisplayTheme(slide.openTheme)}
|
||||||
>
|
>
|
||||||
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-full" />
|
<img src={slide.imageUrl} alt="" class="object-cover w-full h-full" />
|
||||||
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
|
{#if slide.badgeFeatured === true}
|
||||||
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
|
<div class="absolute top-4 left-4 z-[2] pointer-events-none">
|
||||||
<p class='text-lg text-white'>{theme.description}</p>
|
<span
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm"
|
||||||
|
aria-label="Featured theme"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Featured
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/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>
|
||||||
<div class='absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80'></div>
|
<div class="absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80"></div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation buttons -->
|
<div class="flex absolute right-2 bottom-2 z-10 gap-2">
|
||||||
<div class='flex absolute right-2 bottom-2 z-10 gap-2'>
|
<button
|
||||||
<button aria-label="Previous" onclick={slidePrev} class='flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800'>
|
aria-label="Previous"
|
||||||
|
onclick={slidePrev}
|
||||||
|
type="button"
|
||||||
|
class="flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800 transition-all duration-200"
|
||||||
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button aria-label="Next" onclick={slideNext} class='flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800'>
|
<button
|
||||||
|
aria-label="Next"
|
||||||
|
onclick={slideNext}
|
||||||
|
type="button"
|
||||||
|
class="flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800 transition-all duration-200"
|
||||||
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,17 +1,55 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Theme } from '@/interface/types/Theme'
|
import type { Theme } from '@/interface/types/Theme'
|
||||||
|
import {
|
||||||
|
masterGridDisplayDownloadCount,
|
||||||
|
gridCardPreviewImageUrls,
|
||||||
|
} from '@/interface/utils/themeStoreFlavours'
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte';
|
import emblaCarouselSvelte from 'embla-carousel-svelte';
|
||||||
|
import Autoplay from 'embla-carousel-autoplay';
|
||||||
let { theme, onClick, toggleFavorite, isLoggedIn } = $props<{
|
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn, allStoreThemeRows } = $props<{
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
toggleFavorite: (theme: Theme) => void;
|
toggleFavorite: (theme: Theme) => void;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
|
onRequestSignIn?: () => void;
|
||||||
|
/** Raw API themes (includes hidden slaves) for aggregated master download totals */
|
||||||
|
allStoreThemeRows?: Theme[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const displayDownloadCount = $derived(
|
||||||
|
allStoreThemeRows != null
|
||||||
|
? masterGridDisplayDownloadCount(theme, allStoreThemeRows)
|
||||||
|
: (theme.download_count ?? 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const gridRotatorUrls = $derived(gridCardPreviewImageUrls(theme, allStoreThemeRows));
|
||||||
|
|
||||||
|
/** Mirrors CoverSwiper (featured bar): horizontal slides + autoplay */
|
||||||
|
function prefersReducedMotion(): boolean {
|
||||||
|
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read once synchronously where `window` exists so reduced-motion doesn’t briefly mount carousel */
|
||||||
|
let allowSlideAutoplay = $state(!prefersReducedMotion());
|
||||||
|
|
||||||
|
const gridEmblaKey = $derived(gridRotatorUrls.join('|'));
|
||||||
|
|
||||||
|
const gridEmblaOptions = $derived({ loop: gridRotatorUrls.length > 1 });
|
||||||
|
|
||||||
|
const gridEmblaPlugins = $derived.by(() => {
|
||||||
|
if (!allowSlideAutoplay || gridRotatorUrls.length <= 1) return [];
|
||||||
|
return [
|
||||||
|
Autoplay({
|
||||||
|
delay: 2000,
|
||||||
|
stopOnInteraction: false,
|
||||||
|
stopOnMouseEnter: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
let showSignInModal = $state(false);
|
|
||||||
let menuRef: HTMLDivElement;
|
let menuRef: HTMLDivElement;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -34,14 +72,36 @@
|
|||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
toggleFavorite(theme);
|
toggleFavorite(theme);
|
||||||
} else {
|
} else {
|
||||||
showSignInModal = true;
|
onRequestSignIn?.();
|
||||||
}
|
}
|
||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={handleCardClick}>
|
<div
|
||||||
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
|
class="relative z-0 hover:z-20 w-full cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
onkeydown={onClick}
|
||||||
|
onclick={handleCardClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-gray-50 w-full transition-all duration-500 ease-out relative group flex flex-col rounded-xl overflow-clip border hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto"
|
||||||
|
transition:fade
|
||||||
|
>
|
||||||
|
{#if theme.featured === true}
|
||||||
|
<div class="absolute top-2 left-2 z-20 pointer-events-none">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm"
|
||||||
|
aria-label="Featured theme"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
|
||||||
|
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Featured
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<!-- Menu dropdown -->
|
<!-- Menu dropdown -->
|
||||||
<div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}>
|
<div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}>
|
||||||
<button
|
<button
|
||||||
@@ -83,12 +143,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-1 left-3 right-3 z-10 mb-1 flex flex-col gap-0.5">
|
<div class="absolute bottom-1 left-3 right-3 z-10 mb-1 flex flex-col gap-0.5">
|
||||||
<span class="text-xl font-bold text-white drop-shadow-md">{theme.name}</span>
|
<span class="text-xl font-bold text-white drop-shadow-md">{theme.name}</span>
|
||||||
|
{#if theme.author}
|
||||||
|
<span class="text-xs text-white/85 drop-shadow-md line-clamp-1">By {theme.author}</span>
|
||||||
|
{/if}
|
||||||
<div class="flex gap-3 text-xs font-medium text-white/90 drop-shadow-sm">
|
<div class="flex gap-3 text-xs font-medium text-white/90 drop-shadow-sm">
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
</svg>
|
</svg>
|
||||||
{(theme.download_count ?? 0).toLocaleString()}
|
{displayDownloadCount.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
|
||||||
@@ -99,12 +162,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
|
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
|
||||||
<div class='w-full'>
|
{#if gridRotatorUrls.length === 0}
|
||||||
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
|
<div class="relative w-full h-48 overflow-hidden rounded-md bg-zinc-200 dark:bg-zinc-700" aria-hidden="true"></div>
|
||||||
</div>
|
{:else if !allowSlideAutoplay || gridRotatorUrls.length === 1}
|
||||||
|
<div class="relative w-full h-48 overflow-hidden rounded-md">
|
||||||
|
<img
|
||||||
|
src={gridRotatorUrls[0] ?? theme.marqueeImage ?? theme.coverImage}
|
||||||
|
alt=""
|
||||||
|
class="object-cover w-full h-full"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#key gridEmblaKey}
|
||||||
|
<div
|
||||||
|
class="relative w-full h-48 overflow-hidden rounded-md"
|
||||||
|
use:emblaCarouselSvelte={{
|
||||||
|
options: gridEmblaOptions,
|
||||||
|
plugins: gridEmblaPlugins,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex h-full">
|
||||||
|
{#each gridRotatorUrls as url (url)}
|
||||||
|
<div class="relative flex-[0_0_100%] min-w-0 h-full shrink-0">
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
class="object-cover w-full h-full select-none"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showSignInModal}
|
|
||||||
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -2,17 +2,31 @@
|
|||||||
import type { Theme } from '@/interface/types/Theme'
|
import type { Theme } from '@/interface/types/Theme'
|
||||||
import ThemeCard from './ThemeCard.svelte';
|
import ThemeCard from './ThemeCard.svelte';
|
||||||
|
|
||||||
let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn } = $props<{
|
let {
|
||||||
|
themes,
|
||||||
|
searchTerm,
|
||||||
|
setDisplayTheme,
|
||||||
|
toggleFavorite,
|
||||||
|
isLoggedIn,
|
||||||
|
onRequestSignIn,
|
||||||
|
allStoreThemeRows,
|
||||||
|
} = $props<{
|
||||||
themes: Theme[];
|
themes: Theme[];
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
setDisplayTheme: (theme: Theme) => void;
|
setDisplayTheme: (theme: Theme) => void;
|
||||||
toggleFavorite: (theme: Theme) => void;
|
toggleFavorite: (theme: Theme) => void;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
|
onRequestSignIn?: () => void;
|
||||||
|
/** Raw API list (includes `slave` rows) for master download aggregation */
|
||||||
|
allStoreThemeRows?: Theme[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let filteredThemes = $derived(themes.filter((theme: Theme) =>
|
let filteredThemes = $derived(themes.filter((theme: Theme) => {
|
||||||
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
|
const q = searchTerm.toLowerCase();
|
||||||
));
|
const name = (theme.name ?? '').toLowerCase();
|
||||||
|
const description = (theme.description ?? '').toLowerCase();
|
||||||
|
return name.includes(q) || description.includes(q);
|
||||||
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative" >
|
<div class="relative" >
|
||||||
@@ -23,12 +37,14 @@
|
|||||||
onClick={() => setDisplayTheme(theme)}
|
onClick={() => setDisplayTheme(theme)}
|
||||||
{toggleFavorite}
|
{toggleFavorite}
|
||||||
{isLoggedIn}
|
{isLoggedIn}
|
||||||
|
{onRequestSignIn}
|
||||||
|
{allStoreThemeRows}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if filteredThemes.length !== 0}
|
{#if filteredThemes.length !== 0}
|
||||||
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='w-full cursor-pointer'>
|
<a href="https://docs.betterseqta.org/theme-creation/" class="block relative z-0 hover:z-20 w-full cursor-pointer">
|
||||||
<div class="bg-zinc-50 h-48 w-full transition-all hover:scale-105 duration-500 relative justify-center items-center group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] rounded-xl overflow-clip border">
|
<div class="bg-zinc-50 h-48 w-full transition-all duration-500 ease-out relative overflow-clip rounded-xl border group group/card flex flex-col justify-center items-center hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1]">
|
||||||
<div class="text-2xl font-IconFamily">{'\uecb3'}</div>
|
<div class="text-2xl font-IconFamily">{'\uecb3'}</div>
|
||||||
<div class="text-xl font-bold text-center transition-all duration-500 dark:text-white">
|
<div class="text-xl font-bold text-center transition-all duration-500 dark:text-white">
|
||||||
Got a Theme Idea?
|
Got a Theme Idea?
|
||||||
@@ -43,7 +59,7 @@
|
|||||||
<div class="absolute top-0 flex flex-col items-center justify-center w-full text-center h-96">
|
<div class="absolute top-0 flex flex-col items-center justify-center w-full text-center h-96">
|
||||||
<h1 class="mt-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl">That doesn't exist! 😭😭😭</h1>
|
<h1 class="mt-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl">That doesn't exist! 😭😭😭</h1>
|
||||||
<p class="mt-6 text-lg leading-7 text-zinc-600 dark:text-zinc-300">Sorry, we couldn't find the theme you're looking for. Maybe... you could create it?</p>
|
<p class="mt-6 text-lg leading-7 text-zinc-600 dark:text-zinc-300">Sorry, we couldn't find the theme you're looking for. Maybe... you could create it?</p>
|
||||||
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
|
<a href="https://docs.betterseqta.org/theme-creation/" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
|
||||||
Show me how!
|
Show me how!
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -85,7 +85,7 @@
|
|||||||
try {
|
try {
|
||||||
const result = JSON.parse(event.target?.result as string);
|
const result = JSON.parse(event.target?.result as string);
|
||||||
tempTheme = result;
|
tempTheme = result;
|
||||||
await themeManager.installTheme(result);
|
await themeManager.installTheme(result, { fromStore: false });
|
||||||
await fetchThemes();
|
await fetchThemes();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing file:', error);
|
console.error('Error parsing file:', error);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
|
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
|
||||||
|
|
||||||
import ColourPicker from "../components/ColourPicker.svelte";
|
import ColourPicker from "../components/ColourPicker.svelte";
|
||||||
|
import FontPickerModal from "../components/FontPickerModal.svelte";
|
||||||
|
import CloudPanel from "../components/CloudPanel.svelte";
|
||||||
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
||||||
import { settingsPopup } from "../hooks/SettingsPopup";
|
import { settingsPopup } from "../hooks/SettingsPopup";
|
||||||
|
|
||||||
@@ -22,6 +24,8 @@
|
|||||||
let settingsActiveTab = $state(0);
|
let settingsActiveTab = $state(0);
|
||||||
let showDisclaimerModal = $state(false);
|
let showDisclaimerModal = $state(false);
|
||||||
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
|
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
|
||||||
|
let disclaimerTitle = $state("Confirm");
|
||||||
|
let disclaimerMessage = $state("");
|
||||||
|
|
||||||
const handleDevModeToggle = () => {
|
const handleDevModeToggle = () => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -44,6 +48,10 @@
|
|||||||
showColourPicker = true;
|
showColourPicker = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openFontPicker = () => {
|
||||||
|
showFontPicker = true;
|
||||||
|
};
|
||||||
|
|
||||||
const openChangelog = () => {
|
const openChangelog = () => {
|
||||||
OpenWhatsNewPopup();
|
OpenWhatsNewPopup();
|
||||||
closeExtensionPopup();
|
closeExtensionPopup();
|
||||||
@@ -66,15 +74,25 @@
|
|||||||
|
|
||||||
let { standalone } = $props<{ standalone?: boolean }>();
|
let { standalone } = $props<{ standalone?: boolean }>();
|
||||||
let showColourPicker = $state<boolean>(false);
|
let showColourPicker = $state<boolean>(false);
|
||||||
|
let showFontPicker = $state<boolean>(false);
|
||||||
|
let showCloudPanel = $state<boolean>(false);
|
||||||
|
|
||||||
const showDisclaimer = (onConfirm: () => void, onCancel: () => void) => {
|
const openCloudPanel = () => {
|
||||||
|
showCloudPanel = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDisclaimer = (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => {
|
||||||
disclaimerCallbacks = { onConfirm, onCancel };
|
disclaimerCallbacks = { onConfirm, onCancel };
|
||||||
|
disclaimerTitle = title ?? "Confirm";
|
||||||
|
disclaimerMessage = message ?? "";
|
||||||
showDisclaimerModal = true;
|
showDisclaimerModal = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
settingsPopup.addListener(() => {
|
settingsPopup.addListener(() => {
|
||||||
showColourPicker = false;
|
showColourPicker = false;
|
||||||
|
showFontPicker = false;
|
||||||
|
showCloudPanel = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (standalone) {
|
if (standalone) {
|
||||||
@@ -84,7 +102,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode
|
class="relative w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode
|
||||||
? 'dark'
|
? 'dark'
|
||||||
: ''} {standalone ? 'h-[600px]' : 'h-full rounded-xl'} overflow-clip"
|
: ''} {standalone ? 'h-[600px]' : 'h-full rounded-xl'} overflow-clip"
|
||||||
>
|
>
|
||||||
@@ -282,7 +300,7 @@
|
|||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
Content: Settings,
|
Content: Settings,
|
||||||
props: { showColourPicker: openColourPicker, showDisclaimer },
|
props: { showColourPicker: openColourPicker, showFontPicker: openFontPicker, showDisclaimer, showCloudPanel: openCloudPanel },
|
||||||
},
|
},
|
||||||
{ title: "Shortcuts", Content: Shortcuts },
|
{ title: "Shortcuts", Content: Shortcuts },
|
||||||
{ title: "Themes", Content: Theme },
|
{ title: "Themes", Content: Theme },
|
||||||
@@ -297,19 +315,28 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showCloudPanel}
|
||||||
|
<CloudPanel
|
||||||
|
hidePanel={() => {
|
||||||
|
showCloudPanel = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showFontPicker}
|
||||||
|
<FontPickerModal
|
||||||
|
hidePicker={() => {
|
||||||
|
showFontPicker = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showDisclaimerModal && disclaimerCallbacks}
|
{#if showDisclaimerModal && disclaimerCallbacks}
|
||||||
<DisclaimerModal
|
<DisclaimerModal
|
||||||
title="Assessment Averages Disclaimer"
|
title={disclaimerTitle}
|
||||||
message="This feature calculates a simple average of your assessment grades. It does not take into account:
|
message={disclaimerMessage}
|
||||||
• Assessment weightings
|
|
||||||
• Different grading scales
|
|
||||||
• Other factors used in official reports
|
|
||||||
|
|
||||||
The displayed average may be inaccurate compared to your actual marks found in reports.
|
|
||||||
|
|
||||||
Do you want to enable this feature?"
|
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
disclaimerCallbacks?.onConfirm();
|
disclaimerCallbacks?.onConfirm();
|
||||||
showDisclaimerModal = false;
|
showDisclaimerModal = false;
|
||||||
|
|||||||
@@ -11,10 +11,41 @@
|
|||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
|
||||||
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
|
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
|
||||||
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
|
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
|
||||||
|
import CloudSettingsSync from "@/interface/components/CloudSettingsSync.svelte"
|
||||||
|
import CloudHeader from "@/interface/components/store/CloudHeader.svelte"
|
||||||
|
import { cloudAuth } from "@/seqta/utils/CloudAuth"
|
||||||
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
|
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
|
||||||
|
import { showThemeOfTheMonthPopupNow } from "@/seqta/utils/Openers/OpenThemeOfTheMonthPopup"
|
||||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
||||||
|
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
|
||||||
|
import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase"
|
||||||
|
|
||||||
|
let devApiBaseInput = $state<string>(getStoredOverride() ?? "")
|
||||||
|
let devApiBaseActive = $state<string | null>(getStoredOverride())
|
||||||
|
|
||||||
|
function applyDevApiBase() {
|
||||||
|
const trimmed = devApiBaseInput.trim()
|
||||||
|
if (trimmed === "") {
|
||||||
|
setApiBase(null)
|
||||||
|
devApiBaseActive = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\//.test(trimmed)) {
|
||||||
|
alert("Please enter a full URL starting with http:// or https://")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setApiBase(trimmed)
|
||||||
|
devApiBaseActive = trimmed.replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDevApiBase() {
|
||||||
|
devApiBaseInput = ""
|
||||||
|
setApiBase(null)
|
||||||
|
devApiBaseActive = null
|
||||||
|
}
|
||||||
|
|
||||||
import { getAllPluginSettings } from "@/plugins"
|
import { getAllPluginSettings } from "@/plugins"
|
||||||
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"
|
||||||
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
|
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
|
||||||
|
|
||||||
// Union type representing all possible settings
|
// Union type representing all possible settings
|
||||||
@@ -49,9 +80,17 @@
|
|||||||
settings: Record<string, SettingType>;
|
settings: Record<string, SettingType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginSettings = getAllPluginSettings() as Plugin[];
|
const pluginSettings = getAllPluginSettings().filter(
|
||||||
|
(plugin) => !(isSeqtaEngageExperience() && plugin.pluginId === "global-search"),
|
||||||
|
) as Plugin[];
|
||||||
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({});
|
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({});
|
||||||
|
|
||||||
|
let cloudState = $state(cloudAuth.state);
|
||||||
|
$effect(() => {
|
||||||
|
const unsub = cloudAuth.subscribe((s) => { cloudState = s; });
|
||||||
|
return unsub;
|
||||||
|
});
|
||||||
|
|
||||||
async function loadPluginSettings() {
|
async function loadPluginSettings() {
|
||||||
for (const plugin of pluginSettings) {
|
for (const plugin of pluginSettings) {
|
||||||
if (Object.keys(plugin.settings).length === 0) continue;
|
if (Object.keys(plugin.settings).length === 0) continue;
|
||||||
@@ -93,10 +132,25 @@
|
|||||||
loadPluginSettings();
|
loadPluginSettings();
|
||||||
})
|
})
|
||||||
|
|
||||||
const { showColourPicker, showDisclaimer } = $props<{
|
const { showColourPicker, showFontPicker, showDisclaimer, showCloudPanel } = $props<{
|
||||||
showColourPicker: () => void;
|
showColourPicker: () => void;
|
||||||
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
|
showFontPicker: () => void;
|
||||||
|
showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void;
|
||||||
|
showCloudPanel: () => void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
async function exportCloudSettingsJsonToFile() {
|
||||||
|
const payload = await getSnapshotForUpload();
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `betterseqta-plus-settings-export-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet Setting({ title, description, Component, props }: SettingsList) }
|
{#snippet Setting({ title, description, Component, props }: SettingsList) }
|
||||||
@@ -115,33 +169,14 @@
|
|||||||
{#each [
|
{#each [
|
||||||
{
|
{
|
||||||
title: "Connect Mobile App",
|
title: "Connect Mobile App",
|
||||||
description: "Link your SEQTA session to DesQTA — the modern desktop and mobile app for SEQTA Learn.",
|
description: "Link your SEQTA session to DesQTA — the modern desktop and mobile app for SEQTA Learn",
|
||||||
id: 0,
|
id: 0,
|
||||||
Component: ConnectMobileApp,
|
Component: ConnectMobileApp,
|
||||||
props: {}
|
props: {}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Transparency Effects",
|
|
||||||
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)",
|
|
||||||
id: 1,
|
|
||||||
Component: Switch,
|
|
||||||
props: {
|
|
||||||
state: $settingsState.transparencyEffects,
|
|
||||||
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Custom Theme Colour",
|
|
||||||
description: "Customise the overall theme colour of SEQTA Learn.",
|
|
||||||
id: 4,
|
|
||||||
Component: PickerSwatch,
|
|
||||||
props: {
|
|
||||||
onClick: showColourPicker
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Edit Sidebar Layout",
|
title: "Edit Sidebar Layout",
|
||||||
description: "Customise the sidebar layout.",
|
description: "Reorder pages on the sidebar",
|
||||||
id: 5,
|
id: 5,
|
||||||
Component: Button,
|
Component: Button,
|
||||||
props: {
|
props: {
|
||||||
@@ -149,9 +184,28 @@
|
|||||||
text: "Edit"
|
text: "Edit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Custom Theme Colour",
|
||||||
|
description: "Customise the overall theme colour of SEQTA Learn",
|
||||||
|
id: 4,
|
||||||
|
Component: PickerSwatch,
|
||||||
|
props: {
|
||||||
|
onClick: showColourPicker
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Interface Font",
|
||||||
|
description: "Choose the typeface used across SEQTA Learn",
|
||||||
|
id: 16,
|
||||||
|
Component: Button,
|
||||||
|
props: {
|
||||||
|
onClick: showFontPicker,
|
||||||
|
text: "Change"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Icon Only Sidebar",
|
title: "Icon Only Sidebar",
|
||||||
description: "Show only icons in the sidebar for a compact layout.",
|
description: "Show only icons in the sidebar for a compact layout",
|
||||||
id: 14,
|
id: 14,
|
||||||
Component: Switch,
|
Component: Switch,
|
||||||
props: {
|
props: {
|
||||||
@@ -161,7 +215,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Animations",
|
title: "Animations",
|
||||||
description: "Enables animations on certain pages.",
|
description: "Enable animations on certain pages",
|
||||||
id: 6,
|
id: 6,
|
||||||
Component: Switch,
|
Component: Switch,
|
||||||
props: {
|
props: {
|
||||||
@@ -180,27 +234,37 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Default Page",
|
title: "Transparency Effects",
|
||||||
description: "The page to load when SEQTA Learn is opened.",
|
description: "Enable transparency effects on certain elements, such as blur (May impact battery life)",
|
||||||
id: 10,
|
id: 1,
|
||||||
Component: Select,
|
Component: Switch,
|
||||||
props: {
|
props: {
|
||||||
state: $settingsState.defaultPage,
|
state: $settingsState.transparencyEffects,
|
||||||
onChange: (value: string) => settingsState.defaultPage = value,
|
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
|
||||||
options: [
|
|
||||||
{ value: 'home', label: 'Home' },
|
|
||||||
{ value: 'dashboard', label: 'Dashboard' },
|
|
||||||
{ value: 'timetable', label: 'Timetable' },
|
|
||||||
{ value: 'welcome', label: 'Welcome' },
|
|
||||||
{ value: 'messages', label: 'Messages' },
|
|
||||||
{ value: 'documents', label: 'Documents' },
|
|
||||||
{ value: 'reports', label: 'Reports' },
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Default Page",
|
||||||
|
description: "Choose which page loads first when you open SEQTA",
|
||||||
|
id: 10,
|
||||||
|
Component: Select,
|
||||||
|
props: {
|
||||||
|
state: $settingsState.defaultPage ?? "home",
|
||||||
|
onChange: (value: string) => (settingsState.defaultPage = value),
|
||||||
|
options: [
|
||||||
|
{ value: "home", label: "Home" },
|
||||||
|
{ value: "dashboard", label: "Dashboard" },
|
||||||
|
{ value: "timetable", label: "Timetable" },
|
||||||
|
{ value: "welcome", label: "Welcome" },
|
||||||
|
{ value: "messages", label: "Messages" },
|
||||||
|
{ value: "documents", label: "Documents" },
|
||||||
|
{ value: "reports", label: "Reports" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "News Feed Source",
|
title: "News Feed Source",
|
||||||
description: "Choose sources of your news feed.",
|
description: "Choose the sources for your news feed",
|
||||||
id: 11,
|
id: 11,
|
||||||
Component: Select,
|
Component: Select,
|
||||||
props: {
|
props: {
|
||||||
@@ -230,7 +294,7 @@
|
|||||||
<div class="flex justify-between items-center px-4 py-3">
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
<div class="pr-4">
|
<div class="pr-4">
|
||||||
<h2 class="text-sm font-bold">Adaptive Theme Colour</h2>
|
<h2 class="text-sm font-bold">Adaptive Theme Colour</h2>
|
||||||
<p class="text-xs">Change the theme colour based on the current class (e.g. when viewing a course or assessments page).</p>
|
<p class="text-xs">Change the theme colour based on the current class (e.g. when viewing a course or assessments page)</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -243,7 +307,7 @@
|
|||||||
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
|
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
|
||||||
<div class="pr-4">
|
<div class="pr-4">
|
||||||
<h2 class="text-sm font-bold">Soft Gradient</h2>
|
<h2 class="text-sm font-bold">Soft Gradient</h2>
|
||||||
<p class="text-xs">Use a soft gradient instead of a solid colour when viewing a class.</p>
|
<p class="text-xs">Use a soft gradient instead of a solid colour when viewing a class</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -252,6 +316,18 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Smooth colour transition</h2>
|
||||||
|
<p class="text-xs">Ease between class/subject colours when navigating instead of switching instantly</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Switch
|
||||||
|
state={$settingsState.adaptiveThemeColourTransition ?? true}
|
||||||
|
onChange={(isOn: boolean) => settingsState.adaptiveThemeColourTransition = isOn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,9 +358,9 @@
|
|||||||
async () => {
|
async () => {
|
||||||
await updatePluginSetting(plugin.pluginId, 'enabled', true);
|
await updatePluginSetting(plugin.pluginId, 'enabled', true);
|
||||||
},
|
},
|
||||||
() => {
|
() => {},
|
||||||
// Do nothing on cancel
|
"Assessment Averages Disclaimer",
|
||||||
}
|
"This feature calculates a simple average of your assessment grades. It does not take into account:\n• Assessment weightings\n• Different grading scales\n• Other factors used in official reports\n\nThe displayed average may be inaccurate compared to your actual marks found in reports.\n\nDo you want to enable this feature?"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -297,8 +373,8 @@
|
|||||||
|
|
||||||
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
|
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
|
||||||
{#each Object.entries(plugin.settings) as [key, setting]}
|
{#each Object.entries(plugin.settings) as [key, setting]}
|
||||||
<!-- Skip the 'enabled' setting if it's part of the settings object -->
|
<!-- Skip the 'enabled' setting and hide cloud-only settings when not signed in -->
|
||||||
{#if key !== 'enabled'}
|
{#if key !== 'enabled' && !(key === 'useCloudPfp' && !cloudState.isLoggedIn)}
|
||||||
<div class="flex justify-between items-center px-4 py-3">
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
<div class="pr-4">
|
<div class="pr-4">
|
||||||
<h2 class="text-sm font-bold">{setting.title || key}</h2>
|
<h2 class="text-sm font-bold">{setting.title || key}</h2>
|
||||||
@@ -358,9 +434,40 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if plugin.pluginId === 'global-search'}
|
||||||
|
{@render Setting({
|
||||||
|
title: "Theme of the Month",
|
||||||
|
description: "Show the monthly featured theme popup when a new entry is available",
|
||||||
|
id: 15,
|
||||||
|
Component: Switch,
|
||||||
|
props: {
|
||||||
|
state: !($settingsState.themeOfTheMonthDisabled ?? false),
|
||||||
|
onChange: (isOn: boolean) => settingsState.themeOfTheMonthDisabled = !isOn
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<div class="border-none">
|
||||||
|
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
|
||||||
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">BetterSEQTA Cloud</h2>
|
||||||
|
<p class="text-xs">Account & sync</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CloudHeader alwaysShowUserName onClick={showCloudPanel} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if cloudState.isLoggedIn}
|
||||||
|
<div class="px-3 pb-3">
|
||||||
|
<CloudSettingsSync showDisclaimer={(onConfirm, onCancel) => showDisclaimer(onConfirm, onCancel, "Restore from cloud?", "This will replace your local settings with the cloud backup. Continue?")} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="p-1 border-none"></div>
|
<div class="p-1 border-none"></div>
|
||||||
|
|
||||||
{@render Setting({
|
{@render Setting({
|
||||||
@@ -428,6 +535,56 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Show Theme of the Month</h2>
|
||||||
|
<p class="text-xs">Fetch and show the current month's popup now (ignores dismissed state)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
closeExtensionPopup();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
await showThemeOfTheMonthPopupNow();
|
||||||
|
}}
|
||||||
|
text="Show Now"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
|
||||||
|
<p class="text-xs">Download the same payload as cloud sync (OAuth tokens stripped). For debugging and server testing.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 px-4 py-3">
|
||||||
|
<div class="flex justify-between items-start gap-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">API Base URL (session only)</h2>
|
||||||
|
<p class="text-xs">Override the content API host for this browser session. Cleared on restart. Affects themes, theme of the month, and other server-driven content.</p>
|
||||||
|
{#if devApiBaseActive}
|
||||||
|
<p class="text-xs mt-1 text-amber-600 dark:text-amber-400">
|
||||||
|
Override active: <span class="font-mono">{devApiBaseActive}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://betterseqta.org"
|
||||||
|
bind:value={devApiBaseInput}
|
||||||
|
class="flex-1 px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
<Button onClick={applyDevApiBase} text="Apply" />
|
||||||
|
{#if devApiBaseActive}
|
||||||
|
<Button onClick={clearDevApiBase} text="Clear" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import SkeletonLoader from '../components/SkeletonLoader.svelte';
|
import SkeletonLoader from '../components/SkeletonLoader.svelte';
|
||||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||||
import type { Theme } from '../types/Theme'
|
import type { Theme } from '../types/Theme'
|
||||||
|
import { visibleStoreThemes, buildCoverSlidesForThemes, normalizeStoreTheme } from '@/interface/utils/themeStoreFlavours'
|
||||||
import browser from 'webextension-polyfill'
|
import browser from 'webextension-polyfill'
|
||||||
import ThemeModal from '../components/store/ThemeModal.svelte'
|
import ThemeModal from '../components/store/ThemeModal.svelte'
|
||||||
import Header from '../components/store/Header.svelte'
|
import Header from '../components/store/Header.svelte'
|
||||||
@@ -16,6 +17,8 @@
|
|||||||
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
|
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
|
||||||
import Backgrounds from '../components/store/Backgrounds.svelte'
|
import Backgrounds from '../components/store/Backgrounds.svelte'
|
||||||
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
||||||
|
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
|
||||||
|
import { consumePendingHighlightThemeId } from '@/seqta/utils/openThemeStoreWithHighlight'
|
||||||
|
|
||||||
const themeManager = ThemeManager.getInstance();
|
const themeManager = ThemeManager.getInstance();
|
||||||
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
|
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
|
||||||
@@ -25,7 +28,12 @@
|
|||||||
// State variables
|
// State variables
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
let themes = $state<Theme[]>([]);
|
let themes = $state<Theme[]>([]);
|
||||||
let coverThemes = $state<Theme[]>([]);
|
|
||||||
|
/** Grid/search/cover: hides flat-listed slaves when API sends them */
|
||||||
|
let listThemes = $derived(visibleStoreThemes(themes));
|
||||||
|
|
||||||
|
/** Cover marquee slides (master + flavour imagery for top masters) */
|
||||||
|
let coverSlides = $derived(buildCoverSlidesForThemes(listThemes.slice(0, 3)));
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let darkMode = $state(false);
|
let darkMode = $state(false);
|
||||||
let displayTheme = $state<Theme | null>(null);
|
let displayTheme = $state<Theme | null>(null);
|
||||||
@@ -33,7 +41,21 @@
|
|||||||
let activeTab = $state('themes');
|
let activeTab = $state('themes');
|
||||||
|
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
let fetchAttempt = $state(0);
|
||||||
let selectedBackground = $state<string | null>(null);
|
let selectedBackground = $state<string | null>(null);
|
||||||
|
let showSignInOverlay = $state(false);
|
||||||
|
|
||||||
|
const MAX_FETCH_ATTEMPTS = 3;
|
||||||
|
const FETCH_MESSAGE_TIMEOUT_MS = 25_000;
|
||||||
|
|
||||||
|
function sendMessageWithTimeout<T>(message: object): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
browser.runtime.sendMessage(message) as Promise<T>,
|
||||||
|
new Promise<T>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('Theme store request timed out — reload the SEQTA page after updating the extension.')), FETCH_MESSAGE_TIMEOUT_MS);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
const fetchCurrentThemes = async () => {
|
const fetchCurrentThemes = async () => {
|
||||||
const themes = await themeManager.getAvailableThemes();
|
const themes = await themeManager.getAvailableThemes();
|
||||||
@@ -52,6 +74,17 @@
|
|||||||
activeTab = tab;
|
activeTab = tab;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Featured themes first; within each group, newest by `created_at` (API: Unix seconds). */
|
||||||
|
function compareStoreThemes(a: Theme, b: Theme): number {
|
||||||
|
const fa = a.featured === true ? 1 : 0;
|
||||||
|
const fb = b.featured === true ? 1 : 0;
|
||||||
|
if (fa !== fb) return fb - fa;
|
||||||
|
const ca = a.created_at ?? 0;
|
||||||
|
const cb = b.created_at ?? 0;
|
||||||
|
if (ca !== cb) return cb - ca;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
|
||||||
const toggleFavorite = async (theme: Theme) => {
|
const toggleFavorite = async (theme: Theme) => {
|
||||||
const token = await cloudAuth.getStoredToken();
|
const token = await cloudAuth.getStoredToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -80,47 +113,112 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Fetch themes via background script (avoids CORS when store runs inside SEQTA page)
|
// Fetch themes via background script (avoids CORS when store runs inside SEQTA page)
|
||||||
const fetchThemes = async () => {
|
const fetchThemes = async (isRetry = false) => {
|
||||||
|
if (!isRetry) {
|
||||||
|
fetchAttempt = 0;
|
||||||
|
error = null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const token = await cloudAuth.getStoredToken();
|
const token = await cloudAuth.getStoredToken();
|
||||||
const data = (await browser.runtime.sendMessage({
|
const data = await sendMessageWithTimeout<{
|
||||||
|
success?: boolean;
|
||||||
|
data?: { themes: unknown[] };
|
||||||
|
error?: string;
|
||||||
|
}>({
|
||||||
type: 'fetchThemes',
|
type: 'fetchThemes',
|
||||||
token: token ?? undefined,
|
token: token ?? undefined,
|
||||||
})) as {
|
});
|
||||||
success?: boolean;
|
if (!data?.success || !Array.isArray(data?.data?.themes)) {
|
||||||
data?: { themes: Theme[] };
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
if (!data?.success || !data?.data?.themes) {
|
|
||||||
throw new Error(data?.error || 'Failed to fetch themes');
|
throw new Error(data?.error || 'Failed to fetch themes');
|
||||||
}
|
}
|
||||||
themes = data.data.themes;
|
themes = data.data.themes
|
||||||
|
.map((row) => normalizeStoreTheme(row as Record<string, unknown>))
|
||||||
// Shuffle for cover themes
|
.filter((t) => t.id.length > 0)
|
||||||
const shuffled = [...themes].sort(() => 0.5 - Math.random());
|
.sort(compareStoreThemes);
|
||||||
coverThemes = shuffled.slice(0, 3);
|
error = null;
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch themes', err);
|
console.error('Failed to fetch themes', err);
|
||||||
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs
|
fetchAttempt += 1;
|
||||||
|
if (fetchAttempt >= MAX_FETCH_ATTEMPTS) {
|
||||||
|
error =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Could not load themes. Reload the SEQTA page, then open the store again.';
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(() => fetchThemes(true), 5000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function focusThemeById(themeId: string) {
|
||||||
|
const match = themes.find((t) => t.id === themeId)
|
||||||
|
?? themes.find((t) => t.flavours?.some((f) => f.id === themeId));
|
||||||
|
if (match) {
|
||||||
|
activeTab = 'themes';
|
||||||
|
searchTerm = '';
|
||||||
|
displayTheme = match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHighlightThemeEvent(e: Event) {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
if (detail?.themeId && typeof detail.themeId === 'string') {
|
||||||
|
focusThemeById(detail.themeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// On mount
|
// On mount
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
window.addEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
|
||||||
|
|
||||||
await fetchThemes();
|
await fetchThemes();
|
||||||
await fetchCurrentThemes();
|
await fetchCurrentThemes();
|
||||||
|
|
||||||
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
|
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
|
||||||
darkMode = $settingsState.DarkMode;
|
darkMode = $settingsState.DarkMode;
|
||||||
|
|
||||||
|
const pending = consumePendingHighlightThemeId();
|
||||||
|
if (pending) focusThemeById(pending);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter themes based on search term
|
// Filter themes (list is already featured-first, then newest; filter preserves order)
|
||||||
let filteredThemes = $derived(themes.filter(theme =>
|
let filteredThemes = $derived(
|
||||||
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
listThemes.filter((theme) => {
|
||||||
theme.description.toLowerCase().includes(searchTerm.toLowerCase())
|
const q = searchTerm.toLowerCase();
|
||||||
));
|
const name = (theme.name ?? '').toLowerCase();
|
||||||
|
const description = (theme.description ?? '').toLowerCase();
|
||||||
|
return name.includes(q) || description.includes(q);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function installThemeFromStore(themeId: string, meta: Theme) {
|
||||||
|
const fullRow = themes.find((x) => x.id === themeId);
|
||||||
|
if (fullRow) {
|
||||||
|
await themeManager.downloadTheme(fullRow);
|
||||||
|
} else {
|
||||||
|
const flavour = meta.flavours?.find((f) => f.id === themeId);
|
||||||
|
await themeManager.downloadTheme({
|
||||||
|
id: themeId,
|
||||||
|
name: flavour?.name ?? meta.name,
|
||||||
|
} as Theme);
|
||||||
|
}
|
||||||
|
await themeManager.setTheme(themeId);
|
||||||
|
themeUpdates.triggerUpdate();
|
||||||
|
await fetchCurrentThemes();
|
||||||
|
void browser.runtime.sendMessage({ type: 'cloudSettingsRequestDebouncedUpload' }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeThemeFromStore(themeId: string) {
|
||||||
|
await themeManager.deleteTheme(themeId);
|
||||||
|
themeUpdates.triggerUpdate();
|
||||||
|
await fetchCurrentThemes();
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
loadBackground();
|
loadBackground();
|
||||||
@@ -153,48 +251,64 @@
|
|||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<SkeletonLoader width="100%" height="200px" />
|
{#each Array(6) as _, i (i)}
|
||||||
|
<SkeletonLoader width="100%" height="200px" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex flex-col items-center justify-center py-24 text-center max-w-lg mx-auto">
|
||||||
|
<h2 class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">Couldn't load themes</h2>
|
||||||
|
<p class="mt-3 text-zinc-600 dark:text-zinc-300">{error}</p>
|
||||||
|
<p class="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
After an extension update, reload your SEQTA tab so the new version can talk to the browser.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-6 px-4 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700"
|
||||||
|
onclick={() => {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
void fetchThemes();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Themes Tab Content -->
|
<!-- Themes Tab Content -->
|
||||||
{#if activeTab === 'themes'}
|
{#if activeTab === 'themes'}
|
||||||
{#if searchTerm === ''}
|
{#if searchTerm === ''}
|
||||||
<CoverSwiper {coverThemes} {setDisplayTheme} />
|
<CoverSwiper slides={coverSlides} {setDisplayTheme} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ThemeGrid to display filtered themes -->
|
<!-- ThemeGrid to display filtered themes -->
|
||||||
<ThemeGrid
|
<ThemeGrid
|
||||||
themes={filteredThemes}
|
themes={filteredThemes}
|
||||||
|
allStoreThemeRows={themes}
|
||||||
{searchTerm}
|
{searchTerm}
|
||||||
{setDisplayTheme}
|
{setDisplayTheme}
|
||||||
{toggleFavorite}
|
{toggleFavorite}
|
||||||
isLoggedIn={cloudLoggedIn}
|
isLoggedIn={cloudLoggedIn}
|
||||||
|
onRequestSignIn={() => (showSignInOverlay = true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if displayTheme}
|
{#if displayTheme}
|
||||||
<ThemeModal
|
<ThemeModal
|
||||||
currentThemes={currentThemes}
|
currentThemes={currentThemes}
|
||||||
allThemes={themes}
|
allThemes={listThemes}
|
||||||
|
allStoreThemeRows={themes}
|
||||||
theme={displayTheme}
|
theme={displayTheme}
|
||||||
{displayTheme}
|
{displayTheme}
|
||||||
{setDisplayTheme}
|
{setDisplayTheme}
|
||||||
{toggleFavorite}
|
{toggleFavorite}
|
||||||
isLoggedIn={cloudLoggedIn}
|
isLoggedIn={cloudLoggedIn}
|
||||||
onInstall={async () => {
|
onRequestSignIn={() => (showSignInOverlay = true)}
|
||||||
if (displayTheme) {
|
onInstall={async (themeId: string) => {
|
||||||
await themeManager.downloadTheme(displayTheme);
|
if (displayTheme) await installThemeFromStore(themeId, displayTheme);
|
||||||
await themeManager.setTheme(displayTheme.id);
|
|
||||||
themeUpdates.triggerUpdate();
|
|
||||||
await fetchCurrentThemes();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onRemove={async () => {
|
onRemove={async (themeId: string) => {
|
||||||
if (displayTheme?.id) {
|
console.debug('deleting theme', themeId);
|
||||||
console.debug('deleting theme', displayTheme.id);
|
await removeThemeFromStore(themeId);
|
||||||
await themeManager.deleteTheme(displayTheme.id);
|
|
||||||
themeUpdates.triggerUpdate();
|
|
||||||
await fetchCurrentThemes();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -204,4 +318,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showSignInOverlay}
|
||||||
|
<SignInToFavoriteModal onClose={() => (showSignInOverlay = false)} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -4,7 +4,10 @@
|
|||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { type LoadedCustomTheme } from '@/types/CustomThemes'
|
import {
|
||||||
|
type LoadedCustomTheme,
|
||||||
|
shouldForceThemeAppearance,
|
||||||
|
} from '@/types/CustomThemes'
|
||||||
|
|
||||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||||
|
|
||||||
@@ -21,9 +24,9 @@
|
|||||||
handleImageVariableChange,
|
handleImageVariableChange,
|
||||||
handleCoverImageUpload
|
handleCoverImageUpload
|
||||||
} from '../utils/themeImageHandlers';
|
} from '../utils/themeImageHandlers';
|
||||||
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
|
||||||
import { themeUpdates } from '../hooks/ThemeUpdates'
|
|
||||||
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
||||||
|
import { themeUpdates } from '../hooks/ThemeUpdates'
|
||||||
|
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
||||||
|
|
||||||
const { themeID } = $props<{ themeID: string }>()
|
const { themeID } = $props<{ themeID: string }>()
|
||||||
const themeManager = ThemeManager.getInstance();
|
const themeManager = ThemeManager.getInstance();
|
||||||
@@ -40,7 +43,9 @@
|
|||||||
coverImage: null,
|
coverImage: null,
|
||||||
isEditable: true,
|
isEditable: true,
|
||||||
hideThemeName: false,
|
hideThemeName: false,
|
||||||
forceDark: undefined
|
forceTheme: undefined,
|
||||||
|
forceDark: undefined,
|
||||||
|
adaptiveCssVariables: [],
|
||||||
})
|
})
|
||||||
let closedAccordions = $state<string[]>([])
|
let closedAccordions = $state<string[]>([])
|
||||||
let themeLoaded = $state(false);
|
let themeLoaded = $state(false);
|
||||||
@@ -80,7 +85,13 @@
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
theme = loadedTheme
|
theme = {
|
||||||
|
...loadedTheme,
|
||||||
|
adaptiveCssVariables: loadedTheme.adaptiveCssVariables ?? [],
|
||||||
|
forceTheme:
|
||||||
|
loadedTheme.forceTheme ??
|
||||||
|
(loadedTheme.forceDark !== undefined ? true : undefined),
|
||||||
|
}
|
||||||
themeLoaded = true
|
themeLoaded = true
|
||||||
} else {
|
} else {
|
||||||
themeLoaded = true
|
themeLoaded = true
|
||||||
@@ -114,6 +125,14 @@
|
|||||||
blob: image.blob
|
blob: image.blob
|
||||||
}))
|
}))
|
||||||
themeClone.coverImage = theme.coverImage
|
themeClone.coverImage = theme.coverImage
|
||||||
|
themeClone.userEdited = true
|
||||||
|
|
||||||
|
if (shouldForceThemeAppearance(themeClone)) {
|
||||||
|
themeClone.forceTheme = true;
|
||||||
|
} else {
|
||||||
|
themeClone.forceTheme = false;
|
||||||
|
themeClone.forceDark = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
themeManager.clearPreview();
|
themeManager.clearPreview();
|
||||||
await themeManager.saveTheme(themeClone);
|
await themeManager.saveTheme(themeClone);
|
||||||
@@ -270,7 +289,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<h1 class='text-xl font-semibold'>Theme Creator</h1>
|
<h1 class='text-xl font-semibold'>Theme Creator</h1>
|
||||||
<a href='https://betterseqta.gitbook.io/betterseqta-docs' target='_blank' class='text-sm font-light text-zinc-500 dark:text-zinc-400'>
|
<a href='https://docs.betterseqta.org/theme-creation/' target='_blank' rel='noopener noreferrer' class='text-sm font-light text-zinc-500 dark:text-zinc-400'>
|
||||||
<span class='pr-0.5 no-underline font-IconFamily'>{'\ueb44'}</span>
|
<span class='pr-0.5 no-underline font-IconFamily'>{'\ueb44'}</span>
|
||||||
<span class='underline'>
|
<span class='underline'>
|
||||||
Need help? Check out the docs!
|
Need help? Check out the docs!
|
||||||
@@ -317,6 +336,27 @@
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
<div class="py-3">
|
||||||
|
<h2 class="text-sm font-bold">Adaptive CSS variables</h2>
|
||||||
|
<p class="text-xs text-zinc-600 dark:text-zinc-400">
|
||||||
|
One per line, each must start with <code class="text-xs">--</code>. These receive the same colour as the adaptive accent when "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 [
|
{#each [
|
||||||
{
|
{
|
||||||
type: 'switch',
|
type: 'switch',
|
||||||
@@ -332,21 +372,28 @@
|
|||||||
title: 'Force Theme',
|
title: 'Force Theme',
|
||||||
description: 'Force users to use either dark or light mode',
|
description: 'Force users to use either dark or light mode',
|
||||||
props: {
|
props: {
|
||||||
state: theme.forceDark !== undefined,
|
state: shouldForceThemeAppearance(theme),
|
||||||
onChange: (value: boolean) => theme = { ...theme, forceDark: value ? false : undefined }
|
onChange: (value: boolean) => {
|
||||||
|
if (value) {
|
||||||
|
theme = { ...theme, forceTheme: true, forceDark: false };
|
||||||
|
} else {
|
||||||
|
theme = { ...theme, forceTheme: false, forceDark: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'conditional',
|
type: 'conditional',
|
||||||
props: {
|
props: {
|
||||||
condition: theme.forceDark !== undefined,
|
condition: shouldForceThemeAppearance(theme),
|
||||||
children: {
|
children: {
|
||||||
type: 'lightDarkToggle',
|
type: 'lightDarkToggle',
|
||||||
title: 'Mode',
|
title: 'Mode',
|
||||||
description: 'Choose whether to force light or dark mode',
|
description: 'Choose whether to force light or dark mode',
|
||||||
props: {
|
props: {
|
||||||
state: theme.forceDark === true,
|
state: theme.forceDark === true,
|
||||||
onChange: (value: boolean) => theme = { ...theme, forceDark: value }
|
onChange: (value: boolean) =>
|
||||||
|
(theme = { ...theme, forceDark: value, forceTheme: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
export type ThemeRole = "standard" | "master" | "slave";
|
||||||
|
|
||||||
|
/** List/detail metadata for variants of a master theme (full theme.json fetched at install by id). */
|
||||||
|
export type ThemeFlavour = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Mirrors theme.json accent (e.g. defaultColour); used for install picker buttons */
|
||||||
|
accent_color: string;
|
||||||
|
cover_image: string;
|
||||||
|
marquee_image?: string;
|
||||||
|
/** Per-variant installs when slaves are not returned as flat `theme_role` rows */
|
||||||
|
download_count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type Theme = {
|
export type Theme = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -8,4 +22,29 @@ export type Theme = {
|
|||||||
is_favorited?: boolean;
|
is_favorited?: boolean;
|
||||||
favorite_count?: number;
|
favorite_count?: number;
|
||||||
download_count?: number;
|
download_count?: number;
|
||||||
|
author?: string;
|
||||||
|
featured?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
/** Unix time in seconds (API list/detail). */
|
||||||
|
created_at?: number;
|
||||||
|
/** Unix seconds — last server update (GET /api/themes). */
|
||||||
|
updated_at?: number;
|
||||||
|
/** Omitted / `standard` — show in grid. `slave` hides from grid. `master` can list `flavours`. */
|
||||||
|
theme_role?: ThemeRole;
|
||||||
|
/** Present when `theme_role === "slave"` and API returns a flat list during migration */
|
||||||
|
master_id?: string;
|
||||||
|
/** Variants nested on master rows; installs use flavour `id` */
|
||||||
|
flavours?: ThemeFlavour[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One marquee slide (cover hero or modal carousel). */
|
||||||
|
export type ThemeCoverSlide = {
|
||||||
|
imageUrl: string;
|
||||||
|
/** Main line — usually master name */
|
||||||
|
title: string;
|
||||||
|
/** Subline — flavour name when applicable */
|
||||||
|
subtitle?: string;
|
||||||
|
/** Opening the modal uses this theme (always the grid row / master object) */
|
||||||
|
openTheme: Theme;
|
||||||
|
badgeFeatured?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,16 +4,21 @@ import type { Action } from "svelte/action";
|
|||||||
* Svelte action that moves the element to a different DOM target.
|
* Svelte action that moves the element to a different DOM target.
|
||||||
* Defaults to the nearest ShadowRoot so styles remain intact when the app
|
* Defaults to the nearest ShadowRoot so styles remain intact when the app
|
||||||
* is rendered inside a shadow DOM. Falls back to document.body otherwise.
|
* is rendered inside a shadow DOM. Falls back to document.body otherwise.
|
||||||
* Keeps all Svelte reactivity/events intact while escaping ancestor stacking contexts.
|
* Pass `document.body` to escape transformed/contained settings popups entirely.
|
||||||
*/
|
*/
|
||||||
export const portal: Action<HTMLElement, HTMLElement | ShadowRoot | undefined> = (node, target) => {
|
export const portal: Action<HTMLElement, HTMLElement | ShadowRoot | undefined> = (
|
||||||
|
node,
|
||||||
|
target,
|
||||||
|
) => {
|
||||||
const root = node.getRootNode();
|
const root = node.getRootNode();
|
||||||
const dest = target ?? (root instanceof ShadowRoot ? root : document.body);
|
const dest = target ?? (root instanceof ShadowRoot ? root : document.body);
|
||||||
dest.appendChild(node);
|
dest.appendChild(node);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
update(newTarget) {
|
update(newTarget) {
|
||||||
(newTarget ?? dest).appendChild(node);
|
const nextDest =
|
||||||
|
newTarget ?? (root instanceof ShadowRoot ? root : document.body);
|
||||||
|
nextDest.appendChild(node);
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
node.remove();
|
node.remove();
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
|
||||||
|
const THEME_CSS_VARS = [
|
||||||
|
"--better-main",
|
||||||
|
"--better-pale",
|
||||||
|
"--better-light",
|
||||||
|
"--text-color",
|
||||||
|
"--background-primary",
|
||||||
|
"--background-secondary",
|
||||||
|
"--text-primary",
|
||||||
|
"--theme-offset-bg",
|
||||||
|
"--better-sub",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ACCENT_CSS_VARS = [
|
||||||
|
"--better-main",
|
||||||
|
"--accent-color-value",
|
||||||
|
"--accentColor",
|
||||||
|
"--colour-betterseqta-blue",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function extractSolidColor(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || trimmed === "initial") return null;
|
||||||
|
if (
|
||||||
|
trimmed.startsWith("#") ||
|
||||||
|
trimmed.startsWith("rgb") ||
|
||||||
|
trimmed.startsWith("hsl")
|
||||||
|
) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
if (trimmed.includes("gradient")) {
|
||||||
|
const match = trimmed.match(
|
||||||
|
/#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/i,
|
||||||
|
);
|
||||||
|
return match?.[0] ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePageAccentColor(): string {
|
||||||
|
const computed = getComputedStyle(document.documentElement);
|
||||||
|
for (const name of ACCENT_CSS_VARS) {
|
||||||
|
const solid = extractSolidColor(computed.getPropertyValue(name));
|
||||||
|
if (solid) return solid;
|
||||||
|
}
|
||||||
|
const fromSettings = settingsState.selectedColor?.trim();
|
||||||
|
if (fromSettings) {
|
||||||
|
const solid = extractSolidColor(fromSettings);
|
||||||
|
if (solid) return solid;
|
||||||
|
}
|
||||||
|
return "#007bff";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy SEQTA page theme tokens onto a portaled UI root (matches analytics sync). */
|
||||||
|
export function syncPageThemeToElement(target: HTMLElement): void {
|
||||||
|
const computed = getComputedStyle(document.documentElement);
|
||||||
|
|
||||||
|
for (const name of THEME_CSS_VARS) {
|
||||||
|
const value = computed.getPropertyValue(name).trim();
|
||||||
|
if (value) {
|
||||||
|
target.style.setProperty(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accent = resolvePageAccentColor();
|
||||||
|
target.style.setProperty("--bsplus-analytics-accent", accent);
|
||||||
|
target.style.setProperty("--better-main", accent);
|
||||||
|
|
||||||
|
target.classList.toggle(
|
||||||
|
"dark",
|
||||||
|
document.documentElement.classList.contains("dark"),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import type { Theme, ThemeCoverSlide, ThemeFlavour } from "@/interface/types/Theme";
|
||||||
|
|
||||||
|
export function isHiddenStoreTheme(theme: Theme): boolean {
|
||||||
|
return theme.theme_role === "slave";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coerce API / fallback rows into the store `Theme` shape (camelCase images, safe strings). */
|
||||||
|
export function normalizeStoreTheme(raw: Record<string, unknown>): Theme {
|
||||||
|
const flavours = Array.isArray(raw.flavours)
|
||||||
|
? (raw.flavours as Record<string, unknown>[]).map(
|
||||||
|
(f): ThemeFlavour => ({
|
||||||
|
id: String(f.id ?? ""),
|
||||||
|
name: String(f.name ?? ""),
|
||||||
|
accent_color: String(f.accent_color ?? f.accentColor ?? ""),
|
||||||
|
cover_image: String(f.cover_image ?? f.coverImage ?? ""),
|
||||||
|
marquee_image:
|
||||||
|
typeof (f.marquee_image ?? f.marqueeImage) === "string"
|
||||||
|
? String(f.marquee_image ?? f.marqueeImage)
|
||||||
|
: undefined,
|
||||||
|
download_count:
|
||||||
|
typeof f.download_count === "number"
|
||||||
|
? f.download_count
|
||||||
|
: typeof f.downloadCount === "number"
|
||||||
|
? f.downloadCount
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(raw.id ?? ""),
|
||||||
|
name: String(raw.name ?? "Untitled"),
|
||||||
|
description: String(raw.description ?? ""),
|
||||||
|
coverImage: String(raw.coverImage ?? raw.cover_image ?? ""),
|
||||||
|
marqueeImage:
|
||||||
|
typeof (raw.marqueeImage ?? raw.marquee_image) === "string"
|
||||||
|
? String(raw.marqueeImage ?? raw.marquee_image)
|
||||||
|
: undefined,
|
||||||
|
theme_json_url:
|
||||||
|
typeof (raw.theme_json_url ?? raw.themeJsonUrl) === "string"
|
||||||
|
? String(raw.theme_json_url ?? raw.themeJsonUrl)
|
||||||
|
: undefined,
|
||||||
|
is_favorited: raw.is_favorited === true || raw.isFavorited === true,
|
||||||
|
favorite_count:
|
||||||
|
typeof raw.favorite_count === "number"
|
||||||
|
? raw.favorite_count
|
||||||
|
: typeof raw.favoriteCount === "number"
|
||||||
|
? raw.favoriteCount
|
||||||
|
: undefined,
|
||||||
|
download_count:
|
||||||
|
typeof raw.download_count === "number"
|
||||||
|
? raw.download_count
|
||||||
|
: typeof raw.downloadCount === "number"
|
||||||
|
? raw.downloadCount
|
||||||
|
: undefined,
|
||||||
|
author: typeof raw.author === "string" ? raw.author : undefined,
|
||||||
|
featured: raw.featured === true,
|
||||||
|
tags: Array.isArray(raw.tags) ? (raw.tags as string[]) : undefined,
|
||||||
|
created_at:
|
||||||
|
typeof raw.created_at === "number"
|
||||||
|
? raw.created_at
|
||||||
|
: typeof raw.createdAt === "number"
|
||||||
|
? raw.createdAt
|
||||||
|
: undefined,
|
||||||
|
updated_at:
|
||||||
|
typeof raw.updated_at === "number"
|
||||||
|
? raw.updated_at
|
||||||
|
: typeof raw.updatedAt === "number"
|
||||||
|
? raw.updatedAt
|
||||||
|
: undefined,
|
||||||
|
theme_role:
|
||||||
|
raw.theme_role === "master" || raw.theme_role === "slave" || raw.theme_role === "standard"
|
||||||
|
? raw.theme_role
|
||||||
|
: undefined,
|
||||||
|
master_id:
|
||||||
|
typeof (raw.master_id ?? raw.masterId) === "string"
|
||||||
|
? String(raw.master_id ?? raw.masterId)
|
||||||
|
: undefined,
|
||||||
|
flavours,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grid and search: omit slave rows (when API sends a flattened list). */
|
||||||
|
export function visibleStoreThemes(themes: Theme[]): Theme[] {
|
||||||
|
const visible = themes.filter((t) => !isHiddenStoreTheme(t));
|
||||||
|
// If every row is a slave (bad/migration payload), avoid an empty grid.
|
||||||
|
if (visible.length === 0 && themes.length > 0) {
|
||||||
|
return themes;
|
||||||
|
}
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as pdfjs from "pdfjs-dist";
|
||||||
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
|
/** Static copies in `src/public` (see `scripts/copy-pdfjs-assets.mjs`, manifest web_accessible_resources). */
|
||||||
|
const PDF_WORKER_RESOURCE = "resources/pdfjs/pdf.worker.min.mjs";
|
||||||
|
const PDF_LEGACY_RESOURCE = "resources/pdfjs/pdf.legacy.min.mjs";
|
||||||
|
|
||||||
|
function extensionAssetUrl(relativePath: string): string {
|
||||||
|
return browser.runtime.getURL(relativePath.replace(/^\/+/, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
let workerConfigured = false;
|
||||||
|
|
||||||
|
/** Required before pdfjs spawns a worker (content-script / extension isolate). */
|
||||||
|
export function ensurePdfjsWorker(): void {
|
||||||
|
if (workerConfigured) return;
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = extensionAssetUrl(PDF_WORKER_RESOURCE);
|
||||||
|
workerConfigured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page-context script on Firefox must load these chrome-extension:// URLs (see web_accessible_resources). */
|
||||||
|
export function getPdfjsPageContextUrls(): { lib: string; worker: string } {
|
||||||
|
return {
|
||||||
|
lib: extensionAssetUrl(PDF_LEGACY_RESOURCE),
|
||||||
|
worker: extensionAssetUrl(PDF_WORKER_RESOURCE),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"64": "resources/icons/icon-64.png"
|
"64": "resources/icons/icon-64.png"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permissions": ["tabs", "notifications", "storage", "cookies"],
|
"permissions": ["tabs", "notifications", "storage"],
|
||||||
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
|
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
|
||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background.ts"
|
"service_worker": "background.ts"
|
||||||
@@ -32,7 +32,12 @@
|
|||||||
],
|
],
|
||||||
"web_accessible_resources": [
|
"web_accessible_resources": [
|
||||||
{
|
{
|
||||||
"resources": ["resources/icons/*", "resources/update-image.webp"],
|
"resources": [
|
||||||
|
"resources/icons/*",
|
||||||
|
"resources/update-image.webp",
|
||||||
|
"resources/pdfjs/pdf.worker.min.mjs",
|
||||||
|
"resources/pdfjs/pdf.legacy.min.mjs"
|
||||||
|
],
|
||||||
"matches": ["*://*/*"]
|
"matches": ["*://*/*"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Setting,
|
Setting,
|
||||||
} from "@/plugins/core/settingsHelpers";
|
} from "@/plugins/core/settingsHelpers";
|
||||||
import styles from "./styles.css?inline";
|
import styles from "./styles.css?inline";
|
||||||
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
|
||||||
const settings = defineSettings({
|
const settings = defineSettings({
|
||||||
speed: numberSetting({
|
speed: numberSetting({
|
||||||
@@ -35,13 +36,10 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
|
|||||||
settings: instance.settings,
|
settings: instance.settings,
|
||||||
|
|
||||||
run: async (api) => {
|
run: async (api) => {
|
||||||
// Create the background elements
|
const [container, menu] = await Promise.all([
|
||||||
const container = document.getElementById("container");
|
waitForElm("#container", true),
|
||||||
const menu = document.getElementById("menu");
|
waitForElm("#menu", true),
|
||||||
|
]);
|
||||||
if (!container || !menu) {
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const backgrounds = [
|
const backgrounds = [
|
||||||
{ classes: ["bg"] },
|
{ classes: ["bg"] },
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
|
||||||
|
|
||||||
|
function randomEngagePdfFileName(): string {
|
||||||
|
const token = Math.random().toString(36).slice(2, 10);
|
||||||
|
return `${token}.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestEngageAssessmentPdf(params: {
|
||||||
|
assessmentID: string | number;
|
||||||
|
metaclassID: string | number;
|
||||||
|
studentID: string | number;
|
||||||
|
}): Promise<string> {
|
||||||
|
const fileName = randomEngagePdfFileName();
|
||||||
|
const cacheBuster = Math.random().toString(36).slice(2, 10);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${location.origin}/seqta/parent/print/assessment?${cacheBuster}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: params.assessmentID,
|
||||||
|
metaclass: params.metaclassID,
|
||||||
|
student: Number(params.studentID),
|
||||||
|
fileName,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to generate PDF: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
payload?: { file?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
return data.payload?.file ?? fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEngageAssessmentReportUrl(fileName: string): string {
|
||||||
|
return `${location.origin}/seqta/parent/report/get?file=${encodeURIComponent(fileName)}`;
|
||||||
|
}
|
||||||
@@ -7,19 +7,21 @@ import {
|
|||||||
import { type Plugin } from "@/plugins/core/types";
|
import { type Plugin } from "@/plugins/core/types";
|
||||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
|
|
||||||
import {
|
import {
|
||||||
clearStuck,
|
clearStuck,
|
||||||
getClassByPattern,
|
getClassByPattern,
|
||||||
initStorage,
|
initStorage,
|
||||||
|
injectWeightingsTab,
|
||||||
letterToNumber,
|
letterToNumber,
|
||||||
parseAssessments,
|
parseAssessments,
|
||||||
processAssessments,
|
processAssessments,
|
||||||
} from "./utils.ts";
|
} from "./utils.ts";
|
||||||
|
import { injectRubricCopyButtons } from "./rubricCopy.ts";
|
||||||
|
|
||||||
interface weightingsStorage {
|
interface weightingsStorage {
|
||||||
weightings: Record<string, string>;
|
weightings: Record<string, string>;
|
||||||
assessments: Record<string, string>;
|
assessments: Record<string, string>;
|
||||||
|
weightingOverrides: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = defineSettings({
|
const settings = defineSettings({
|
||||||
@@ -37,6 +39,8 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
|
|||||||
|
|
||||||
const instance = new AssessmentsAveragePluginClass();
|
const instance = new AssessmentsAveragePluginClass();
|
||||||
|
|
||||||
|
let overrideListenerController: AbortController | null = null;
|
||||||
|
|
||||||
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||||
id: "assessments-average",
|
id: "assessments-average",
|
||||||
name: "Assessment Averages",
|
name: "Assessment Averages",
|
||||||
@@ -58,143 +62,150 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await parseAssessments(api);
|
await parseAssessments(api);
|
||||||
|
await renderSubjectAverage(api);
|
||||||
const sampleAssessmentItem = document.querySelector(
|
overrideListenerController?.abort();
|
||||||
"[class*='AssessmentItem__AssessmentItem___']",
|
overrideListenerController = new AbortController();
|
||||||
|
document.addEventListener(
|
||||||
|
"betterseqta:overrideChanged",
|
||||||
|
() => renderSubjectAverage(api),
|
||||||
|
{ signal: overrideListenerController.signal },
|
||||||
);
|
);
|
||||||
if (!sampleAssessmentItem) return;
|
|
||||||
|
|
||||||
const assessmentItemClass =
|
|
||||||
Array.from(sampleAssessmentItem.classList).find((c) =>
|
|
||||||
c.startsWith("AssessmentItem__AssessmentItem___"),
|
|
||||||
) || "";
|
|
||||||
|
|
||||||
const metaContainerClass = getClassByPattern(
|
|
||||||
sampleAssessmentItem,
|
|
||||||
"AssessmentItem__metaContainer___",
|
|
||||||
);
|
|
||||||
const metaClass = getClassByPattern(
|
|
||||||
sampleAssessmentItem,
|
|
||||||
"AssessmentItem__meta___",
|
|
||||||
);
|
|
||||||
const simpleResultClass = getClassByPattern(
|
|
||||||
sampleAssessmentItem,
|
|
||||||
"AssessmentItem__simpleResult___",
|
|
||||||
);
|
|
||||||
const titleClass = getClassByPattern(
|
|
||||||
sampleAssessmentItem,
|
|
||||||
"AssessmentItem__title___",
|
|
||||||
);
|
|
||||||
|
|
||||||
const thermoscoreElement = document.querySelector(
|
|
||||||
"[class*='Thermoscore__Thermoscore___']",
|
|
||||||
);
|
|
||||||
if (!thermoscoreElement) return;
|
|
||||||
|
|
||||||
const thermoscoreClass =
|
|
||||||
Array.from(thermoscoreElement.classList).find((c) =>
|
|
||||||
c.startsWith("Thermoscore__Thermoscore___"),
|
|
||||||
) || "";
|
|
||||||
const fillClass = getClassByPattern(
|
|
||||||
thermoscoreElement,
|
|
||||||
"Thermoscore__fill___",
|
|
||||||
);
|
|
||||||
const textClass = getClassByPattern(
|
|
||||||
thermoscoreElement,
|
|
||||||
"Thermoscore__text___",
|
|
||||||
);
|
|
||||||
|
|
||||||
const assessmentsList = document.querySelector(
|
|
||||||
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
|
|
||||||
);
|
|
||||||
if (!assessmentsList) return;
|
|
||||||
|
|
||||||
const state = await ReactFiber.find(
|
|
||||||
"[class*='AssessmentList__items___']",
|
|
||||||
).getState();
|
|
||||||
const marks = state["marks"];
|
|
||||||
if (!marks || !marks.length) return;
|
|
||||||
|
|
||||||
const assessmentItems = Array.from(
|
|
||||||
assessmentsList.querySelectorAll(
|
|
||||||
`[class*='AssessmentItem__AssessmentItem___']`,
|
|
||||||
),
|
|
||||||
).filter(
|
|
||||||
(item) =>
|
|
||||||
!item
|
|
||||||
.querySelector(`[class*='AssessmentItem__title___']`)
|
|
||||||
?.textContent?.includes("Subject Average"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
|
|
||||||
await processAssessments(api, assessmentItems);
|
|
||||||
|
|
||||||
if (!count || totalWeight === 0) return;
|
|
||||||
|
|
||||||
const avg = weightedTotal / totalWeight;
|
|
||||||
const rounded = Math.ceil(avg / 5) * 5;
|
|
||||||
const numberToLetter = Object.entries(letterToNumber).reduce(
|
|
||||||
(acc, [k, v]) => {
|
|
||||||
acc[v] = k;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<number, string>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const letterAvg = numberToLetter[rounded] ?? "N/A";
|
|
||||||
const display = api.settings.lettergrade
|
|
||||||
? letterAvg
|
|
||||||
: `${avg.toFixed(2)}%`;
|
|
||||||
|
|
||||||
const existing = assessmentsList.querySelector(
|
|
||||||
`[class*='AssessmentItem__title___']`,
|
|
||||||
);
|
|
||||||
if (existing?.textContent === "Subject Average") return;
|
|
||||||
|
|
||||||
let warningHTML = "";
|
|
||||||
if (hasInaccurateWeighting) {
|
|
||||||
warningHTML = /* html */ `
|
|
||||||
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
|
|
||||||
⚠ Some weightings unavailable
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
assessmentsList.insertBefore(
|
|
||||||
stringToHTML(/* html */ `
|
|
||||||
<div class="${assessmentItemClass}">
|
|
||||||
<div class="${metaContainerClass}">
|
|
||||||
<div class="${metaClass}">
|
|
||||||
<div class="${simpleResultClass}">
|
|
||||||
<div class="${titleClass}">Subject Average</div>
|
|
||||||
${warningHTML}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="${thermoscoreClass}">
|
|
||||||
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
|
|
||||||
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).firstChild!,
|
|
||||||
assessmentsList.firstChild,
|
|
||||||
);
|
|
||||||
|
|
||||||
applySubjectColourToOverallResult();
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
applySubjectColourToOverallResult();
|
|
||||||
});
|
|
||||||
const wrapper = document.querySelector(".assessmentsWrapper");
|
const wrapper = document.querySelector(".assessmentsWrapper");
|
||||||
if (wrapper) {
|
if (wrapper) {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
applySubjectColourToOverallResult();
|
||||||
|
});
|
||||||
observer.observe(wrapper, { childList: true, subtree: true });
|
observer.observe(wrapper, { childList: true, subtree: true });
|
||||||
setTimeout(() => observer.disconnect(), 10000);
|
setTimeout(() => observer.disconnect(), 10000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
api.seqta.onMount("[class*='SelectedAssessment__']", () => {
|
||||||
|
injectWeightingsTab(api);
|
||||||
|
injectRubricCopyButtons();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let renderInFlight = false;
|
||||||
|
async function renderSubjectAverage(api: any) {
|
||||||
|
if (renderInFlight) return;
|
||||||
|
renderInFlight = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const assessmentsList = document.querySelector(
|
||||||
|
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
|
||||||
|
);
|
||||||
|
if (!assessmentsList) return;
|
||||||
|
|
||||||
|
// Remove existing subject average before re-rendering
|
||||||
|
Array.from(
|
||||||
|
assessmentsList.querySelectorAll(`[class*='AssessmentItem__title___']`),
|
||||||
|
)
|
||||||
|
.find((el) => el.textContent === "Subject Average")
|
||||||
|
?.closest("[class*='AssessmentItem__AssessmentItem___']")
|
||||||
|
?.remove();
|
||||||
|
|
||||||
|
const sampleAssessmentItem = document.querySelector(
|
||||||
|
"[class*='AssessmentItem__AssessmentItem___']",
|
||||||
|
);
|
||||||
|
if (!sampleAssessmentItem) return;
|
||||||
|
const assessmentItemClass =
|
||||||
|
Array.from(sampleAssessmentItem.classList).find((c) =>
|
||||||
|
c.startsWith("AssessmentItem__AssessmentItem___"),
|
||||||
|
) || "";
|
||||||
|
const metaContainerClass = getClassByPattern(
|
||||||
|
sampleAssessmentItem,
|
||||||
|
"AssessmentItem__metaContainer___",
|
||||||
|
);
|
||||||
|
const metaClass = getClassByPattern(
|
||||||
|
sampleAssessmentItem,
|
||||||
|
"AssessmentItem__meta___",
|
||||||
|
);
|
||||||
|
const simpleResultClass = getClassByPattern(
|
||||||
|
sampleAssessmentItem,
|
||||||
|
"AssessmentItem__simpleResult___",
|
||||||
|
);
|
||||||
|
const titleClass = getClassByPattern(
|
||||||
|
sampleAssessmentItem,
|
||||||
|
"AssessmentItem__title___",
|
||||||
|
);
|
||||||
|
|
||||||
|
const assessmentItems = Array.from(
|
||||||
|
assessmentsList.querySelectorAll(
|
||||||
|
`[class*='AssessmentItem__AssessmentItem___']`,
|
||||||
|
),
|
||||||
|
).filter(
|
||||||
|
(item) =>
|
||||||
|
!item
|
||||||
|
.querySelector(`[class*='AssessmentItem__title___']`)
|
||||||
|
?.textContent?.includes("Subject Average"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
|
||||||
|
await processAssessments(api, assessmentItems);
|
||||||
|
if (!count || totalWeight === 0) return;
|
||||||
|
|
||||||
|
const thermoscoreElement = document.querySelector(
|
||||||
|
"[class*='Thermoscore__Thermoscore___']",
|
||||||
|
);
|
||||||
|
if (!thermoscoreElement) return;
|
||||||
|
const thermoscoreClass =
|
||||||
|
Array.from(thermoscoreElement.classList).find((c) =>
|
||||||
|
c.startsWith("Thermoscore__Thermoscore___"),
|
||||||
|
) || "";
|
||||||
|
const fillClass = getClassByPattern(
|
||||||
|
thermoscoreElement,
|
||||||
|
"Thermoscore__fill___",
|
||||||
|
);
|
||||||
|
const textClass = getClassByPattern(
|
||||||
|
thermoscoreElement,
|
||||||
|
"Thermoscore__text___",
|
||||||
|
);
|
||||||
|
|
||||||
|
const avg = weightedTotal / totalWeight;
|
||||||
|
const rounded = Math.ceil(avg / 5) * 5;
|
||||||
|
const numberToLetter = Object.entries(letterToNumber).reduce(
|
||||||
|
(acc, [k, v]) => {
|
||||||
|
acc[v] = k;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<number, string>,
|
||||||
|
);
|
||||||
|
const letterAvg = numberToLetter[rounded] ?? "N/A";
|
||||||
|
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
|
||||||
|
let warningHTML = "";
|
||||||
|
if (hasInaccurateWeighting) {
|
||||||
|
warningHTML = /* html */ `
|
||||||
|
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
|
||||||
|
⚠ Some weightings unavailable
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
assessmentsList.insertBefore(
|
||||||
|
stringToHTML(/* html */ `
|
||||||
|
<div class="${assessmentItemClass}">
|
||||||
|
<div class="${metaContainerClass}">
|
||||||
|
<div class="${metaClass}">
|
||||||
|
<div class="${simpleResultClass}">
|
||||||
|
<div class="${titleClass}">Subject Average</div>
|
||||||
|
${warningHTML}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="${thermoscoreClass}">
|
||||||
|
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
|
||||||
|
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).firstChild!,
|
||||||
|
assessmentsList.firstChild,
|
||||||
|
);
|
||||||
|
applySubjectColourToOverallResult();
|
||||||
|
} finally {
|
||||||
|
renderInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
function applySubjectColourToOverallResult() {
|
function applySubjectColourToOverallResult() {
|
||||||
const selectedAssessmentItem = document.querySelector(
|
const selectedAssessmentItem = document.querySelector(
|
||||||
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
|
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
const RUBRIC_SELECTOR =
|
||||||
|
"[class*='AssessableCriterion__rubric___'][class*='Rubric__Rubric___'], [class*='Rubric__Rubric___'][class*='AssessableCriterion__rubric___']";
|
||||||
|
const ENHANCED_ATTR = "data-betterseqta-rubric-copy";
|
||||||
|
const STYLE_ID = "betterseqta-rubric-copy-styles-v2";
|
||||||
|
|
||||||
|
let observer: MutationObserver | null = null;
|
||||||
|
|
||||||
|
function ensureStyles() {
|
||||||
|
if (document.getElementById(STYLE_ID)) return;
|
||||||
|
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
.betterseqta-rubric-copy-host {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.betterseqta-rubric-copy-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 0 0 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
transition:
|
||||||
|
opacity 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
background: linear-gradient(
|
||||||
|
to top,
|
||||||
|
rgba(0, 0, 0, 0.72) 0%,
|
||||||
|
rgba(0, 0, 0, 0.42) 42%,
|
||||||
|
rgba(0, 0, 0, 0.08) 72%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.betterseqta-rubric-copy-host:hover .betterseqta-rubric-copy-overlay,
|
||||||
|
.betterseqta-rubric-copy-host:focus-within .betterseqta-rubric-copy-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.betterseqta-rubric-copy-btn {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.45rem 0.75rem !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.12) !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
background: rgba(255, 255, 255, 0.96) !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
font-family: Rubik, system-ui, sans-serif !important;
|
||||||
|
font-size: 0.8125rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
line-height: 1 !important;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.28);
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
transition:
|
||||||
|
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
background 0.28s ease,
|
||||||
|
color 0.28s ease,
|
||||||
|
box-shadow 0.28s ease,
|
||||||
|
border-color 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.betterseqta-rubric-copy-btn:hover {
|
||||||
|
transform: translateY(-1px) scale(1.04) !important;
|
||||||
|
background: #f1f5f9 !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
border-color: rgba(15, 23, 42, 0.18) !important;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.betterseqta-rubric-copy-btn:active {
|
||||||
|
transform: translateY(0) scale(0.98) !important;
|
||||||
|
background: #e2e8f0 !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.betterseqta-rubric-copy-btn:focus-visible {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(255, 255, 255, 0.95),
|
||||||
|
0 0 0 4px rgba(59, 130, 246, 0.85) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.betterseqta-rubric-copy-btn svg {
|
||||||
|
width: 1rem !important;
|
||||||
|
height: 1rem !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
stroke: currentColor !important;
|
||||||
|
fill: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.betterseqta-rubric-copy-btn.is-copied {
|
||||||
|
background: #ecfdf5 !important;
|
||||||
|
color: #047857 !important;
|
||||||
|
border-color: rgba(4, 120, 87, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.betterseqta-rubric-copy-btn.is-copied:hover {
|
||||||
|
background: #d1fae5 !important;
|
||||||
|
color: #065f46 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.betterseqta-rubric-copy-overlay,
|
||||||
|
.betterseqta-rubric-copy-btn {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellText(element: Element | null | undefined): string {
|
||||||
|
return element?.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RubricCell {
|
||||||
|
text: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RubricTableData {
|
||||||
|
header: string[];
|
||||||
|
rows: RubricCell[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRubricTable(rubric: Element): RubricTableData | null {
|
||||||
|
const lines = rubric.querySelectorAll("[class*='Rubric__line___']");
|
||||||
|
const rows: RubricCell[][] = [];
|
||||||
|
|
||||||
|
lines.forEach((line) => {
|
||||||
|
const meta = line.querySelector("[class*='Rubric__meta___']");
|
||||||
|
const label = cellText(meta?.querySelector("[class*='Rubric__label___']"));
|
||||||
|
const criterion = cellText(
|
||||||
|
meta?.querySelector("[class*='Rubric__description___']"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const row: RubricCell[] = [
|
||||||
|
{ text: label, selected: false },
|
||||||
|
{ text: criterion, selected: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
line.querySelectorAll("[class*='Rubric__descriptor___']").forEach((descriptor) => {
|
||||||
|
const text = cellText(
|
||||||
|
descriptor.querySelector("[class*='Rubric__description___']"),
|
||||||
|
);
|
||||||
|
const selected = Array.from(descriptor.classList).some((cls) =>
|
||||||
|
cls.startsWith("Rubric__selected___"),
|
||||||
|
);
|
||||||
|
row.push({ text, selected });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (row.some((cell) => cell.text)) rows.push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rows.length) return null;
|
||||||
|
|
||||||
|
const maxCols = Math.max(...rows.map((row) => row.length));
|
||||||
|
const normalized = rows.map((row) => {
|
||||||
|
const copy = [...row];
|
||||||
|
while (copy.length < maxCols) {
|
||||||
|
copy.push({ text: "", selected: false });
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = [
|
||||||
|
"Category",
|
||||||
|
"Criterion",
|
||||||
|
...Array.from({ length: maxCols - 2 }, (_, i) => `Band ${i + 1}`),
|
||||||
|
].slice(0, maxCols);
|
||||||
|
|
||||||
|
return { header, rows: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rubricToPlainText(table: RubricTableData): string {
|
||||||
|
const formatCell = (cell: RubricCell) =>
|
||||||
|
cell.selected && cell.text ? `${cell.text} (selected)` : cell.text;
|
||||||
|
|
||||||
|
return [
|
||||||
|
table.header.join("\t"),
|
||||||
|
...table.rows.map((row) => row.map(formatCell).join("\t")),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUBRIC_PASTE_FONT_PT = 7;
|
||||||
|
|
||||||
|
function rubricPasteFontStyle(): string {
|
||||||
|
return [
|
||||||
|
`font-size:${RUBRIC_PASTE_FONT_PT}pt`,
|
||||||
|
"mso-ansi-font-size:8.0pt",
|
||||||
|
"mso-bidi-font-size:8.0pt",
|
||||||
|
"font-family:Calibri,Arial,sans-serif",
|
||||||
|
"line-height:1.2",
|
||||||
|
].join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
function rubricPasteCellContent(text: string): string {
|
||||||
|
return `<span style="${rubricPasteFontStyle()}">${escapeHtml(text)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rubricToHtmlTable(table: RubricTableData): string {
|
||||||
|
const baseFont = rubricPasteFontStyle();
|
||||||
|
const cellStyle =
|
||||||
|
`border:1px solid #000000;border-collapse:collapse;padding:4px;vertical-align:top;${baseFont}`;
|
||||||
|
const headerStyle = `${cellStyle}background:#f3f4f6;font-weight:700;`;
|
||||||
|
const selectedStyle = `${cellStyle}background:#dbeafe;font-weight:600;`;
|
||||||
|
|
||||||
|
const headerRow = table.header
|
||||||
|
.map(
|
||||||
|
(heading) =>
|
||||||
|
`<th style="${headerStyle}">${rubricPasteCellContent(heading)}</th>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const bodyRows = table.rows
|
||||||
|
.map((row) => {
|
||||||
|
const cells = row
|
||||||
|
.map((cell) => {
|
||||||
|
const style = cell.selected ? selectedStyle : cellStyle;
|
||||||
|
return `<td style="${style}">${rubricPasteCellContent(cell.text)}</td>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
return `<tr>${cells}</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return [
|
||||||
|
`<table border="1" cellpadding="0" cellspacing="0" style="border-collapse:collapse;width:100%;${baseFont}">`,
|
||||||
|
`<thead><tr>${headerRow}</tr></thead>`,
|
||||||
|
`<tbody>${bodyRows}</tbody>`,
|
||||||
|
"</table>",
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function rubricToHtmlDocument(table: RubricTableData): string {
|
||||||
|
return [
|
||||||
|
"<!DOCTYPE html>",
|
||||||
|
"<html>",
|
||||||
|
"<head><meta charset=\"utf-8\"></head>",
|
||||||
|
`<body style="${rubricPasteFontStyle()}">`,
|
||||||
|
rubricToHtmlTable(table),
|
||||||
|
"</body>",
|
||||||
|
"</html>",
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyRubricTable(rubric: Element, button: HTMLButtonElement) {
|
||||||
|
const table = parseRubricTable(rubric);
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
const plain = rubricToPlainText(table);
|
||||||
|
const htmlTable = rubricToHtmlTable(table);
|
||||||
|
const htmlDocument = rubricToHtmlDocument(table);
|
||||||
|
|
||||||
|
let copied = false;
|
||||||
|
|
||||||
|
if (navigator.clipboard?.write && typeof ClipboardItem !== "undefined") {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
"text/plain": new Blob([plain], { type: "text/plain" }),
|
||||||
|
"text/html": new Blob([htmlDocument], { type: "text/html" }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
copied = true;
|
||||||
|
} catch {
|
||||||
|
// Fall through to legacy rich-text copy.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!copied) {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.contentEditable = "true";
|
||||||
|
host.innerHTML = htmlTable;
|
||||||
|
host.style.position = "fixed";
|
||||||
|
host.style.left = "-9999px";
|
||||||
|
host.style.top = "0";
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(host);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
|
||||||
|
copied = document.execCommand("copy");
|
||||||
|
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
host.remove();
|
||||||
|
|
||||||
|
if (!copied) {
|
||||||
|
await navigator.clipboard.writeText(plain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = button.querySelector(".betterseqta-rubric-copy-label");
|
||||||
|
const original = label?.textContent ?? "Copy rubric";
|
||||||
|
button.classList.add("is-copied");
|
||||||
|
if (label) label.textContent = "Copied!";
|
||||||
|
window.setTimeout(() => {
|
||||||
|
button.classList.remove("is-copied");
|
||||||
|
if (label) label.textContent = original;
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCopyButton(rubric: Element): HTMLButtonElement {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "betterseqta-rubric-copy-btn";
|
||||||
|
button.setAttribute("aria-label", "Copy rubric");
|
||||||
|
button.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||||
|
</svg>
|
||||||
|
<span class="betterseqta-rubric-copy-label">Copy rubric</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
button.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
void copyRubricTable(rubric, button);
|
||||||
|
});
|
||||||
|
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceRubric(rubric: HTMLElement) {
|
||||||
|
if (rubric.hasAttribute(ENHANCED_ATTR)) return;
|
||||||
|
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.className = "betterseqta-rubric-copy-host";
|
||||||
|
rubric.parentElement?.insertBefore(host, rubric);
|
||||||
|
host.appendChild(rubric);
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "betterseqta-rubric-copy-overlay";
|
||||||
|
overlay.appendChild(createCopyButton(rubric));
|
||||||
|
host.appendChild(overlay);
|
||||||
|
|
||||||
|
rubric.setAttribute(ENHANCED_ATTR, "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceRubrics(root: ParentNode = document) {
|
||||||
|
ensureStyles();
|
||||||
|
root.querySelectorAll<HTMLElement>(RUBRIC_SELECTOR).forEach(enhanceRubric);
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchRubrics(root: ParentNode) {
|
||||||
|
observer?.disconnect();
|
||||||
|
enhanceRubrics(root);
|
||||||
|
|
||||||
|
observer = new MutationObserver(() => {
|
||||||
|
enhanceRubrics(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(root, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function injectRubricCopyButtons() {
|
||||||
|
const root =
|
||||||
|
document.querySelector("[class*='SelectedAssessment__']") ?? document;
|
||||||
|
watchRubrics(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teardownRubricCopyButtons() {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = null;
|
||||||
|
}
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
|
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
|
||||||
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
|
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
|
||||||
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
|
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
|
||||||
|
import {
|
||||||
|
getEngageAssessmentReportUrl,
|
||||||
|
requestEngageAssessmentPdf,
|
||||||
|
} from "./engage.ts";
|
||||||
|
import {
|
||||||
|
ensurePdfjsWorker,
|
||||||
|
getPdfjsPageContextUrls,
|
||||||
|
} from "@/lib/pdfjsExtension.ts";
|
||||||
import * as pdfjs from "pdfjs-dist";
|
import * as pdfjs from "pdfjs-dist";
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc =
|
|
||||||
`https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
|
ensurePdfjsWorker();
|
||||||
|
|
||||||
export async function initStorage(api: any) {
|
export async function initStorage(api: any) {
|
||||||
await api.storage.loaded;
|
await api.storage.loaded;
|
||||||
@@ -13,6 +23,9 @@ export async function initStorage(api: any) {
|
|||||||
if (!api.storage.assessments) {
|
if (!api.storage.assessments) {
|
||||||
api.storage.assessments = {};
|
api.storage.assessments = {};
|
||||||
}
|
}
|
||||||
|
if (!api.storage.weightingOverrides) {
|
||||||
|
api.storage.weightingOverrides = {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearStuck(api: any) {
|
export function clearStuck(api: any) {
|
||||||
@@ -71,51 +84,179 @@ function parseGrade(text: string): number {
|
|||||||
return letterToNumber[str] ?? 0;
|
return letterToNumber[str] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatWeightDisplay(weighting: string): string {
|
||||||
|
return `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveWeightingOverride(
|
||||||
|
api: any,
|
||||||
|
assessmentID: string,
|
||||||
|
raw: string,
|
||||||
|
): { ok: boolean; error?: string } {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
const { [assessmentID]: _, ...rest } = api.storage.weightingOverrides;
|
||||||
|
api.storage.weightingOverrides = rest;
|
||||||
|
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const val = parseFloat(trimmed);
|
||||||
|
if (isNaN(val) || val < 0) {
|
||||||
|
return { ok: false, error: "Invalid. Must be 0 or greater" };
|
||||||
|
}
|
||||||
|
|
||||||
|
api.storage.weightingOverrides = {
|
||||||
|
...api.storage.weightingOverrides,
|
||||||
|
[assessmentID]: String(val),
|
||||||
|
};
|
||||||
|
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachWeightInputListeners(
|
||||||
|
input: HTMLInputElement,
|
||||||
|
api: any,
|
||||||
|
assessmentID: string,
|
||||||
|
) {
|
||||||
|
const save = () => {
|
||||||
|
const result = saveWeightingOverride(api, assessmentID, input.value);
|
||||||
|
input.style.borderColor = result.ok
|
||||||
|
? "rgba(128,128,128,0.35)"
|
||||||
|
: "rgba(255,80,80,0.6)";
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("blur", save);
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") input.blur();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWeightLabelContent(
|
||||||
|
weightLabel: HTMLElement,
|
||||||
|
weighting: string | undefined,
|
||||||
|
assessmentID: string | undefined,
|
||||||
|
api: any,
|
||||||
|
) {
|
||||||
|
const existingInput = weightLabel.querySelector(
|
||||||
|
".betterseqta-weight-input",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
if (existingInput && document.activeElement === existingInput) return;
|
||||||
|
|
||||||
|
weightLabel.querySelector(".betterseqta-weight-value")?.remove();
|
||||||
|
weightLabel.querySelector(".betterseqta-weight-input")?.remove();
|
||||||
|
Array.from(weightLabel.childNodes)
|
||||||
|
.filter((node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim())
|
||||||
|
.forEach((node) => node.remove());
|
||||||
|
|
||||||
|
weightLabel.title = "";
|
||||||
|
|
||||||
|
if (weighting === "processing") {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "betterseqta-weight-value";
|
||||||
|
span.textContent = "...";
|
||||||
|
span.style.opacity = "0.5";
|
||||||
|
weightLabel.appendChild(span);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (weighting === "N/A" && assessmentID) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "number";
|
||||||
|
input.min = "0";
|
||||||
|
input.step = "5";
|
||||||
|
input.className = "betterseqta-weight-input";
|
||||||
|
input.placeholder = "Set %";
|
||||||
|
input.setAttribute("aria-label", "Assessment weighting percentage");
|
||||||
|
input.style.cssText =
|
||||||
|
"width:52px;padding:1px 4px;border-radius:4px;border:1px solid rgba(128,128,128,0.35);background:rgba(128,128,128,0.08);color:inherit;font-size:inherit;outline:none;";
|
||||||
|
attachWeightInputListeners(input, api, assessmentID);
|
||||||
|
weightLabel.appendChild(input);
|
||||||
|
weightLabel.title = "Enter assessment weighting %";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "betterseqta-weight-value";
|
||||||
|
span.textContent =
|
||||||
|
weighting && weighting !== "N/A"
|
||||||
|
? formatWeightDisplay(weighting)
|
||||||
|
: "N/A";
|
||||||
|
weightLabel.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
function createWeightLabel(
|
function createWeightLabel(
|
||||||
assessmentItem: Element,
|
assessmentItem: Element,
|
||||||
weighting: string | undefined,
|
weighting: string | undefined,
|
||||||
|
api: any,
|
||||||
) {
|
) {
|
||||||
const statsContainer = assessmentItem.querySelector(
|
let statsContainer = assessmentItem.querySelector(
|
||||||
`[class*='AssessmentItem__stats___']`,
|
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
|
||||||
) as HTMLElement;
|
) as HTMLElement | null;
|
||||||
|
|
||||||
if (
|
if (!statsContainer) {
|
||||||
!statsContainer ||
|
const statsClass = getClassByPattern(document, "AssessmentItem__stats___");
|
||||||
statsContainer.querySelector(".betterseqta-weight-label")
|
statsContainer = document.createElement("div");
|
||||||
)
|
statsContainer.className = statsClass;
|
||||||
return;
|
statsContainer.classList.add("betterseqta-stats-container");
|
||||||
|
const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`);
|
||||||
const label = statsContainer.querySelector(
|
if (thermoscore) {
|
||||||
`[class*='Label__Label___']`,
|
thermoscore.insertAdjacentElement("afterend", statsContainer);
|
||||||
) as HTMLElement;
|
} else {
|
||||||
|
assessmentItem.appendChild(statsContainer);
|
||||||
if (!label) return;
|
}
|
||||||
|
|
||||||
const weightLabel = label.cloneNode(true) as HTMLElement;
|
|
||||||
weightLabel.classList.add("betterseqta-weight-label");
|
|
||||||
|
|
||||||
const innerTextDiv = weightLabel.querySelector(
|
|
||||||
`[class*='Label__innerText___']`,
|
|
||||||
);
|
|
||||||
if (innerTextDiv) innerTextDiv.textContent = "Weight";
|
|
||||||
|
|
||||||
const textNodes = Array.from(weightLabel.childNodes).filter(
|
|
||||||
(node) => node.nodeType === Node.TEXT_NODE,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (textNodes.length) {
|
|
||||||
textNodes[0].textContent =
|
|
||||||
weighting && weighting !== "processing"
|
|
||||||
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
|
|
||||||
: "N/A";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
statsContainer.style.position = "relative";
|
const hasNativeLabel = !!statsContainer.querySelector(
|
||||||
weightLabel.style.position = "absolute";
|
`[class*='Label__Label___']:not(.betterseqta-weight-label)`,
|
||||||
weightLabel.style.right = "0";
|
);
|
||||||
weightLabel.style.top = "50%";
|
statsContainer.style.justifyContent = hasNativeLabel
|
||||||
weightLabel.style.transform = "translateY(-50%)";
|
? "space-between"
|
||||||
|
: "flex-end";
|
||||||
|
|
||||||
|
const title = assessmentItem
|
||||||
|
.querySelector(`[class*='AssessmentItem__title___']`)
|
||||||
|
?.textContent?.trim();
|
||||||
|
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
|
||||||
|
|
||||||
|
const existingLabel = statsContainer.querySelector(
|
||||||
|
".betterseqta-weight-label",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
if (existingLabel) {
|
||||||
|
updateWeightLabelContent(existingLabel, weighting, assessmentID, api);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statsContainer.style.display = "flex";
|
||||||
|
statsContainer.style.alignItems = "center";
|
||||||
|
statsContainer.style.width = "100%";
|
||||||
|
|
||||||
|
// Try to clone an existing label from the stats container first,
|
||||||
|
// fall back to building from scratch if none exists
|
||||||
|
const existingNativeLabel = statsContainer.querySelector(
|
||||||
|
`[class*='Label__Label___']`,
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
const weightLabel = existingNativeLabel
|
||||||
|
? (existingNativeLabel.cloneNode(true) as HTMLElement)
|
||||||
|
: (() => {
|
||||||
|
const labelClass = getClassByPattern(document, "Label__Label___");
|
||||||
|
const innerTextClass = getClassByPattern(document, "Label__innerText___");
|
||||||
|
const el = document.createElement("label");
|
||||||
|
el.className = labelClass;
|
||||||
|
el.innerHTML = `<div class="${innerTextClass}">Weight</div>`;
|
||||||
|
return el;
|
||||||
|
})();
|
||||||
|
|
||||||
|
weightLabel.classList.add("betterseqta-weight-label");
|
||||||
|
weightLabel.style.flex = "none";
|
||||||
|
weightLabel.style.width = "fit-content";
|
||||||
|
|
||||||
|
const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`);
|
||||||
|
if (innerTextDiv) innerTextDiv.textContent = "Weight";
|
||||||
|
|
||||||
|
updateWeightLabelContent(weightLabel, weighting, assessmentID, api);
|
||||||
statsContainer.appendChild(weightLabel);
|
statsContainer.appendChild(weightLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +360,13 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
|||||||
export async function extractPDFText(url: string): Promise<string> {
|
export async function extractPDFText(url: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
if (isFirefox) {
|
if (isFirefox) {
|
||||||
|
const { lib: pdfLibUrl, worker: pdfWorkerUrl } =
|
||||||
|
getPdfjsPageContextUrls();
|
||||||
|
const escJsSingleQuoted = (s: string) =>
|
||||||
|
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||||
|
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
|
||||||
|
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
|
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
|
||||||
@@ -232,13 +380,15 @@ export async function extractPDFText(url: string): Promise<string> {
|
|||||||
(function() {
|
(function() {
|
||||||
const requestId = '${requestId}';
|
const requestId = '${requestId}';
|
||||||
const url = '${escapedUrl}';
|
const url = '${escapedUrl}';
|
||||||
|
const pdfLibSrc = '${pdfLibInj}';
|
||||||
|
const pdfWorkerSrc = '${pdfWorkerInj}';
|
||||||
|
|
||||||
if (window.pdfjsLib) {
|
if (window.pdfjsLib) {
|
||||||
extractPDF();
|
extractPDF();
|
||||||
} else {
|
} else {
|
||||||
const pdfjsScript = document.createElement('script');
|
const pdfjsScript = document.createElement('script');
|
||||||
pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.min.js';
|
pdfjsScript.src = pdfLibSrc;
|
||||||
pdfjsScript.type = 'text/javascript';
|
pdfjsScript.type = 'module';
|
||||||
|
|
||||||
pdfjsScript.onload = function() {
|
pdfjsScript.onload = function() {
|
||||||
extractPDF();
|
extractPDF();
|
||||||
@@ -256,7 +406,7 @@ export async function extractPDFText(url: string): Promise<string> {
|
|||||||
|
|
||||||
function extractPDF() {
|
function extractPDF() {
|
||||||
try {
|
try {
|
||||||
window.pdfjsLib.GlobalWorkerOptions.workerSrc = '';
|
window.pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('GET', url, true);
|
xhr.open('GET', url, true);
|
||||||
@@ -411,8 +561,6 @@ export async function extractPDFText(url: string): Promise<string> {
|
|||||||
async function handleWeightings(mark: any, api: any) {
|
async function handleWeightings(mark: any, api: any) {
|
||||||
const assessmentID = mark.id;
|
const assessmentID = mark.id;
|
||||||
const metaclassID = mark.metaclassID;
|
const metaclassID = mark.metaclassID;
|
||||||
const userInfo = await getUserInfo();
|
|
||||||
const userID = userInfo.id;
|
|
||||||
const title = mark.title;
|
const title = mark.title;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -433,35 +581,55 @@ async function handleWeightings(mark: any, api: any) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filename =
|
let pdfUrl: string;
|
||||||
"BetterSEQTA-" +
|
|
||||||
String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
|
|
||||||
|
|
||||||
const printResponse = await fetch(
|
if (isSeqtaEngageExperience()) {
|
||||||
`${location.origin}/seqta/student/print/assessment`,
|
const studentID = getEngageAssessmentStudentId();
|
||||||
{
|
if (!studentID) {
|
||||||
method: "POST",
|
throw new Error("Could not resolve Engage student ID from URL or storage");
|
||||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
}
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({
|
|
||||||
fileName: filename,
|
|
||||||
id: assessmentID,
|
|
||||||
metaclass: metaclassID,
|
|
||||||
student: userID,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!printResponse.ok) {
|
const reportFile = await requestEngageAssessmentPdf({
|
||||||
throw new Error(
|
assessmentID,
|
||||||
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
|
metaclassID,
|
||||||
|
studentID,
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
pdfUrl = getEngageAssessmentReportUrl(reportFile);
|
||||||
|
} else {
|
||||||
|
const userInfo = await getUserInfo();
|
||||||
|
const userID = userInfo.id;
|
||||||
|
|
||||||
|
const filename =
|
||||||
|
"BetterSEQTA-" +
|
||||||
|
String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
|
||||||
|
|
||||||
|
const printResponse = await fetch(
|
||||||
|
`${location.origin}/seqta/student/print/assessment`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileName: filename,
|
||||||
|
id: assessmentID,
|
||||||
|
metaclass: metaclassID,
|
||||||
|
student: userID,
|
||||||
|
}),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!printResponse.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
|
|
||||||
|
|
||||||
if (pdfUrl.startsWith("blob:")) {
|
if (pdfUrl.startsWith("blob:")) {
|
||||||
throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`);
|
throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`);
|
||||||
}
|
}
|
||||||
@@ -502,7 +670,11 @@ export async function parseAssessments(api: any) {
|
|||||||
"[class*='AssessmentList__items___']",
|
"[class*='AssessmentList__items___']",
|
||||||
).getState();
|
).getState();
|
||||||
|
|
||||||
const marks = state["marks"];
|
const marks = [
|
||||||
|
...(state["marks"] ?? []),
|
||||||
|
...(state["upcoming"] ?? []),
|
||||||
|
...(state["pending"] ?? []),
|
||||||
|
];
|
||||||
if (!marks) return;
|
if (!marks) return;
|
||||||
|
|
||||||
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
|
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
|
||||||
@@ -515,15 +687,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
for (const assessmentItem of assessmentItems) {
|
for (const assessmentItem of assessmentItems) {
|
||||||
const gradeElement = assessmentItem.querySelector(
|
|
||||||
`[class*='Thermoscore__text___']`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!gradeElement) continue;
|
|
||||||
|
|
||||||
const grade = parseGrade(gradeElement.textContent || "");
|
|
||||||
if (grade <= 0) continue;
|
|
||||||
|
|
||||||
const titleEl = assessmentItem.querySelector(
|
const titleEl = assessmentItem.querySelector(
|
||||||
`[class*='AssessmentItem__title___']`,
|
`[class*='AssessmentItem__title___']`,
|
||||||
);
|
);
|
||||||
@@ -533,11 +696,22 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
if (!title) continue;
|
if (!title) continue;
|
||||||
|
|
||||||
const assessmentID = api.storage.assessments?.[title];
|
const assessmentID = api.storage.assessments?.[title];
|
||||||
const weighting = assessmentID
|
const autoWeighting = assessmentID
|
||||||
? api.storage.weightings?.[assessmentID]
|
? api.storage.weightings?.[assessmentID]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const override = assessmentID
|
||||||
|
? api.storage.weightingOverrides?.[assessmentID]
|
||||||
|
: undefined;
|
||||||
|
const weighting = override ?? autoWeighting;
|
||||||
|
|
||||||
createWeightLabel(assessmentItem, weighting);
|
createWeightLabel(assessmentItem, weighting, api);
|
||||||
|
|
||||||
|
const gradeElement = assessmentItem.querySelector(
|
||||||
|
`[class*='Thermoscore__text___']`,
|
||||||
|
);
|
||||||
|
if (!gradeElement) continue;
|
||||||
|
const grade = parseGrade(gradeElement.textContent || "");
|
||||||
|
if (grade <= 0) continue;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
weighting === null ||
|
weighting === null ||
|
||||||
@@ -546,8 +720,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
weighting === "processing"
|
weighting === "processing"
|
||||||
) {
|
) {
|
||||||
hasInaccurateWeighting = true;
|
hasInaccurateWeighting = true;
|
||||||
weightedTotal += grade;
|
continue
|
||||||
totalWeight += 1;
|
|
||||||
} else {
|
} else {
|
||||||
const weight = parseFloat(weighting);
|
const weight = parseFloat(weighting);
|
||||||
|
|
||||||
@@ -570,3 +743,263 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
count,
|
count,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTabSetClasses(): Record<string, string> {
|
||||||
|
const patterns = [
|
||||||
|
"TabSet__tabsheet___",
|
||||||
|
"TabSet__hidden___",
|
||||||
|
"TabSet__selected___",
|
||||||
|
"TabSet__disappearToLeft___",
|
||||||
|
"TabSet__disappearToRight___",
|
||||||
|
"TabSet__appearFromRight___",
|
||||||
|
"TabSet__appearFromLeft___",
|
||||||
|
];
|
||||||
|
|
||||||
|
const resolved: Record<string, string> = {};
|
||||||
|
|
||||||
|
// First pass: scan live DOM elements (fast, covers currently-applied classes)
|
||||||
|
const allClasses = Array.from(
|
||||||
|
document.querySelectorAll('[class*="TabSet__"]'),
|
||||||
|
).flatMap((el) => Array.from(el.classList));
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const found = allClasses.find((c) => c.startsWith(pattern));
|
||||||
|
if (found) resolved[pattern] = found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: scan stylesheets for any classes not yet in the DOM
|
||||||
|
// (e.g. animation classes that haven't been applied yet)
|
||||||
|
const missing = patterns.filter((p) => !resolved[p]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
try {
|
||||||
|
for (const sheet of Array.from(document.styleSheets)) {
|
||||||
|
if (missing.every((p) => resolved[p])) break;
|
||||||
|
try {
|
||||||
|
for (const rule of Array.from(sheet.cssRules ?? [])) {
|
||||||
|
if (!(rule instanceof CSSStyleRule)) continue;
|
||||||
|
const selectorClasses =
|
||||||
|
rule.selectorText.match(/\.([\w-]+)/g) ?? [];
|
||||||
|
for (const pattern of missing) {
|
||||||
|
if (!resolved[pattern]) {
|
||||||
|
const match = selectorClasses.find((c) =>
|
||||||
|
c.slice(1).startsWith(pattern),
|
||||||
|
);
|
||||||
|
if (match) resolved[pattern] = match.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cross-origin stylesheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use the base pattern as-is so the function doesn't crash,
|
||||||
|
// though styles won't apply if the hash is truly unknown.
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (!resolved[pattern]) resolved[pattern] = pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
|
||||||
|
const titleEl = document.querySelector(
|
||||||
|
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']",
|
||||||
|
);
|
||||||
|
const title = titleEl?.textContent?.trim();
|
||||||
|
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
|
||||||
|
|
||||||
|
const rawWeight = assessmentID
|
||||||
|
? api.storage.weightings?.[assessmentID]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const weightingUnavailable = rawWeight === "N/A";
|
||||||
|
|
||||||
|
const autoWeight =
|
||||||
|
rawWeight && rawWeight !== "processing" && rawWeight !== "N/A"
|
||||||
|
? rawWeight
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const override = assessmentID
|
||||||
|
? api.storage.weightingOverrides?.[assessmentID]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const statusNote = !assessmentID
|
||||||
|
? ""
|
||||||
|
: rawWeight === "processing"
|
||||||
|
? "Weighting is still being detected."
|
||||||
|
: weightingUnavailable
|
||||||
|
? "No weighting was found in the marksheet. Set one manually."
|
||||||
|
: "Overrides the auto-detected value.";
|
||||||
|
|
||||||
|
sheet.innerHTML = `
|
||||||
|
<style>
|
||||||
|
#betterseqta-weight-override::placeholder {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div style="padding:16px;max-width:360px">
|
||||||
|
<h2 style="margin:0 0 4px;font-size:15px;font-weight:600">Weighting Override</h2>
|
||||||
|
<p style="margin:0 0 16px;font-size:12px;opacity:0.6">
|
||||||
|
Set the weighting for this assessment.
|
||||||
|
${statusNote}
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
|
||||||
|
<label style="font-size:13px;opacity:0.7;flex-shrink:0">Auto-detected</label>
|
||||||
|
<span style="font-size:13px;opacity:${autoWeight != null ? "1" : "0.4"}">${autoWeight != null ? `${autoWeight}%` : "none"}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px">
|
||||||
|
<label for="betterseqta-weight-override" style="font-size:13px;opacity:0.7;flex-shrink:0">${weightingUnavailable ? "Weight %" : "Override %"}</label>
|
||||||
|
<input
|
||||||
|
id="betterseqta-weight-override"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="5"
|
||||||
|
placeholder="${weightingUnavailable ? "Enter weight" : autoWeight ?? ""}"
|
||||||
|
value="${override ?? ""}"
|
||||||
|
${!assessmentID ? "disabled" : ""}
|
||||||
|
style="
|
||||||
|
width:90px;
|
||||||
|
padding:5px 8px;
|
||||||
|
border-radius:6px;
|
||||||
|
border:1px solid rgba(128,128,128,0.3);
|
||||||
|
background:rgba(128,128,128,0.08);
|
||||||
|
color:inherit;
|
||||||
|
font-size:13px;
|
||||||
|
outline:none;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px;min-height:18px">
|
||||||
|
<span class="betterseqta-save-status" style="font-size:12px;opacity:0.5"></span>
|
||||||
|
</div>
|
||||||
|
${!assessmentID ? `<p style="font-size:12px;color:rgba(255,80,80,0.8);margin-top:8px">Assessment not yet indexed — try refreshing.</p>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!assessmentID) return;
|
||||||
|
|
||||||
|
const input = sheet.querySelector(
|
||||||
|
"#betterseqta-weight-override",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const statusEl = sheet.querySelector(
|
||||||
|
".betterseqta-save-status",
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const raw = input.value.trim();
|
||||||
|
if (raw === "") {
|
||||||
|
const result = saveWeightingOverride(api, assessmentID, "");
|
||||||
|
if (!result.ok) return;
|
||||||
|
input.style.borderColor = "rgba(128,128,128,0.3)";
|
||||||
|
} else {
|
||||||
|
const result = saveWeightingOverride(api, assessmentID, raw);
|
||||||
|
if (!result.ok) {
|
||||||
|
input.style.borderColor = "rgba(255,80,80,0.6)";
|
||||||
|
statusEl.textContent = result.error ?? "Invalid. Must be 0 or greater";
|
||||||
|
statusEl.style.color = "rgba(255,80,80,0.8)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.style.borderColor = "rgba(128,128,128,0.3)";
|
||||||
|
}
|
||||||
|
statusEl.textContent = "Saved";
|
||||||
|
statusEl.style.color = "";
|
||||||
|
setTimeout(() => (statusEl.textContent = ""), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("blur", save);
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
input.blur();
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
input.style.borderColor = "rgba(128,128,128,0.3)";
|
||||||
|
if (statusEl.textContent === "Invalid. Must be 0 or greater.")
|
||||||
|
statusEl.textContent = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function injectWeightingsTab(api: any) {
|
||||||
|
const tabList = document.querySelector(
|
||||||
|
'[class*="TabSet__tabs___"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
const container = document.querySelector(
|
||||||
|
'[class*="TabSet__tabContainer___"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
if (!tabList || !container) return;
|
||||||
|
if (tabList.querySelector(".betterseqta-weightings-tab")) return;
|
||||||
|
|
||||||
|
const cls = resolveTabSetClasses();
|
||||||
|
|
||||||
|
const prefix = (tabList.querySelector("li") as HTMLElement).id.replace(
|
||||||
|
/-tab-\d+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const newIndex = tabList.querySelectorAll("li").length;
|
||||||
|
|
||||||
|
const newTab = document.createElement("li");
|
||||||
|
newTab.id = `${prefix}-tab-${newIndex}`;
|
||||||
|
newTab.className = "";
|
||||||
|
newTab.setAttribute("aria-selected", "false");
|
||||||
|
newTab.setAttribute("aria-controls", `${prefix}-tabsheet-${newIndex}`);
|
||||||
|
newTab.classList.add("betterseqta-weightings-tab");
|
||||||
|
newTab.textContent = "Weightings";
|
||||||
|
tabList.appendChild(newTab);
|
||||||
|
|
||||||
|
const newSheet = document.createElement("div");
|
||||||
|
newSheet.id = `${prefix}-tabsheet-${newIndex}`;
|
||||||
|
newSheet.setAttribute("aria-labelledby", `${prefix}-tab-${newIndex}`);
|
||||||
|
newSheet.className = [
|
||||||
|
cls["TabSet__tabsheet___"],
|
||||||
|
cls["TabSet__hidden___"],
|
||||||
|
cls["TabSet__disappearToRight___"],
|
||||||
|
].join(" ");
|
||||||
|
container.appendChild(newSheet);
|
||||||
|
|
||||||
|
newTab.addEventListener("click", () => {
|
||||||
|
buildWeightingsTabContent(api, newSheet);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allTabs = Array.from(tabList.querySelectorAll("li"));
|
||||||
|
const allSheets = Array.from(
|
||||||
|
container.querySelectorAll('[class*="tabsheet"]'),
|
||||||
|
);
|
||||||
|
|
||||||
|
allTabs.forEach((tab, i) => {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
const currentIndex = allTabs.findIndex((t) =>
|
||||||
|
t.className.includes("TabSet__selected___"),
|
||||||
|
);
|
||||||
|
if (i === currentIndex) return;
|
||||||
|
const goingRight = i > currentIndex;
|
||||||
|
|
||||||
|
allTabs.forEach((t) => {
|
||||||
|
t.className = "";
|
||||||
|
t.setAttribute("aria-selected", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
allSheets[currentIndex].className = [
|
||||||
|
cls["TabSet__tabsheet___"],
|
||||||
|
cls["TabSet__hidden___"],
|
||||||
|
goingRight
|
||||||
|
? cls["TabSet__disappearToLeft___"]
|
||||||
|
: cls["TabSet__disappearToRight___"],
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
allSheets[i].className = [
|
||||||
|
cls["TabSet__tabsheet___"],
|
||||||
|
cls["TabSet__selected___"],
|
||||||
|
goingRight
|
||||||
|
? cls["TabSet__appearFromRight___"]
|
||||||
|
: cls["TabSet__appearFromLeft___"],
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
tab.className = cls["TabSet__selected___"];
|
||||||
|
tab.setAttribute("aria-selected", "true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { determineStatus, formatDate, getGradeValue } from "./utils";
|
import { determineStatus, formatDate, getGradeValue } from "./utils";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
|
import { buildEngageAssessmentPagePath } from "@/seqta/utils/engageAssessmentStudent";
|
||||||
|
import OverviewIcon from "./OverviewIcon.svelte";
|
||||||
|
import {
|
||||||
|
GROUP_SORT_ICONS,
|
||||||
|
STATUS_COLUMN_ICONS,
|
||||||
|
type OverviewIconName,
|
||||||
|
} from "./icons";
|
||||||
import confetti from "canvas-confetti";
|
import confetti from "canvas-confetti";
|
||||||
|
|
||||||
export let data: any;
|
export let data: any;
|
||||||
|
|
||||||
interface FilterOptions {
|
interface FilterOptions {
|
||||||
subject: string;
|
subject: string;
|
||||||
|
student: string;
|
||||||
sortBy: "due" | "grade" | "subject" | "title" | "year";
|
sortBy: "due" | "grade" | "subject" | "title" | "year";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,12 +47,21 @@
|
|||||||
|
|
||||||
let currentFilters: FilterOptions = {
|
let currentFilters: FilterOptions = {
|
||||||
subject: "all",
|
subject: "all",
|
||||||
|
student: "all",
|
||||||
sortBy: "due",
|
sortBy: "due",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isEngage = isSeqtaEngageExperience();
|
||||||
|
$: showStudentFilter = isEngage && (data?.students?.length ?? 0) > 1;
|
||||||
|
|
||||||
let filteredAssessments: any[] = [];
|
let filteredAssessments: any[] = [];
|
||||||
let statusGroups: Record<string, any[]> = {};
|
let statusGroups: Record<string, any[]> = {};
|
||||||
let columns: { key: string; title: string; className: string; icon: string }[] = [];
|
let columns: {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
className: string;
|
||||||
|
icon: OverviewIconName;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
function getAssessmentYear(a: any): number {
|
function getAssessmentYear(a: any): number {
|
||||||
const dateStr = a.due || a.date || a.dueDate || a.created;
|
const dateStr = a.due || a.date || a.dueDate || a.created;
|
||||||
@@ -82,14 +100,23 @@
|
|||||||
return new Date(a.due || a.date || 0).getTime() - new Date(b.due || b.date || 0).getTime();
|
return new Date(a.due || a.date || 0).getTime() - new Date(b.due || b.date || 0).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLUMNS = [
|
const STATUS_COLUMNS: {
|
||||||
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "📅" },
|
key: string;
|
||||||
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "⏰" },
|
title: string;
|
||||||
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "🚨" },
|
className: string;
|
||||||
{ key: "SUBMITTED", title: "Submitted", className: "column-submitted", icon: "📝" },
|
icon: OverviewIconName;
|
||||||
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "✅" },
|
}[] = [
|
||||||
|
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "calendar-days" },
|
||||||
|
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "clock" },
|
||||||
|
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "exclamation-triangle" },
|
||||||
|
{ key: "SUBMITTED", title: "Submitted", className: "column-submitted", icon: "document-check" },
|
||||||
|
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "check-circle" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function groupSortIcon(): OverviewIconName {
|
||||||
|
return GROUP_SORT_ICONS[currentFilters.sortBy] ?? "queue-list";
|
||||||
|
}
|
||||||
|
|
||||||
function buildGroupsAndColumns() {
|
function buildGroupsAndColumns() {
|
||||||
if (!data?.assessments) return { filteredAssessments: [], statusGroups: {}, columns: [] };
|
if (!data?.assessments) return { filteredAssessments: [], statusGroups: {}, columns: [] };
|
||||||
const subjectFilters = settingsState.subjectfilters || {};
|
const subjectFilters = settingsState.subjectfilters || {};
|
||||||
@@ -100,7 +127,17 @@
|
|||||||
const filtered = data.assessments.filter((a: any) => {
|
const filtered = data.assessments.filter((a: any) => {
|
||||||
if (hiddenAssessmentIds.has(String(a.id))) return false;
|
if (hiddenAssessmentIds.has(String(a.id))) return false;
|
||||||
if (subjectFilters[a.code] === false) return false;
|
if (subjectFilters[a.code] === false) return false;
|
||||||
return currentFilters.subject === "all" || a.code === currentFilters.subject;
|
if (currentFilters.subject !== "all" && a.code !== currentFilters.subject) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isEngage &&
|
||||||
|
currentFilters.student !== "all" &&
|
||||||
|
String(a.studentId) !== currentFilters.student
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups: Record<string, any[]> = {};
|
const groups: Record<string, any[]> = {};
|
||||||
@@ -114,18 +151,19 @@
|
|||||||
groups[key].sort(sortCompare);
|
groups[key].sort(sortCompare);
|
||||||
});
|
});
|
||||||
|
|
||||||
let cols: { key: string; title: string; className: string; icon: string }[];
|
let cols: { key: string; title: string; className: string; icon: OverviewIconName }[];
|
||||||
if (currentFilters.sortBy === "due") {
|
if (currentFilters.sortBy === "due") {
|
||||||
cols = STATUS_COLUMNS;
|
cols = STATUS_COLUMNS;
|
||||||
} else {
|
} else {
|
||||||
const keys = Object.keys(groups).filter((k) => groups[k]?.length > 0);
|
const keys = Object.keys(groups).filter((k) => groups[k]?.length > 0);
|
||||||
|
const sortIcon = groupSortIcon();
|
||||||
if (currentFilters.sortBy === "year") {
|
if (currentFilters.sortBy === "year") {
|
||||||
cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: "📆" }));
|
cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: sortIcon }));
|
||||||
} else if (currentFilters.sortBy === "subject") {
|
} else if (currentFilters.sortBy === "subject") {
|
||||||
const subjectTitles = new Map(data?.subjects?.map((s: any) => [s.code, `${s.code} - ${s.title}`]) || []);
|
const subjectTitles = new Map(data?.subjects?.map((s: any) => [s.code, `${s.code} - ${s.title}`]) || []);
|
||||||
cols = keys.sort().map((k) => ({ key: k, title: subjectTitles.get(k) || k, className: "column-custom", icon: "📚" }));
|
cols = keys.sort().map((k) => ({ key: k, title: subjectTitles.get(k) || k, className: "column-custom", icon: sortIcon }));
|
||||||
} else {
|
} else {
|
||||||
cols = keys.sort().map((k) => ({ key: k, title: k, className: "column-custom", icon: "📋" }));
|
cols = keys.sort().map((k) => ({ key: k, title: k, className: "column-custom", icon: sortIcon }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +347,19 @@
|
|||||||
if ((event.target as HTMLElement).closest(".card-menu")) {
|
if ((event.target as HTMLElement).closest(".card-menu")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSeqtaEngageExperience()) {
|
||||||
|
const studentId = assessment.studentId ?? data?.studentId;
|
||||||
|
if (!studentId) return;
|
||||||
|
window.location.hash = buildEngageAssessmentPagePath(
|
||||||
|
studentId,
|
||||||
|
assessment.programmeID,
|
||||||
|
assessment.metaclassID,
|
||||||
|
assessment.id,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`;
|
window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,16 +393,28 @@
|
|||||||
updateAssessments();
|
updateAssessments();
|
||||||
void currentFilters.sortBy;
|
void currentFilters.sortBy;
|
||||||
void currentFilters.subject;
|
void currentFilters.subject;
|
||||||
|
void currentFilters.student;
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={closeAllMenus} />
|
<svelte:window on:click={closeAllMenus} />
|
||||||
|
|
||||||
<div id="grid-view-container">
|
<div class="bsplus-overview-page">
|
||||||
<div class="grid-view-header">
|
<header class="grid-view-header bsplus-overview-animate">
|
||||||
<h1 class="grid-view-title">Assessments</h1>
|
<div class="grid-view-header-text">
|
||||||
<div class="grid-view-filters">
|
<h1 class="grid-view-title">Assessments</h1>
|
||||||
|
<p class="grid-view-subtitle">Track upcoming tasks, submissions, and released marks</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid-view-filters bsplus-overview-toolbar">
|
||||||
|
{#if showStudentFilter}
|
||||||
|
<select class="filter-select" bind:value={currentFilters.student}>
|
||||||
|
<option value="all">All Students</option>
|
||||||
|
{#each data.students as student}
|
||||||
|
<option value={String(student.id)}>{student.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
<select class="filter-select" bind:value={currentFilters.subject}>
|
<select class="filter-select" bind:value={currentFilters.subject}>
|
||||||
<option value="all">All Subjects</option>
|
<option value="all">All Subjects</option>
|
||||||
{#each data.subjects as subject}
|
{#each data.subjects as subject}
|
||||||
@@ -372,14 +435,15 @@
|
|||||||
on:click={() => (showVisibilityPanel = !showVisibilityPanel)}
|
on:click={() => (showVisibilityPanel = !showVisibilityPanel)}
|
||||||
title="Manage hidden subjects and assessments"
|
title="Manage hidden subjects and assessments"
|
||||||
>
|
>
|
||||||
👁 Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})
|
<OverviewIcon name="eye" size={18} />
|
||||||
|
<span>Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{#if showVisibilityPanel && hasHiddenItems}
|
{#if showVisibilityPanel && hasHiddenItems}
|
||||||
<div class="visibility-panel">
|
<div class="visibility-panel bsplus-overview-animate">
|
||||||
<h4 class="visibility-panel-title">Hidden items</h4>
|
<h4 class="visibility-panel-title">Hidden items</h4>
|
||||||
{#if hiddenSubjects.length > 0}
|
{#if hiddenSubjects.length > 0}
|
||||||
<div class="visibility-section">
|
<div class="visibility-section">
|
||||||
@@ -410,10 +474,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div id="main-grid-content">
|
<div id="main-grid-content" class="bsplus-overview-animate bsplus-overview-delay-1">
|
||||||
{#if filteredAssessments.length === 0}
|
{#if filteredAssessments.length === 0}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-icon">📋</div>
|
<OverviewIcon name="clipboard-document-list" size={40} class="empty-icon" />
|
||||||
<p>No assessments found matching your filters</p>
|
<p>No assessments found matching your filters</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -424,9 +488,15 @@
|
|||||||
<div class="kanban-column {column.className}">
|
<div class="kanban-column {column.className}">
|
||||||
<div class="column-header">
|
<div class="column-header">
|
||||||
<div class="column-title">
|
<div class="column-title">
|
||||||
{column.icon} {column.title}
|
<span class="column-title-main">
|
||||||
<span class="column-count">{statusGroups[column.key].length}</span>
|
<OverviewIcon
|
||||||
</div>
|
name={column.icon ?? STATUS_COLUMN_ICONS[column.key] ?? "queue-list"}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
{column.title}
|
||||||
|
</span>
|
||||||
|
<span class="column-count">{statusGroups[column.key].length}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
|
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
|
||||||
{#each statusGroups[column.key] as assessment}
|
{#each statusGroups[column.key] as assessment}
|
||||||
@@ -445,6 +515,9 @@
|
|||||||
on:keydown={(e) => e.key === 'Enter' && handleCardClick(assessment, e)}
|
on:keydown={(e) => e.key === 'Enter' && handleCardClick(assessment, e)}
|
||||||
>
|
>
|
||||||
<div class="card-labels">
|
<div class="card-labels">
|
||||||
|
{#if isEngage && assessment.studentName}
|
||||||
|
<span class="card-label label-student">{assessment.studentName}</span>
|
||||||
|
{/if}
|
||||||
<span class="card-label label-subject">{assessment.code}</span>
|
<span class="card-label label-subject">{assessment.code}</span>
|
||||||
{#if assessment.submitted}
|
{#if assessment.submitted}
|
||||||
<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>
|
<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>
|
||||||
@@ -462,11 +535,7 @@
|
|||||||
on:click={(e) => toggleMenu(assessment.id, e)}
|
on:click={(e) => toggleMenu(assessment.id, e)}
|
||||||
aria-label="Open menu"
|
aria-label="Open menu"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<OverviewIcon name="ellipsis-vertical" size={16} />
|
||||||
<circle cx="12" cy="5" r="2"/>
|
|
||||||
<circle cx="12" cy="12" r="2"/>
|
|
||||||
<circle cx="12" cy="19" r="2"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<div class="menu-dropdown" style="display: {openMenuId === assessment.id ? 'block' : 'none'};">
|
<div class="menu-dropdown" style="display: {openMenuId === assessment.id ? 'block' : 'none'};">
|
||||||
{#if status !== "MARKS_RELEASED"}
|
{#if status !== "MARKS_RELEASED"}
|
||||||
@@ -493,7 +562,8 @@
|
|||||||
{#if !assessment.results && !isCompleted}
|
{#if !assessment.results && !isCompleted}
|
||||||
<div class="assessment-meta">
|
<div class="assessment-meta">
|
||||||
<div class="due-date {dueDateClass}">
|
<div class="due-date {dueDateClass}">
|
||||||
📅 {formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)}
|
<OverviewIcon name="calendar-days" size={14} />
|
||||||
|
{formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import OverviewIcon from "./OverviewIcon.svelte";
|
||||||
|
|
||||||
export let error: string;
|
export let error: string;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="error-container">
|
<div class="error-container bsplus-overview-animate">
|
||||||
|
<OverviewIcon name="exclamation-circle" size={40} class="error-icon" />
|
||||||
<p class="error-text">Failed to load assessments</p>
|
<p class="error-text">Failed to load assessments</p>
|
||||||
<p style="color: #94a3b8; font-size: 0.875rem;">{error}</p>
|
<p class="error-detail">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { OVERVIEW_ICON_PATHS, type OverviewIconName } from "./icons";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: OverviewIconName;
|
||||||
|
class?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { name, class: className = "", size = 20 }: Props = $props();
|
||||||
|
|
||||||
|
const paths = $derived.by(() => {
|
||||||
|
const raw = OVERVIEW_ICON_PATHS[name];
|
||||||
|
return Array.isArray(raw) ? raw : [raw];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
class="bsplus-overview-icon {className}"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{#each paths as path, index (index)}
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d={path} />
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
@@ -1,7 +1,28 @@
|
|||||||
<div id="grid-view-container">
|
<script lang="ts">
|
||||||
<div class="grid-view-header">
|
import OverviewIcon from "./OverviewIcon.svelte";
|
||||||
<h1 class="grid-view-title">Assessments</h1>
|
import type { OverviewIconName } from "./icons";
|
||||||
<div class="grid-view-filters">
|
|
||||||
|
const columns: {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
className: string;
|
||||||
|
icon: OverviewIconName;
|
||||||
|
skeletonCount: number;
|
||||||
|
}[] = [
|
||||||
|
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "calendar-days", skeletonCount: 3 },
|
||||||
|
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "clock", skeletonCount: 2 },
|
||||||
|
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "exclamation-triangle", skeletonCount: 1 },
|
||||||
|
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "check-circle", skeletonCount: 4 },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bsplus-overview-page">
|
||||||
|
<header class="grid-view-header bsplus-overview-animate">
|
||||||
|
<div class="grid-view-header-text">
|
||||||
|
<h1 class="grid-view-title">Assessments</h1>
|
||||||
|
<p class="grid-view-subtitle">Loading your assessment overview…</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid-view-filters bsplus-overview-toolbar">
|
||||||
<select class="filter-select" disabled>
|
<select class="filter-select" disabled>
|
||||||
<option value="all">Loading subjects...</option>
|
<option value="all">Loading subjects...</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -9,17 +30,20 @@
|
|||||||
<option value="due">Sort by Due Date</option>
|
<option value="due">Sort by Due Date</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div id="main-grid-content">
|
<div id="main-grid-content" class="bsplus-overview-animate bsplus-overview-delay-1">
|
||||||
<div class="kanban-board">
|
<div class="kanban-board">
|
||||||
{#each columns as column}
|
{#each columns as column}
|
||||||
<div class="kanban-column-parent">
|
<div class="kanban-column-parent">
|
||||||
<div class="kanban-column {column.className}">
|
<div class="kanban-column {column.className}">
|
||||||
<div class="column-header">
|
<div class="column-header">
|
||||||
<div class="column-title">
|
<div class="column-title">
|
||||||
{column.icon} {column.title}
|
<span class="column-title-main">
|
||||||
<span class="column-count">...</span>
|
<OverviewIcon name={column.icon} size={18} />
|
||||||
|
{column.title}
|
||||||
|
</span>
|
||||||
|
<span class="column-count">…</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
|
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
|
||||||
@@ -43,36 +67,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
key: "UPCOMING",
|
|
||||||
title: "Upcoming",
|
|
||||||
className: "column-upcoming",
|
|
||||||
icon: "📅",
|
|
||||||
skeletonCount: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "DUE_SOON",
|
|
||||||
title: "Due Soon",
|
|
||||||
className: "column-due-soon",
|
|
||||||
icon: "⏰",
|
|
||||||
skeletonCount: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "OVERDUE",
|
|
||||||
title: "Overdue",
|
|
||||||
className: "column-overdue",
|
|
||||||
icon: "🚨",
|
|
||||||
skeletonCount: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "MARKS_RELEASED",
|
|
||||||
title: "Marked",
|
|
||||||
className: "column-marked",
|
|
||||||
icon: "✅",
|
|
||||||
skeletonCount: 4,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
interface Subject {
|
import {
|
||||||
code: string;
|
activeSubjectsFromLearnPayload,
|
||||||
programme: number;
|
assessmentBelongsToActiveSubjects,
|
||||||
metaclass: number;
|
filterAssessmentsForActiveSubjects,
|
||||||
title: string;
|
type OverviewSubject,
|
||||||
}
|
} from "./utils";
|
||||||
|
|
||||||
interface PrefItem {
|
interface PrefItem {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import { getMockAssessmentsData } from "@/seqta/ui/dev/hideSensitiveContent";
|
import { getMockAssessmentsData } from "@/seqta/ui/dev/hideSensitiveContent";
|
||||||
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
|
import {
|
||||||
|
getEngageAssessmentsData,
|
||||||
|
} from "./engageApi";
|
||||||
|
|
||||||
let cache: { time: number; data: any } | null = null;
|
let cache: { time: number; engageAll?: boolean; studentId: number; data: any } | null =
|
||||||
|
null;
|
||||||
const CACHE_MS = 10 * 60 * 1000;
|
const CACHE_MS = 10 * 60 * 1000;
|
||||||
const student = 69;
|
|
||||||
|
|
||||||
async function fetchJSON(url: string, body: any) {
|
async function fetchJSON(url: string, body: any) {
|
||||||
const res = await fetch(`${location.origin}${url}`, {
|
const res = await fetch(`${location.origin}${url}`, {
|
||||||
@@ -26,11 +32,9 @@ async function fetchJSON(url: string, body: any) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSubjects() {
|
async function loadSubjects(): Promise<OverviewSubject[]> {
|
||||||
const res = await fetchJSON("/seqta/student/load/subjects?", {});
|
const res = await fetchJSON("/seqta/student/load/subjects?", {});
|
||||||
return res.payload
|
return activeSubjectsFromLearnPayload(res.payload);
|
||||||
.filter((s: any) => s.active === 1)
|
|
||||||
.flatMap((s: any) => s.subjects);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPrefs(student: number) {
|
async function loadPrefs(student: number) {
|
||||||
@@ -56,9 +60,8 @@ async function loadUpcoming(student: number) {
|
|||||||
return res.payload;
|
return res.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAssessmentDates(t: any, subject: Subject): any {
|
function normalizeAssessmentDates(t: any, subject: OverviewSubject): any {
|
||||||
const normalized = { ...t };
|
const normalized = { ...t };
|
||||||
// Past API may use different date fields - ensure we have 'due' for year filter & display
|
|
||||||
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
|
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
|
||||||
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
|
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
|
||||||
}
|
}
|
||||||
@@ -68,7 +71,7 @@ function normalizeAssessmentDates(t: any, subject: Subject): any {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPast(student: number, subjects: Subject[]) {
|
async function loadPast(student: number, subjects: OverviewSubject[]) {
|
||||||
const map: Record<number, any> = {};
|
const map: Record<number, any> = {};
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
subjects.map(async (s) => {
|
subjects.map(async (s) => {
|
||||||
@@ -128,35 +131,65 @@ async function loadSubmissions(student: number, assessments: any[]) {
|
|||||||
return submissionMap;
|
return submissionMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAssessmentsData() {
|
async function getLearnAssessmentsData(studentId: number) {
|
||||||
if (settingsState.mockNotices) {
|
|
||||||
return getMockAssessmentsData();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cache && Date.now() - cache.time < CACHE_MS) return cache.data;
|
|
||||||
const [subjects, colors, upcoming] = await Promise.all([
|
const [subjects, colors, upcoming] = await Promise.all([
|
||||||
loadSubjects(),
|
loadSubjects(),
|
||||||
loadPrefs(student),
|
loadPrefs(studentId),
|
||||||
loadUpcoming(student),
|
loadUpcoming(studentId),
|
||||||
]);
|
]);
|
||||||
const pastMap = await loadPast(student, subjects);
|
const pastMap = await loadPast(studentId, subjects);
|
||||||
const map: Record<number, any> = {};
|
const map: Record<number, any> = {};
|
||||||
upcoming.forEach((a: any) => {
|
upcoming.forEach((a: any) => {
|
||||||
map[a.id] = { ...a };
|
if (assessmentBelongsToActiveSubjects(a, subjects)) {
|
||||||
|
map[a.id] = { ...a };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Object.values(pastMap).forEach((t: any) => {
|
Object.values(pastMap).forEach((t: any) => {
|
||||||
|
if (!assessmentBelongsToActiveSubjects(t, subjects)) return;
|
||||||
if (map[t.id]) Object.assign(map[t.id], t);
|
if (map[t.id]) Object.assign(map[t.id], t);
|
||||||
else map[t.id] = t;
|
else map[t.id] = t;
|
||||||
});
|
});
|
||||||
|
|
||||||
const allAssessments = Object.values(map);
|
const allAssessments = filterAssessmentsForActiveSubjects(
|
||||||
const submissions = await loadSubmissions(student, allAssessments);
|
Object.values(map),
|
||||||
|
subjects,
|
||||||
|
);
|
||||||
|
const submissions = await loadSubmissions(studentId, allAssessments);
|
||||||
|
|
||||||
allAssessments.forEach((assessment: any) => {
|
allAssessments.forEach((assessment: any) => {
|
||||||
assessment.submitted = submissions[assessment.id] || false;
|
assessment.submitted = submissions[assessment.id] || false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = { assessments: allAssessments, subjects, colors };
|
return { assessments: allAssessments, subjects, colors, studentId };
|
||||||
cache = { time: Date.now(), data };
|
}
|
||||||
|
|
||||||
|
export async function getAssessmentsData() {
|
||||||
|
if (settingsState.mockNotices) {
|
||||||
|
return getMockAssessmentsData();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSeqtaEngageExperience()) {
|
||||||
|
if (cache && Date.now() - cache.time < CACHE_MS && cache.engageAll) {
|
||||||
|
return cache.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getEngageAssessmentsData();
|
||||||
|
cache = { time: Date.now(), studentId: 0, engageAll: true, data };
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const studentId = (await getUserInfo()).id;
|
||||||
|
|
||||||
|
if (
|
||||||
|
cache &&
|
||||||
|
Date.now() - cache.time < CACHE_MS &&
|
||||||
|
cache.studentId === studentId
|
||||||
|
) {
|
||||||
|
return cache.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getLearnAssessmentsData(studentId);
|
||||||
|
|
||||||
|
cache = { time: Date.now(), studentId, data };
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
|
||||||
|
import {
|
||||||
|
activeSubjectsFromEngageChild,
|
||||||
|
assessmentBelongsToActiveSubjects,
|
||||||
|
filterAssessmentsForActiveSubjects,
|
||||||
|
type OverviewSubject,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
interface PrefItem {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EngageStudent {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EngageChildPayload {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
terms?: {
|
||||||
|
active?: number;
|
||||||
|
subjects?: {
|
||||||
|
code?: string;
|
||||||
|
programme?: number;
|
||||||
|
metaclass?: number;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJSON(url: string, body: unknown) {
|
||||||
|
const res = await fetch(`${location.origin}${url}`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEngageChildrenPayload(): Promise<EngageChildPayload[]> {
|
||||||
|
const res = await fetchJSON("/seqta/parent/load/subjects", {});
|
||||||
|
return Array.isArray(res.payload) ? res.payload : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveEngageStudentId(): Promise<number> {
|
||||||
|
const fromUrlOrStorage = getEngageAssessmentStudentId();
|
||||||
|
if (fromUrlOrStorage) return Number(fromUrlOrStorage);
|
||||||
|
|
||||||
|
const children = await loadEngageChildrenPayload();
|
||||||
|
const firstChild = children[0];
|
||||||
|
if (firstChild?.id != null) return Number(firstChild.id);
|
||||||
|
|
||||||
|
throw new Error("Could not resolve Engage student ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjectsFromChild(child: EngageChildPayload): OverviewSubject[] {
|
||||||
|
return activeSubjectsFromEngageChild(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEngagePrefs(): Promise<Record<string, string>> {
|
||||||
|
const res = await fetchJSON("/seqta/parent/load/prefs?", {
|
||||||
|
request: "userPrefs",
|
||||||
|
asArray: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const colors: Record<string, string> = {};
|
||||||
|
(res.payload ?? []).forEach((pref: PrefItem) => {
|
||||||
|
if (pref.name.startsWith("timetable.subject.colour.")) {
|
||||||
|
const code = pref.name.replace("timetable.subject.colour.", "");
|
||||||
|
colors[code] = pref.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEngageUpcoming(studentId: number) {
|
||||||
|
const res = await fetchJSON("/seqta/parent/assessment/list/upcoming?", {
|
||||||
|
student: studentId,
|
||||||
|
});
|
||||||
|
return res.payload ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAssessmentDates(t: any, subject: OverviewSubject): any {
|
||||||
|
const normalized = { ...t };
|
||||||
|
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
|
||||||
|
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
|
||||||
|
}
|
||||||
|
if (!normalized.programmeID) normalized.programmeID = subject.programme;
|
||||||
|
if (!normalized.metaclassID) normalized.metaclassID = subject.metaclass;
|
||||||
|
if (!normalized.code && t.subject) normalized.code = t.subject;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEngagePast(studentId: number, subjects: OverviewSubject[]) {
|
||||||
|
const map: Record<number, any> = {};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
subjects.map(async (subject) => {
|
||||||
|
const res = await fetchJSON("/seqta/parent/assessment/list/past?", {
|
||||||
|
programme: subject.programme,
|
||||||
|
metaclass: subject.metaclass,
|
||||||
|
student: studentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const processAssessment = (task: any) => {
|
||||||
|
if (task?.id) {
|
||||||
|
const merged = {
|
||||||
|
...task,
|
||||||
|
programmeID: task.programmeID || task.programme || subject.programme,
|
||||||
|
metaclassID: task.metaclassID || task.metaclass || subject.metaclass,
|
||||||
|
code: task.code || task.subject || subject.code,
|
||||||
|
};
|
||||||
|
map[task.id] = normalizeAssessmentDates(merged, subject);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(res.payload?.pending)) {
|
||||||
|
res.payload.pending.forEach(processAssessment);
|
||||||
|
}
|
||||||
|
if (Array.isArray(res.payload?.tasks)) {
|
||||||
|
res.payload.tasks.forEach(processAssessment);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEngageSubmissions(studentId: number, assessments: any[]) {
|
||||||
|
const submissionMap: Record<number, boolean> = {};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
assessments.map(async (assessment) => {
|
||||||
|
try {
|
||||||
|
const res = await fetchJSON("/seqta/parent/assessment/submissions/get", {
|
||||||
|
assessment: assessment.id,
|
||||||
|
metaclass: assessment.metaclassID,
|
||||||
|
student: studentId,
|
||||||
|
});
|
||||||
|
submissionMap[assessment.id] =
|
||||||
|
Array.isArray(res.payload) && res.payload.length > 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[BetterSEQTA+] Failed to fetch Engage submission for assessment ${assessment.id}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
submissionMap[assessment.id] = false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return submissionMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEngageAssessmentsForStudent(
|
||||||
|
child: EngageChildPayload,
|
||||||
|
): Promise<any[]> {
|
||||||
|
const studentId = Number(child.id);
|
||||||
|
const studentName = child.name ?? "Student";
|
||||||
|
const subjects = subjectsFromChild(child);
|
||||||
|
const [upcoming, pastMap] = await Promise.all([
|
||||||
|
loadEngageUpcoming(studentId),
|
||||||
|
loadEngagePast(studentId, subjects),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const map: Record<number, any> = {};
|
||||||
|
upcoming.forEach((assessment: any) => {
|
||||||
|
if (assessmentBelongsToActiveSubjects(assessment, subjects)) {
|
||||||
|
map[assessment.id] = { ...assessment };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.values(pastMap).forEach((task: any) => {
|
||||||
|
if (!assessmentBelongsToActiveSubjects(task, subjects)) return;
|
||||||
|
if (map[task.id]) Object.assign(map[task.id], task);
|
||||||
|
else map[task.id] = task;
|
||||||
|
});
|
||||||
|
|
||||||
|
const assessments = filterAssessmentsForActiveSubjects(
|
||||||
|
Object.values(map),
|
||||||
|
subjects,
|
||||||
|
).map((assessment) => ({
|
||||||
|
...assessment,
|
||||||
|
studentId,
|
||||||
|
studentName,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const submissions = await loadEngageSubmissions(studentId, assessments);
|
||||||
|
assessments.forEach((assessment) => {
|
||||||
|
assessment.submitted = submissions[assessment.id] || false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return assessments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEngageAssessmentsData() {
|
||||||
|
const childrenPayload = await loadEngageChildrenPayload();
|
||||||
|
const students: EngageStudent[] = childrenPayload
|
||||||
|
.filter((child) => child.id != null)
|
||||||
|
.map((child) => ({
|
||||||
|
id: Number(child.id),
|
||||||
|
name: child.name ?? "Student",
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!students.length) {
|
||||||
|
throw new Error("No Engage students found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [colors, assessmentsByChild] = await Promise.all([
|
||||||
|
loadEngagePrefs(),
|
||||||
|
Promise.all(childrenPayload.map((child) => loadEngageAssessmentsForStudent(child))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const subjectsMap = new Map<string, OverviewSubject>();
|
||||||
|
childrenPayload.forEach((child) => {
|
||||||
|
subjectsFromChild(child).forEach((subject) => {
|
||||||
|
if (!subjectsMap.has(subject.code)) {
|
||||||
|
subjectsMap.set(subject.code, subject);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultStudentId = await resolveEngageStudentId();
|
||||||
|
|
||||||
|
return {
|
||||||
|
assessments: assessmentsByChild.flat(),
|
||||||
|
subjects: Array.from(subjectsMap.values()),
|
||||||
|
colors,
|
||||||
|
students,
|
||||||
|
studentId: defaultStudentId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/** Heroicons v2 outline paths (https://heroicons.com) */
|
||||||
|
export type OverviewIconName =
|
||||||
|
| "calendar-days"
|
||||||
|
| "clock"
|
||||||
|
| "exclamation-triangle"
|
||||||
|
| "document-check"
|
||||||
|
| "check-circle"
|
||||||
|
| "book-open"
|
||||||
|
| "calendar"
|
||||||
|
| "chart-bar"
|
||||||
|
| "queue-list"
|
||||||
|
| "eye"
|
||||||
|
| "clipboard-document-list"
|
||||||
|
| "ellipsis-vertical"
|
||||||
|
| "exclamation-circle";
|
||||||
|
|
||||||
|
export const OVERVIEW_ICON_PATHS: Record<
|
||||||
|
OverviewIconName,
|
||||||
|
string | string[]
|
||||||
|
> = {
|
||||||
|
"calendar-days":
|
||||||
|
"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5",
|
||||||
|
clock: "M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||||
|
"exclamation-triangle":
|
||||||
|
"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z",
|
||||||
|
"document-check":
|
||||||
|
"M10.125 2.25h-4.5c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 8.625a2.625 2.625 0 100-5.25 2.625 2.625 0 000 5.25zm0 0l-3 3m3-3l3 3",
|
||||||
|
"check-circle":
|
||||||
|
"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||||
|
"book-open":
|
||||||
|
"M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25",
|
||||||
|
calendar:
|
||||||
|
"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5",
|
||||||
|
"chart-bar":
|
||||||
|
"M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z",
|
||||||
|
"queue-list":
|
||||||
|
"M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z",
|
||||||
|
eye: [
|
||||||
|
"M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z",
|
||||||
|
"M15 12a3 3 0 11-6 0 3 3 0 016 0z",
|
||||||
|
],
|
||||||
|
"clipboard-document-list": [
|
||||||
|
"M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 17.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z",
|
||||||
|
"M8.25 6.75V4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V6.75H8.25z",
|
||||||
|
],
|
||||||
|
"ellipsis-vertical":
|
||||||
|
"M12 6.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 12.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 18.75a.75.75 0 110-1.5.75.75 0 010 1.5z",
|
||||||
|
"exclamation-circle":
|
||||||
|
"M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_COLUMN_ICONS: Record<string, OverviewIconName> = {
|
||||||
|
UPCOMING: "calendar-days",
|
||||||
|
DUE_SOON: "clock",
|
||||||
|
OVERDUE: "exclamation-triangle",
|
||||||
|
SUBMITTED: "document-check",
|
||||||
|
MARKS_RELEASED: "check-circle",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GROUP_SORT_ICONS: Record<string, OverviewIconName> = {
|
||||||
|
year: "calendar",
|
||||||
|
subject: "book-open",
|
||||||
|
grade: "chart-bar",
|
||||||
|
title: "queue-list",
|
||||||
|
};
|
||||||
@@ -1,9 +1,50 @@
|
|||||||
import type { Plugin } from "../../core/types";
|
import type { Plugin } from "../../core/types";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
import { getAssessmentsData } from "./api";
|
import { getAssessmentsData } from "./api";
|
||||||
import { renderErrorState, renderSkeletonLoader } from "./ui";
|
import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
|
||||||
import styles from "./styles.css?inline";
|
import styles from "./styles.css?inline";
|
||||||
import { delay } from "@/seqta/utils/delay";
|
import { delay } from "@/seqta/utils/delay";
|
||||||
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
|
import {
|
||||||
|
isEngageAssessmentOverviewRoute,
|
||||||
|
} from "@/seqta/utils/engageAssessmentStudent";
|
||||||
|
import { resolveEngageStudentId } from "./engageApi";
|
||||||
|
|
||||||
|
const OVERVIEW_MENU_CLASS = "betterseqta-assessments-overview-item";
|
||||||
|
|
||||||
|
function ensureOverviewMenuPosition(
|
||||||
|
menu: HTMLElement,
|
||||||
|
gridItem: HTMLElement,
|
||||||
|
) {
|
||||||
|
if (menu.firstElementChild !== gridItem) {
|
||||||
|
menu.insertBefore(gridItem, menu.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverviewRoute() {
|
||||||
|
if (isSeqtaEngageExperience()) {
|
||||||
|
return isEngageAssessmentOverviewRoute();
|
||||||
|
}
|
||||||
|
return window.location.hash.includes("/assessments/overview");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAssessmentsSubmenu(): Promise<HTMLElement> {
|
||||||
|
if (!isSeqtaEngageExperience()) {
|
||||||
|
return (await waitForElm(
|
||||||
|
'[data-key="assessments"] > .sub > ul',
|
||||||
|
true,
|
||||||
|
100,
|
||||||
|
60,
|
||||||
|
)) as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await waitForElm(
|
||||||
|
'[data-key="assessments"] .sub ul, [data-key="assessments"] ul',
|
||||||
|
true,
|
||||||
|
100,
|
||||||
|
350,
|
||||||
|
)) as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
const assessmentsOverviewPlugin: Plugin<{}> = {
|
const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||||
id: "assessments-overview",
|
id: "assessments-overview",
|
||||||
@@ -16,33 +57,46 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
|||||||
styles,
|
styles,
|
||||||
|
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const menu = (await waitForElm(
|
const menu = await waitForAssessmentsSubmenu();
|
||||||
'[data-key="assessments"] > .sub > ul',
|
|
||||||
true,
|
|
||||||
100,
|
|
||||||
60,
|
|
||||||
)) as HTMLElement;
|
|
||||||
const gridItem = document.createElement("li");
|
const gridItem = document.createElement("li");
|
||||||
gridItem.className = "item";
|
gridItem.className = "item";
|
||||||
|
gridItem.classList.add(OVERVIEW_MENU_CLASS);
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
label.textContent = "Overview";
|
label.textContent = "Overview";
|
||||||
gridItem.appendChild(label);
|
gridItem.appendChild(label);
|
||||||
menu.insertBefore(gridItem, menu.children[1] || null);
|
menu.insertBefore(gridItem, menu.firstChild);
|
||||||
|
|
||||||
if (window.location.hash.includes("/assessments/overview")) {
|
const menuObserver = new MutationObserver(() => {
|
||||||
loadGridView();
|
ensureOverviewMenuPosition(menu, gridItem);
|
||||||
|
});
|
||||||
|
menuObserver.observe(menu, { childList: true });
|
||||||
|
|
||||||
|
if (isOverviewRoute()) {
|
||||||
|
void loadGridView();
|
||||||
}
|
}
|
||||||
|
|
||||||
const clickHandler = (e: Event) => {
|
const clickHandler = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
loadGridView();
|
void loadGridView();
|
||||||
};
|
};
|
||||||
gridItem.addEventListener("click", clickHandler);
|
gridItem.addEventListener("click", clickHandler);
|
||||||
|
|
||||||
async function loadGridView() {
|
async function loadGridView() {
|
||||||
await delay(1);
|
await delay(1);
|
||||||
window.history.pushState({}, "", "/#?page=/assessments/overview");
|
|
||||||
document.title = "Overview ― SEQTA Learn";
|
if (isSeqtaEngageExperience()) {
|
||||||
|
const studentId = await resolveEngageStudentId();
|
||||||
|
window.history.pushState(
|
||||||
|
{},
|
||||||
|
"",
|
||||||
|
`/#?page=/assessments/${studentId}/overview`,
|
||||||
|
);
|
||||||
|
document.title = "Overview ― SEQTA Engage";
|
||||||
|
} else {
|
||||||
|
window.history.pushState({}, "", "/#?page=/assessments/overview");
|
||||||
|
document.title = "Overview ― SEQTA Learn";
|
||||||
|
}
|
||||||
|
|
||||||
const main = document.getElementById("main");
|
const main = document.getElementById("main");
|
||||||
if (!main) return;
|
if (!main) return;
|
||||||
|
|
||||||
@@ -56,7 +110,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
|||||||
.querySelector('[data-key="assessments"]')
|
.querySelector('[data-key="assessments"]')
|
||||||
?.classList.add("active");
|
?.classList.add("active");
|
||||||
|
|
||||||
main.innerHTML = '<div id="grid-view-container"></div>';
|
main.innerHTML = '<div id="grid-view-container" class="bsplus-overview-host"></div>';
|
||||||
const container = document.getElementById(
|
const container = document.getElementById(
|
||||||
"grid-view-container",
|
"grid-view-container",
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
@@ -65,7 +119,6 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getAssessmentsData();
|
const data = await getAssessmentsData();
|
||||||
const { renderGrid } = await import("./ui");
|
|
||||||
renderGrid(container, data);
|
renderGrid(container, data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load assessments:", err);
|
console.error("Failed to load assessments:", err);
|
||||||
@@ -77,6 +130,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
menuObserver.disconnect();
|
||||||
gridItem.removeEventListener("click", clickHandler);
|
gridItem.removeEventListener("click", clickHandler);
|
||||||
gridItem.remove();
|
gridItem.remove();
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,45 +1,155 @@
|
|||||||
import renderSvelte from "@/interface/main";
|
import renderSvelte from "@/interface/main";
|
||||||
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import AssessmentsOverview from "./AssessmentsOverview.svelte";
|
import AssessmentsOverview from "./AssessmentsOverview.svelte";
|
||||||
import SkeletonLoader from "./SkeletonLoader.svelte";
|
import SkeletonLoader from "./SkeletonLoader.svelte";
|
||||||
import ErrorState from "./ErrorState.svelte";
|
import ErrorState from "./ErrorState.svelte";
|
||||||
import { unmount } from "svelte";
|
import { unmount } from "svelte";
|
||||||
|
|
||||||
let currentApp: any = null;
|
let currentApp: any = null;
|
||||||
|
let themeObserver: MutationObserver | null = null;
|
||||||
|
type ThemeSettingKey =
|
||||||
|
| "selectedColor"
|
||||||
|
| "DarkMode"
|
||||||
|
| "adaptiveThemeColour"
|
||||||
|
| "adaptiveThemeGradient"
|
||||||
|
| "selectedTheme";
|
||||||
|
|
||||||
export function renderGrid(container: HTMLElement, data: any) {
|
let themeListeners: Array<{ key: ThemeSettingKey; listener: () => void }> = [];
|
||||||
if (currentApp) {
|
|
||||||
unmount(currentApp);
|
const THEME_CSS_VARS = [
|
||||||
|
"--better-main",
|
||||||
|
"--better-pale",
|
||||||
|
"--better-light",
|
||||||
|
"--text-color",
|
||||||
|
"--background-primary",
|
||||||
|
"--background-secondary",
|
||||||
|
"--text-primary",
|
||||||
|
"--theme-offset-bg",
|
||||||
|
"--better-sub",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ACCENT_CSS_VARS = [
|
||||||
|
"--better-main",
|
||||||
|
"--accent-color-value",
|
||||||
|
"--accentColor",
|
||||||
|
"--colour-betterseqta-blue",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function extractSolidColor(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || trimmed === "initial") return null;
|
||||||
|
if (
|
||||||
|
trimmed.startsWith("#") ||
|
||||||
|
trimmed.startsWith("rgb") ||
|
||||||
|
trimmed.startsWith("hsl")
|
||||||
|
) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
if (trimmed.includes("gradient")) {
|
||||||
|
const match = trimmed.match(
|
||||||
|
/#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/i,
|
||||||
|
);
|
||||||
|
return match?.[0] ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePageAccentColor(): string {
|
||||||
|
const computed = getComputedStyle(document.documentElement);
|
||||||
|
for (const name of ACCENT_CSS_VARS) {
|
||||||
|
const solid = extractSolidColor(computed.getPropertyValue(name));
|
||||||
|
if (solid) return solid;
|
||||||
|
}
|
||||||
|
const fromSettings = settingsState.selectedColor?.trim();
|
||||||
|
if (fromSettings) {
|
||||||
|
const solid = extractSolidColor(fromSettings);
|
||||||
|
if (solid) return solid;
|
||||||
|
}
|
||||||
|
return "#007bff";
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncOverviewTheme(target: HTMLElement) {
|
||||||
|
const computed = getComputedStyle(document.documentElement);
|
||||||
|
for (const name of THEME_CSS_VARS) {
|
||||||
|
const value = document.documentElement.style.getPropertyValue(name).trim()
|
||||||
|
|| computed.getPropertyValue(name).trim();
|
||||||
|
if (value) target.style.setProperty(name, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = "";
|
const accent = resolvePageAccentColor();
|
||||||
container.className = "";
|
target.style.setProperty("--bsplus-overview-accent", accent);
|
||||||
|
target.style.setProperty("--better-main", accent);
|
||||||
|
target.classList.toggle(
|
||||||
|
"dark",
|
||||||
|
document.documentElement.classList.contains("dark"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchOverviewTheme(root: HTMLElement) {
|
||||||
|
for (const { key, listener } of themeListeners) {
|
||||||
|
settingsState.unregister(key, listener);
|
||||||
|
}
|
||||||
|
themeListeners = [];
|
||||||
|
|
||||||
|
const listener = () => syncOverviewTheme(root);
|
||||||
|
for (const key of [
|
||||||
|
"selectedColor",
|
||||||
|
"DarkMode",
|
||||||
|
"adaptiveThemeColour",
|
||||||
|
"adaptiveThemeGradient",
|
||||||
|
"selectedTheme",
|
||||||
|
] satisfies ThemeSettingKey[]) {
|
||||||
|
settingsState.register(key, listener);
|
||||||
|
themeListeners.push({ key, listener });
|
||||||
|
}
|
||||||
|
|
||||||
|
themeObserver?.disconnect();
|
||||||
|
themeObserver = new MutationObserver(() => syncOverviewTheme(root));
|
||||||
|
themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["style", "class"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareContainer(container: HTMLElement) {
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.className = "bsplus-overview-host";
|
||||||
|
container.classList.add("bsplus-overview-root");
|
||||||
|
syncOverviewTheme(container);
|
||||||
|
watchOverviewTheme(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderGrid(container: HTMLElement, data: any) {
|
||||||
|
if (currentApp) unmount(currentApp);
|
||||||
|
prepareContainer(container);
|
||||||
currentApp = renderSvelte(AssessmentsOverview, container, { data });
|
currentApp = renderSvelte(AssessmentsOverview, container, { data });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderSkeletonLoader(container: HTMLElement) {
|
export function renderSkeletonLoader(container: HTMLElement) {
|
||||||
if (currentApp) {
|
if (currentApp) unmount(currentApp);
|
||||||
unmount(currentApp);
|
prepareContainer(container);
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = "";
|
|
||||||
container.className = "";
|
|
||||||
|
|
||||||
currentApp = renderSvelte(SkeletonLoader, container);
|
currentApp = renderSvelte(SkeletonLoader, container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function renderLoadingState(container: HTMLElement) {
|
export function renderLoadingState(container: HTMLElement) {
|
||||||
renderSkeletonLoader(container);
|
renderSkeletonLoader(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderErrorState(container: HTMLElement, error: string) {
|
export function renderErrorState(container: HTMLElement, error: string) {
|
||||||
if (currentApp) {
|
if (currentApp) unmount(currentApp);
|
||||||
unmount(currentApp);
|
prepareContainer(container);
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = "";
|
|
||||||
container.className = "";
|
|
||||||
|
|
||||||
currentApp = renderSvelte(ErrorState, container, { error });
|
currentApp = renderSvelte(ErrorState, container, { error });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function teardownOverviewUi() {
|
||||||
|
for (const { key, listener } of themeListeners) {
|
||||||
|
settingsState.unregister(key, listener);
|
||||||
|
}
|
||||||
|
themeListeners = [];
|
||||||
|
themeObserver?.disconnect();
|
||||||
|
themeObserver = null;
|
||||||
|
if (currentApp) {
|
||||||
|
unmount(currentApp);
|
||||||
|
currentApp = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,115 @@
|
|||||||
|
export interface OverviewSubject {
|
||||||
|
code: string;
|
||||||
|
programme: number;
|
||||||
|
metaclass: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveTermFlag(active: unknown): boolean {
|
||||||
|
return active === 1 || active === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOverviewSubject(raw: unknown): OverviewSubject | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
|
||||||
|
const subject = raw as Record<string, unknown>;
|
||||||
|
const programme = Number(subject.programme ?? subject.programmeID);
|
||||||
|
const metaclass = Number(subject.metaclass ?? subject.metaclassID);
|
||||||
|
if (!programme || !metaclass || Number.isNaN(programme) || Number.isNaN(metaclass)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = String(subject.code ?? subject.subject ?? "").trim();
|
||||||
|
if (!code) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
programme,
|
||||||
|
metaclass,
|
||||||
|
title: String(subject.title ?? subject.description ?? code),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subjects from the active programme-year folder(s) in `/seqta/student/load/subjects`. */
|
||||||
|
export function activeSubjectsFromLearnPayload(payload: unknown): OverviewSubject[] {
|
||||||
|
if (!Array.isArray(payload)) return [];
|
||||||
|
|
||||||
|
const subjects: OverviewSubject[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const folder of payload) {
|
||||||
|
if (!folder || typeof folder !== "object") continue;
|
||||||
|
const term = folder as { active?: unknown; subjects?: unknown[] };
|
||||||
|
if (!isActiveTermFlag(term.active) || !Array.isArray(term.subjects)) continue;
|
||||||
|
|
||||||
|
for (const raw of term.subjects) {
|
||||||
|
const subject = normalizeOverviewSubject(raw);
|
||||||
|
if (!subject) continue;
|
||||||
|
const key = `${subject.programme}-${subject.metaclass}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
subjects.push(subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return subjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activeSubjectsFromEngageChild(child: {
|
||||||
|
terms?: { active?: number; subjects?: unknown[] }[];
|
||||||
|
}): OverviewSubject[] {
|
||||||
|
const subjects: OverviewSubject[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const term of child.terms ?? []) {
|
||||||
|
if (term.active !== 1) continue;
|
||||||
|
for (const raw of term.subjects ?? []) {
|
||||||
|
const subject = normalizeOverviewSubject(raw);
|
||||||
|
if (!subject) continue;
|
||||||
|
const key = `${subject.programme}-${subject.metaclass}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
subjects.push(subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return subjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assessmentBelongsToActiveSubjects(
|
||||||
|
assessment: Record<string, unknown>,
|
||||||
|
activeSubjects: OverviewSubject[],
|
||||||
|
): boolean {
|
||||||
|
if (!activeSubjects.length) return false;
|
||||||
|
|
||||||
|
const programme = Number(
|
||||||
|
assessment.programmeID ?? assessment.programme,
|
||||||
|
);
|
||||||
|
const metaclass = Number(
|
||||||
|
assessment.metaclassID ?? assessment.metaclass,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (programme && metaclass && !Number.isNaN(programme) && !Number.isNaN(metaclass)) {
|
||||||
|
return activeSubjects.some(
|
||||||
|
(subject) =>
|
||||||
|
subject.programme === programme && subject.metaclass === metaclass,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = String(assessment.code ?? assessment.subject ?? "").trim();
|
||||||
|
if (!code) return false;
|
||||||
|
return activeSubjects.some((subject) => subject.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterAssessmentsForActiveSubjects<T extends Record<string, unknown>>(
|
||||||
|
assessments: T[],
|
||||||
|
activeSubjects: OverviewSubject[],
|
||||||
|
): T[] {
|
||||||
|
return assessments.filter((assessment) =>
|
||||||
|
assessmentBelongsToActiveSubjects(assessment, activeSubjects),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDate(dateStr: string, submitted?: boolean): string {
|
export function formatDate(dateStr: string, submitted?: boolean): string {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import localforage from "localforage";
|
|||||||
const settings = defineSettings({
|
const settings = defineSettings({
|
||||||
uploader: componentSetting({
|
uploader: componentSetting({
|
||||||
title: "Background Music",
|
title: "Background Music",
|
||||||
description: "Upload a .wav or .mp3 audio file to play in the background.",
|
description: "Upload a .wav or .mp3 audio file to play in the background",
|
||||||
component: BackgroundMusicSetting,
|
component: BackgroundMusicSetting,
|
||||||
}),
|
}),
|
||||||
volume: numberSetting({
|
volume: numberSetting({
|
||||||
@@ -99,7 +99,7 @@ async function startPlayback(volume: number): Promise<void> {
|
|||||||
const backgroundMusicPlugin: Plugin<typeof settings> = {
|
const backgroundMusicPlugin: Plugin<typeof settings> = {
|
||||||
id: "background-music",
|
id: "background-music",
|
||||||
name: "Background Music",
|
name: "Background Music",
|
||||||
description: "Play your own music in the background while SEQTA is open.",
|
description: "Play your own music in the background while SEQTA is open",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
settings,
|
settings,
|
||||||
styles,
|
styles,
|
||||||
@@ -142,7 +142,6 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
|
|||||||
if (!currentAudio) return;
|
if (!currentAudio) return;
|
||||||
const pauseOnHidden = (api.settings as any).pauseOnHidden ?? true;
|
const pauseOnHidden = (api.settings as any).pauseOnHidden ?? true;
|
||||||
if (!pauseOnHidden) return;
|
if (!pauseOnHidden) return;
|
||||||
|
|
||||||
if (document.visibilityState === "hidden") {
|
if (document.visibilityState === "hidden") {
|
||||||
if (visibilityResumeTimeout !== null) {
|
if (visibilityResumeTimeout !== null) {
|
||||||
clearTimeout(visibilityResumeTimeout);
|
clearTimeout(visibilityResumeTimeout);
|
||||||
@@ -183,5 +182,3 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default backgroundMusicPlugin;
|
export default backgroundMusicPlugin;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
defineSettings,
|
defineSettings,
|
||||||
hotkeySetting,
|
hotkeySetting,
|
||||||
} from "../../core/settingsHelpers";
|
} from "../../core/settingsHelpers";
|
||||||
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
import styles from "./src/core/styles.css?inline";
|
import styles from "./src/core/styles.css?inline";
|
||||||
|
import { resetSearchIndexes } from "./src/indexing/resetIndexes";
|
||||||
|
|
||||||
// Platform-aware default hotkey
|
// Platform-aware default hotkey
|
||||||
const getDefaultHotkey = () => {
|
const getDefaultHotkey = () => {
|
||||||
@@ -34,85 +36,42 @@ const settings = defineSettings({
|
|||||||
title: "Index on Page Load",
|
title: "Index on Page Load",
|
||||||
description: "Run content indexing when SEQTA loads",
|
description: "Run content indexing when SEQTA loads",
|
||||||
}),
|
}),
|
||||||
|
passiveIndexing: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Index Browsed Content",
|
||||||
|
description:
|
||||||
|
"Capture safe text from SEQTA pages you visit so they're searchable. Sensitive routes (settings, files, login) are always excluded.",
|
||||||
|
}),
|
||||||
resetIndex: buttonSetting({
|
resetIndex: buttonSetting({
|
||||||
title: "Reset Index",
|
title: "Reset Index",
|
||||||
description: "Reset the search index and storage",
|
description: "Reset the search index and storage",
|
||||||
trigger: async () => {
|
trigger: async () => {
|
||||||
const confirmed = confirm("Are you sure you want to reset the search index and storage?");
|
const confirmed = confirm(
|
||||||
|
"Reset the search index and all stored Global Search data?\n\nAfter this, reload this SEQTA tab so indexing can run again and rebuild the index.",
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
if (confirmed) {
|
try {
|
||||||
try {
|
// `resetSearchIndexes` is a tiny statically-imported helper: no
|
||||||
// Dynamically import modules to avoid loading heavy dependencies
|
// dynamic chunks to chase, so the button keeps working even when
|
||||||
const { VectorWorkerManager } = await import("./src/indexing/worker/vectorWorkerManager");
|
// the settings page has been open across an extension update.
|
||||||
const { resetDatabase } = await import("./src/indexing/db");
|
await resetSearchIndexes();
|
||||||
|
alert(
|
||||||
// Reset vector worker first
|
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
|
||||||
try {
|
);
|
||||||
const workerManager = VectorWorkerManager.getInstance();
|
} catch (e) {
|
||||||
await workerManager.resetWorker();
|
alert(
|
||||||
console.log("Vector worker reset successfully");
|
"Failed to reset index: " +
|
||||||
} catch (e) {
|
String(e) +
|
||||||
console.warn("Failed to reset vector worker:", e);
|
"\n\nTry closing other browser tabs and try again.",
|
||||||
}
|
);
|
||||||
|
|
||||||
// Close all database connections properly before deletion
|
|
||||||
try {
|
|
||||||
await resetDatabase();
|
|
||||||
console.log("betterseqta-index database closed and reset");
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Failed to reset betterseqta-index database:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait a bit for connections to fully close
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
// Delete embeddiaDB (vector search database)
|
|
||||||
const deleteDb = (dbName: string) => {
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const req = indexedDB.deleteDatabase(dbName);
|
|
||||||
req.onsuccess = () => {
|
|
||||||
console.log(`Successfully deleted database: ${dbName}`);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
req.onerror = () => {
|
|
||||||
console.error(`Error deleting database ${dbName}:`, req.error);
|
|
||||||
reject(req.error);
|
|
||||||
};
|
|
||||||
req.onblocked = () => {
|
|
||||||
console.warn(`Database ${dbName} deletion blocked - connections still open`);
|
|
||||||
// Wait and retry once
|
|
||||||
setTimeout(() => {
|
|
||||||
const retryReq = indexedDB.deleteDatabase(dbName);
|
|
||||||
retryReq.onsuccess = () => {
|
|
||||||
console.log(`Successfully deleted database on retry: ${dbName}`);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
retryReq.onerror = () => reject(retryReq.error);
|
|
||||||
retryReq.onblocked = () => {
|
|
||||||
reject(new Error(`One database is open, failed to remove: ${dbName}. Please close other tabs and try again.`));
|
|
||||||
};
|
|
||||||
}, 500);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteDb("embeddiaDB");
|
|
||||||
await deleteDb("betterseqta-index");
|
|
||||||
alert("Search index and storage have been reset successfully.");
|
|
||||||
} catch (e) {
|
|
||||||
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert("Failed to reset index: " + String(e));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create the lazy plugin definition - this loads immediately but doesn't import heavy dependencies
|
// Create the lazy plugin definition - this loads immediately but doesn't import heavy dependencies
|
||||||
export default defineLazyPlugin({
|
const globalSearchPlugin = defineLazyPlugin({
|
||||||
id: "global-search",
|
id: "global-search",
|
||||||
name: "Global Search",
|
name: "Global Search",
|
||||||
description: "Quick search for everything in SEQTA",
|
description: "Quick search for everything in SEQTA",
|
||||||
@@ -120,9 +79,20 @@ export default defineLazyPlugin({
|
|||||||
settings,
|
settings,
|
||||||
disableToggle: true,
|
disableToggle: true,
|
||||||
defaultEnabled: false,
|
defaultEnabled: false,
|
||||||
beta: true,
|
|
||||||
styles: styles,
|
styles: styles,
|
||||||
|
|
||||||
// Lazy loader - only imports the heavy plugin when actually needed
|
// Lazy loader - only imports the heavy plugin when actually needed
|
||||||
loader: () => import("./src/core/index")
|
loader: () => import("./src/core/index")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const runGlobalSearch = globalSearchPlugin.run!;
|
||||||
|
|
||||||
|
globalSearchPlugin.run = async (api) => {
|
||||||
|
if (isSeqtaEngageExperience()) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return runGlobalSearch(api);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default globalSearchPlugin;
|
||||||
|
|||||||
@@ -48,6 +48,13 @@
|
|||||||
let calculatorResult = $state<string | null>(null);
|
let calculatorResult = $state<string | null>(null);
|
||||||
let resultsList = $state<HTMLUListElement>();
|
let resultsList = $state<HTMLUListElement>();
|
||||||
|
|
||||||
|
// Monotonic counter so a slow async search (vector reranking) cannot
|
||||||
|
// overwrite results from a newer keystroke. Without this guard, the user
|
||||||
|
// observes results "flickering" — e.g. typing `world w` finds the assessment
|
||||||
|
// but `world wa` triggers a new search whose vector pass returns later than
|
||||||
|
// the `world w` pass and clobbers the more relevant matches.
|
||||||
|
let searchRequestId = 0;
|
||||||
|
|
||||||
const updateCalculatorState = (hasResult: string | null) => {
|
const updateCalculatorState = (hasResult: string | null) => {
|
||||||
calculatorResult = hasResult;
|
calculatorResult = hasResult;
|
||||||
};
|
};
|
||||||
@@ -166,9 +173,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const term = searchTerm.trim().toLowerCase();
|
const term = searchTerm.trim().toLowerCase();
|
||||||
|
const requestId = ++searchRequestId;
|
||||||
|
|
||||||
if (commandsFuse && dynamicContentFuse) {
|
if (commandsFuse && dynamicContentFuse) {
|
||||||
combinedResults = await doSearch(
|
const results = await doSearch(
|
||||||
term,
|
term,
|
||||||
commandsFuse,
|
commandsFuse,
|
||||||
commandIdToItemMap,
|
commandIdToItemMap,
|
||||||
@@ -176,7 +184,16 @@
|
|||||||
dynamicIdToItemMap,
|
dynamicIdToItemMap,
|
||||||
true, // sortByRecent
|
true, // sortByRecent
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Drop the result if the user has typed since this search started, or
|
||||||
|
// if the current term no longer matches what we searched for. This
|
||||||
|
// keeps the visible list anchored to the latest query.
|
||||||
|
if (requestId !== searchRequestId) return;
|
||||||
|
if (searchTerm.trim().toLowerCase() !== term) return;
|
||||||
|
|
||||||
|
combinedResults = results;
|
||||||
} else {
|
} else {
|
||||||
|
if (requestId !== searchRequestId) return;
|
||||||
combinedResults = [];
|
combinedResults = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import HighlightedText from '../../utils/HighlightedText.svelte';
|
||||||
|
import type { DynamicContentItem } from '../../utils/dynamicItems';
|
||||||
|
import type { FuseResultMatch } from '../../core/types';
|
||||||
|
|
||||||
|
const { item, isSelected, searchTerm, matches, onclick } = $props<{
|
||||||
|
item: DynamicContentItem;
|
||||||
|
isSelected: boolean;
|
||||||
|
searchTerm: string;
|
||||||
|
matches?: readonly FuseResultMatch[];
|
||||||
|
onclick: () => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const categoryLabel = (category: string): string => {
|
||||||
|
if (!category) return '';
|
||||||
|
return category.charAt(0).toUpperCase() + category.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const gradientForCategory = (category: string): string => {
|
||||||
|
switch (category) {
|
||||||
|
case 'courses':
|
||||||
|
return 'from-[#7c5fe0] to-[#4d2bb8]';
|
||||||
|
case 'notices':
|
||||||
|
return 'from-[#f6c453] to-[#d39007]';
|
||||||
|
case 'documents':
|
||||||
|
return 'from-[#4FBBFE] to-[#2090F3]';
|
||||||
|
case 'folio':
|
||||||
|
return 'from-[#22c55e] to-[#0f9b3a]';
|
||||||
|
case 'portals':
|
||||||
|
return 'from-[#22d3ee] to-[#0e7490]';
|
||||||
|
case 'reports':
|
||||||
|
return 'from-[#f97316] to-[#c2410c]';
|
||||||
|
case 'goals':
|
||||||
|
return 'from-[#10b981] to-[#047857]';
|
||||||
|
case 'passive':
|
||||||
|
return 'from-[#6b7280] to-[#374151]';
|
||||||
|
default:
|
||||||
|
return 'from-[#4FBBFE] to-[#2090F3]';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallbackIcon = (category: string): string => {
|
||||||
|
switch (category) {
|
||||||
|
case 'courses':
|
||||||
|
return '\ueb4d';
|
||||||
|
case 'notices':
|
||||||
|
return '\ueb24';
|
||||||
|
case 'documents':
|
||||||
|
return '\ueb6f';
|
||||||
|
case 'folio':
|
||||||
|
return '\ueb16';
|
||||||
|
case 'portals':
|
||||||
|
return '\ueb01';
|
||||||
|
case 'reports':
|
||||||
|
return '\ueb70';
|
||||||
|
case 'goals':
|
||||||
|
return '\uea15';
|
||||||
|
case 'passive':
|
||||||
|
return '\ueb71';
|
||||||
|
default:
|
||||||
|
return '\ue924';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group transition-colors duration-100 ring-0 dark:ring-zinc-600/50
|
||||||
|
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white dark:ring-[1px] dark:shadow' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
|
||||||
|
onclick={onclick}
|
||||||
|
>
|
||||||
|
<div class="flex items-center w-full">
|
||||||
|
<div
|
||||||
|
class="flex-none scale-90 w-8 h-8 text-xl font-IconFamily flex items-center justify-center text-white rounded-md bg-gradient-to-br {gradientForCategory(item.category)}"
|
||||||
|
>
|
||||||
|
{item.metadata?.icon || fallbackIcon(item.category)}
|
||||||
|
</div>
|
||||||
|
<span class="ml-4 text-lg truncate">
|
||||||
|
<HighlightedText text={item.text} term={searchTerm} matches={matches} />
|
||||||
|
</span>
|
||||||
|
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{item.metadata?.subjectCode || categoryLabel(item.category)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if item.content}
|
||||||
|
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
|
||||||
|
<HighlightedText text={item.content} term={searchTerm} matches={matches} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
@@ -25,11 +25,11 @@ async function getCurrentLesson() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`${location.origin}/seqta/student/load/timetable?`, {
|
const response = await fetch(`${location.origin}/seqta/student/load/timetable?`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
from: todayFormatted,
|
from: todayFormatted,
|
||||||
until: todayFormatted,
|
until: todayFormatted,
|
||||||
student: 69,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
|
|||||||
import { IndexedDbManager } from "embeddia";
|
import { IndexedDbManager } from "embeddia";
|
||||||
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
|
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
|
||||||
import { checkAndHandleUpdate } from "../utils/versionCheck";
|
import { checkAndHandleUpdate } from "../utils/versionCheck";
|
||||||
|
import {
|
||||||
|
getStoredPassiveItems,
|
||||||
|
installPassiveObserver,
|
||||||
|
} from "../indexing/passiveObserver";
|
||||||
|
|
||||||
// Platform-aware default hotkey
|
// Platform-aware default hotkey
|
||||||
const getDefaultHotkey = () => {
|
const getDefaultHotkey = () => {
|
||||||
@@ -43,11 +47,19 @@ const settings = defineSettings({
|
|||||||
title: "Index on Page Load",
|
title: "Index on Page Load",
|
||||||
description: "Run content indexing when SEQTA loads",
|
description: "Run content indexing when SEQTA loads",
|
||||||
}),
|
}),
|
||||||
|
passiveIndexing: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Index Browsed Content",
|
||||||
|
description:
|
||||||
|
"Capture safe text from SEQTA pages you visit so they're searchable. Sensitive routes (settings, files, login) are always excluded.",
|
||||||
|
}),
|
||||||
resetIndex: buttonSetting({
|
resetIndex: buttonSetting({
|
||||||
title: "Reset Index",
|
title: "Reset Index",
|
||||||
description: "Reset the search index and storage",
|
description: "Reset the search index and storage",
|
||||||
trigger: async () => {
|
trigger: async () => {
|
||||||
const confirmed = confirm("Are you sure you want to reset the search index and storage?");
|
const confirmed = confirm(
|
||||||
|
"Reset the search index and all stored Global Search data?\n\nAfter this, reload this SEQTA tab so indexing can run again and rebuild the index.",
|
||||||
|
);
|
||||||
|
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
try {
|
try {
|
||||||
@@ -106,7 +118,9 @@ const settings = defineSettings({
|
|||||||
try {
|
try {
|
||||||
await deleteDb("embeddiaDB");
|
await deleteDb("embeddiaDB");
|
||||||
await deleteDb("betterseqta-index");
|
await deleteDb("betterseqta-index");
|
||||||
alert("Search index and storage have been reset successfully.");
|
alert(
|
||||||
|
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
|
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
|
||||||
}
|
}
|
||||||
@@ -131,6 +145,9 @@ class GlobalSearchPlugin extends BasePlugin<typeof settings> {
|
|||||||
@Setting(settings.runIndexingOnLoad)
|
@Setting(settings.runIndexingOnLoad)
|
||||||
runIndexingOnLoad!: boolean;
|
runIndexingOnLoad!: boolean;
|
||||||
|
|
||||||
|
@Setting(settings.passiveIndexing)
|
||||||
|
passiveIndexing!: boolean;
|
||||||
|
|
||||||
@Setting(settings.resetIndex)
|
@Setting(settings.resetIndex)
|
||||||
resetIndex!: () => void;
|
resetIndex!: () => void;
|
||||||
}
|
}
|
||||||
@@ -145,32 +162,40 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
|||||||
settings: settingsInstance.settings,
|
settings: settingsInstance.settings,
|
||||||
disableToggle: true,
|
disableToggle: true,
|
||||||
defaultEnabled: false,
|
defaultEnabled: false,
|
||||||
beta: true,
|
|
||||||
styles: styles,
|
styles: styles,
|
||||||
|
|
||||||
run: async (api) => {
|
run: async (api) => {
|
||||||
const appRef = { current: null };
|
const appRef = { current: null };
|
||||||
|
|
||||||
// Check for extension updates and clear caches if needed
|
// Run the version check BEFORE we open any IndexedDB connections.
|
||||||
// Use a timeout to avoid blocking initialization
|
// On a normal load (no version change) this is just a string compare
|
||||||
setTimeout(async () => {
|
// and a manifest read, so the cost is negligible. On a real update,
|
||||||
try {
|
// we want the database wipe to complete before `IndexedDbManager`
|
||||||
const wasUpdated = await checkAndHandleUpdate();
|
// grabs a handle on `embeddiaDB`, otherwise the delete request comes
|
||||||
if (wasUpdated) {
|
// back blocked.
|
||||||
console.log("[Global Search] Extension updated - caches cleared");
|
try {
|
||||||
}
|
const wasUpdated = await checkAndHandleUpdate();
|
||||||
} catch (error: any) {
|
if (wasUpdated) {
|
||||||
// Handle CSS preload errors and other failures gracefully
|
console.log(
|
||||||
// These can happen in Firefox or when assets aren't available
|
"[Global Search] Extension updated — search index reset; the next indexing pass will repopulate.",
|
||||||
if (error?.message?.includes("preload CSS") ||
|
);
|
||||||
error?.message?.includes("MIME type") ||
|
|
||||||
error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")) {
|
|
||||||
console.debug("[Global Search] Version check skipped due to asset loading restrictions:", error.message);
|
|
||||||
} else {
|
|
||||||
console.warn("[Global Search] Failed to check for updates:", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 100);
|
} catch (error: any) {
|
||||||
|
// Firefox sometimes refuses CSS preloads or asset reads; we never
|
||||||
|
// want this path to take the whole plugin down.
|
||||||
|
if (
|
||||||
|
error?.message?.includes("preload CSS") ||
|
||||||
|
error?.message?.includes("MIME type") ||
|
||||||
|
error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")
|
||||||
|
) {
|
||||||
|
console.debug(
|
||||||
|
"[Global Search] Version check skipped due to asset loading restrictions:",
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.warn("[Global Search] Failed to check for updates:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
|
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
|
||||||
@@ -211,6 +236,17 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
|||||||
const workerManager = VectorWorkerManager.getInstance();
|
const workerManager = VectorWorkerManager.getInstance();
|
||||||
console.log("Streaming active:", workerManager.isStreamingActive());
|
console.log("Streaming active:", workerManager.isStreamingActive());
|
||||||
},
|
},
|
||||||
|
passiveItems: async () => {
|
||||||
|
const items = await getStoredPassiveItems();
|
||||||
|
console.log(`Captured ${items.length} passive items`);
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
runSelfTests: async () => {
|
||||||
|
const { runGlobalSearchSelfTests } = await import(
|
||||||
|
"../indexing/selfTests"
|
||||||
|
);
|
||||||
|
return runGlobalSearchSelfTests();
|
||||||
|
},
|
||||||
checkIndexedDBSize: async () => {
|
checkIndexedDBSize: async () => {
|
||||||
try {
|
try {
|
||||||
const estimate = await navigator.storage.estimate();
|
const estimate = await navigator.storage.estimate();
|
||||||
@@ -233,6 +269,14 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (api.settings.passiveIndexing) {
|
||||||
|
try {
|
||||||
|
installPassiveObserver();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Global Search] Passive observer install failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (api.settings.runIndexingOnLoad) {
|
if (api.settings.runIndexingOnLoad) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await runIndexing();
|
await runIndexing();
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import browser from "webextension-polyfill";
|
|||||||
export function mountSearchBar(
|
export function mountSearchBar(
|
||||||
titleElement: Element,
|
titleElement: Element,
|
||||||
api: any,
|
api: any,
|
||||||
appRef: { current: any; storageChangeHandler?: any; progressHandler?: any },
|
appRef: {
|
||||||
|
current: any;
|
||||||
|
storageChangeHandler?: any;
|
||||||
|
progressHandler?: any;
|
||||||
|
clearDoneFlashTimer?: () => void;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
if (titleElement.querySelector(".search-trigger")) {
|
if (titleElement.querySelector(".search-trigger")) {
|
||||||
return;
|
return;
|
||||||
@@ -18,74 +23,215 @@ export function mountSearchBar(
|
|||||||
let currentHotkey = isValidHotkey(api.settings.searchHotkey) ? api.settings.searchHotkey : "ctrl+k";
|
let currentHotkey = isValidHotkey(api.settings.searchHotkey) ? api.settings.searchHotkey : "ctrl+k";
|
||||||
let hotkeyDisplay = formatHotkeyForDisplay(currentHotkey);
|
let hotkeyDisplay = formatHotkeyForDisplay(currentHotkey);
|
||||||
|
|
||||||
|
// Search trigger + progress UI live in one wrapper so the auto-margin
|
||||||
|
// pushes the whole group to the left edge of the topbar instead of
|
||||||
|
// stranding the progress text on the far right of the screen.
|
||||||
|
const searchWrapper = document.createElement("div");
|
||||||
|
searchWrapper.className = "search-trigger-wrapper";
|
||||||
|
|
||||||
|
// Anchor stacks button + slim progress strip in one rounded chip (see
|
||||||
|
// `.search-trigger-anchor` in styles.css).
|
||||||
|
const searchAnchor = document.createElement("div");
|
||||||
|
searchAnchor.className = "search-trigger-anchor";
|
||||||
|
|
||||||
const searchButton = document.createElement("div");
|
const searchButton = document.createElement("div");
|
||||||
searchButton.className = "search-trigger";
|
searchButton.className = "search-trigger";
|
||||||
|
|
||||||
// Create progress indicator container
|
|
||||||
const progressContainer = document.createElement("div");
|
|
||||||
progressContainer.className = "search-progress-container";
|
|
||||||
progressContainer.style.cssText = "display: flex; align-items: center; gap: 8px; margin-left: 8px; min-width: 120px;";
|
|
||||||
|
|
||||||
// Create progress bar
|
|
||||||
const progressBarWrapper = document.createElement("div");
|
const progressBarWrapper = document.createElement("div");
|
||||||
progressBarWrapper.className = "search-progress-bar-wrapper";
|
progressBarWrapper.className = "search-progress-bar-wrapper";
|
||||||
progressBarWrapper.style.cssText = "flex: 1; height: 4px; background: rgba(0, 0, 0, 0.1); border-radius: 2px; overflow: hidden; display: none;";
|
|
||||||
|
const progressTrack = document.createElement("div");
|
||||||
|
progressTrack.className = "search-progress-track";
|
||||||
|
|
||||||
const progressBar = document.createElement("div");
|
const progressBar = document.createElement("div");
|
||||||
progressBar.className = "search-progress-bar";
|
progressBar.className = "search-progress-bar";
|
||||||
progressBar.style.cssText = "height: 100%; background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6); transition: width 0.3s ease-out; width: 0%; position: relative;";
|
progressTrack.appendChild(progressBar);
|
||||||
|
progressBarWrapper.appendChild(progressTrack);
|
||||||
|
|
||||||
// Add shimmer effect
|
// Use a block-level <div> so the label reliably participates in flex
|
||||||
const shimmer = document.createElement("div");
|
// layout. A <span> defaults to `display: inline`, which silently ignores
|
||||||
shimmer.style.cssText = "position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); animation: shimmer 2s infinite;";
|
// `max-width`, `overflow`, and `text-overflow: ellipsis`, and was the
|
||||||
progressBar.appendChild(shimmer);
|
// reason the label appeared blank when the bar was visible.
|
||||||
progressBarWrapper.appendChild(progressBar);
|
const progressText = document.createElement("div");
|
||||||
|
|
||||||
// Create progress text
|
|
||||||
const progressText = document.createElement("span");
|
|
||||||
progressText.className = "search-progress-text";
|
progressText.className = "search-progress-text";
|
||||||
progressText.style.cssText = "font-size: 11px; color: #666; white-space: nowrap; display: none;";
|
progressText.setAttribute("aria-live", "polite");
|
||||||
|
|
||||||
progressContainer.appendChild(progressBarWrapper);
|
searchAnchor.appendChild(searchButton);
|
||||||
progressContainer.appendChild(progressText);
|
searchAnchor.appendChild(progressBarWrapper);
|
||||||
|
searchWrapper.appendChild(searchAnchor);
|
||||||
|
searchWrapper.appendChild(progressText);
|
||||||
|
|
||||||
// Indexing state
|
// Indexing state
|
||||||
let isIndexing = false;
|
let isIndexing = false;
|
||||||
|
/** True while indexing has run until it finishes/fails — used for Done! flash only */
|
||||||
|
let ranIndexingCycle = false;
|
||||||
let completedJobs = 0;
|
let completedJobs = 0;
|
||||||
let totalJobs = 0;
|
let totalJobs = 0;
|
||||||
let indexingStatus: string | null = null;
|
let indexingStatus: string | null = null;
|
||||||
|
let doneFlashTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let doneFadeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
/** Captures `wasIndexing && !indexing` for the current dispatcher tick */
|
||||||
|
let indexingJustStoppedFlag = false;
|
||||||
|
|
||||||
|
const DONE_HOLD_MS = 5000;
|
||||||
|
const DONE_FADE_MS = 550;
|
||||||
|
|
||||||
|
/** Treat as failure copy — plain “Done!” would be misleading */
|
||||||
|
const statusLooksRough = (s: string) =>
|
||||||
|
/\b(fail|error|cancel)\b/i.test(s);
|
||||||
|
|
||||||
|
const truncateStatus = (s: string, max = 44) =>
|
||||||
|
s.length > max ? s.slice(0, max - 1) + "…" : s;
|
||||||
|
|
||||||
|
const clearDoneFlashTimer = () => {
|
||||||
|
if (doneFlashTimer) {
|
||||||
|
clearTimeout(doneFlashTimer);
|
||||||
|
doneFlashTimer = null;
|
||||||
|
}
|
||||||
|
if (doneFadeTimer) {
|
||||||
|
clearTimeout(doneFadeTimer);
|
||||||
|
doneFadeTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateProgressDisplay = () => {
|
const updateProgressDisplay = () => {
|
||||||
if (isIndexing && totalJobs > 0) {
|
const indexingStoppedThisTick = indexingJustStoppedFlag;
|
||||||
|
indexingJustStoppedFlag = false;
|
||||||
|
|
||||||
|
const active = isIndexing && totalJobs > 0;
|
||||||
|
|
||||||
|
// Stray pulses (missing total, 0 completed, etc.) used to hit the idle
|
||||||
|
// branch and call clearDoneFlashTimer(), killing the Done! hold/fade.
|
||||||
|
if (doneFlashTimer !== null || doneFadeTimer !== null) {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearDoneFlashTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const completionEligible =
|
||||||
|
ranIndexingCycle &&
|
||||||
|
!active &&
|
||||||
|
totalJobs > 0 &&
|
||||||
|
(completedJobs >= totalJobs || indexingStoppedThisTick);
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
clearDoneFlashTimer();
|
||||||
|
progressBarWrapper.classList.remove("is-rough-complete");
|
||||||
|
progressText.classList.remove(
|
||||||
|
"is-rough",
|
||||||
|
"is-fading-done",
|
||||||
|
"is-done-message",
|
||||||
|
);
|
||||||
const percentage = Math.round((completedJobs / totalJobs) * 100);
|
const percentage = Math.round((completedJobs / totalJobs) * 100);
|
||||||
progressBar.style.width = `${Math.max(2, percentage)}%`;
|
progressBar.style.width = `${Math.max(2, percentage)}%`;
|
||||||
progressBarWrapper.style.display = "block";
|
progressBarWrapper.classList.add("is-active");
|
||||||
|
searchAnchor.classList.add("is-indexing");
|
||||||
|
searchButton.classList.add("is-indexing");
|
||||||
|
|
||||||
if (indexingStatus) {
|
if (indexingStatus) {
|
||||||
progressText.textContent = indexingStatus.length > 20 ? indexingStatus.substring(0, 20) + "..." : indexingStatus;
|
progressText.textContent = `${truncateStatus(indexingStatus)} · ${percentage}%`;
|
||||||
progressText.style.display = "block";
|
|
||||||
} else {
|
} else {
|
||||||
progressText.textContent = `${completedJobs}/${totalJobs} (${percentage}%)`;
|
progressText.textContent = `Indexing ${completedJobs}/${totalJobs} (${percentage}%)`;
|
||||||
progressText.style.display = "block";
|
|
||||||
}
|
}
|
||||||
} else {
|
progressText.classList.add("is-active");
|
||||||
progressBarWrapper.style.display = "none";
|
return;
|
||||||
progressText.style.display = "none";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (completionEligible) {
|
||||||
|
// Duplicate end-of-run ticks must not reschedule hold/fade timers
|
||||||
|
if (doneFlashTimer !== null || doneFadeTimer !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rough =
|
||||||
|
indexingStatus != null && statusLooksRough(indexingStatus);
|
||||||
|
|
||||||
|
progressBar.style.width = "0%";
|
||||||
|
progressBarWrapper.classList.remove("is-active");
|
||||||
|
searchAnchor.classList.remove("is-indexing");
|
||||||
|
searchButton.classList.remove("is-indexing");
|
||||||
|
progressText.classList.remove("is-fading-done");
|
||||||
|
|
||||||
|
progressText.textContent = rough ? truncateStatus(indexingStatus!, 52) : "Done!";
|
||||||
|
if (rough) {
|
||||||
|
progressText.classList.add("is-rough");
|
||||||
|
progressBarWrapper.classList.add("is-rough-complete");
|
||||||
|
} else {
|
||||||
|
progressText.classList.remove("is-rough");
|
||||||
|
progressBarWrapper.classList.remove("is-rough-complete");
|
||||||
|
}
|
||||||
|
progressText.classList.add("is-active", "is-done-message");
|
||||||
|
|
||||||
|
doneFlashTimer = setTimeout(() => {
|
||||||
|
doneFlashTimer = null;
|
||||||
|
progressText.classList.add("is-fading-done");
|
||||||
|
doneFadeTimer = setTimeout(() => {
|
||||||
|
doneFadeTimer = null;
|
||||||
|
ranIndexingCycle = false;
|
||||||
|
indexingStatus = null;
|
||||||
|
progressBar.style.width = "0%";
|
||||||
|
progressBarWrapper.classList.remove("is-active");
|
||||||
|
progressBarWrapper.classList.remove("is-rough-complete");
|
||||||
|
searchAnchor.classList.remove("is-indexing");
|
||||||
|
searchButton.classList.remove("is-indexing");
|
||||||
|
progressText.classList.remove(
|
||||||
|
"is-active",
|
||||||
|
"is-rough",
|
||||||
|
"is-fading-done",
|
||||||
|
"is-done-message",
|
||||||
|
);
|
||||||
|
progressText.textContent = "";
|
||||||
|
}, DONE_FADE_MS);
|
||||||
|
}, DONE_HOLD_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDoneFlashTimer();
|
||||||
|
progressBarWrapper.classList.remove("is-active");
|
||||||
|
progressBarWrapper.classList.remove("is-rough-complete");
|
||||||
|
searchAnchor.classList.remove("is-indexing");
|
||||||
|
searchButton.classList.remove("is-indexing");
|
||||||
|
progressText.classList.remove(
|
||||||
|
"is-active",
|
||||||
|
"is-rough",
|
||||||
|
"is-fading-done",
|
||||||
|
"is-done-message",
|
||||||
|
);
|
||||||
|
progressBar.style.width = "0%";
|
||||||
|
progressText.textContent = "";
|
||||||
|
ranIndexingCycle = false;
|
||||||
|
indexingStatus = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Listen for indexing progress events
|
// Listen for indexing progress events
|
||||||
const progressHandler = (event: CustomEvent) => {
|
const progressHandler = (event: CustomEvent) => {
|
||||||
const { completed, total, indexing, status } = event.detail;
|
const { completed, total, indexing, status } = event.detail as {
|
||||||
completedJobs = completed || 0;
|
completed?: number;
|
||||||
totalJobs = total || 0;
|
total?: number;
|
||||||
isIndexing = indexing || false;
|
indexing?: boolean;
|
||||||
indexingStatus = status || null;
|
status?: string;
|
||||||
|
};
|
||||||
|
const wasIndexing = isIndexing;
|
||||||
|
|
||||||
|
completedJobs = completed ?? 0;
|
||||||
|
totalJobs = total ?? 0;
|
||||||
|
isIndexing = Boolean(indexing);
|
||||||
|
indexingStatus = status ?? null;
|
||||||
|
indexingJustStoppedFlag = wasIndexing && !isIndexing;
|
||||||
|
|
||||||
|
if (!wasIndexing && isIndexing) ranIndexingCycle = true;
|
||||||
|
if (wasIndexing && !isIndexing) ranIndexingCycle = true;
|
||||||
|
if (totalJobs > 0 && completedJobs >= totalJobs && !isIndexing) {
|
||||||
|
ranIndexingCycle = true;
|
||||||
|
}
|
||||||
|
|
||||||
updateProgressDisplay();
|
updateProgressDisplay();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
||||||
appRef.progressHandler = progressHandler;
|
appRef.progressHandler = progressHandler;
|
||||||
|
appRef.clearDoneFlashTimer = clearDoneFlashTimer;
|
||||||
|
|
||||||
const updateSearchButtonDisplay = () => {
|
const updateSearchButtonDisplay = () => {
|
||||||
searchButton.innerHTML = /* html */ `
|
searchButton.innerHTML = /* html */ `
|
||||||
@@ -99,8 +245,7 @@ export function mountSearchBar(
|
|||||||
};
|
};
|
||||||
|
|
||||||
updateSearchButtonDisplay();
|
updateSearchButtonDisplay();
|
||||||
titleElement.appendChild(searchButton);
|
titleElement.appendChild(searchWrapper);
|
||||||
titleElement.appendChild(progressContainer);
|
|
||||||
|
|
||||||
// Listen for hotkey setting changes
|
// Listen for hotkey setting changes
|
||||||
const handleStorageChange = (changes: any, area: string) => {
|
const handleStorageChange = (changes: any, area: string) => {
|
||||||
@@ -139,7 +284,12 @@ export function mountSearchBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any; progressHandler?: any }) {
|
export function cleanupSearchBar(appRef: {
|
||||||
|
current: any;
|
||||||
|
storageChangeHandler?: any;
|
||||||
|
progressHandler?: any;
|
||||||
|
clearDoneFlashTimer?: () => void;
|
||||||
|
}) {
|
||||||
if (appRef.current) {
|
if (appRef.current) {
|
||||||
try {
|
try {
|
||||||
unmount(appRef.current);
|
unmount(appRef.current);
|
||||||
@@ -149,23 +299,29 @@ export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
appRef.clearDoneFlashTimer?.();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
appRef.clearDoneFlashTimer = undefined;
|
||||||
|
|
||||||
// Remove progress event listener
|
// Remove progress event listener
|
||||||
if (appRef.progressHandler) {
|
if (appRef.progressHandler) {
|
||||||
window.removeEventListener('indexing-progress', appRef.progressHandler as EventListener);
|
window.removeEventListener('indexing-progress', appRef.progressHandler as EventListener);
|
||||||
appRef.progressHandler = null;
|
appRef.progressHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove search trigger button
|
// Remove search trigger wrapper (which contains the button and progress UI)
|
||||||
const searchTrigger = document.querySelector(".search-trigger");
|
const searchWrapper = document.querySelector(".search-trigger-wrapper");
|
||||||
if (searchTrigger) {
|
if (searchWrapper) {
|
||||||
searchTrigger.remove();
|
searchWrapper.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove progress container
|
// Defensive cleanup for older mounts that may have left the trigger or
|
||||||
const progressContainer = document.querySelector(".search-progress-container");
|
// progress container as direct children of the topbar.
|
||||||
if (progressContainer) {
|
document.querySelector(".search-trigger")?.remove();
|
||||||
progressContainer.remove();
|
document.querySelector(".search-progress-container")?.remove();
|
||||||
}
|
|
||||||
|
|
||||||
// Remove search root
|
// Remove search root
|
||||||
const searchRoot = document.querySelector("div[data-search-root]");
|
const searchRoot = document.querySelector("div[data-search-root]");
|
||||||
|
|||||||
@@ -1,15 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* Wrapper that owns the auto-margin so the whole search-trigger-and-progress
|
||||||
|
* group sits at the left of the SEQTA topbar. Previously, only the
|
||||||
|
* `.search-trigger` had `margin-right: auto`, which pushed the progress text
|
||||||
|
* all the way to the far right of the screen.
|
||||||
|
*/
|
||||||
|
.search-trigger-wrapper {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-right: auto !important;
|
||||||
|
/* Allow the bar's bottom portion to peek out below the wrapper without
|
||||||
|
getting clipped by the topbar's flex line. */
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Stacks the clickable row and the progress strip as one visual “chip”
|
||||||
|
* so the bar is flush under the button (no floating gap).
|
||||||
|
*/
|
||||||
|
.search-trigger-anchor {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 rgba(255, 255, 255, 0.06) inset,
|
||||||
|
0 3px 8px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-trigger-anchor {
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 rgba(255, 255, 255, 0.04) inset,
|
||||||
|
0 3px 10px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-trigger-anchor.is-indexing {
|
||||||
|
/* Very soft “rear card” edge — tweak opacity if SEQTA chrome is noisy */
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 rgba(255, 255, 255, 0.06) inset,
|
||||||
|
0 3px 8px rgba(0, 0, 0, 0.14),
|
||||||
|
1px 3px 0 rgba(139, 92, 246, 0.14),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-trigger-anchor.is-indexing {
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 rgba(255, 255, 255, 0.05) inset,
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.5),
|
||||||
|
1px 3px 0 rgba(167, 139, 250, 0.12),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.search-trigger {
|
.search-trigger {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
margin-left: 10px;
|
border-radius: 0;
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition:
|
||||||
margin-right: auto !important;
|
background-color 0.2s ease,
|
||||||
|
border-color 0.2s ease;
|
||||||
padding: 3px 12px;
|
padding: 3px 12px;
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
box-shadow: none;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
@@ -28,10 +85,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light mode styles */
|
/* Light mode chip */
|
||||||
.search-trigger {
|
.search-trigger {
|
||||||
background-color: rgba(248, 250, 252, 0.05) !important;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1) !important;
|
border: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
background-color: rgba(248, 250, 252, 0.94) !important;
|
||||||
color: #555 !important;
|
color: #555 !important;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@@ -44,8 +103,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark .search-trigger {
|
.dark .search-trigger {
|
||||||
background-color: rgba(0, 0, 0, 0.03) !important;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
background-color: rgba(24, 24, 27, 0.92) !important;
|
||||||
color: #aaa !important;
|
color: #aaa !important;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@@ -57,7 +118,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Idle: full pill rounding + closed bottom border on the anchor chip.
|
||||||
|
*/
|
||||||
|
.search-trigger-anchor:not(.is-indexing) .search-trigger {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-trigger-anchor:not(.is-indexing) .search-trigger {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||||
|
}
|
||||||
.highlight {
|
.highlight {
|
||||||
background-color: rgba(255, 213, 0, 0.3);
|
background-color: rgba(255, 213, 0, 0.3);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -83,57 +154,139 @@
|
|||||||
animation: shimmer 2s infinite;
|
animation: shimmer 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Progress indicator next to search trigger */
|
/*
|
||||||
.search-progress-container {
|
* Thin track flush under `.search-trigger` — same width as chip, shared
|
||||||
display: flex;
|
* `overflow:hidden` rounding on `.search-trigger-anchor`.
|
||||||
align-items: center;
|
*/
|
||||||
gap: 8px;
|
|
||||||
margin-left: 8px;
|
|
||||||
min-width: 120px;
|
|
||||||
max-width: 200px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-progress-bar-wrapper {
|
.search-progress-bar-wrapper {
|
||||||
flex: 1;
|
flex: none;
|
||||||
height: 4px;
|
height: 0;
|
||||||
background: rgba(0, 0, 0, 0.1);
|
min-height: 0;
|
||||||
border-radius: 2px;
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: none;
|
opacity: 1;
|
||||||
min-width: 60px;
|
transform: none;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: height 0.22s cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .search-progress-bar-wrapper {
|
.search-progress-bar-wrapper.is-active {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-progress-track {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-progress-track {
|
||||||
|
background: rgba(248, 250, 252, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-progress-bar {
|
.search-progress-bar {
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6);
|
|
||||||
transition: width 0.3s ease-out;
|
|
||||||
width: 0%;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 2px;
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(90deg, #38bdf8, #2563eb);
|
||||||
|
transition:
|
||||||
|
width 0.35s cubic-bezier(0.2, 0.7, 0.35, 1),
|
||||||
|
background 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-progress-bar-wrapper.is-rough-complete .search-progress-track {
|
||||||
|
background: rgba(185, 28, 28, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-progress-bar-wrapper.is-rough-complete .search-progress-track {
|
||||||
|
background: rgba(248, 113, 113, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-progress-bar-wrapper.is-rough-complete .search-progress-bar {
|
||||||
|
background: linear-gradient(90deg, #f87171, #dc2626);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-progress-bar::after {
|
.search-progress-bar::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.28),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
animation: shimmer 2s infinite;
|
animation: shimmer 2s infinite;
|
||||||
border-radius: 2px;
|
}
|
||||||
|
/*
|
||||||
|
* Progress label sits as a flex child immediately to the right of the
|
||||||
|
* search button (gap is provided by .search-trigger-wrapper). It's hidden
|
||||||
|
* by default and fades in once an indexing pass is active.
|
||||||
|
*/
|
||||||
|
.search-progress-text {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-4px);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease, color 0.25s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-progress-text {
|
/* While indexing: same neutral label colour as default (only “Done!” is green). */
|
||||||
font-size: 11px;
|
.search-progress-text.is-active {
|
||||||
color: #666;
|
opacity: 1;
|
||||||
white-space: nowrap;
|
transform: translateX(0);
|
||||||
display: none;
|
color: #475569;
|
||||||
font-weight: 500;
|
}
|
||||||
|
|
||||||
|
/* Completed pass — green text only here, not on the strip or chip */
|
||||||
|
.search-progress-text.is-active.is-done-message {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #15803d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-progress-text.is-active.is-done-message {
|
||||||
|
color: #4ade80 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* After DONE_HOLD_MS, fade out before DOM teardown */
|
||||||
|
.search-progress-text.is-active.is-fading-done {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-4px);
|
||||||
|
transition:
|
||||||
|
opacity 0.5s ease,
|
||||||
|
transform 0.45s ease,
|
||||||
|
color 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .search-progress-text {
|
.dark .search-progress-text {
|
||||||
color: #999;
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-progress-text.is-active {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-progress-text.is-active.is-rough {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-progress-text.is-active.is-rough {
|
||||||
|
color: #fca5a5;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Representative SEQTA response shapes captured from a real `/seqta/student/`
|
||||||
|
* session via the websiteskimmer recorder. These are static fixtures used
|
||||||
|
* by `selfTests.ts` to verify our extractors and the passive observer
|
||||||
|
* remain compatible with the upstream API as it evolves.
|
||||||
|
*
|
||||||
|
* NOTE: These fixtures are scrubbed of any secrets and reduced in size; the
|
||||||
|
* structure (keys, types, nesting) faithfully matches what SEQTA returns
|
||||||
|
* but the values are illustrative rather than real student data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const subjectsListPayload = [
|
||||||
|
{
|
||||||
|
code: "2026S1",
|
||||||
|
description: "Sample Semester 1 timetable",
|
||||||
|
active: 1,
|
||||||
|
id: 77,
|
||||||
|
subjects: [
|
||||||
|
{
|
||||||
|
code: "ENGG1",
|
||||||
|
classunit: 29248,
|
||||||
|
description: "English GEN 1",
|
||||||
|
metaclass: 29611,
|
||||||
|
title: "English GEN 1",
|
||||||
|
programme: 3830,
|
||||||
|
marksbook_type: "numeric",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "MASA1",
|
||||||
|
classunit: 29247,
|
||||||
|
description: "Mathematics Specialist 1",
|
||||||
|
metaclass: 29610,
|
||||||
|
title: "Mathematics Specialist 1",
|
||||||
|
programme: 3831,
|
||||||
|
marksbook_type: "numeric",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const coursesPayload = {
|
||||||
|
c: "ENGG1#1",
|
||||||
|
t: "English GEN 1",
|
||||||
|
i: 3830,
|
||||||
|
m: 29611,
|
||||||
|
document:
|
||||||
|
'{"document":{"modules":[{"uuid":"1641cf87-ae08-4bcb-832d-d5709d84d0c5"}]}}',
|
||||||
|
w: [
|
||||||
|
[
|
||||||
|
{ t: "", h: "", i: 248293, l: "", n: 0, o: "" },
|
||||||
|
{
|
||||||
|
t: "",
|
||||||
|
i: 248316,
|
||||||
|
l: '<p><a href="http://ed.ted.com/on/r80lnJL0#watch">http://ed.ted.com/on/r80lnJL0#watch</a></p>',
|
||||||
|
n: 1,
|
||||||
|
o: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[{ t: "Lesson 2", h: "<h1>Module 2</h1>", i: 248294, l: "", n: 0, o: "" }],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const messagesListPayload = {
|
||||||
|
hasMore: false,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
date: "2026-04-29 04:26:25.075868+00",
|
||||||
|
attachments: false,
|
||||||
|
read: 1,
|
||||||
|
sender: "Jacob Johannesburg",
|
||||||
|
subject: "test",
|
||||||
|
sender_type: "student",
|
||||||
|
attachmentCount: 0,
|
||||||
|
id: 81469,
|
||||||
|
sender_id: 3111,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ts: "2026-04-30 03:25:02.27900",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentsPayload = [
|
||||||
|
{
|
||||||
|
docs: [
|
||||||
|
{
|
||||||
|
file: 49555,
|
||||||
|
filename: "School Glossary.docx",
|
||||||
|
size: "14931",
|
||||||
|
context_uuid: "3162189c-2052-4f83-ad83-a66c57460ea2",
|
||||||
|
mimetype:
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
created_date: "2021-08-04 12:55:55.102653+00",
|
||||||
|
title: "School Glossary",
|
||||||
|
uuid: "3162189c-2052-4f83-ad83-a66c57460ea2",
|
||||||
|
created_by: "537",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 9,
|
||||||
|
category: "Document repository",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const noticesPayload = [
|
||||||
|
{
|
||||||
|
id: 12345,
|
||||||
|
title: "Lunchtime sport tomorrow",
|
||||||
|
contents: "<p>Bring shoes.</p>",
|
||||||
|
staff: "Mr Coach",
|
||||||
|
staff_id: 246,
|
||||||
|
label: 9,
|
||||||
|
label_title: "All Students",
|
||||||
|
colour: "#ff5722",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const portalsPayload = [
|
||||||
|
{
|
||||||
|
is_power_portal: false,
|
||||||
|
inherit_styles: true,
|
||||||
|
icon: "colour-cerulean",
|
||||||
|
id: 328,
|
||||||
|
label: "Mathletics",
|
||||||
|
priority: 20,
|
||||||
|
uuid: "9d20f40c-fdc9-4aa3-91f1-905d86e240c4",
|
||||||
|
url: "www.mathletics.com/",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const folioListPayload = {
|
||||||
|
me: "Jacob Johannesburg",
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
student: "Jacob Johannesburg",
|
||||||
|
id: 203,
|
||||||
|
published: "2026-04-14 20:02:50",
|
||||||
|
title: "My folio",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const folioEntryPayload = {
|
||||||
|
forum: 478,
|
||||||
|
contents:
|
||||||
|
'[[embed:raw|<p>Some <strong>reflection</strong> text.</p>]] Plain trailing text.',
|
||||||
|
created: "2026-04-14 10:32:34.264641+00",
|
||||||
|
allow_comments: true,
|
||||||
|
author: { year: "Year 10", name: "Jacob Johannesburg", id: 3111 },
|
||||||
|
files: [],
|
||||||
|
id: 203,
|
||||||
|
published: "2026-04-14 20:02:50",
|
||||||
|
title: "My folio",
|
||||||
|
updated: "2026-04-14 10:32:50.696678+00",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings payload contains tenant-wide configuration including third-party
|
||||||
|
* URLs and API keys. The passive observer must NEVER index this route.
|
||||||
|
*/
|
||||||
|
export const settingsPayload = {
|
||||||
|
"global.dropbox.api.key": { value: "xxx-do-not-index" },
|
||||||
|
"global.ai.api.baseurl": { value: "https://example.com" },
|
||||||
|
};
|
||||||
@@ -28,6 +28,40 @@ interface AssessmentMetadata {
|
|||||||
|
|
||||||
type ActionHandler<T = any> = (item: IndexItem & { metadata: T }) => void;
|
type ActionHandler<T = any> = (item: IndexItem & { metadata: T }) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a SEQTA SPA hash route in the most reliable way available.
|
||||||
|
*
|
||||||
|
* Setting `location.hash` works when the destination module is already
|
||||||
|
* registered with SEQTA's hashchange router (as is the case for the
|
||||||
|
* existing `message`/`assessment` actions, which then poke at the live
|
||||||
|
* DOM). For navigations that switch to a module the SPA may not have
|
||||||
|
* loaded yet (courses, forums, folios, portals, documents, reports,
|
||||||
|
* goals, notices, ...) we instead assign through `location.href` against
|
||||||
|
* the canonical `${origin}/` base. The path stays `/`, so the browser
|
||||||
|
* still treats this as a hash-only change in practice — but if anything
|
||||||
|
* went sideways with the path, we get a clean reload that bootstraps the
|
||||||
|
* SPA fresh, which is far less surprising than a blank screen.
|
||||||
|
*/
|
||||||
|
function navigateToHashRoute(routeWithLeadingSlash: string): void {
|
||||||
|
const target = `${location.origin}/#?page=${routeWithLeadingSlash}`;
|
||||||
|
window.location.href = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateInCurrentSeqtaApp(routeWithLeadingSlash: string): void {
|
||||||
|
window.location.hash = `#?page=${routeWithLeadingSlash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Final-fallback hub when an item has no usable deep-link metadata.
|
||||||
|
*
|
||||||
|
* `/dashboard` is the standard SEQTA Learn landing page and is the
|
||||||
|
* destination the websiteskimmer recording captured for unknown routes.
|
||||||
|
* `/home` is BetterSEQTA-Plus's custom replacement which only renders
|
||||||
|
* after our content script has hooked the SPA — using it as a fallback
|
||||||
|
* from a fresh nav can produce a blank frame.
|
||||||
|
*/
|
||||||
|
const FALLBACK_ROUTE = "/dashboard";
|
||||||
|
|
||||||
export const actionMap: Record<string, ActionHandler<any>> = {
|
export const actionMap: Record<string, ActionHandler<any>> = {
|
||||||
message: (async (item: IndexItem & { metadata: MessageMetadata }) => {
|
message: (async (item: IndexItem & { metadata: MessageMetadata }) => {
|
||||||
window.location.hash = `#?page=/messages`;
|
window.location.hash = `#?page=/messages`;
|
||||||
@@ -81,32 +115,34 @@ export const actionMap: Record<string, ActionHandler<any>> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract metadata values using multiple methods to handle XrayWrapper
|
// Try to extract metadata values using multiple methods to handle XrayWrapper.
|
||||||
|
// The metadata bag is intentionally typed loosely here because Firefox's
|
||||||
|
// XrayWrapper occasionally surfaces extra/casing-variant keys we still
|
||||||
|
// want to read defensively.
|
||||||
const getMetadataValue = (key: string, altKey?: string): any => {
|
const getMetadataValue = (key: string, altKey?: string): any => {
|
||||||
|
const bag = metadata as unknown as Record<string, any>;
|
||||||
try {
|
try {
|
||||||
// Try direct access first
|
const value = bag[key];
|
||||||
const value = metadata[key];
|
|
||||||
if (value !== undefined && value !== null) {
|
if (value !== undefined && value !== null) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
if (altKey) {
|
if (altKey) {
|
||||||
const altValue = metadata[altKey];
|
const altValue = bag[altKey];
|
||||||
if (altValue !== undefined && altValue !== null) {
|
if (altValue !== undefined && altValue !== null) {
|
||||||
return altValue;
|
return altValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Try accessing via Object.keys iteration (works around XrayWrapper)
|
|
||||||
try {
|
try {
|
||||||
const keys = Object.keys(metadata);
|
const keys = Object.keys(bag);
|
||||||
for (const k of keys) {
|
for (const k of keys) {
|
||||||
if (k === key || k === altKey) {
|
if (k === key || k === altKey) {
|
||||||
const val = metadata[k];
|
const val = bag[k];
|
||||||
if (val !== undefined && val !== null) {
|
if (val !== undefined && val !== null) {
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Object.keys might fail on XrayWrapper, that's okay
|
// Object.keys might fail on XrayWrapper, that's okay
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -189,14 +225,218 @@ export const actionMap: Record<string, ActionHandler<any>> = {
|
|||||||
}) as ActionHandler<any>,
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
subjectassessment: ((item: IndexItem) => {
|
subjectassessment: ((item: IndexItem) => {
|
||||||
window.location.href = `/#?page=/assessments/${item.metadata.programme}:${item.metadata.subjectId}`;
|
navigateToHashRoute(
|
||||||
|
`/assessments/${item.metadata.programme}:${item.metadata.subjectId}`,
|
||||||
|
);
|
||||||
}) as ActionHandler<any>,
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
subjectcourse: ((item: IndexItem) => {
|
subjectcourse: ((item: IndexItem) => {
|
||||||
window.location.href = `/#?page=/courses/${item.metadata.programme}:${item.metadata.subjectId}`;
|
navigateToHashRoute(
|
||||||
|
`/courses/${item.metadata.programme}:${item.metadata.subjectId}`,
|
||||||
|
);
|
||||||
}) as ActionHandler<any>,
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
forum: ((item: IndexItem) => {
|
forum: ((item: IndexItem) => {
|
||||||
window.location.href = `/#?page=/forums/${item.metadata.forumId}`;
|
navigateToHashRoute(`/forums/${item.metadata.forumId}`);
|
||||||
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
|
course: ((item: IndexItem) => {
|
||||||
|
const programme = item.metadata?.programme;
|
||||||
|
const metaclass = item.metadata?.metaclass ?? item.metadata?.subjectId;
|
||||||
|
if (programme !== undefined && metaclass !== undefined) {
|
||||||
|
navigateToHashRoute(`/courses/${programme}:${metaclass}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.metadata?.route) {
|
||||||
|
navigateToHashRoute(String(item.metadata.route));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateToHashRoute(FALLBACK_ROUTE);
|
||||||
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
|
notice: ((_item: IndexItem) => {
|
||||||
|
// SEQTA's notices route doesn't honour `&date=` from the hash, so just
|
||||||
|
// open the listing.
|
||||||
|
navigateToHashRoute("/notices");
|
||||||
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
|
document: ((_item: IndexItem) => {
|
||||||
|
// We don't trigger downloads automatically: opening the documents page
|
||||||
|
// gives users full SEQTA controls (preview, download, share) without
|
||||||
|
// needing the JWT-stamped streaming URL we deliberately avoid storing.
|
||||||
|
navigateToHashRoute("/documents");
|
||||||
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
|
folio: ((_item: IndexItem) => {
|
||||||
|
// SEQTA's folio SPA does not expose a per-id route; the previous
|
||||||
|
// `?page=/folios/read?id=N` shape contained a literal `?` inside the
|
||||||
|
// `page` query value and was unmatchable, which sent users to the
|
||||||
|
// dashboard. Always land on the read view and let the user pick.
|
||||||
|
navigateToHashRoute("/folios/read");
|
||||||
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
|
portal: ((item: IndexItem) => {
|
||||||
|
// SEQTA renders portals via the in-app viewer at `?page=/portals/<uuid>`
|
||||||
|
// (verified via the websiteskimmer capture). Prefer that so SSO/headers
|
||||||
|
// are preserved; only pop the external URL as a fallback if we don't
|
||||||
|
// have a UUID; final fallback to the dashboard rather than blanking.
|
||||||
|
const uuid = item.metadata?.portalUuid;
|
||||||
|
if (typeof uuid === "string" && uuid) {
|
||||||
|
navigateToHashRoute(`/portals/${uuid}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = item.metadata?.url;
|
||||||
|
if (typeof url === "string" && url) {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateToHashRoute(FALLBACK_ROUTE);
|
||||||
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
|
report: ((_item: IndexItem) => {
|
||||||
|
navigateToHashRoute("/reports");
|
||||||
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
|
goal: ((item: IndexItem) => {
|
||||||
|
const year = item.metadata?.year;
|
||||||
|
if (year !== undefined) {
|
||||||
|
navigateToHashRoute(`/goals/${year}`);
|
||||||
|
} else {
|
||||||
|
navigateToHashRoute("/goals");
|
||||||
|
}
|
||||||
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes for passively-captured items.
|
||||||
|
*
|
||||||
|
* The passive observer captures whatever `/seqta/student/...` JSON the
|
||||||
|
* page is fetching, so we can't trust a single category to imply a
|
||||||
|
* single SEQTA SPA route. Instead, derive the destination from the API
|
||||||
|
* route the entity came from, augmented with entity-shaped hints
|
||||||
|
* (programme/metaclass/year/uuid/...) that the observer hoists into
|
||||||
|
* metadata. We never replay the original POST: actions are user-driven
|
||||||
|
* and must stay safe even though the observer's own denylist excludes
|
||||||
|
* `save/*` and friends.
|
||||||
|
*/
|
||||||
|
passive: ((item: IndexItem) => {
|
||||||
|
const md = (item.metadata ?? {}) as Record<string, unknown>;
|
||||||
|
const route = typeof md.route === "string" ? (md.route as string) : "";
|
||||||
|
const sourcePage =
|
||||||
|
typeof md.sourcePage === "string" ? (md.sourcePage as string) : "";
|
||||||
|
const routeParts = route
|
||||||
|
.replace(/^\/seqta\/student\/?/, "")
|
||||||
|
.replace(/^load\//, "")
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part.toLowerCase());
|
||||||
|
const tail = routeParts[0] ?? "";
|
||||||
|
const child = routeParts[1] ?? "";
|
||||||
|
|
||||||
|
const num = (key: string): number | undefined => {
|
||||||
|
const value = md[key];
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string" && value && Number.isFinite(Number(value))) {
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const str = (key: string): string | undefined => {
|
||||||
|
const value = md[key];
|
||||||
|
return typeof value === "string" && value ? value : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const programme = num("programme") ?? num("programmeId") ?? num("programmeID");
|
||||||
|
const metaclass =
|
||||||
|
num("metaclass") ?? num("metaclassId") ?? num("metaclassID");
|
||||||
|
const portalUuid = str("portalUuid") ?? str("uuid");
|
||||||
|
const forumId = num("forumId") ?? num("forum");
|
||||||
|
const year = num("year");
|
||||||
|
const assessmentId =
|
||||||
|
num("assessmentId") ?? num("assessmentID") ?? num("id");
|
||||||
|
const messageId = num("messageId");
|
||||||
|
|
||||||
|
if (sourcePage === "/messages") {
|
||||||
|
navigateInCurrentSeqtaApp("/messages");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (tail) {
|
||||||
|
case "courses":
|
||||||
|
if (programme !== undefined && metaclass !== undefined) {
|
||||||
|
navigateToHashRoute(`/courses/${programme}:${metaclass}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "assessments":
|
||||||
|
if (programme !== undefined && metaclass !== undefined) {
|
||||||
|
const itemSuffix =
|
||||||
|
assessmentId !== undefined ? `&item=${assessmentId}` : "";
|
||||||
|
navigateToHashRoute(
|
||||||
|
`/assessments/${programme}:${metaclass}${itemSuffix}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (assessmentId !== undefined) {
|
||||||
|
navigateToHashRoute(`/assessments/upcoming&item=${assessmentId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateToHashRoute("/assessments/upcoming");
|
||||||
|
return;
|
||||||
|
case "forums":
|
||||||
|
case "forum":
|
||||||
|
if (forumId !== undefined) {
|
||||||
|
navigateToHashRoute(`/forums/${forumId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "portals":
|
||||||
|
case "portal":
|
||||||
|
if (portalUuid) {
|
||||||
|
navigateToHashRoute(`/portals/${portalUuid}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "goals":
|
||||||
|
case "goal":
|
||||||
|
navigateToHashRoute(year !== undefined ? `/goals/${year}` : "/goals");
|
||||||
|
return;
|
||||||
|
case "folio":
|
||||||
|
case "folios":
|
||||||
|
navigateToHashRoute("/folios/read");
|
||||||
|
return;
|
||||||
|
case "notices":
|
||||||
|
case "notice":
|
||||||
|
navigateToHashRoute("/notices");
|
||||||
|
return;
|
||||||
|
case "documents":
|
||||||
|
case "document":
|
||||||
|
navigateToHashRoute("/documents");
|
||||||
|
return;
|
||||||
|
case "reports":
|
||||||
|
case "report":
|
||||||
|
navigateToHashRoute("/reports");
|
||||||
|
return;
|
||||||
|
case "messages":
|
||||||
|
case "message":
|
||||||
|
// `/seqta/student/load/message/people` and related endpoints are
|
||||||
|
// only meaningful while SEQTA's message module is mounted. Use the
|
||||||
|
// same live hash navigation as the real message action instead of
|
||||||
|
// forcing a fresh bootstrap, which can drop back to dashboard for
|
||||||
|
// context-only endpoints.
|
||||||
|
void messageId; // noqa — preserved for future deep-select work
|
||||||
|
navigateInCurrentSeqtaApp("/messages");
|
||||||
|
return;
|
||||||
|
case "people":
|
||||||
|
if (route.includes("/load/message/people") || child === "people") {
|
||||||
|
navigateInCurrentSeqtaApp("/messages");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "timetable":
|
||||||
|
navigateToHashRoute("/timetable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToHashRoute(FALLBACK_ROUTE);
|
||||||
}) as ActionHandler<any>,
|
}) as ActionHandler<any>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import { delay } from "@/seqta/utils/delay";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared SEQTA HTTP layer used by every indexing job.
|
||||||
|
*
|
||||||
|
* - All requests are same-origin POSTs against `/seqta/student/...` with
|
||||||
|
* `credentials: "include"` so they inherit the user's existing session.
|
||||||
|
* - Responses are parsed as JSON and lightly validated (status === "200" and
|
||||||
|
* payload present, mirroring the SEQTA convention).
|
||||||
|
* - Failures are retried with exponential backoff up to a configurable limit.
|
||||||
|
* - A simple per-route concurrency / spacing limiter prevents heavy jobs (e.g.
|
||||||
|
* per-subject course crawls) from hammering SEQTA.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SeqtaResponse<T = any> {
|
||||||
|
payload: T;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeqtaFetchOptions {
|
||||||
|
/** Defaults to "POST". */
|
||||||
|
method?: "POST" | "GET";
|
||||||
|
/** Maximum number of retries for transient failures (default 2). */
|
||||||
|
retries?: number;
|
||||||
|
/** Initial backoff delay in ms (default 200). */
|
||||||
|
baseDelayMs?: number;
|
||||||
|
/** Hard cap on total request time in ms (default 20s). */
|
||||||
|
timeoutMs?: number;
|
||||||
|
/** AbortSignal for cancellation. */
|
||||||
|
signal?: AbortSignal;
|
||||||
|
/** Skip the routing limiter (rare; only for already-throttled callers). */
|
||||||
|
skipLimiter?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_RETRIES = 2;
|
||||||
|
const DEFAULT_BASE_DELAY = 200;
|
||||||
|
const DEFAULT_TIMEOUT = 20_000;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* limiter */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caps concurrent in-flight requests per normalized SEQTA route. Indexing
|
||||||
|
* jobs often fan out (e.g. one /load/courses per subject); we don't want them
|
||||||
|
* sending dozens of requests in parallel.
|
||||||
|
*/
|
||||||
|
class RouteLimiter {
|
||||||
|
private inFlight = new Map<string, number>();
|
||||||
|
private waiters = new Map<string, Array<() => void>>();
|
||||||
|
private readonly maxConcurrent: number;
|
||||||
|
|
||||||
|
constructor(maxConcurrent = 4) {
|
||||||
|
this.maxConcurrent = maxConcurrent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquire(route: string): Promise<() => void> {
|
||||||
|
const current = this.inFlight.get(route) ?? 0;
|
||||||
|
if (current < this.maxConcurrent) {
|
||||||
|
this.inFlight.set(route, current + 1);
|
||||||
|
return () => this.release(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const queue = this.waiters.get(route) ?? [];
|
||||||
|
queue.push(() => {
|
||||||
|
this.inFlight.set(route, (this.inFlight.get(route) ?? 0) + 1);
|
||||||
|
resolve(() => this.release(route));
|
||||||
|
});
|
||||||
|
this.waiters.set(route, queue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private release(route: string) {
|
||||||
|
const next = (this.inFlight.get(route) ?? 1) - 1;
|
||||||
|
if (next <= 0) {
|
||||||
|
this.inFlight.delete(route);
|
||||||
|
} else {
|
||||||
|
this.inFlight.set(route, next);
|
||||||
|
}
|
||||||
|
const queue = this.waiters.get(route);
|
||||||
|
if (queue && queue.length > 0) {
|
||||||
|
const wake = queue.shift()!;
|
||||||
|
if (queue.length === 0) this.waiters.delete(route);
|
||||||
|
wake();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeLimiter = new RouteLimiter(4);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* route normalization */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips the volatile anti-replay query token (e.g. `?mokx3qef`) so we can
|
||||||
|
* key caches and limiters off the canonical route.
|
||||||
|
*/
|
||||||
|
export function normalizeSeqtaPath(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, location.origin);
|
||||||
|
// SEQTA appends a single random query token like `?mokx3qef`. Drop the
|
||||||
|
// entire query string so canonicalization is robust.
|
||||||
|
return parsed.pathname;
|
||||||
|
} catch {
|
||||||
|
// Fallback for already-relative URLs.
|
||||||
|
return url.split("?")[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* sensitive routes */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes whose responses must never be indexed because they contain
|
||||||
|
* credentials, secrets, JWTs, or arbitrary configuration blobs.
|
||||||
|
*/
|
||||||
|
const SENSITIVE_PATH_PATTERNS: RegExp[] = [
|
||||||
|
/\/seqta\/student\/login(\b|\/)/i,
|
||||||
|
/\/seqta\/student\/save\//i,
|
||||||
|
/\/seqta\/student\/load\/settings(\b|\/)/i,
|
||||||
|
/\/seqta\/student\/load\/prefs(\b|\/)/i,
|
||||||
|
/\/seqta\/student\/heartbeat(\b|\/)/i,
|
||||||
|
/\/seqta\/student\/storage(\b|\/)/i,
|
||||||
|
/\/seqta\/student\/themes\//i,
|
||||||
|
/\/seqta\/student\/branding\//i,
|
||||||
|
/\/seqta\/student\/releasealert\//i,
|
||||||
|
/\/seqta\/student\/files\/stream(\b|\/)/i,
|
||||||
|
/\/seqta\/student\/load\/file(\b|\/)/i,
|
||||||
|
/\/seqta\/ta\/masquerade(\b|\/)/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isSensitiveSeqtaPath(path: string): boolean {
|
||||||
|
const normalized = normalizeSeqtaPath(path);
|
||||||
|
return SENSITIVE_PATH_PATTERNS.some((re) => re.test(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* student / user identity */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface SeqtaUserInfo {
|
||||||
|
id?: number;
|
||||||
|
personUUID?: string;
|
||||||
|
username?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedUserInfo: SeqtaUserInfo | null = null;
|
||||||
|
let inflightUserInfo: Promise<SeqtaUserInfo | null> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the current SEQTA user identity by re-using the same `login`
|
||||||
|
* handshake that the host page performs. This is the canonical way to
|
||||||
|
* discover the active student id and avoids the historical hard-coded
|
||||||
|
* `student: 69` placeholder that was incorrect on every real instance.
|
||||||
|
*
|
||||||
|
* Failures are intentionally NOT cached — a transient login glitch on the
|
||||||
|
* very first call must not poison the cache for the lifetime of the page,
|
||||||
|
* because every subsequent indexing pass that needs the student id (e.g.
|
||||||
|
* the assignments job) would skip silently.
|
||||||
|
*/
|
||||||
|
export async function getCurrentUserInfo(): Promise<SeqtaUserInfo | null> {
|
||||||
|
if (cachedUserInfo) return cachedUserInfo;
|
||||||
|
if (inflightUserInfo) return inflightUserInfo;
|
||||||
|
|
||||||
|
inflightUserInfo = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${location.origin}/seqta/student/login`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
mode: "normal",
|
||||||
|
query: null,
|
||||||
|
redirect_url: location.origin,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const json = (await res.json()) as { payload?: SeqtaUserInfo };
|
||||||
|
const payload = json?.payload ?? null;
|
||||||
|
if (payload && typeof payload === "object") {
|
||||||
|
cachedUserInfo = payload;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
"[Global Search API] Failed to resolve current user info:",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
inflightUserInfo = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return inflightUserInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort lookup of the active student id. Returns `undefined` when the
|
||||||
|
* value cannot be discovered (jobs should fall back gracefully rather than
|
||||||
|
* fabricating an id).
|
||||||
|
*/
|
||||||
|
export async function getCurrentStudentId(): Promise<number | undefined> {
|
||||||
|
const info = await getCurrentUserInfo();
|
||||||
|
const id = info?.id;
|
||||||
|
if (typeof id === "number" && Number.isFinite(id)) return id;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* core fetch */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
class SeqtaApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
route: string;
|
||||||
|
constructor(message: string, status: number, route: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "SeqtaApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.route = route;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTransientError(err: unknown): boolean {
|
||||||
|
if (err instanceof SeqtaApiError) {
|
||||||
|
if (err.status === 0 || err.status >= 500) return true;
|
||||||
|
if (err.status === 429) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (err instanceof TypeError) return true;
|
||||||
|
if ((err as any)?.name === "AbortError") return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a JSON POST against a SEQTA route and returns the parsed envelope.
|
||||||
|
*
|
||||||
|
* - Adds `credentials: "include"` so requests reuse the active session.
|
||||||
|
* - Sets `X-Requested-With: XMLHttpRequest` so SEQTA classifies the request
|
||||||
|
* the same way as the first-party SPA (some routes 4xx without it).
|
||||||
|
* - Retries transient network/server errors with exponential backoff.
|
||||||
|
* - Validates that the response is JSON and has `status === "200"` (matches
|
||||||
|
* the SEQTA convention; jobs that need raw payloads can pass `path` but
|
||||||
|
* call `seqtaFetch` directly via the underlying API if they need to).
|
||||||
|
*/
|
||||||
|
export async function seqtaFetchJson<T = any>(
|
||||||
|
path: string,
|
||||||
|
body: Record<string, unknown> | undefined = {},
|
||||||
|
options: SeqtaFetchOptions = {},
|
||||||
|
): Promise<SeqtaResponse<T>> {
|
||||||
|
const route = normalizeSeqtaPath(path);
|
||||||
|
const retries = Math.max(0, options.retries ?? DEFAULT_RETRIES);
|
||||||
|
const baseDelay = Math.max(50, options.baseDelayMs ?? DEFAULT_BASE_DELAY);
|
||||||
|
const timeoutMs = Math.max(1_000, options.timeoutMs ?? DEFAULT_TIMEOUT);
|
||||||
|
|
||||||
|
let release: (() => void) | null = null;
|
||||||
|
if (!options.skipLimiter) {
|
||||||
|
release = await routeLimiter.acquire(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let attempt = 0;
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
while (attempt <= retries) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
const onAbort = () => controller.abort();
|
||||||
|
if (options.signal) {
|
||||||
|
if (options.signal.aborted) controller.abort();
|
||||||
|
else options.signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${location.origin}${route}`, {
|
||||||
|
method: options.method ?? "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
Accept: "text/javascript, text/html, application/xml, text/xml, */*",
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new SeqtaApiError(
|
||||||
|
`HTTP ${res.status} ${res.statusText} for ${route}`,
|
||||||
|
res.status,
|
||||||
|
route,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawJson = (await res.json()) as unknown;
|
||||||
|
if (!rawJson || typeof rawJson !== "object") {
|
||||||
|
throw new SeqtaApiError(
|
||||||
|
`Invalid SEQTA response (not a JSON object) for ${route}`,
|
||||||
|
res.status,
|
||||||
|
route,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEQTA's "envelope" convention is `{ status, payload }`, but in
|
||||||
|
// practice some endpoints — notably `/seqta/student/load/subjects`
|
||||||
|
// and `/seqta/student/assessment/list/*` — occasionally return
|
||||||
|
// either a bare array or an envelope with a non-"200" status.
|
||||||
|
// Strict validation here was historically silently killing the
|
||||||
|
// assignments + courses indexing pipelines when those endpoints
|
||||||
|
// returned a quirky shape, so we normalize permissively and let
|
||||||
|
// callers handle missing/empty payloads.
|
||||||
|
let json: SeqtaResponse<T>;
|
||||||
|
if (Array.isArray(rawJson)) {
|
||||||
|
json = { payload: rawJson as unknown as T, status: "200" };
|
||||||
|
} else {
|
||||||
|
const obj = rawJson as Record<string, unknown>;
|
||||||
|
const hasEnvelopeKey = "payload" in obj || "status" in obj;
|
||||||
|
if (hasEnvelopeKey) {
|
||||||
|
json = {
|
||||||
|
payload: ("payload" in obj ? obj.payload : undefined) as T,
|
||||||
|
status:
|
||||||
|
typeof obj.status === "string"
|
||||||
|
? obj.status
|
||||||
|
: typeof obj.status === "number"
|
||||||
|
? String(obj.status)
|
||||||
|
: "200",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
json = { payload: rawJson as unknown as T, status: "200" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.status && json.status !== "200") {
|
||||||
|
console.warn(
|
||||||
|
`[Global Search API] Non-200 SEQTA status "${json.status}" for ${route} — returning payload anyway`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
if (!isTransientError(err) || attempt === retries) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const wait = Math.min(5_000, baseDelay * Math.pow(2, attempt));
|
||||||
|
await delay(wait);
|
||||||
|
attempt++;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (options.signal) options.signal.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error(`seqtaFetchJson exhausted retries for ${route}`);
|
||||||
|
} finally {
|
||||||
|
if (release) release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience helper: fetch and unwrap `.payload` directly. Returns `null`
|
||||||
|
* on failure rather than throwing, so jobs can use the value optionally.
|
||||||
|
*/
|
||||||
|
export async function seqtaFetchPayload<T = any>(
|
||||||
|
path: string,
|
||||||
|
body: Record<string, unknown> | undefined = {},
|
||||||
|
options: SeqtaFetchOptions = {},
|
||||||
|
): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const res = await seqtaFetchJson<T>(path, body, options);
|
||||||
|
return res.payload ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`[Global Search API] Request to ${normalizeSeqtaPath(path)} failed:`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { htmlToPlainText } from "./utils";
|
||||||
|
import type { IndexItem } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe extraction helpers used by both active SEQTA jobs and the passive
|
||||||
|
* network observer.
|
||||||
|
*
|
||||||
|
* The goal is to take arbitrary SEQTA JSON / embedded HTML fragments and
|
||||||
|
* derive concise, redacted, search-friendly text without ever indexing
|
||||||
|
* obvious credentials, tokens, JWTs, or large binary blobs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* sensitive keys */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field names whose values should never be indexed regardless of context.
|
||||||
|
* Matches SEQTA's frequently-used credential / config keys plus generic
|
||||||
|
* security-related names. Comparison is case-insensitive and matches both
|
||||||
|
* the full key and any sub-string fragments (so `client_secret`,
|
||||||
|
* `apiKey`, `dropboxKey` all hit).
|
||||||
|
*/
|
||||||
|
const SENSITIVE_KEY_FRAGMENTS: readonly string[] = [
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"pwd",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
"jwt",
|
||||||
|
"session",
|
||||||
|
"cookie",
|
||||||
|
"auth",
|
||||||
|
"apikey",
|
||||||
|
"api_key",
|
||||||
|
"clientid",
|
||||||
|
"client_id",
|
||||||
|
"clientsecret",
|
||||||
|
"client_secret",
|
||||||
|
"credential",
|
||||||
|
"private",
|
||||||
|
"salt",
|
||||||
|
"hash",
|
||||||
|
"csrf",
|
||||||
|
"x-api",
|
||||||
|
"bearer",
|
||||||
|
"dropbox",
|
||||||
|
"oauth",
|
||||||
|
"signature",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isSensitiveKey(key: string): boolean {
|
||||||
|
if (!key) return false;
|
||||||
|
const lower = key.toLowerCase();
|
||||||
|
return SENSITIVE_KEY_FRAGMENTS.some((frag) => lower.includes(frag));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the supplied scalar value looks credential-shaped: a long
|
||||||
|
* hex/base64-like blob that doesn't decode to readable text. This catches
|
||||||
|
* arbitrary tokens that don't have a clear field-name signal.
|
||||||
|
*/
|
||||||
|
export function looksLikeSecretValue(value: unknown): boolean {
|
||||||
|
if (typeof value !== "string") return false;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length < 32) return false;
|
||||||
|
|
||||||
|
// Long contiguous base64 / hex with no whitespace and no humanish punctuation.
|
||||||
|
if (/\s/.test(trimmed)) return false;
|
||||||
|
if (/^[A-Za-z0-9+/=._-]{32,}$/.test(trimmed) && !/[.,!?]/.test(trimmed)) {
|
||||||
|
// Reject obvious URLs and UUIDs (they're useful and not secret).
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) return false;
|
||||||
|
if (
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
|
trimmed,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT detection: three base64url segments separated by dots.
|
||||||
|
if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(trimmed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* text extraction */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively pulls human-readable text out of an arbitrary JSON value.
|
||||||
|
*
|
||||||
|
* - HTML strings are passed through `htmlToPlainText`.
|
||||||
|
* - Sensitive keys and secret-shaped values are skipped.
|
||||||
|
* - Long blobs are truncated to keep the index lean.
|
||||||
|
* - Arrays and objects are walked; depth is bounded to avoid pathological
|
||||||
|
* structures.
|
||||||
|
*/
|
||||||
|
export interface ExtractTextOptions {
|
||||||
|
/** Hard cap on combined characters across the walk (default 4000). */
|
||||||
|
maxChars?: number;
|
||||||
|
/** Maximum recursion depth (default 6). */
|
||||||
|
maxDepth?: number;
|
||||||
|
/** Maximum array length to traverse (default 200). */
|
||||||
|
maxArrayItems?: number;
|
||||||
|
/** Skip individual string values longer than this (default 8000). */
|
||||||
|
maxStringLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_EXTRACT_OPTIONS: Required<ExtractTextOptions> = {
|
||||||
|
maxChars: 4000,
|
||||||
|
maxDepth: 6,
|
||||||
|
maxArrayItems: 200,
|
||||||
|
maxStringLength: 8000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extractTextFromValue(
|
||||||
|
value: unknown,
|
||||||
|
options: ExtractTextOptions = {},
|
||||||
|
): string {
|
||||||
|
const opts = { ...DEFAULT_EXTRACT_OPTIONS, ...options };
|
||||||
|
const parts: string[] = [];
|
||||||
|
let remaining = opts.maxChars;
|
||||||
|
|
||||||
|
const push = (text: string) => {
|
||||||
|
if (!text || remaining <= 0) return;
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const slice = trimmed.length > remaining ? trimmed.slice(0, remaining) : trimmed;
|
||||||
|
parts.push(slice);
|
||||||
|
remaining -= slice.length + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const walk = (node: unknown, depth: number, parentKey: string | null) => {
|
||||||
|
if (remaining <= 0) return;
|
||||||
|
if (node === null || node === undefined) return;
|
||||||
|
if (parentKey && isSensitiveKey(parentKey)) return;
|
||||||
|
|
||||||
|
if (typeof node === "string") {
|
||||||
|
if (node.length > opts.maxStringLength) return;
|
||||||
|
if (looksLikeSecretValue(node)) return;
|
||||||
|
if (node.includes("<") && node.includes(">")) {
|
||||||
|
push(htmlToPlainText(node));
|
||||||
|
} else {
|
||||||
|
push(node);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node === "number" || typeof node === "boolean") {
|
||||||
|
// Numbers/booleans rarely contribute to search recall; skip to keep
|
||||||
|
// the index focused on text.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth >= opts.maxDepth) return;
|
||||||
|
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
const limit = Math.min(node.length, opts.maxArrayItems);
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
walk(node[i], depth + 1, parentKey);
|
||||||
|
if (remaining <= 0) return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node === "object") {
|
||||||
|
for (const [key, child] of Object.entries(node as Record<string, unknown>)) {
|
||||||
|
if (remaining <= 0) return;
|
||||||
|
if (isSensitiveKey(key)) continue;
|
||||||
|
walk(child, depth + 1, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(value, 0, null);
|
||||||
|
|
||||||
|
return parts.join("\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* redacted clones */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a deep clone of `value` with sensitive keys/values stripped. The
|
||||||
|
* passive observer uses this when persisting metadata so we never store
|
||||||
|
* raw tokens or settings blobs in IndexedDB.
|
||||||
|
*/
|
||||||
|
export function redactSensitive<T>(value: T, depth = 0): T {
|
||||||
|
if (value === null || value === undefined) return value;
|
||||||
|
if (depth >= 8) return value;
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.slice(0, 200)
|
||||||
|
.map((v) => redactSensitive(v, depth + 1)) as unknown as T;
|
||||||
|
}
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
if (isSensitiveKey(key)) continue;
|
||||||
|
if (typeof child === "string" && looksLikeSecretValue(child)) continue;
|
||||||
|
out[key] = redactSensitive(child, depth + 1);
|
||||||
|
}
|
||||||
|
return out as T;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && looksLikeSecretValue(value)) {
|
||||||
|
return "" as unknown as T;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* title / id heuristics */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const TITLE_KEYS = [
|
||||||
|
"title",
|
||||||
|
"subject",
|
||||||
|
"name",
|
||||||
|
"label",
|
||||||
|
"heading",
|
||||||
|
"displayName",
|
||||||
|
"filename",
|
||||||
|
"code",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ID_KEYS = ["id", "uuid", "messageID", "assessmentID", "notificationID"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort title extraction: returns the first sensible string-valued
|
||||||
|
* field commonly used by SEQTA payloads. Falls back to an empty string when
|
||||||
|
* none are present.
|
||||||
|
*/
|
||||||
|
export function pickTitle(node: unknown, fallback = ""): string {
|
||||||
|
if (!node || typeof node !== "object") return fallback;
|
||||||
|
const obj = node as Record<string, unknown>;
|
||||||
|
for (const key of TITLE_KEYS) {
|
||||||
|
const v = obj[key];
|
||||||
|
if (typeof v === "string" && v.trim()) return v.trim();
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickId(node: unknown, fallback = ""): string {
|
||||||
|
if (!node || typeof node !== "object") return fallback;
|
||||||
|
const obj = node as Record<string, unknown>;
|
||||||
|
for (const key of ID_KEYS) {
|
||||||
|
const v = obj[key];
|
||||||
|
if (typeof v === "string" && v.trim()) return v.trim();
|
||||||
|
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* IndexItem builders */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an `IndexItem` from a raw entity, applying our standard
|
||||||
|
* extraction rules. Callers fill in the things that need domain knowledge
|
||||||
|
* (`category`, `actionId`, `metadata`, deep-link route hints) and we handle
|
||||||
|
* the boring text + redaction work.
|
||||||
|
*/
|
||||||
|
export function buildIndexItem(input: {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
category: string;
|
||||||
|
rawForContent?: unknown;
|
||||||
|
contentOverride?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
actionId: string;
|
||||||
|
renderComponentId: string;
|
||||||
|
dateAdded?: number;
|
||||||
|
contentMaxChars?: number;
|
||||||
|
}): IndexItem {
|
||||||
|
const content =
|
||||||
|
input.contentOverride !== undefined
|
||||||
|
? input.contentOverride
|
||||||
|
: extractTextFromValue(input.rawForContent, {
|
||||||
|
maxChars: input.contentMaxChars ?? 1500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadata = input.metadata ? redactSensitive(input.metadata) : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
text: input.text,
|
||||||
|
category: input.category,
|
||||||
|
content,
|
||||||
|
dateAdded: input.dateAdded ?? Date.now(),
|
||||||
|
metadata,
|
||||||
|
actionId: input.actionId,
|
||||||
|
renderComponentId: input.renderComponentId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { clear, get, getAll, put, remove } from "./db";
|
import { clear, get, getAll, put, remove, resetDatabase } from "./db";
|
||||||
import { jobs } from "./jobs";
|
import { jobs } from "./jobs";
|
||||||
import { renderComponentMap } from "./renderComponents";
|
import { renderComponentMap } from "./renderComponents";
|
||||||
import type { IndexItem, Job, JobContext } from "./types";
|
import type { IndexItem, Job, JobContext } from "./types";
|
||||||
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
||||||
import { loadDynamicItems } from "../utils/dynamicItems";
|
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||||
import { getVectorizedItemIds } from "./utils";
|
import { getVectorizedItemIds } from "./utils";
|
||||||
|
import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion";
|
||||||
|
|
||||||
const META_STORE = "meta";
|
const META_STORE = "meta";
|
||||||
const LOCK_KEY = "bsq-indexer-lock";
|
const LOCK_KEY = "bsq-indexer-lock";
|
||||||
@@ -12,6 +13,50 @@ const HEARTBEAT_INTERVAL = 10000;
|
|||||||
const LOCK_TIMEOUT = 20000;
|
const LOCK_TIMEOUT = 20000;
|
||||||
const LOCK_ACQUIRE_TIMEOUT = 5000;
|
const LOCK_ACQUIRE_TIMEOUT = 5000;
|
||||||
|
|
||||||
|
let schemaCheckPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
async function ensureSchemaCurrent(): Promise<void> {
|
||||||
|
if (schemaCheckPromise) return schemaCheckPromise;
|
||||||
|
schemaCheckPromise = (async () => {
|
||||||
|
let storedRaw: string | null = null;
|
||||||
|
try {
|
||||||
|
storedRaw = localStorage.getItem(SCHEMA_VERSION_KEY);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stored = storedRaw ? parseInt(storedRaw, 10) : 0;
|
||||||
|
if (stored === INDEX_SCHEMA_VERSION) return;
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`[Indexer] Schema version changed (${stored} -> ${INDEX_SCHEMA_VERSION}); resetting structured + vector indexes.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await resetDatabase();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Indexer] Failed to reset structured database:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const req = indexedDB.deleteDatabase("embeddiaDB");
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => resolve();
|
||||||
|
req.onblocked = () => resolve();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Indexer] Failed to reset embeddiaDB:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SCHEMA_VERSION_KEY, String(INDEX_SCHEMA_VERSION));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return schemaCheckPromise;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─────────── Progress‑meta helpers ─────────── */
|
/* ─────────── Progress‑meta helpers ─────────── */
|
||||||
async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
|
async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
|
||||||
const rec = await get(META_STORE, `progress:${jobId}`);
|
const rec = await get(META_STORE, `progress:${jobId}`);
|
||||||
@@ -162,6 +207,8 @@ export async function loadAllStoredItems(): Promise<IndexItem[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runIndexing(): Promise<void> {
|
export async function runIndexing(): Promise<void> {
|
||||||
|
await ensureSchemaCurrent();
|
||||||
|
|
||||||
if (!(await acquireLock())) {
|
if (!(await acquireLock())) {
|
||||||
console.debug(
|
console.debug(
|
||||||
"%c[Indexer] Could not acquire lock - another tab is indexing or this tab is already indexing",
|
"%c[Indexer] Could not acquire lock - another tab is indexing or this tab is already indexing",
|
||||||
@@ -178,8 +225,6 @@ export async function runIndexing(): Promise<void> {
|
|||||||
const totalSteps = jobIds.length + 1;
|
const totalSteps = jobIds.length + 1;
|
||||||
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs");
|
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs");
|
||||||
|
|
||||||
let hasStreamingJobs = false;
|
|
||||||
|
|
||||||
for (const jobId of jobIds) {
|
for (const jobId of jobIds) {
|
||||||
dispatchProgress(
|
dispatchProgress(
|
||||||
completedJobs,
|
completedJobs,
|
||||||
@@ -255,10 +300,6 @@ export async function runIndexing(): Promise<void> {
|
|||||||
await setStoredItems(merged);
|
await setStoredItems(merged);
|
||||||
await updateLastRunMeta(jobId);
|
await updateLastRunMeta(jobId);
|
||||||
|
|
||||||
if (jobId === 'messages' || jobId === 'notifications') {
|
|
||||||
hasStreamingJobs = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug(
|
console.debug(
|
||||||
`%c[Indexer] ${job.label}: ${newItemsRaw.length} new items reported by run, ${merged.length} total items now in '${jobId}' store.`,
|
`%c[Indexer] ${job.label}: ${newItemsRaw.length} new items reported by run, ${merged.length} total items now in '${jobId}' store.`,
|
||||||
"color: #00c46f",
|
"color: #00c46f",
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { notificationsJob } from "./jobs/notifications";
|
|||||||
import { forumsJob } from "./jobs/forums";
|
import { forumsJob } from "./jobs/forums";
|
||||||
import { subjectsJob } from "./jobs/subjects";
|
import { subjectsJob } from "./jobs/subjects";
|
||||||
import { assignmentsJob } from "./jobs/assignments";
|
import { assignmentsJob } from "./jobs/assignments";
|
||||||
|
import { coursesJob } from "./jobs/courses";
|
||||||
|
import { noticesJob } from "./jobs/notices";
|
||||||
|
import { documentsJob } from "./jobs/documents";
|
||||||
|
import { folioJob } from "./jobs/folio";
|
||||||
|
import { portalsJob } from "./jobs/portals";
|
||||||
|
import { reportsJob } from "./jobs/reports";
|
||||||
|
import { goalsJob } from "./jobs/goals";
|
||||||
|
import { passiveJob } from "./jobs/passive";
|
||||||
|
|
||||||
export const jobs: Record<string, Job> = {
|
export const jobs: Record<string, Job> = {
|
||||||
messages: messagesJob,
|
messages: messagesJob,
|
||||||
@@ -11,4 +19,12 @@ export const jobs: Record<string, Job> = {
|
|||||||
forums: forumsJob,
|
forums: forumsJob,
|
||||||
subjects: subjectsJob,
|
subjects: subjectsJob,
|
||||||
assignments: assignmentsJob,
|
assignments: assignmentsJob,
|
||||||
|
courses: coursesJob,
|
||||||
|
notices: noticesJob,
|
||||||
|
documents: documentsJob,
|
||||||
|
folio: folioJob,
|
||||||
|
portals: portalsJob,
|
||||||
|
reports: reportsJob,
|
||||||
|
goals: goalsJob,
|
||||||
|
passive: passiveJob,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import type { IndexItem, Job } from "../types";
|
||||||
|
import { seqtaFetchPayload } from "../api";
|
||||||
|
import { buildIndexItem } from "../extract";
|
||||||
|
import { htmlToPlainText } from "../utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indexes per-subject course content from `/seqta/student/load/courses`.
|
||||||
|
*
|
||||||
|
* The course payload contains the lesson grid in `w[][]` where each cell's
|
||||||
|
* `l` field is a (possibly empty) HTML snippet authored by teachers. We
|
||||||
|
* concatenate these into searchable text per course, plus the course title
|
||||||
|
* and code from `t` / `c`. Embedded files referenced via TED/SEQTA URLs are
|
||||||
|
* preserved as plain-text links so users can find them by URL fragment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SubjectsListPayload {
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
active: number;
|
||||||
|
subjects: Array<{
|
||||||
|
code: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
metaclass: number;
|
||||||
|
programme: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoursePayload {
|
||||||
|
c?: string;
|
||||||
|
t?: string;
|
||||||
|
i?: number;
|
||||||
|
m?: number;
|
||||||
|
w?: Array<Array<{ l?: string; h?: string; t?: string; o?: string; i?: number }>>;
|
||||||
|
document?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchActiveSubjects = async (): Promise<
|
||||||
|
SubjectsListPayload["subjects"]
|
||||||
|
> => {
|
||||||
|
const payload = await seqtaFetchPayload<SubjectsListPayload[]>(
|
||||||
|
"/seqta/student/load/subjects",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
if (!Array.isArray(payload)) return [];
|
||||||
|
|
||||||
|
const out: SubjectsListPayload["subjects"] = [];
|
||||||
|
for (const semester of payload) {
|
||||||
|
if (!semester || !Array.isArray(semester.subjects)) continue;
|
||||||
|
if (semester.active !== 1) continue;
|
||||||
|
for (const subject of semester.subjects) {
|
||||||
|
if (
|
||||||
|
subject &&
|
||||||
|
Number.isFinite(subject.programme) &&
|
||||||
|
Number.isFinite(subject.metaclass)
|
||||||
|
) {
|
||||||
|
out.push(subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
function flattenLessonHtml(payload: CoursePayload): string {
|
||||||
|
if (!Array.isArray(payload.w)) return "";
|
||||||
|
const fragments: string[] = [];
|
||||||
|
for (const row of payload.w) {
|
||||||
|
if (!Array.isArray(row)) continue;
|
||||||
|
for (const cell of row) {
|
||||||
|
if (!cell) continue;
|
||||||
|
if (typeof cell.l === "string" && cell.l.trim()) {
|
||||||
|
fragments.push(cell.l);
|
||||||
|
}
|
||||||
|
if (typeof cell.h === "string" && cell.h.trim()) {
|
||||||
|
fragments.push(cell.h);
|
||||||
|
}
|
||||||
|
if (typeof cell.t === "string" && cell.t.trim()) {
|
||||||
|
fragments.push(cell.t);
|
||||||
|
}
|
||||||
|
if (typeof cell.o === "string" && cell.o.trim()) {
|
||||||
|
fragments.push(cell.o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fragments.length === 0) return "";
|
||||||
|
return htmlToPlainText(fragments.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const coursesJob: Job = {
|
||||||
|
id: "courses",
|
||||||
|
label: "Courses",
|
||||||
|
renderComponentId: "course",
|
||||||
|
// Course content rarely changes minute-to-minute but does evolve per term.
|
||||||
|
// Refresh once per day (after pageLoad cool-down) to keep new lessons
|
||||||
|
// discoverable without hammering SEQTA.
|
||||||
|
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 },
|
||||||
|
|
||||||
|
boostCriteria: (item, searchTerm) => {
|
||||||
|
if (!searchTerm) return -50;
|
||||||
|
let score = 0;
|
||||||
|
if (item.metadata?.subjectCode) score += 0.05;
|
||||||
|
if (item.metadata?.isActive) score += 0.02;
|
||||||
|
return score;
|
||||||
|
},
|
||||||
|
|
||||||
|
run: async (_ctx) => {
|
||||||
|
const subjects = await fetchActiveSubjects();
|
||||||
|
if (subjects.length === 0) {
|
||||||
|
console.debug("[Courses job] No active subjects discovered.");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: IndexItem[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
|
||||||
|
// Sequential per-subject fetch keeps load on SEQTA bounded; the shared
|
||||||
|
// API layer also limits concurrency per route as a defense in depth.
|
||||||
|
for (const subject of subjects) {
|
||||||
|
const id = `course-${subject.programme}-${subject.metaclass}`;
|
||||||
|
if (seenIds.has(id)) continue;
|
||||||
|
seenIds.add(id);
|
||||||
|
|
||||||
|
const payload = await seqtaFetchPayload<CoursePayload>(
|
||||||
|
"/seqta/student/load/courses",
|
||||||
|
{
|
||||||
|
programme: String(subject.programme),
|
||||||
|
metaclass: String(subject.metaclass),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!payload) continue;
|
||||||
|
|
||||||
|
const title =
|
||||||
|
(typeof payload.t === "string" && payload.t.trim()) ||
|
||||||
|
subject.title ||
|
||||||
|
subject.description ||
|
||||||
|
subject.code ||
|
||||||
|
"Course";
|
||||||
|
|
||||||
|
const lessonText = flattenLessonHtml(payload);
|
||||||
|
const courseCode =
|
||||||
|
(typeof payload.c === "string" && payload.c.trim()) || subject.code;
|
||||||
|
|
||||||
|
const summary = [courseCode, lessonText]
|
||||||
|
.filter((s) => s && s.length > 0)
|
||||||
|
.join("\n")
|
||||||
|
.slice(0, 4000);
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
buildIndexItem({
|
||||||
|
id,
|
||||||
|
text: title,
|
||||||
|
category: "courses",
|
||||||
|
contentOverride: summary || `Course content for ${title}`,
|
||||||
|
metadata: {
|
||||||
|
subjectCode: subject.code,
|
||||||
|
subjectName: subject.title ?? title,
|
||||||
|
programme: subject.programme,
|
||||||
|
metaclass: subject.metaclass,
|
||||||
|
courseCode,
|
||||||
|
isActive: true,
|
||||||
|
route: `/courses/${subject.programme}:${subject.metaclass}`,
|
||||||
|
entityType: "course",
|
||||||
|
icon: "\ueb4d",
|
||||||
|
},
|
||||||
|
actionId: "course",
|
||||||
|
renderComponentId: "course",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(
|
||||||
|
`[Courses job] Indexed ${items.length} courses across ${subjects.length} subjects.`,
|
||||||
|
);
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => items,
|
||||||
|
};
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { IndexItem, Job } from "../types";
|
||||||
|
import { seqtaFetchPayload } from "../api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indexes file metadata from `/seqta/student/load/documents`.
|
||||||
|
*
|
||||||
|
* Each top-level entry is a category containing one or more documents
|
||||||
|
* (`docs[]`). We capture the human-readable title, filename, mimetype, and
|
||||||
|
* stable UUID/category for every doc, but never download or index the
|
||||||
|
* binary content itself - the document streaming endpoint uses one-time
|
||||||
|
* JWTs that are unsafe to persist or replay.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface DocumentEntry {
|
||||||
|
file?: number | string;
|
||||||
|
filename?: string;
|
||||||
|
size?: string | number;
|
||||||
|
context_uuid?: string;
|
||||||
|
mimetype?: string;
|
||||||
|
created_date?: string;
|
||||||
|
title?: string;
|
||||||
|
uuid?: string;
|
||||||
|
created_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentCategory {
|
||||||
|
id: number | string;
|
||||||
|
category: string;
|
||||||
|
colour?: string;
|
||||||
|
docs: DocumentEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettySize(size: string | number | undefined): string | null {
|
||||||
|
if (size === undefined || size === null) return null;
|
||||||
|
const bytes = typeof size === "string" ? parseInt(size, 10) : size;
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return null;
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
let value = bytes;
|
||||||
|
let i = 0;
|
||||||
|
while (value >= 1024 && i < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeMime(mime: string | undefined): string | null {
|
||||||
|
if (!mime) return null;
|
||||||
|
if (mime.startsWith("application/pdf")) return "PDF";
|
||||||
|
if (mime.includes("officedocument.wordprocessingml")) return "Word";
|
||||||
|
if (mime.includes("officedocument.spreadsheetml")) return "Excel";
|
||||||
|
if (mime.includes("officedocument.presentationml")) return "PowerPoint";
|
||||||
|
if (mime.startsWith("image/")) return "Image";
|
||||||
|
if (mime.startsWith("video/")) return "Video";
|
||||||
|
if (mime.startsWith("audio/")) return "Audio";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const documentsJob: Job = {
|
||||||
|
id: "documents",
|
||||||
|
label: "Documents",
|
||||||
|
renderComponentId: "document",
|
||||||
|
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 12 }, // 12 hours
|
||||||
|
|
||||||
|
boostCriteria: (_item, searchTerm) => {
|
||||||
|
if (!searchTerm) return -20;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
run: async (_ctx) => {
|
||||||
|
const payload = await seqtaFetchPayload<DocumentCategory[] | null>(
|
||||||
|
"/seqta/student/load/documents",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
if (!Array.isArray(payload)) return [];
|
||||||
|
|
||||||
|
const items: IndexItem[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const category of payload) {
|
||||||
|
if (!category || !Array.isArray(category.docs)) continue;
|
||||||
|
for (const doc of category.docs) {
|
||||||
|
const uuid = doc.uuid || doc.context_uuid;
|
||||||
|
if (!uuid && !doc.file) continue;
|
||||||
|
const id = `document-${uuid ?? doc.file}`;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
|
||||||
|
const title =
|
||||||
|
doc.title?.trim() ||
|
||||||
|
doc.filename?.trim() ||
|
||||||
|
`Document ${doc.file ?? uuid}`;
|
||||||
|
|
||||||
|
const sizeText = prettySize(doc.size);
|
||||||
|
const mimeLabel = describeMime(doc.mimetype);
|
||||||
|
|
||||||
|
const contentParts: string[] = [];
|
||||||
|
if (doc.filename && doc.filename !== title) contentParts.push(doc.filename);
|
||||||
|
if (category.category) contentParts.push(`Category: ${category.category}`);
|
||||||
|
if (mimeLabel) contentParts.push(mimeLabel);
|
||||||
|
if (sizeText) contentParts.push(sizeText);
|
||||||
|
if (doc.created_date) contentParts.push(`Added ${doc.created_date}`);
|
||||||
|
|
||||||
|
const dateAdded = doc.created_date
|
||||||
|
? new Date(doc.created_date).getTime() || Date.now()
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
text: title,
|
||||||
|
category: "documents",
|
||||||
|
content: contentParts.join(" \u2022 "),
|
||||||
|
dateAdded,
|
||||||
|
metadata: {
|
||||||
|
documentUuid: uuid,
|
||||||
|
fileId: doc.file,
|
||||||
|
filename: doc.filename,
|
||||||
|
mimetype: doc.mimetype,
|
||||||
|
sizeBytes:
|
||||||
|
typeof doc.size === "string" ? parseInt(doc.size, 10) : doc.size,
|
||||||
|
categoryId: category.id,
|
||||||
|
categoryName: category.category,
|
||||||
|
createdDate: doc.created_date,
|
||||||
|
entityType: "document",
|
||||||
|
route: "/documents",
|
||||||
|
icon: "\ueb6f",
|
||||||
|
},
|
||||||
|
actionId: "document",
|
||||||
|
renderComponentId: "document",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`[Documents job] Indexed ${items.length} document entries.`);
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => items,
|
||||||
|
};
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { IndexItem, Job } from "../types";
|
||||||
|
import { seqtaFetchPayload } from "../api";
|
||||||
|
import { htmlToPlainText } from "../utils";
|
||||||
|
import { delay } from "@/seqta/utils/delay";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indexes student folio entries from `/seqta/student/folio`.
|
||||||
|
*
|
||||||
|
* The list mode returns `{ me, list: [{ id, title, published, student }] }`,
|
||||||
|
* and the load mode returns the full body via `{ contents, files, ... }`.
|
||||||
|
* Folio bodies frequently contain `[[embed:raw|<html>]]` blocks which we
|
||||||
|
* normalize to plain text before indexing - the htmlToPlainText sanitizer
|
||||||
|
* never executes scripts because it parses into an inert document.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface FolioListPayload {
|
||||||
|
me?: string;
|
||||||
|
list?: Array<{
|
||||||
|
id: number | string;
|
||||||
|
title?: string;
|
||||||
|
published?: string;
|
||||||
|
student?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolioEntryPayload {
|
||||||
|
forum?: number;
|
||||||
|
contents?: string;
|
||||||
|
created?: string;
|
||||||
|
allow_comments?: boolean;
|
||||||
|
author?: { name?: string; year?: string; id?: number };
|
||||||
|
files?: unknown[];
|
||||||
|
id?: number | string;
|
||||||
|
published?: string;
|
||||||
|
title?: string;
|
||||||
|
updated?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PER_ITEM_DELAY_MS = 80;
|
||||||
|
|
||||||
|
function stripEmbedRaw(text: string): string {
|
||||||
|
if (!text) return "";
|
||||||
|
return text.replace(/\[\[embed:raw\|([\s\S]*?)\]\]/g, (_match, inner) => {
|
||||||
|
return htmlToPlainText(typeof inner === "string" ? inner : "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const folioJob: Job = {
|
||||||
|
id: "folio",
|
||||||
|
label: "Folio",
|
||||||
|
renderComponentId: "folio",
|
||||||
|
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 },
|
||||||
|
|
||||||
|
boostCriteria: (_item, searchTerm) => {
|
||||||
|
if (!searchTerm) return -30;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
run: async (ctx) => {
|
||||||
|
const stored = await ctx.getStoredItems("folio");
|
||||||
|
const existing = new Map(stored.map((i) => [i.id, i]));
|
||||||
|
|
||||||
|
const list = await seqtaFetchPayload<FolioListPayload | null>(
|
||||||
|
"/seqta/student/folio",
|
||||||
|
{ mode: "list", page: 0, filters: {} },
|
||||||
|
);
|
||||||
|
if (!list || !Array.isArray(list.list)) return [];
|
||||||
|
|
||||||
|
const items: IndexItem[] = [];
|
||||||
|
for (const entry of list.list) {
|
||||||
|
if (!entry || entry.id === undefined) continue;
|
||||||
|
const id = `folio-${entry.id}`;
|
||||||
|
const dateAdded = entry.published
|
||||||
|
? new Date(entry.published).getTime() || Date.now()
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
|
// If we already have this folio and the title hasn't changed, reuse
|
||||||
|
// the stored content instead of paying for another /folio?mode=load.
|
||||||
|
const existingItem = existing.get(id);
|
||||||
|
const titleChanged = existingItem && existingItem.text !== (entry.title ?? "");
|
||||||
|
if (existingItem && !titleChanged) {
|
||||||
|
items.push({
|
||||||
|
...existingItem,
|
||||||
|
dateAdded,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await seqtaFetchPayload<FolioEntryPayload | null>(
|
||||||
|
"/seqta/student/folio",
|
||||||
|
{ mode: "load", id: entry.id },
|
||||||
|
);
|
||||||
|
const rawContents = detail?.contents ?? "";
|
||||||
|
const flattened = stripEmbedRaw(rawContents);
|
||||||
|
const content = flattened.slice(0, 4000);
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
text: entry.title?.trim() || `Folio ${entry.id}`,
|
||||||
|
category: "folio",
|
||||||
|
content,
|
||||||
|
dateAdded,
|
||||||
|
metadata: {
|
||||||
|
folioId: entry.id,
|
||||||
|
student: list.me ?? entry.student,
|
||||||
|
publishedAt: entry.published,
|
||||||
|
updatedAt: detail?.updated,
|
||||||
|
createdAt: detail?.created,
|
||||||
|
authorName: detail?.author?.name,
|
||||||
|
authorId: detail?.author?.id,
|
||||||
|
forumId: detail?.forum,
|
||||||
|
allowComments: detail?.allow_comments,
|
||||||
|
fileCount: Array.isArray(detail?.files) ? detail!.files!.length : 0,
|
||||||
|
entityType: "folio",
|
||||||
|
route: "/folios/read",
|
||||||
|
icon: "\ueb16",
|
||||||
|
},
|
||||||
|
actionId: "folio",
|
||||||
|
renderComponentId: "folio",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Folio job] Failed to load folio ${entry.id}:`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
await delay(PER_ITEM_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`[Folio job] Indexed ${items.length} folio entries.`);
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => items,
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { IndexItem, Job } from "../types";
|
||||||
|
import { seqtaFetchPayload } from "../api";
|
||||||
|
import { extractTextFromValue } from "../extract";
|
||||||
|
import { delay } from "@/seqta/utils/delay";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indexes student goals from `/seqta/student/load/goals`.
|
||||||
|
*
|
||||||
|
* The endpoint exposes `mode: "years"` which returns the list of available
|
||||||
|
* years and `mode: "list"` (per-year) which returns the actual goals. We
|
||||||
|
* gracefully degrade if the school has goals disabled (the years payload
|
||||||
|
* is empty in that case).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface GoalEntry {
|
||||||
|
id?: number | string;
|
||||||
|
uuid?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
year?: number | string;
|
||||||
|
created?: string;
|
||||||
|
updated?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PER_YEAR_DELAY_MS = 80;
|
||||||
|
|
||||||
|
export const goalsJob: Job = {
|
||||||
|
id: "goals",
|
||||||
|
label: "Goals",
|
||||||
|
renderComponentId: "goal",
|
||||||
|
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 * 3 }, // every 3 days
|
||||||
|
|
||||||
|
boostCriteria: (_item, searchTerm) => {
|
||||||
|
if (!searchTerm) return -40;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
run: async (_ctx) => {
|
||||||
|
const years = await seqtaFetchPayload<Array<string | number> | null>(
|
||||||
|
"/seqta/student/load/goals",
|
||||||
|
{ mode: "years" },
|
||||||
|
);
|
||||||
|
if (!Array.isArray(years) || years.length === 0) {
|
||||||
|
console.debug("[Goals job] No goal years available; skipping.");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: IndexItem[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const year of years) {
|
||||||
|
try {
|
||||||
|
const yearGoals = await seqtaFetchPayload<GoalEntry[] | null>(
|
||||||
|
"/seqta/student/load/goals",
|
||||||
|
{ mode: "list", year },
|
||||||
|
);
|
||||||
|
if (!Array.isArray(yearGoals)) continue;
|
||||||
|
|
||||||
|
for (const goal of yearGoals) {
|
||||||
|
if (!goal) continue;
|
||||||
|
const stableId = goal.uuid ?? goal.id;
|
||||||
|
if (stableId === undefined || stableId === null) continue;
|
||||||
|
const id = `goal-${stableId}`;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
|
||||||
|
const title =
|
||||||
|
goal.title?.trim() || goal.description?.slice(0, 80) || `Goal ${stableId}`;
|
||||||
|
const dateAdded = goal.updated || goal.created
|
||||||
|
? new Date(goal.updated ?? goal.created!).getTime() || Date.now()
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
text: title,
|
||||||
|
category: "goals",
|
||||||
|
content: extractTextFromValue(
|
||||||
|
{ description: goal.description, status: goal.status },
|
||||||
|
{ maxChars: 1000 },
|
||||||
|
),
|
||||||
|
dateAdded,
|
||||||
|
metadata: {
|
||||||
|
goalId: goal.id,
|
||||||
|
goalUuid: goal.uuid,
|
||||||
|
status: goal.status,
|
||||||
|
year: goal.year ?? year,
|
||||||
|
createdAt: goal.created,
|
||||||
|
updatedAt: goal.updated,
|
||||||
|
entityType: "goal",
|
||||||
|
route: `/goals/${year}`,
|
||||||
|
icon: "\uea15",
|
||||||
|
},
|
||||||
|
actionId: "goal",
|
||||||
|
renderComponentId: "goal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Goals job] Failed to fetch goals for year ${year}:`, e);
|
||||||
|
}
|
||||||
|
await delay(PER_YEAR_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`[Goals job] Indexed ${items.length} goal entries.`);
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => items,
|
||||||
|
};
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import type { IndexItem, Job } from "../types";
|
||||||
|
import { seqtaFetchPayload } from "../api";
|
||||||
|
import { htmlToPlainText } from "../utils";
|
||||||
|
import { delay } from "@/seqta/utils/delay";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indexes daily notices from `/seqta/student/load/notices`.
|
||||||
|
*
|
||||||
|
* SEQTA returns notices keyed by date, so we sweep a sliding window
|
||||||
|
* (default: 14 days back) the first time we run, then incrementally pull
|
||||||
|
* the most recent days on subsequent runs. Sensitive routes are excluded
|
||||||
|
* because notices are surfaced for the active student already.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface NoticeRecord {
|
||||||
|
id?: number | string;
|
||||||
|
title?: string;
|
||||||
|
contents?: string;
|
||||||
|
staff?: string;
|
||||||
|
staff_id?: number;
|
||||||
|
date?: string;
|
||||||
|
label?: number;
|
||||||
|
label_title?: string;
|
||||||
|
colour?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NoticesProgress {
|
||||||
|
earliestDate: string | null;
|
||||||
|
lastSweepBackTo: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SWEEP_DAYS = 14;
|
||||||
|
const MAX_HISTORY_DAYS = 365;
|
||||||
|
const FETCH_DELAY_MS = 60;
|
||||||
|
|
||||||
|
function formatYmd(date: Date): string {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
const d = date.getDate().toString().padStart(2, "0");
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYmd(value: string | null | undefined): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, y, m, d] = match;
|
||||||
|
return new Date(Number(y), Number(m) - 1, Number(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchNoticesForDate = async (date: string): Promise<NoticeRecord[]> => {
|
||||||
|
const payload = await seqtaFetchPayload<NoticeRecord[] | { notices?: NoticeRecord[] } | null>(
|
||||||
|
"/seqta/student/load/notices",
|
||||||
|
{ date },
|
||||||
|
);
|
||||||
|
if (!payload) return [];
|
||||||
|
if (Array.isArray(payload)) return payload;
|
||||||
|
if (Array.isArray((payload as any).notices)) return (payload as any).notices;
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchLabelLookup = async (): Promise<Map<number, string>> => {
|
||||||
|
const payload = await seqtaFetchPayload<
|
||||||
|
Array<{ id: number; title?: string }>
|
||||||
|
>("/seqta/student/load/notices", { mode: "labels" });
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
for (const entry of payload) {
|
||||||
|
if (entry && typeof entry.id === "number" && entry.title) {
|
||||||
|
map.set(entry.id, entry.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const noticesJob: Job = {
|
||||||
|
id: "notices",
|
||||||
|
label: "Notices",
|
||||||
|
renderComponentId: "notice",
|
||||||
|
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 6 }, // 6 hours
|
||||||
|
|
||||||
|
boostCriteria: (item, searchTerm) => {
|
||||||
|
if (!searchTerm) return -10;
|
||||||
|
let score = 0;
|
||||||
|
const ts = item.metadata?.timestamp;
|
||||||
|
if (typeof ts === "string") {
|
||||||
|
const ageDays =
|
||||||
|
(Date.now() - new Date(ts).getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
if (ageDays >= 0 && ageDays <= 7) score += 0.05;
|
||||||
|
}
|
||||||
|
return score;
|
||||||
|
},
|
||||||
|
|
||||||
|
run: async (ctx) => {
|
||||||
|
const stored = await ctx.getStoredItems("notices");
|
||||||
|
const existingIds = new Set(stored.map((i) => i.id));
|
||||||
|
const progress = (await ctx.getProgress<NoticesProgress>()) ?? {
|
||||||
|
earliestDate: null,
|
||||||
|
lastSweepBackTo: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelLookup = await fetchLabelLookup();
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Sweep window: always the most recent SWEEP_DAYS, plus extend further
|
||||||
|
// back the first time we run until we hit MAX_HISTORY_DAYS.
|
||||||
|
const earliestEverIso = formatYmd(
|
||||||
|
new Date(today.getTime() - MAX_HISTORY_DAYS * 86_400_000),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dates: string[] = [];
|
||||||
|
for (let offset = 0; offset < SWEEP_DAYS; offset++) {
|
||||||
|
const day = new Date(today.getTime() - offset * 86_400_000);
|
||||||
|
dates.push(formatYmd(day));
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!progress.lastSweepBackTo ||
|
||||||
|
progress.lastSweepBackTo > earliestEverIso
|
||||||
|
) {
|
||||||
|
// Walk backwards in batches of ~30 days per run so we don't blow up
|
||||||
|
// a single indexing pass.
|
||||||
|
const startBack = parseYmd(progress.lastSweepBackTo) ?? today;
|
||||||
|
const targetBack = new Date(startBack.getTime() - 30 * 86_400_000);
|
||||||
|
const minBack = parseYmd(earliestEverIso) ?? targetBack;
|
||||||
|
const stopBack = targetBack < minBack ? minBack : targetBack;
|
||||||
|
for (
|
||||||
|
let cursor = new Date(startBack.getTime() - SWEEP_DAYS * 86_400_000);
|
||||||
|
cursor >= stopBack;
|
||||||
|
cursor = new Date(cursor.getTime() - 86_400_000)
|
||||||
|
) {
|
||||||
|
dates.push(formatYmd(cursor));
|
||||||
|
}
|
||||||
|
progress.lastSweepBackTo = formatYmd(stopBack);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: IndexItem[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const date of dates) {
|
||||||
|
try {
|
||||||
|
const notices = await fetchNoticesForDate(date);
|
||||||
|
for (const notice of notices) {
|
||||||
|
if (!notice || (notice.id === undefined && !notice.title)) continue;
|
||||||
|
const id = `notice-${date}-${notice.id ?? notice.title}`;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
|
||||||
|
const labelTitle =
|
||||||
|
notice.label_title ??
|
||||||
|
(typeof notice.label === "number"
|
||||||
|
? labelLookup.get(notice.label) ?? null
|
||||||
|
: null);
|
||||||
|
|
||||||
|
const bodyText = notice.contents
|
||||||
|
? htmlToPlainText(notice.contents)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
text: notice.title?.trim() || `Notice ${notice.id ?? date}`,
|
||||||
|
category: "notices",
|
||||||
|
content: bodyText.slice(0, 4000),
|
||||||
|
dateAdded: new Date(date).getTime(),
|
||||||
|
metadata: {
|
||||||
|
noticeId: notice.id,
|
||||||
|
date,
|
||||||
|
author: notice.staff,
|
||||||
|
authorId: notice.staff_id,
|
||||||
|
label: labelTitle,
|
||||||
|
labelId: notice.label,
|
||||||
|
colour: notice.colour,
|
||||||
|
timestamp: date,
|
||||||
|
entityType: "notice",
|
||||||
|
route: "/notices",
|
||||||
|
icon: "\ueb24",
|
||||||
|
},
|
||||||
|
actionId: "notice",
|
||||||
|
renderComponentId: "notice",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Notices job] Failed to fetch notices for ${date}:`, e);
|
||||||
|
}
|
||||||
|
await delay(FETCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length > 0) {
|
||||||
|
const dateStrings = items
|
||||||
|
.map((i) => i.metadata?.date as string | undefined)
|
||||||
|
.filter((d): d is string => !!d);
|
||||||
|
if (dateStrings.length > 0) {
|
||||||
|
const earliest = dateStrings.sort()[0];
|
||||||
|
if (
|
||||||
|
!progress.earliestDate ||
|
||||||
|
earliest < progress.earliestDate
|
||||||
|
) {
|
||||||
|
progress.earliestDate = earliest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.setProgress(progress);
|
||||||
|
|
||||||
|
const newCount = items.filter((i) => !existingIds.has(i.id)).length;
|
||||||
|
console.debug(
|
||||||
|
`[Notices job] Indexed ${items.length} notices across ${dates.length} dates (${newCount} new).`,
|
||||||
|
);
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => {
|
||||||
|
const oneYearAgo = Date.now() - 365 * 24 * 60 * 60 * 1000;
|
||||||
|
return items.filter((i) => i.dateAdded >= oneYearAgo);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Job } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub job for the passive-observer store.
|
||||||
|
*
|
||||||
|
* The passive observer (see `passiveObserver.ts`) writes captured items
|
||||||
|
* directly into IndexedDB via `getAll`/`put`. We still register a job here
|
||||||
|
* so the indexer:
|
||||||
|
* - Creates the `passive` object store on first use.
|
||||||
|
* - Picks up the right `renderComponentId` when materializing in-memory
|
||||||
|
* items in `loadAllStoredItems()`.
|
||||||
|
* - Applies a deterministic boost / purge policy to passive results.
|
||||||
|
*
|
||||||
|
* `run()` is a no-op: the passive observer has its own write path so it
|
||||||
|
* works whether or not an active indexing pass is running.
|
||||||
|
*/
|
||||||
|
export const passiveJob: Job = {
|
||||||
|
id: "passive",
|
||||||
|
label: "Recently viewed",
|
||||||
|
renderComponentId: "passive",
|
||||||
|
// Run frequently so any newly captured items are merged into the
|
||||||
|
// dynamic-items cache on the next indexing tick. The actual capture is
|
||||||
|
// continuous; this is only the synchronization cadence.
|
||||||
|
frequency: { type: "interval", ms: 1000 * 60 * 5 },
|
||||||
|
|
||||||
|
boostCriteria: (item, searchTerm) => {
|
||||||
|
// Passive items are noisier than curated ones, so penalize them
|
||||||
|
// slightly when there's no query and only modestly help on matches.
|
||||||
|
if (!searchTerm) return -60;
|
||||||
|
let score = 0;
|
||||||
|
if (item.metadata?.entityType) score += 0.02;
|
||||||
|
return score;
|
||||||
|
},
|
||||||
|
|
||||||
|
run: async () => {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => {
|
||||||
|
// Keep the most recent ~500 passive entries and anything newer than
|
||||||
|
// 30 days. This caps storage growth from heavy browsing sessions.
|
||||||
|
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const recent = items
|
||||||
|
.filter((i) => i.dateAdded >= cutoff)
|
||||||
|
.sort((a, b) => b.dateAdded - a.dateAdded)
|
||||||
|
.slice(0, 500);
|
||||||
|
return recent;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { IndexItem, Job } from "../types";
|
||||||
|
import { seqtaFetchPayload } from "../api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indexes the user's external portal entries from `/seqta/student/load/portals`.
|
||||||
|
*
|
||||||
|
* Portals are user-facing tiles linking to third-party tools (Mathletics,
|
||||||
|
* Seesaw, Google Classroom, ...). We index their labels and external URLs
|
||||||
|
* so users can jump to them via the global search palette without scrolling
|
||||||
|
* the dashboard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface PortalPayload {
|
||||||
|
id: number | string;
|
||||||
|
label?: string;
|
||||||
|
url?: string;
|
||||||
|
uuid?: string;
|
||||||
|
icon?: string;
|
||||||
|
priority?: number;
|
||||||
|
is_power_portal?: boolean;
|
||||||
|
contents?: string;
|
||||||
|
inherit_styles?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePortalUrl(raw: string | undefined): string | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||||
|
return `https://${trimmed.replace(/^\/+/, "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const portalsJob: Job = {
|
||||||
|
id: "portals",
|
||||||
|
label: "Portals",
|
||||||
|
renderComponentId: "portal",
|
||||||
|
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 * 7 }, // weekly
|
||||||
|
|
||||||
|
boostCriteria: (_item, searchTerm) => {
|
||||||
|
if (!searchTerm) return -50;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
run: async (_ctx) => {
|
||||||
|
const payload = await seqtaFetchPayload<PortalPayload[] | null>(
|
||||||
|
"/seqta/student/load/portals",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
if (!Array.isArray(payload)) return [];
|
||||||
|
|
||||||
|
const items: IndexItem[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const portal of payload) {
|
||||||
|
if (!portal || (portal.id === undefined && !portal.uuid)) continue;
|
||||||
|
const id = `portal-${portal.uuid ?? portal.id}`;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
|
||||||
|
const url = normalizePortalUrl(portal.url);
|
||||||
|
const label = portal.label?.trim() || `Portal ${portal.id}`;
|
||||||
|
const contentParts: string[] = [];
|
||||||
|
if (url) contentParts.push(url);
|
||||||
|
if (portal.is_power_portal) contentParts.push("Power Portal");
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
text: label,
|
||||||
|
category: "portals",
|
||||||
|
content: contentParts.join(" \u2022 "),
|
||||||
|
dateAdded: Date.now(),
|
||||||
|
metadata: {
|
||||||
|
portalId: portal.id,
|
||||||
|
portalUuid: portal.uuid,
|
||||||
|
url,
|
||||||
|
isPowerPortal: !!portal.is_power_portal,
|
||||||
|
entityType: "portal",
|
||||||
|
icon: "\ueb01",
|
||||||
|
},
|
||||||
|
actionId: "portal",
|
||||||
|
renderComponentId: "portal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`[Portals job] Indexed ${items.length} portal entries.`);
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => items,
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { IndexItem, Job } from "../types";
|
||||||
|
import { seqtaFetchPayload } from "../api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indexes report metadata from `/seqta/student/load/reports`.
|
||||||
|
*
|
||||||
|
* Reports are PDFs gated behind SEQTA's authenticated download endpoint, so
|
||||||
|
* we only index the human-readable metadata (year, term, title, file UUID)
|
||||||
|
* and a stable hash route so the search palette can deep-link straight
|
||||||
|
* into the reports page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ReportEntry {
|
||||||
|
id?: number | string;
|
||||||
|
uuid?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
date_published?: string;
|
||||||
|
date_created?: string;
|
||||||
|
year?: number | string;
|
||||||
|
term?: number | string;
|
||||||
|
metaclass?: number;
|
||||||
|
programme?: number;
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reportsJob: Job = {
|
||||||
|
id: "reports",
|
||||||
|
label: "Reports",
|
||||||
|
renderComponentId: "report",
|
||||||
|
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 }, // daily
|
||||||
|
|
||||||
|
boostCriteria: (_item, searchTerm) => {
|
||||||
|
if (!searchTerm) return -25;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
run: async (_ctx) => {
|
||||||
|
const payload = await seqtaFetchPayload<ReportEntry[] | null>(
|
||||||
|
"/seqta/student/load/reports",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
if (!Array.isArray(payload)) return [];
|
||||||
|
|
||||||
|
const items: IndexItem[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const report of payload) {
|
||||||
|
if (!report) continue;
|
||||||
|
const stableId = report.uuid ?? report.id;
|
||||||
|
if (stableId === undefined || stableId === null) continue;
|
||||||
|
const id = `report-${stableId}`;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
|
||||||
|
const title = report.title?.trim() || `Report ${stableId}`;
|
||||||
|
const dateAdded = report.date_published
|
||||||
|
? new Date(report.date_published).getTime() || Date.now()
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
|
const contentParts: string[] = [];
|
||||||
|
if (report.description) contentParts.push(report.description);
|
||||||
|
if (report.year) contentParts.push(`Year ${report.year}`);
|
||||||
|
if (report.term) contentParts.push(`Term ${report.term}`);
|
||||||
|
if (report.date_published) contentParts.push(report.date_published);
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
text: title,
|
||||||
|
category: "reports",
|
||||||
|
content: contentParts.join(" \u2022 "),
|
||||||
|
dateAdded,
|
||||||
|
metadata: {
|
||||||
|
reportId: report.id,
|
||||||
|
reportUuid: report.uuid,
|
||||||
|
year: report.year,
|
||||||
|
term: report.term,
|
||||||
|
metaclass: report.metaclass,
|
||||||
|
programme: report.programme,
|
||||||
|
publishedAt: report.date_published,
|
||||||
|
createdAt: report.date_created,
|
||||||
|
filename: report.filename,
|
||||||
|
entityType: "report",
|
||||||
|
route: "/reports",
|
||||||
|
icon: "\ueb70",
|
||||||
|
},
|
||||||
|
actionId: "report",
|
||||||
|
renderComponentId: "report",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`[Reports job] Indexed ${items.length} reports.`);
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => items,
|
||||||
|
};
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
import type { IndexItem } from "./types";
|
||||||
|
import { put, getAll } from "./db";
|
||||||
|
import {
|
||||||
|
buildIndexItem,
|
||||||
|
extractTextFromValue,
|
||||||
|
pickId,
|
||||||
|
pickTitle,
|
||||||
|
} from "./extract";
|
||||||
|
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
|
||||||
|
import { loadAllStoredItems } from "./indexer";
|
||||||
|
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||||
|
import { renderComponentMap } from "./renderComponents";
|
||||||
|
import { jobs } from "./jobs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passive network observer.
|
||||||
|
*
|
||||||
|
* Wraps the page's `fetch` (and best-effort `XMLHttpRequest`) so that any
|
||||||
|
* successful same-origin SEQTA JSON response observed while the user
|
||||||
|
* browses is opportunistically distilled into IndexItems and persisted to
|
||||||
|
* the `passive` object store.
|
||||||
|
*
|
||||||
|
* Hard guarantees:
|
||||||
|
* - Only same-origin requests under `/seqta/student/` are considered.
|
||||||
|
* - The shared sensitive-route denylist (login, save/*, settings, prefs,
|
||||||
|
* heartbeat, branding, themes, file streams, masquerade, ...) is checked
|
||||||
|
* before any persistence.
|
||||||
|
* - Response bodies are read via `Response.clone()` so we never consume the
|
||||||
|
* body the host page intends to use.
|
||||||
|
* - Sensitive keys/values are stripped via `redactSensitive` before the
|
||||||
|
* item is stored.
|
||||||
|
* - Binary file contents are never indexed (we only work on JSON responses
|
||||||
|
* served as `text/json` / `application/json`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORE_ID = "passive";
|
||||||
|
const FLUSH_DEBOUNCE_MS = 1500;
|
||||||
|
const MAX_ITEMS_PER_RESPONSE = 50;
|
||||||
|
const MAX_PER_RESPONSE_TEXT_CHARS = 1500;
|
||||||
|
|
||||||
|
let installed = false;
|
||||||
|
let pendingFlush: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pendingDirty = false;
|
||||||
|
|
||||||
|
export function isPassiveObserverInstalled(): boolean {
|
||||||
|
return installed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* eligibility checks */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function isSameOriginSeqtaUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, location.origin);
|
||||||
|
if (parsed.origin !== location.origin) return false;
|
||||||
|
return parsed.pathname.startsWith("/seqta/student/");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeJsonContentType(contentType: string | null): boolean {
|
||||||
|
if (!contentType) return false;
|
||||||
|
return /json/i.test(contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* item synthesis */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface CapturedContext {
|
||||||
|
route: string;
|
||||||
|
requestBody: unknown;
|
||||||
|
observedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryFromRoute(route: string): string {
|
||||||
|
// /seqta/student/load/courses -> courses
|
||||||
|
// /seqta/student/load/message -> message
|
||||||
|
const tail = route.replace(/^\/seqta\/student\//, "").split("/").filter(Boolean);
|
||||||
|
if (tail.length === 0) return "passive";
|
||||||
|
// message/people is a support endpoint that backs the messages compose UI.
|
||||||
|
// We treat it as a low-priority `messages-support` record rather than a
|
||||||
|
// standalone "people" category so it never competes with real assessments
|
||||||
|
// / messages in the result list.
|
||||||
|
if (route.includes("/load/message/people")) return "messages-support";
|
||||||
|
return tail[tail.length - 1].toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/seqta/student/load/message/people` returns the contact picker dataset
|
||||||
|
* used by the messages compose view. We only want to surface entries that
|
||||||
|
* actually carry a human display name — the rest is structural noise that
|
||||||
|
* historically caused raw API paths to appear as titles.
|
||||||
|
*/
|
||||||
|
function isPeopleEntityWorthIndexing(entity: unknown): boolean {
|
||||||
|
if (!entity || typeof entity !== "object") return false;
|
||||||
|
const obj = entity as Record<string, unknown>;
|
||||||
|
const first = stringField(obj, [
|
||||||
|
"preferredName",
|
||||||
|
"preferred",
|
||||||
|
"firstname",
|
||||||
|
"firstName",
|
||||||
|
"first_name",
|
||||||
|
"given",
|
||||||
|
"givenName",
|
||||||
|
]);
|
||||||
|
const last = stringField(obj, [
|
||||||
|
"surname",
|
||||||
|
"lastname",
|
||||||
|
"lastName",
|
||||||
|
"last_name",
|
||||||
|
"familyName",
|
||||||
|
]);
|
||||||
|
const display = stringField(obj, ["displayName", "name", "fullName"]);
|
||||||
|
return Boolean((first && last) || display);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourcePageForRoute(route: string): string | undefined {
|
||||||
|
if (route.includes("/load/message/people")) return "/messages";
|
||||||
|
if (route.includes("/load/message")) return "/messages";
|
||||||
|
if (route.includes("/load/messages")) return "/messages";
|
||||||
|
if (route.includes("/load/courses")) return "/courses";
|
||||||
|
if (route.includes("/load/assessments")) return "/assessments/upcoming";
|
||||||
|
if (route.includes("/load/notices")) return "/notices";
|
||||||
|
if (route.includes("/load/documents")) return "/documents";
|
||||||
|
if (route.includes("/folio")) return "/folios/read";
|
||||||
|
if (route.includes("/load/forums")) return "/forums";
|
||||||
|
if (route.includes("/load/goals")) return "/goals";
|
||||||
|
if (route.includes("/load/reports")) return "/reports";
|
||||||
|
if (route.includes("/load/portals")) return "/dashboard";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Programme + metaclass for `/load/courses` POST body or embedded course JSON. */
|
||||||
|
function extractProgrammeMetaclass(
|
||||||
|
requestBody: unknown,
|
||||||
|
entity: unknown,
|
||||||
|
): { programme: number; metaclass: number } | null {
|
||||||
|
const coerce = (value: unknown): number | undefined => {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const t = value.trim();
|
||||||
|
if (!t) return undefined;
|
||||||
|
const n = Number(t);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const read = (
|
||||||
|
src: Record<string, unknown> | null,
|
||||||
|
): { programme: number; metaclass: number } | null => {
|
||||||
|
if (!src) return null;
|
||||||
|
const programme = coerce(
|
||||||
|
src.programme ?? src.programmeId ?? src.programmeID,
|
||||||
|
);
|
||||||
|
const metaclass = coerce(
|
||||||
|
src.metaclass ?? src.metaclassId ?? src.metaclassID ?? src.subjectId,
|
||||||
|
);
|
||||||
|
if (programme !== undefined && metaclass !== undefined) {
|
||||||
|
return { programme, metaclass };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (requestBody && typeof requestBody === "object" && !Array.isArray(requestBody)) {
|
||||||
|
const r = read(requestBody as Record<string, unknown>);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
if (entity && typeof entity === "object" && !Array.isArray(entity)) {
|
||||||
|
const r = read(entity as Record<string, unknown>);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entitiesFromPayload(payload: unknown): unknown[] {
|
||||||
|
if (Array.isArray(payload)) return payload;
|
||||||
|
if (payload && typeof payload === "object") {
|
||||||
|
const obj = payload as Record<string, unknown>;
|
||||||
|
// SEQTA frequently nests arrays as `payload.list`, `.messages`,
|
||||||
|
// `.items`, `.tasks`, etc. Pull the first array-shaped child as our
|
||||||
|
// best guess; if none exists, fall back to the object itself so we
|
||||||
|
// still index a single entry.
|
||||||
|
for (const key of [
|
||||||
|
"list",
|
||||||
|
"items",
|
||||||
|
"messages",
|
||||||
|
"tasks",
|
||||||
|
"pending",
|
||||||
|
"forums",
|
||||||
|
"docs",
|
||||||
|
]) {
|
||||||
|
const value = obj[key];
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
}
|
||||||
|
return [payload];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whitelist of entity-shaped fields we hoist into item metadata so the
|
||||||
|
* `passive` action handler can deep-link into the right SEQTA SPA route.
|
||||||
|
* These mirror what the active jobs already store (see `courses.ts`,
|
||||||
|
* `portals.ts`, etc.) so the action only has to consult one source.
|
||||||
|
*/
|
||||||
|
const DEEP_LINK_FIELDS = [
|
||||||
|
"programme",
|
||||||
|
"programmeId",
|
||||||
|
"programmeID",
|
||||||
|
"metaclass",
|
||||||
|
"metaclassId",
|
||||||
|
"metaclassID",
|
||||||
|
"year",
|
||||||
|
"uuid",
|
||||||
|
"portalUuid",
|
||||||
|
"forum",
|
||||||
|
"forumId",
|
||||||
|
"assessmentId",
|
||||||
|
"assessmentID",
|
||||||
|
"messageId",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function pickDeepLinkHints(
|
||||||
|
entity: unknown,
|
||||||
|
): Record<string, string | number> {
|
||||||
|
if (!entity || typeof entity !== "object") return {};
|
||||||
|
const src = entity as Record<string, unknown>;
|
||||||
|
const out: Record<string, string | number> = {};
|
||||||
|
for (const key of DEEP_LINK_FIELDS) {
|
||||||
|
const value = src[key];
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
out[key] = value;
|
||||||
|
} else if (typeof value === "string" && value) {
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(
|
||||||
|
entity: Record<string, unknown>,
|
||||||
|
keys: readonly string[],
|
||||||
|
): string | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = entity[key];
|
||||||
|
if (typeof value === "string" && value.trim()) return value.trim();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFromEndpoint(
|
||||||
|
route: string,
|
||||||
|
entity: unknown,
|
||||||
|
extractedContent: string,
|
||||||
|
fallback: string,
|
||||||
|
): string {
|
||||||
|
if (route.includes("/load/message/people") && entity && typeof entity === "object") {
|
||||||
|
const obj = entity as Record<string, unknown>;
|
||||||
|
const first = stringField(obj, [
|
||||||
|
"preferredName",
|
||||||
|
"preferred",
|
||||||
|
"firstname",
|
||||||
|
"firstName",
|
||||||
|
"first_name",
|
||||||
|
"given",
|
||||||
|
"givenName",
|
||||||
|
]);
|
||||||
|
const last = stringField(obj, [
|
||||||
|
"surname",
|
||||||
|
"lastname",
|
||||||
|
"lastName",
|
||||||
|
"last_name",
|
||||||
|
"familyName",
|
||||||
|
]);
|
||||||
|
const full = [first, last].filter(Boolean).join(" ").trim();
|
||||||
|
if (full) return full.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const picked = pickTitle(entity, "");
|
||||||
|
if (picked) return picked.slice(0, 200);
|
||||||
|
|
||||||
|
// Last resort: show a human-readable content preview instead of a raw API
|
||||||
|
// path like `/seqta/student/load/message/people#20`.
|
||||||
|
const firstLine = extractedContent
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean);
|
||||||
|
return (firstLine || fallback).slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function synthesizeItems(
|
||||||
|
ctx: CapturedContext,
|
||||||
|
payload: unknown,
|
||||||
|
): IndexItem[] {
|
||||||
|
const entities = entitiesFromPayload(payload);
|
||||||
|
if (entities.length === 0) return [];
|
||||||
|
|
||||||
|
const category = categoryFromRoute(ctx.route);
|
||||||
|
const now = ctx.observedAt;
|
||||||
|
const out: IndexItem[] = [];
|
||||||
|
|
||||||
|
const isPeopleSupport = ctx.route.includes("/load/message/people");
|
||||||
|
|
||||||
|
const limit = Math.min(entities.length, MAX_ITEMS_PER_RESPONSE);
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
const entity = entities[i];
|
||||||
|
if (!entity || (typeof entity !== "object" && typeof entity !== "string")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For the messages compose-people endpoint, skip records that don't
|
||||||
|
// carry a real human name. We never want raw entries like
|
||||||
|
// `/seqta/student/load/message/people#20` becoming titles, and we
|
||||||
|
// explicitly route the rest to /messages so they're treated as support
|
||||||
|
// records, not standalone "people" results.
|
||||||
|
if (isPeopleSupport && !isPeopleEntityWorthIndexing(entity)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackId = `${ctx.route}#${i}`;
|
||||||
|
const entityId = pickId(entity, fallbackId);
|
||||||
|
const stableId = `passive-${ctx.route.replace(/\//g, "_")}-${entityId}`;
|
||||||
|
|
||||||
|
const content = extractTextFromValue(entity, {
|
||||||
|
maxChars: MAX_PER_RESPONSE_TEXT_CHARS,
|
||||||
|
});
|
||||||
|
const title = titleFromEndpoint(ctx.route, entity, content, fallbackId);
|
||||||
|
if (!content && (!title || title === fallbackId)) {
|
||||||
|
// Skip records that produced neither title nor content; they are
|
||||||
|
// structurally noise (e.g. tiny acknowledgement payloads).
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deepLinkHints = pickDeepLinkHints(entity);
|
||||||
|
const sourcePage = sourcePageForRoute(ctx.route);
|
||||||
|
const coursePm = ctx.route.includes("/load/courses")
|
||||||
|
? extractProgrammeMetaclass(ctx.requestBody, entity)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
out.push(
|
||||||
|
buildIndexItem({
|
||||||
|
id: stableId,
|
||||||
|
text: title,
|
||||||
|
category,
|
||||||
|
contentOverride: content,
|
||||||
|
metadata: {
|
||||||
|
route: ctx.route,
|
||||||
|
source: "passive",
|
||||||
|
observedAt: new Date(now).toISOString(),
|
||||||
|
entityType: category,
|
||||||
|
entityId,
|
||||||
|
icon: "\ueb71",
|
||||||
|
sourcePage,
|
||||||
|
// Mark message/people as a low-priority support record so the
|
||||||
|
// search ranker can deprioritize it relative to real messages,
|
||||||
|
// assessments, courses, etc.
|
||||||
|
...(isPeopleSupport ? { supportRecord: true, priority: "low" } : {}),
|
||||||
|
...deepLinkHints,
|
||||||
|
...(coursePm
|
||||||
|
? { programme: coursePm.programme, metaclass: coursePm.metaclass }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
actionId: "passive",
|
||||||
|
renderComponentId: "passive",
|
||||||
|
dateAdded: now,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* persistence */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function persistItems(items: IndexItem[]): Promise<void> {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
// Dedupe against existing entries. We replace on collision so the latest
|
||||||
|
// observation wins (e.g. if a message changes title).
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
await put(STORE_ID, item, item.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`[Passive Observer] Failed to persist item ${item.id}:`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDirty = true;
|
||||||
|
scheduleFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFlush() {
|
||||||
|
if (pendingFlush) return;
|
||||||
|
pendingFlush = setTimeout(() => {
|
||||||
|
pendingFlush = null;
|
||||||
|
if (!pendingDirty) return;
|
||||||
|
pendingDirty = false;
|
||||||
|
void flushDynamicItems();
|
||||||
|
}, FLUSH_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushDynamicItems(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const all = await loadAllStoredItems();
|
||||||
|
const decorated = all.map((item) => {
|
||||||
|
try {
|
||||||
|
const jobDef =
|
||||||
|
jobs[item.category] ||
|
||||||
|
Object.values(jobs).find((j) => j.id === item.category) ||
|
||||||
|
jobs[item.renderComponentId];
|
||||||
|
let renderComponent = item.renderComponent;
|
||||||
|
if (jobDef) {
|
||||||
|
renderComponent =
|
||||||
|
renderComponentMap[jobDef.renderComponentId] || renderComponent;
|
||||||
|
} else if (renderComponentMap[item.renderComponentId]) {
|
||||||
|
renderComponent = renderComponentMap[item.renderComponentId];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cloned = JSON.parse(JSON.stringify(item));
|
||||||
|
cloned.renderComponent = renderComponent;
|
||||||
|
return cloned;
|
||||||
|
} catch {
|
||||||
|
return { ...item, renderComponent };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
loadDynamicItems(decorated);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("dynamic-items-updated", {
|
||||||
|
detail: {
|
||||||
|
incremental: true,
|
||||||
|
jobId: STORE_ID,
|
||||||
|
streaming: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Passive Observer] Failed to refresh dynamic items:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* fetch hook */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function consumeResponse(
|
||||||
|
response: Response,
|
||||||
|
url: string,
|
||||||
|
requestBody: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const route = normalizeSeqtaPath(url);
|
||||||
|
if (isSensitiveSeqtaPath(route)) return;
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
if (!looksLikeJsonContentType(contentType)) return;
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await response.clone().json();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body || typeof body !== "object") return;
|
||||||
|
if (body.status && body.status !== "200") return;
|
||||||
|
|
||||||
|
const payload = body.payload;
|
||||||
|
if (payload === undefined || payload === null) return;
|
||||||
|
|
||||||
|
const items = synthesizeItems(
|
||||||
|
{
|
||||||
|
route,
|
||||||
|
requestBody,
|
||||||
|
observedAt: Date.now(),
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length > 0) {
|
||||||
|
await persistItems(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson(value: unknown): unknown {
|
||||||
|
if (typeof value !== "string") return value;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs the passive observer once. Subsequent calls are no-ops.
|
||||||
|
*
|
||||||
|
* Designed to be called from the global-search plugin bootstrap after
|
||||||
|
* `mountSearchBar` succeeds so the observer is only active when the
|
||||||
|
* plugin itself is enabled.
|
||||||
|
*/
|
||||||
|
export function installPassiveObserver(): void {
|
||||||
|
if (installed) return;
|
||||||
|
if (typeof window === "undefined" || typeof window.fetch !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
installed = true;
|
||||||
|
|
||||||
|
const originalFetch = window.fetch.bind(window);
|
||||||
|
window.fetch = async function patchedFetch(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Response> {
|
||||||
|
const response = await originalFetch(input, init);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
if (isSameOriginSeqtaUrl(url)) {
|
||||||
|
const body = init?.body;
|
||||||
|
const parsed =
|
||||||
|
body && typeof body === "string"
|
||||||
|
? tryParseJson(body)
|
||||||
|
: undefined;
|
||||||
|
// Fire-and-forget: never block the host page on indexing work.
|
||||||
|
void consumeResponse(response, url, parsed);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Never let observer errors bubble up to the host page.
|
||||||
|
console.debug("[Passive Observer] fetch hook error:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Best-effort XHR hook for the rare callers that bypass fetch.
|
||||||
|
const ProtoXhr = (window as any).XMLHttpRequest?.prototype;
|
||||||
|
if (ProtoXhr) {
|
||||||
|
const originalOpen = ProtoXhr.open;
|
||||||
|
const originalSend = ProtoXhr.send;
|
||||||
|
ProtoXhr.open = function patchedOpen(
|
||||||
|
this: XMLHttpRequest,
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
...rest: any[]
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
(this as any).__bsplusUrl = url;
|
||||||
|
(this as any).__bsplusMethod = method;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return originalOpen.call(this, method, url, ...rest);
|
||||||
|
};
|
||||||
|
ProtoXhr.send = function patchedSend(
|
||||||
|
this: XMLHttpRequest,
|
||||||
|
body?: any,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const url = (this as any).__bsplusUrl as string | undefined;
|
||||||
|
if (url && isSameOriginSeqtaUrl(url)) {
|
||||||
|
const parsed =
|
||||||
|
typeof body === "string" ? tryParseJson(body) : undefined;
|
||||||
|
this.addEventListener("load", () => {
|
||||||
|
try {
|
||||||
|
if (this.status < 200 || this.status >= 300) return;
|
||||||
|
const ct = this.getResponseHeader("content-type");
|
||||||
|
if (!looksLikeJsonContentType(ct)) return;
|
||||||
|
const route = normalizeSeqtaPath(url);
|
||||||
|
if (isSensitiveSeqtaPath(route)) return;
|
||||||
|
let json: any;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(this.responseText);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!json || typeof json !== "object") return;
|
||||||
|
if (json.status && json.status !== "200") return;
|
||||||
|
const payload = json.payload;
|
||||||
|
if (payload === undefined || payload === null) return;
|
||||||
|
const items = synthesizeItems(
|
||||||
|
{
|
||||||
|
route,
|
||||||
|
requestBody: parsed,
|
||||||
|
observedAt: Date.now(),
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
if (items.length > 0) {
|
||||||
|
void persistItems(items);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.debug("[Passive Observer] xhr load error:", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return originalSend.call(this, body);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug("[Passive Observer] Installed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns currently-stored passive items. Mainly used for diagnostics from
|
||||||
|
* `window.globalSearchDebug`.
|
||||||
|
*/
|
||||||
|
export async function getStoredPassiveItems(): Promise<IndexItem[]> {
|
||||||
|
try {
|
||||||
|
return (await getAll(STORE_ID)) as IndexItem[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,23 @@ import type { SvelteComponent } from "svelte";
|
|||||||
import AssessmentItem from "../components/items/AssessmentItem.svelte";
|
import AssessmentItem from "../components/items/AssessmentItem.svelte";
|
||||||
import ForumItem from "../components/items/ForumItem.svelte";
|
import ForumItem from "../components/items/ForumItem.svelte";
|
||||||
import SubjectItem from "../components/items/SubjectItem.svelte";
|
import SubjectItem from "../components/items/SubjectItem.svelte";
|
||||||
|
import GenericItem from "../components/items/GenericItem.svelte";
|
||||||
|
|
||||||
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
||||||
assessment: AssessmentItem as unknown as typeof SvelteComponent,
|
assessment: AssessmentItem as unknown as typeof SvelteComponent,
|
||||||
message: AssessmentItem as unknown as typeof SvelteComponent,
|
message: AssessmentItem as unknown as typeof SvelteComponent,
|
||||||
forum: ForumItem as unknown as typeof SvelteComponent,
|
forum: ForumItem as unknown as typeof SvelteComponent,
|
||||||
subject: SubjectItem as unknown as typeof SvelteComponent,
|
subject: SubjectItem as unknown as typeof SvelteComponent,
|
||||||
|
// New categories share a generic, category-aware row component to keep
|
||||||
|
// the palette consistent without bespoke layouts for every job. The
|
||||||
|
// component reads `item.metadata.icon` and the `category` to pick a
|
||||||
|
// sensible default visual treatment.
|
||||||
|
course: GenericItem as unknown as typeof SvelteComponent,
|
||||||
|
notice: GenericItem as unknown as typeof SvelteComponent,
|
||||||
|
document: GenericItem as unknown as typeof SvelteComponent,
|
||||||
|
folio: GenericItem as unknown as typeof SvelteComponent,
|
||||||
|
portal: GenericItem as unknown as typeof SvelteComponent,
|
||||||
|
report: GenericItem as unknown as typeof SvelteComponent,
|
||||||
|
goal: GenericItem as unknown as typeof SvelteComponent,
|
||||||
|
passive: GenericItem as unknown as typeof SvelteComponent,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { SCHEMA_VERSION_KEY } from "./schemaVersion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard-reset of all global-search persistence.
|
||||||
|
*
|
||||||
|
* This module is intentionally dependency-free (no imports from `db.ts`,
|
||||||
|
* the worker manager, embeddia, or any heavy bundle) so it can be
|
||||||
|
* statically imported from:
|
||||||
|
*
|
||||||
|
* - The always-loaded plugin shell (`lazy.ts`) for the manual
|
||||||
|
* "Reset Index" settings button. Statically importing means the button
|
||||||
|
* keeps working across extension updates — there's no chunk hash to
|
||||||
|
* chase via dynamic import, which previously produced
|
||||||
|
* `Failed to fetch dynamically imported module: .../assets/<chunk>.js`
|
||||||
|
* when an older settings page tried to load a chunk that the new build
|
||||||
|
* had already replaced.
|
||||||
|
*
|
||||||
|
* - The version-check path (`utils/versionCheck.ts`) for the auto-reset
|
||||||
|
* that fires whenever the extension's manifest version changes.
|
||||||
|
*
|
||||||
|
* The function:
|
||||||
|
* 1. Notifies in-process modules to drop in-memory caches and any open
|
||||||
|
* IndexedDB connections via custom DOM events (best effort).
|
||||||
|
* 2. Deletes the structured `betterseqta-index` and the vector
|
||||||
|
* `embeddiaDB` databases.
|
||||||
|
* 3. Clears version-tracking localStorage keys so the next indexing
|
||||||
|
* pass treats the world as fresh.
|
||||||
|
*
|
||||||
|
* It never throws on partial failure: each step is wrapped in try/catch
|
||||||
|
* so a stuck connection on one DB doesn't block the other.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STRUCTURED_DB = "betterseqta-index";
|
||||||
|
const VECTOR_DB = "embeddiaDB";
|
||||||
|
const STRUCTURED_VERSION_KEY = "betterseqta-index-version";
|
||||||
|
|
||||||
|
function deleteIndexedDb(name: string): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let resolved = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
let req: IDBOpenDBRequest;
|
||||||
|
try {
|
||||||
|
req = indexedDB.deleteDatabase(name);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Reset] Could not start delete of ${name}:`, e);
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.onsuccess = () => finish();
|
||||||
|
req.onerror = () => {
|
||||||
|
console.warn(`[Reset] Error deleting ${name}:`, req.error);
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
req.onblocked = () => {
|
||||||
|
// Connections are still open in another tab. Wait briefly, retry,
|
||||||
|
// then resolve regardless so we never hang the caller forever.
|
||||||
|
console.warn(
|
||||||
|
`[Reset] Delete of ${name} blocked; will retry then resolve.`,
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const retry = indexedDB.deleteDatabase(name);
|
||||||
|
retry.onsuccess = () => finish();
|
||||||
|
retry.onerror = () => finish();
|
||||||
|
retry.onblocked = () => finish();
|
||||||
|
} catch {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetSearchIndexes(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("betterseqta-clear-search-cache"),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("betterseqta-clear-embedding-cache"),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("betterseqta-reset-search-index"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore — events are best-effort */
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give listeners a tick to close any open IDB connections; otherwise
|
||||||
|
// the delete request below comes back with `onblocked`.
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
await Promise.allSettled([
|
||||||
|
deleteIndexedDb(STRUCTURED_DB),
|
||||||
|
deleteIndexedDb(VECTOR_DB),
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STRUCTURED_VERSION_KEY);
|
||||||
|
localStorage.removeItem(SCHEMA_VERSION_KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Index schema version. Bump whenever the IndexItem shape, category set,
|
||||||
|
* or text construction changes in a way that should invalidate previously
|
||||||
|
* stored items (and their embeddings).
|
||||||
|
*
|
||||||
|
* On mismatch, both the structured IndexedDB store and the embeddiaDB are
|
||||||
|
* wiped before the next indexing pass so we don't serve stale results.
|
||||||
|
*
|
||||||
|
* Kept in its own file (with no imports) so very lightweight callers — the
|
||||||
|
* always-loaded plugin shell in `lazy.ts`, the version-check path — can
|
||||||
|
* pull it in without bringing the heavy indexer/worker bundle along.
|
||||||
|
*/
|
||||||
|
export const INDEX_SCHEMA_VERSION = 6;
|
||||||
|
|
||||||
|
/** Key used to track the schema version a previous run wrote out. */
|
||||||
|
export const SCHEMA_VERSION_KEY = "bsq-index-schema-version";
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import {
|
||||||
|
isSensitiveKey,
|
||||||
|
looksLikeSecretValue,
|
||||||
|
redactSensitive,
|
||||||
|
extractTextFromValue,
|
||||||
|
pickTitle,
|
||||||
|
pickId,
|
||||||
|
buildIndexItem,
|
||||||
|
} from "./extract";
|
||||||
|
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
|
||||||
|
import {
|
||||||
|
coursesPayload,
|
||||||
|
documentsPayload,
|
||||||
|
folioEntryPayload,
|
||||||
|
noticesPayload,
|
||||||
|
portalsPayload,
|
||||||
|
settingsPayload,
|
||||||
|
subjectsListPayload,
|
||||||
|
} from "./__fixtures__/seqtaResponses";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight in-process self-tests for the global-search overhaul.
|
||||||
|
*
|
||||||
|
* The repository does not (yet) ship with a test runner, so we instead
|
||||||
|
* expose a deterministic suite of assertions over the pure helpers that
|
||||||
|
* back active jobs and the passive observer. This is intentionally
|
||||||
|
* dependency-free so it can run inside the extension page (`window.
|
||||||
|
* globalSearchDebug.runSelfTests()`) and from any future Vitest harness
|
||||||
|
* without modification.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface TestCase {
|
||||||
|
name: string;
|
||||||
|
run: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssertionError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AssertionError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new AssertionError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEqual<T>(actual: T, expected: T, label: string) {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new AssertionError(
|
||||||
|
`${label}: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertContains(haystack: string, needle: string, label: string) {
|
||||||
|
if (!haystack.includes(needle)) {
|
||||||
|
throw new AssertionError(
|
||||||
|
`${label}: expected "${haystack}" to contain "${needle}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNotContains(haystack: string, needle: string, label: string) {
|
||||||
|
if (haystack.includes(needle)) {
|
||||||
|
throw new AssertionError(
|
||||||
|
`${label}: expected "${haystack}" NOT to contain "${needle}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cases: TestCase[] = [
|
||||||
|
{
|
||||||
|
name: "normalizeSeqtaPath strips query tokens",
|
||||||
|
run: () => {
|
||||||
|
assertEqual(
|
||||||
|
normalizeSeqtaPath("/seqta/student/load/messages?mokx3qef"),
|
||||||
|
"/seqta/student/load/messages",
|
||||||
|
"trailing token",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
normalizeSeqtaPath(
|
||||||
|
"https://learn.example.com/seqta/student/load/courses?abc123",
|
||||||
|
),
|
||||||
|
"/seqta/student/load/courses",
|
||||||
|
"absolute URL",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "isSensitiveSeqtaPath catches credential routes",
|
||||||
|
run: () => {
|
||||||
|
assert(
|
||||||
|
isSensitiveSeqtaPath("/seqta/student/login?xyz"),
|
||||||
|
"login is sensitive",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
isSensitiveSeqtaPath("/seqta/student/save/message"),
|
||||||
|
"save/* is sensitive",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
isSensitiveSeqtaPath("/seqta/student/load/settings"),
|
||||||
|
"settings is sensitive",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
isSensitiveSeqtaPath("/seqta/student/load/prefs?z=1"),
|
||||||
|
"prefs is sensitive",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
isSensitiveSeqtaPath("/seqta/ta/masquerade"),
|
||||||
|
"masquerade is sensitive",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!isSensitiveSeqtaPath("/seqta/student/load/messages"),
|
||||||
|
"messages is NOT sensitive",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!isSensitiveSeqtaPath("/seqta/student/load/courses"),
|
||||||
|
"courses is NOT sensitive",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "isSensitiveKey covers the credential vocabulary",
|
||||||
|
run: () => {
|
||||||
|
for (const key of [
|
||||||
|
"password",
|
||||||
|
"Password",
|
||||||
|
"client_secret",
|
||||||
|
"apiKey",
|
||||||
|
"X-API-Token",
|
||||||
|
"jwtSession",
|
||||||
|
"oauth_signature",
|
||||||
|
]) {
|
||||||
|
assert(isSensitiveKey(key), `expected ${key} to be sensitive`);
|
||||||
|
}
|
||||||
|
for (const key of ["title", "subject", "uuid", "metaclass"]) {
|
||||||
|
assert(!isSensitiveKey(key), `expected ${key} to be safe`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "looksLikeSecretValue catches token-shaped strings",
|
||||||
|
run: () => {
|
||||||
|
assert(
|
||||||
|
looksLikeSecretValue(
|
||||||
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.abc123def456",
|
||||||
|
),
|
||||||
|
"JWT looks secret",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
looksLikeSecretValue("a".repeat(40) + "b".repeat(40)),
|
||||||
|
"long base64-ish string looks secret",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!looksLikeSecretValue("Hello world"),
|
||||||
|
"short readable text is safe",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!looksLikeSecretValue("https://example.com/foo/bar"),
|
||||||
|
"URLs are not secrets",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!looksLikeSecretValue("3162189c-2052-4f83-ad83-a66c57460ea2"),
|
||||||
|
"UUIDs are useful and not secret",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "redactSensitive scrubs settings payloads",
|
||||||
|
run: () => {
|
||||||
|
const cleaned = redactSensitive(settingsPayload);
|
||||||
|
const json = JSON.stringify(cleaned);
|
||||||
|
assertNotContains(json, "global.dropbox.api.key", "dropbox key dropped");
|
||||||
|
assertNotContains(json, "xxx-do-not-index", "secret value dropped");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extractTextFromValue distills HTML and skips secrets",
|
||||||
|
run: () => {
|
||||||
|
const text = extractTextFromValue({
|
||||||
|
title: "Hello",
|
||||||
|
body: "<p>Some <strong>HTML</strong> body.</p>",
|
||||||
|
password: "should-not-appear",
|
||||||
|
nested: { token: "leak-me-please" },
|
||||||
|
});
|
||||||
|
assertContains(text, "Hello", "title preserved");
|
||||||
|
assertContains(text, "HTML body", "html flattened");
|
||||||
|
assertNotContains(text, "should-not-appear", "password redacted");
|
||||||
|
assertNotContains(text, "leak-me-please", "nested token redacted");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pickTitle / pickId prefer common SEQTA fields",
|
||||||
|
run: () => {
|
||||||
|
assertEqual(
|
||||||
|
pickTitle({ title: "Hello", name: "Other" }),
|
||||||
|
"Hello",
|
||||||
|
"title wins over name",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
pickTitle({ filename: "doc.pdf" }),
|
||||||
|
"doc.pdf",
|
||||||
|
"filename fallback",
|
||||||
|
);
|
||||||
|
assertEqual(pickId({ id: 42 }), "42", "numeric id stringified");
|
||||||
|
assertEqual(pickId({ uuid: "abc" }), "abc", "uuid id");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "buildIndexItem produces redacted, well-formed records",
|
||||||
|
run: () => {
|
||||||
|
const item = buildIndexItem({
|
||||||
|
id: "x-1",
|
||||||
|
text: "Test",
|
||||||
|
category: "passive",
|
||||||
|
rawForContent: {
|
||||||
|
title: "Test",
|
||||||
|
body: "<p>Hello</p>",
|
||||||
|
token: "should-be-stripped",
|
||||||
|
},
|
||||||
|
metadata: { route: "/seqta/student/load/whatever", apiKey: "drop" },
|
||||||
|
actionId: "passive",
|
||||||
|
renderComponentId: "passive",
|
||||||
|
});
|
||||||
|
assertEqual(item.id, "x-1", "id propagated");
|
||||||
|
assertContains(item.content, "Hello", "html distilled");
|
||||||
|
assertNotContains(item.content, "should-be-stripped", "token stripped");
|
||||||
|
assert(
|
||||||
|
!("apiKey" in (item.metadata as Record<string, unknown>)),
|
||||||
|
"apiKey metadata stripped",
|
||||||
|
);
|
||||||
|
assertEqual(item.category, "passive", "category passes through");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "courses fixture flattens lesson HTML",
|
||||||
|
run: () => {
|
||||||
|
// Verify that the structural shape we depend on still matches.
|
||||||
|
assert(Array.isArray(coursesPayload.w), "lesson grid present");
|
||||||
|
const lessonHtml = (coursesPayload.w[0]?.[1] as { l?: string })?.l ?? "";
|
||||||
|
assertContains(lessonHtml, "ed.ted.com", "lesson html link present");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subjects fixture exposes programme/metaclass",
|
||||||
|
run: () => {
|
||||||
|
const subject = subjectsListPayload[0]?.subjects[0];
|
||||||
|
assert(subject, "fixture has at least one subject");
|
||||||
|
assert(
|
||||||
|
Number.isFinite(subject!.programme) &&
|
||||||
|
Number.isFinite(subject!.metaclass),
|
||||||
|
"programme & metaclass numeric",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "documents fixture exposes uuid + filename",
|
||||||
|
run: () => {
|
||||||
|
const doc = documentsPayload[0]?.docs[0];
|
||||||
|
assert(doc?.uuid && doc?.filename, "uuid + filename present");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notices fixture is HTML-bearing",
|
||||||
|
run: () => {
|
||||||
|
assertContains(
|
||||||
|
noticesPayload[0]?.contents ?? "",
|
||||||
|
"<p>",
|
||||||
|
"notice html present",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "portals fixture has external url",
|
||||||
|
run: () => {
|
||||||
|
assert(portalsPayload[0]?.url?.includes("mathletics"), "portal url");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "folio entry contents passes html-flattening",
|
||||||
|
run: () => {
|
||||||
|
const distilled = extractTextFromValue(folioEntryPayload, {
|
||||||
|
maxChars: 4000,
|
||||||
|
});
|
||||||
|
assertContains(distilled, "reflection", "folio body extracted");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface SelfTestReport {
|
||||||
|
passed: number;
|
||||||
|
failed: number;
|
||||||
|
failures: Array<{ name: string; error: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs every assertion case and resolves with a summary. Never throws.
|
||||||
|
*
|
||||||
|
* Designed to be invoked from `window.globalSearchDebug.runSelfTests()`
|
||||||
|
* by maintainers who want to validate the indexing pipeline against a
|
||||||
|
* real SEQTA tab.
|
||||||
|
*/
|
||||||
|
export async function runGlobalSearchSelfTests(): Promise<SelfTestReport> {
|
||||||
|
const report: SelfTestReport = { passed: 0, failed: 0, failures: [] };
|
||||||
|
for (const test of cases) {
|
||||||
|
try {
|
||||||
|
await test.run();
|
||||||
|
report.passed++;
|
||||||
|
} catch (e) {
|
||||||
|
report.failed++;
|
||||||
|
const error =
|
||||||
|
e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
||||||
|
report.failures.push({ name: test.name, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (report.failed > 0) {
|
||||||
|
console.warn(
|
||||||
|
`[Global Search Self-Tests] ${report.failed} failed / ${report.passed} passed`,
|
||||||
|
report.failures,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.info(
|
||||||
|
`[Global Search Self-Tests] All ${report.passed} cases passed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ export class VectorWorkerManager {
|
|||||||
private initializationMutex = false;
|
private initializationMutex = false;
|
||||||
private idleTimer: NodeJS.Timeout | null = null;
|
private idleTimer: NodeJS.Timeout | null = null;
|
||||||
private unloadTimer: NodeJS.Timeout | null = null;
|
private unloadTimer: NodeJS.Timeout | null = null;
|
||||||
|
/** Non-streaming `process` jobs must not hit the idle shutdown mid-flight. */
|
||||||
|
private vectorizationLockCount = 0;
|
||||||
|
|
||||||
private streamingSession: {
|
private streamingSession: {
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
@@ -92,6 +94,12 @@ export class VectorWorkerManager {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "progress":
|
case "progress":
|
||||||
|
if (
|
||||||
|
data.status === "processing" ||
|
||||||
|
data.status === "started"
|
||||||
|
) {
|
||||||
|
this.bumpActivityDuringVectorization();
|
||||||
|
}
|
||||||
if (this.progressCallback) {
|
if (this.progressCallback) {
|
||||||
this.progressCallback(data);
|
this.progressCallback(data);
|
||||||
|
|
||||||
@@ -120,6 +128,7 @@ export class VectorWorkerManager {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "streamingProgress":
|
case "streamingProgress":
|
||||||
|
this.bumpActivityDuringVectorization();
|
||||||
if (this.progressCallback && this.streamingSession?.isActive) {
|
if (this.progressCallback && this.streamingSession?.isActive) {
|
||||||
const { processed } = data;
|
const { processed } = data;
|
||||||
this.progressCallback({
|
this.progressCallback({
|
||||||
@@ -150,6 +159,7 @@ export class VectorWorkerManager {
|
|||||||
this.readyPromise = null;
|
this.readyPromise = null;
|
||||||
this.progressCallback = null;
|
this.progressCallback = null;
|
||||||
this.initializationMutex = false;
|
this.initializationMutex = false;
|
||||||
|
this.vectorizationLockCount = 0;
|
||||||
this.clearIdleTimer();
|
this.clearIdleTimer();
|
||||||
this.clearUnloadTimer();
|
this.clearUnloadTimer();
|
||||||
if (this.streamingSession?.isActive) {
|
if (this.streamingSession?.isActive) {
|
||||||
@@ -158,15 +168,27 @@ export class VectorWorkerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startIdleTimer() {
|
private startIdleTimer() {
|
||||||
|
if (this.vectorizationLockCount > 0 || this.streamingSession?.isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.clearIdleTimer();
|
this.clearIdleTimer();
|
||||||
this.idleTimer = setTimeout(() => {
|
this.idleTimer = setTimeout(() => {
|
||||||
if (!this.streamingSession?.isActive && this.isInitialized) {
|
if (this.vectorizationLockCount > 0) return;
|
||||||
console.debug("[VectorWorker] Auto-shutting down due to 2 minutes of inactivity");
|
if (this.streamingSession?.isActive) return;
|
||||||
this.resetWorkerState();
|
if (!this.isInitialized) return;
|
||||||
}
|
console.debug("[VectorWorker] Auto-shutting down due to 2 minutes of inactivity");
|
||||||
|
this.resetWorkerState();
|
||||||
}, 120000); // 2 minutes
|
}, 120000); // 2 minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extends idle deadline while embeddings run; cheap if no idle timer is scheduled. */
|
||||||
|
private bumpActivityDuringVectorization() {
|
||||||
|
if (this.vectorizationLockCount > 0 || this.streamingSession?.isActive) {
|
||||||
|
this.clearIdleTimer();
|
||||||
|
}
|
||||||
|
this.updateActivity();
|
||||||
|
}
|
||||||
|
|
||||||
private clearIdleTimer() {
|
private clearIdleTimer() {
|
||||||
if (this.idleTimer) {
|
if (this.idleTimer) {
|
||||||
clearTimeout(this.idleTimer);
|
clearTimeout(this.idleTimer);
|
||||||
@@ -184,6 +206,7 @@ export class VectorWorkerManager {
|
|||||||
private scheduleUnload(delay: number = 10000) {
|
private scheduleUnload(delay: number = 10000) {
|
||||||
this.clearUnloadTimer();
|
this.clearUnloadTimer();
|
||||||
this.unloadTimer = setTimeout(() => {
|
this.unloadTimer = setTimeout(() => {
|
||||||
|
if (this.vectorizationLockCount > 0) return;
|
||||||
if (!this.streamingSession?.isActive && this.isInitialized) {
|
if (!this.streamingSession?.isActive && this.isInitialized) {
|
||||||
console.debug("[VectorWorker] Auto-unloading after processing complete");
|
console.debug("[VectorWorker] Auto-unloading after processing complete");
|
||||||
this.resetWorkerState();
|
this.resetWorkerState();
|
||||||
@@ -193,6 +216,9 @@ export class VectorWorkerManager {
|
|||||||
|
|
||||||
private updateActivity() {
|
private updateActivity() {
|
||||||
this.clearUnloadTimer();
|
this.clearUnloadTimer();
|
||||||
|
if (this.vectorizationLockCount > 0 || this.streamingSession?.isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.startIdleTimer();
|
this.startIdleTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,17 +324,50 @@ export class VectorWorkerManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.progressCallback = onProgress || null;
|
// Wait until the worker reports a terminal status. Previously this method
|
||||||
this.updateActivity();
|
// returned as soon as the job was queued, so indexers.ts continued into
|
||||||
|
// stopHeartbeat/loadAll/loadDynamicItems on the main thread while
|
||||||
|
// vectorization was still running — blocking indexing-progress handlers
|
||||||
|
// and freezing the chip on “Vectorization in progress”.
|
||||||
|
this.vectorizationLockCount++;
|
||||||
|
this.clearIdleTimer();
|
||||||
|
this.clearUnloadTimer();
|
||||||
|
|
||||||
console.debug(
|
try {
|
||||||
`Sending ${uniqueItems.length} unique items to worker for processing.`,
|
await new Promise<void>((resolve) => {
|
||||||
);
|
let settled = false;
|
||||||
|
const wrap: ProgressCallback = (data) => {
|
||||||
|
onProgress?.(data);
|
||||||
|
if (
|
||||||
|
!settled &&
|
||||||
|
(data.status === "complete" ||
|
||||||
|
data.status === "error" ||
|
||||||
|
data.status === "cancelled")
|
||||||
|
) {
|
||||||
|
settled = true;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.progressCallback = wrap;
|
||||||
|
|
||||||
this.worker!.postMessage({
|
console.debug(
|
||||||
type: "process",
|
`Sending ${uniqueItems.length} unique items to worker for processing.`,
|
||||||
data: { items: uniqueItems },
|
);
|
||||||
});
|
|
||||||
|
this.worker!.postMessage({
|
||||||
|
type: "process",
|
||||||
|
data: { items: uniqueItems },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.vectorizationLockCount = Math.max(0, this.vectorizationLockCount - 1);
|
||||||
|
if (
|
||||||
|
this.vectorizationLockCount === 0 &&
|
||||||
|
!this.streamingSession?.isActive
|
||||||
|
) {
|
||||||
|
this.startIdleTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async startStreamingSession(
|
async startStreamingSession(
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import type { CombinedResult } from "../core/types";
|
||||||
|
import type { IndexItem } from "../indexing/types";
|
||||||
|
|
||||||
|
function toFiniteNumber(value: unknown): number | undefined {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const t = value.trim();
|
||||||
|
if (!t) return undefined;
|
||||||
|
const n = Number(t);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same SPA destination as handlers for `course` / `subjectcourse` / passive `courses`. */
|
||||||
|
function shouldDedupeAsSameCourseSPA(item: IndexItem): boolean {
|
||||||
|
if (item.actionId === "subjectassessment") return false;
|
||||||
|
if (item.metadata?.type === "assessments") return false;
|
||||||
|
|
||||||
|
if (item.renderComponentId === "course") return true;
|
||||||
|
if (item.actionId === "course") return true;
|
||||||
|
if (item.actionId === "subjectcourse") return true;
|
||||||
|
if (
|
||||||
|
item.actionId === "passive" &&
|
||||||
|
item.metadata?.sourcePage === "/courses"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function courseDestinationKey(item: IndexItem): string | undefined {
|
||||||
|
if (!shouldDedupeAsSameCourseSPA(item)) return undefined;
|
||||||
|
const md = item.metadata ?? {};
|
||||||
|
const programme = toFiniteNumber(
|
||||||
|
md.programme ?? md.programmeId ?? md.programmeID,
|
||||||
|
);
|
||||||
|
const metaclass = toFiniteNumber(
|
||||||
|
md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId,
|
||||||
|
);
|
||||||
|
if (programme === undefined || metaclass === undefined) return undefined;
|
||||||
|
return `course:${programme}:${metaclass}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPassiveLike(item: IndexItem): boolean {
|
||||||
|
return (
|
||||||
|
item.actionId === "passive" || item.metadata?.source === "passive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem {
|
||||||
|
const aP = isPassiveLike(a);
|
||||||
|
const bP = isPassiveLike(b);
|
||||||
|
if (aP && !bP) return b;
|
||||||
|
if (!aP && bP) return a;
|
||||||
|
// Prefer curated job row (courses store) vs other categories
|
||||||
|
if (a.category === "courses" && b.category !== "courses") return a;
|
||||||
|
if (b.category === "courses" && a.category !== "courses") return b;
|
||||||
|
if (a.renderComponentId === "course" && b.renderComponentId !== "course")
|
||||||
|
return a;
|
||||||
|
if (b.renderComponentId === "course" && a.renderComponentId !== "course")
|
||||||
|
return b;
|
||||||
|
const ad = typeof a.dateAdded === "number" ? a.dateAdded : 0;
|
||||||
|
const bd = typeof b.dateAdded === "number" ? b.dateAdded : 0;
|
||||||
|
return ad >= bd ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapses multiple index rows that open the same course hash route
|
||||||
|
* (e.g. `course` job + passive `/load/courses` capture) so search shows one hit.
|
||||||
|
*/
|
||||||
|
export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
|
||||||
|
const winners = new Map<string, IndexItem>();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const key = courseDestinationKey(item);
|
||||||
|
if (!key) continue;
|
||||||
|
const prev = winners.get(key);
|
||||||
|
winners.set(
|
||||||
|
key,
|
||||||
|
prev ? pickBetterCourseNavDuplicate(prev, item) : item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenCanon = new Set<string>();
|
||||||
|
const out: IndexItem[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const key = courseDestinationKey(item);
|
||||||
|
if (!key) {
|
||||||
|
out.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seenCanon.has(key)) continue;
|
||||||
|
seenCanon.add(key);
|
||||||
|
out.push(winners.get(key)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dynamicCourseKey(row: CombinedResult): string | undefined {
|
||||||
|
if (row.type !== "dynamic") return undefined;
|
||||||
|
return courseDestinationKey(row.item as IndexItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Final pass after hybrid expansion: vector-only recall can still surface a
|
||||||
|
* second row for the same `/courses/P:M` SPA route using a stale passive id.
|
||||||
|
*/
|
||||||
|
export function dedupeCombinedResultsByCourseNav(
|
||||||
|
results: CombinedResult[],
|
||||||
|
): CombinedResult[] {
|
||||||
|
const best = new Map<string, CombinedResult>();
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
const key = dynamicCourseKey(r);
|
||||||
|
if (!key) continue;
|
||||||
|
const prev = best.get(key);
|
||||||
|
if (!prev) {
|
||||||
|
best.set(key, r);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const aItem = prev.item as IndexItem;
|
||||||
|
const bItem = r.item as IndexItem;
|
||||||
|
const winnerItem = pickBetterCourseNavDuplicate(aItem, bItem);
|
||||||
|
const envelope = winnerItem.id === aItem.id ? prev : r;
|
||||||
|
best.set(key, {
|
||||||
|
...envelope,
|
||||||
|
score: Math.max(prev.score, r.score),
|
||||||
|
id: winnerItem.id,
|
||||||
|
item: winnerItem,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenCanon = new Set<string>();
|
||||||
|
const out: CombinedResult[] = [];
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
const key = dynamicCourseKey(r);
|
||||||
|
if (!key) {
|
||||||
|
out.push(r);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seenCanon.has(key)) continue;
|
||||||
|
seenCanon.add(key);
|
||||||
|
out.push(best.get(key)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -2,6 +2,32 @@ import type { IndexItem } from "../indexing/types";
|
|||||||
import type { CombinedResult } from "../core/types";
|
import type { CombinedResult } from "../core/types";
|
||||||
import { searchVectors, type VectorSearchResult } from "./vector/vectorSearch";
|
import { searchVectors, type VectorSearchResult } from "./vector/vectorSearch";
|
||||||
import { jobs } from "../indexing/jobs";
|
import { jobs } from "../indexing/jobs";
|
||||||
|
import {
|
||||||
|
getLexicalMatchQuality,
|
||||||
|
isStrongLexicalMatch,
|
||||||
|
STRONG_LEXICAL_THRESHOLD,
|
||||||
|
} from "./lexicalMatch";
|
||||||
|
|
||||||
|
function isIndexItem(item: CombinedResult["item"]): item is IndexItem {
|
||||||
|
return (item as IndexItem).dateAdded !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristic for "this query is still too short / too sparse for vector
|
||||||
|
* recall to be reliable". When true we should not promote vector-only
|
||||||
|
* results above lexical ones.
|
||||||
|
*
|
||||||
|
* Note: this is intentionally distinct from the absolute >2 character cut-off
|
||||||
|
* used for `hybridSearch`. Vector recall on 3-7 character single-token
|
||||||
|
* queries is noisy enough that we should keep lexical results dominant.
|
||||||
|
*/
|
||||||
|
function isWeakSemanticQuery(trimmedQuery: string): boolean {
|
||||||
|
if (trimmedQuery.length < 8) return true;
|
||||||
|
const meaningfulTokens = trimmedQuery
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((t) => t.length >= 3);
|
||||||
|
return meaningfulTokens.length < 2;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hybrid Search Implementation
|
* Hybrid Search Implementation
|
||||||
@@ -36,14 +62,6 @@ const DEFAULT_OPTIONS: Required<HybridSearchOptions> = {
|
|||||||
recencyWeight: 0.1,
|
recencyWeight: 0.1,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes a score to 0-1 range
|
|
||||||
*/
|
|
||||||
function normalizeScore(score: number, min: number, max: number): number {
|
|
||||||
if (max === min) return 0.5;
|
|
||||||
return Math.max(0, Math.min(1, (score - min) / (max - min)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates recency boost based on item age
|
* Calculates recency boost based on item age
|
||||||
*/
|
*/
|
||||||
@@ -55,28 +73,56 @@ function calculateRecencyBoost(item: IndexItem, now: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates popularity boost (can be extended with click tracking, etc.)
|
* Category-aware popularity / structure boost.
|
||||||
|
*
|
||||||
|
* High-confidence curated content (assignments, courses, subjects, forums)
|
||||||
|
* sits above noisier sources (notices, documents) which sit above the
|
||||||
|
* passive store. This keeps the most actionable hits at the top while
|
||||||
|
* still surfacing wide-recall semantic matches when relevant.
|
||||||
*/
|
*/
|
||||||
function calculatePopularityBoost(item: IndexItem): number {
|
function calculatePopularityBoost(item: IndexItem): number {
|
||||||
// For now, boost based on category and metadata
|
|
||||||
let boost = 0;
|
let boost = 0;
|
||||||
|
|
||||||
// Boost assignments/assessments
|
switch (item.category) {
|
||||||
if (item.category === "assignments") {
|
case "assignments":
|
||||||
boost += 0.1;
|
boost += 0.12;
|
||||||
|
break;
|
||||||
|
case "subjects":
|
||||||
|
case "courses":
|
||||||
|
boost += 0.08;
|
||||||
|
break;
|
||||||
|
case "forums":
|
||||||
|
case "messages":
|
||||||
|
boost += 0.06;
|
||||||
|
break;
|
||||||
|
case "notices":
|
||||||
|
case "folio":
|
||||||
|
case "reports":
|
||||||
|
case "goals":
|
||||||
|
boost += 0.04;
|
||||||
|
break;
|
||||||
|
case "documents":
|
||||||
|
boost += 0.03;
|
||||||
|
break;
|
||||||
|
case "portals":
|
||||||
|
boost += 0.02;
|
||||||
|
break;
|
||||||
|
case "passive":
|
||||||
|
boost -= 0.1;
|
||||||
|
break;
|
||||||
|
case "messages-support":
|
||||||
|
boost -= 0.18;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boost upcoming items
|
if (item.metadata?.isUpcoming) boost += 0.12;
|
||||||
if (item.metadata?.isUpcoming) {
|
if (item.metadata?.subjectCode) boost += 0.04;
|
||||||
boost += 0.15;
|
if (item.metadata?.entityType === "course") boost += 0.02;
|
||||||
}
|
if (item.metadata?.source === "passive") boost -= 0.08;
|
||||||
|
if (item.metadata?.supportRecord) boost -= 0.12;
|
||||||
|
if (item.metadata?.priority === "low") boost -= 0.05;
|
||||||
|
|
||||||
// Boost items with subject codes (more structured)
|
return Math.max(-0.2, Math.min(boost, 0.3));
|
||||||
if (item.metadata?.subjectCode) {
|
|
||||||
boost += 0.05;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.min(boost, 0.3); // Cap at 0.3
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,10 +144,6 @@ export async function hybridSearch(
|
|||||||
// Limit BM25 results to top K
|
// Limit BM25 results to top K
|
||||||
const topBm25Results = bm25Results.slice(0, opts.bm25TopK);
|
const topBm25Results = bm25Results.slice(0, opts.bm25TopK);
|
||||||
|
|
||||||
// Get vector search results for reranking
|
|
||||||
// We'll search the full index and then filter to our BM25 results
|
|
||||||
let vectorResults: VectorSearchResult[] = [];
|
|
||||||
|
|
||||||
if (trimmedQuery.length > 2) {
|
if (trimmedQuery.length > 2) {
|
||||||
try {
|
try {
|
||||||
// Get more vector results than BM25 results to ensure coverage
|
// Get more vector results than BM25 results to ensure coverage
|
||||||
@@ -121,59 +163,59 @@ export async function hybridSearch(
|
|||||||
// Now rerank BM25 results with vector scores
|
// Now rerank BM25 results with vector scores
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const rerankedResults = topBm25Results.map(result => {
|
const rerankedResults: CombinedResult[] = topBm25Results.map(result => {
|
||||||
const item = result.item;
|
const item = result.item;
|
||||||
|
|
||||||
// Normalize BM25 score to 0-1
|
// Static command items don't have dateAdded/metadata/category to score
|
||||||
// Fuse.js scores: lower is better (0 = perfect match)
|
// against — pass them through untouched so palette commands still
|
||||||
// We need to invert: higher score = better match
|
// surface correctly.
|
||||||
// Result.score is typically 0-100, where higher = better
|
if (!isIndexItem(item)) {
|
||||||
// So we normalize it to 0-1
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize BM25 score to 0-1.
|
||||||
|
// Result.score is typically 0-100, where higher = better, so we
|
||||||
|
// clamp into the 0..1 range.
|
||||||
const normalizedBm25Score = Math.max(0, Math.min(1, result.score / 100));
|
const normalizedBm25Score = Math.max(0, Math.min(1, result.score / 100));
|
||||||
|
|
||||||
// Get vector similarity (0-1, already normalized)
|
// Get vector similarity (0-1, already normalized). If item wasn't in
|
||||||
// If item wasn't in vector results, use a default low score
|
// vector results, use a default mid-low score.
|
||||||
const vectorSimilarity = vectorMap.get(item.id) || 0.3; // Default to 0.3 if not found
|
const vectorSimilarity = vectorMap.get(item.id) || 0.3;
|
||||||
|
|
||||||
// Calculate recency boost (0-1 range)
|
|
||||||
const recencyBoost = opts.recencyBoost
|
const recencyBoost = opts.recencyBoost
|
||||||
? calculateRecencyBoost(item, now) * opts.recencyWeight
|
? calculateRecencyBoost(item, now) * opts.recencyWeight
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Calculate popularity boost (0-1 range)
|
|
||||||
const popularityBoost = calculatePopularityBoost(item);
|
const popularityBoost = calculatePopularityBoost(item);
|
||||||
|
|
||||||
// Apply job-specific boost if available
|
|
||||||
const job = jobs[item.category];
|
const job = jobs[item.category];
|
||||||
let jobBoost = 0;
|
let jobBoost = 0;
|
||||||
if (job && typeof job.boostCriteria === 'function') {
|
if (job && typeof job.boostCriteria === 'function') {
|
||||||
const boost = job.boostCriteria(item, trimmedQuery);
|
const boost = job.boostCriteria(item, trimmedQuery);
|
||||||
if (boost) {
|
if (boost) {
|
||||||
jobBoost = boost / 100; // Normalize boost to 0-1
|
jobBoost = boost / 100;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine scores using weighted average
|
// Lexical guardrail: title matches must outweigh fuzzy vector/content
|
||||||
// BM25 and vector are weighted, boosts are additive
|
// overlap so exact titles lead the list.
|
||||||
|
const lexicalQuality = getLexicalMatchQuality(item, trimmedQuery);
|
||||||
|
let lexicalBonus = lexicalQuality > 0 ? lexicalQuality / 80 : 0;
|
||||||
|
if (lexicalQuality >= 12) lexicalBonus += 0.42;
|
||||||
|
else if (lexicalQuality >= 10) lexicalBonus += 0.24;
|
||||||
|
else if (lexicalQuality >= 8) lexicalBonus += 0.14;
|
||||||
|
|
||||||
const hybridScore =
|
const hybridScore =
|
||||||
(normalizedBm25Score * opts.bm25Weight) +
|
(normalizedBm25Score * opts.bm25Weight) +
|
||||||
(vectorSimilarity * opts.vectorWeight) +
|
(vectorSimilarity * opts.vectorWeight) +
|
||||||
recencyBoost +
|
recencyBoost +
|
||||||
popularityBoost +
|
popularityBoost +
|
||||||
jobBoost;
|
jobBoost +
|
||||||
|
lexicalBonus;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
score: hybridScore * 100, // Scale back to 0-100 for consistency
|
score: hybridScore * 100,
|
||||||
// Store component scores for debugging (optional, can be removed in production)
|
|
||||||
_hybridScores: {
|
|
||||||
bm25: normalizedBm25Score,
|
|
||||||
vector: vectorSimilarity,
|
|
||||||
recency: recencyBoost,
|
|
||||||
popularity: popularityBoost,
|
|
||||||
jobBoost: jobBoost,
|
|
||||||
final: hybridScore,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,7 +242,7 @@ export async function hybridSearch(
|
|||||||
export async function hybridSearchWithExpansion(
|
export async function hybridSearchWithExpansion(
|
||||||
bm25Results: CombinedResult[],
|
bm25Results: CombinedResult[],
|
||||||
query: string,
|
query: string,
|
||||||
allItems: IndexItem[],
|
_allItems: IndexItem[],
|
||||||
options: HybridSearchOptions = {},
|
options: HybridSearchOptions = {},
|
||||||
): Promise<CombinedResult[]> {
|
): Promise<CombinedResult[]> {
|
||||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
@@ -214,6 +256,13 @@ export async function hybridSearchWithExpansion(
|
|||||||
return rerankedBm25;
|
return rerankedBm25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For short / single-token queries vector expansion brings in too much
|
||||||
|
// noise (and is the main reason results "flicker" between adjacent
|
||||||
|
// keystrokes). Keep semantic recall for longer queries.
|
||||||
|
if (isWeakSemanticQuery(trimmedQuery)) {
|
||||||
|
return rerankedBm25.slice(0, opts.finalLimit);
|
||||||
|
}
|
||||||
|
|
||||||
// Get vector search results
|
// Get vector search results
|
||||||
let vectorResults: VectorSearchResult[] = [];
|
let vectorResults: VectorSearchResult[] = [];
|
||||||
try {
|
try {
|
||||||
@@ -229,44 +278,73 @@ export async function hybridSearchWithExpansion(
|
|||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
vectorResults.forEach(v => {
|
// Compute the floor at which a vector-only result is allowed to enter the
|
||||||
if (!bm25Ids.has(v.object.id)) {
|
// ranking. Strong lexical matches in the BM25 list set this floor — a
|
||||||
// This is a semantic match that BM25 missed
|
// vector-only result must beat the lowest strong lexical match's score by
|
||||||
const item = v.object;
|
// a margin to displace it.
|
||||||
|
let strongLexicalFloor = -Infinity;
|
||||||
// Calculate boosts
|
for (const r of rerankedBm25) {
|
||||||
const recencyBoost = opts.recencyBoost
|
if (isIndexItem(r.item) && isStrongLexicalMatch(r.item, trimmedQuery)) {
|
||||||
? calculateRecencyBoost(item, now) * opts.recencyWeight
|
if (r.score > strongLexicalFloor) {
|
||||||
: 0;
|
strongLexicalFloor = r.score;
|
||||||
const popularityBoost = calculatePopularityBoost(item);
|
|
||||||
|
|
||||||
// Vector-only results get lower base score but high vector similarity
|
|
||||||
const vectorScore = v.similarity * opts.vectorWeight + recencyBoost + popularityBoost;
|
|
||||||
|
|
||||||
// Apply job-specific boost if available
|
|
||||||
const job = jobs[item.category];
|
|
||||||
let jobBoost = 0;
|
|
||||||
if (job && typeof job.boostCriteria === 'function') {
|
|
||||||
const boost = job.boostCriteria(item, trimmedQuery);
|
|
||||||
if (boost) {
|
|
||||||
jobBoost = boost / 100; // Normalize boost
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
vectorOnlyResults.push({
|
|
||||||
id: item.id,
|
|
||||||
type: "dynamic" as const,
|
|
||||||
score: (vectorScore + jobBoost) * 100,
|
|
||||||
item,
|
|
||||||
_hybridScores: {
|
|
||||||
bm25: 0,
|
|
||||||
vector: v.similarity,
|
|
||||||
recency: recencyBoost,
|
|
||||||
popularity: popularityBoost,
|
|
||||||
final: vectorScore + jobBoost,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Vector-only results may sit at most at this score:
|
||||||
|
const vectorOnlyCeiling = strongLexicalFloor === -Infinity
|
||||||
|
? Infinity
|
||||||
|
: strongLexicalFloor - 1;
|
||||||
|
|
||||||
|
vectorResults.forEach(v => {
|
||||||
|
if (bm25Ids.has(v.object.id)) return;
|
||||||
|
|
||||||
|
// This is a semantic match that BM25 missed
|
||||||
|
const item = v.object;
|
||||||
|
|
||||||
|
// Calculate boosts
|
||||||
|
const recencyBoost = opts.recencyBoost
|
||||||
|
? calculateRecencyBoost(item, now) * opts.recencyWeight
|
||||||
|
: 0;
|
||||||
|
const popularityBoost = calculatePopularityBoost(item);
|
||||||
|
|
||||||
|
// Penalize vector-only matches that have no lexical content overlap.
|
||||||
|
// Vector recall on its own is fuzzy — without lexical confirmation we
|
||||||
|
// should rank these below curated keyword hits.
|
||||||
|
const lexicalQuality = getLexicalMatchQuality(item, trimmedQuery);
|
||||||
|
let vectorOnlyPenalty = 0;
|
||||||
|
if (lexicalQuality === 0) {
|
||||||
|
vectorOnlyPenalty -= 0.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passive captures without lexical confirmation are demoted further —
|
||||||
|
// they're often raw API records that should never lead the result list.
|
||||||
|
if (item.category === "passive" && lexicalQuality < STRONG_LEXICAL_THRESHOLD) {
|
||||||
|
vectorOnlyPenalty -= 0.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vector-only results get lower base score but high vector similarity
|
||||||
|
const vectorScore =
|
||||||
|
v.similarity * opts.vectorWeight + recencyBoost + popularityBoost + vectorOnlyPenalty;
|
||||||
|
|
||||||
|
// Apply job-specific boost if available
|
||||||
|
const job = jobs[item.category];
|
||||||
|
let jobBoost = 0;
|
||||||
|
if (job && typeof job.boostCriteria === 'function') {
|
||||||
|
const boost = job.boostCriteria(item, trimmedQuery);
|
||||||
|
if (boost) {
|
||||||
|
jobBoost = boost / 100; // Normalize boost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalScore = (vectorScore + jobBoost) * 100;
|
||||||
|
if (finalScore > vectorOnlyCeiling) finalScore = vectorOnlyCeiling;
|
||||||
|
|
||||||
|
vectorOnlyResults.push({
|
||||||
|
id: item.id,
|
||||||
|
type: "dynamic" as const,
|
||||||
|
score: finalScore,
|
||||||
|
item,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Combine reranked BM25 results with vector-only results
|
// Combine reranked BM25 results with vector-only results
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { IndexItem } from "../indexing/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum bonus a strong lexical title match can contribute on top of the
|
||||||
|
* underlying Fuse / hybrid score. Tuned to outweigh small vector reranking
|
||||||
|
* deltas so a true assessment-title match cannot be displaced by a vector
|
||||||
|
* neighbour as the user types one more character.
|
||||||
|
*/
|
||||||
|
export const LEXICAL_TITLE_BONUS = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threshold at or above which a result counts as a "strong lexical match".
|
||||||
|
* Strong matches must always be surfaced and protected from vector reranking
|
||||||
|
* displacing them.
|
||||||
|
*/
|
||||||
|
export const STRONG_LEXICAL_THRESHOLD = 6;
|
||||||
|
|
||||||
|
const WORD_SPLIT_RE = /\s+/;
|
||||||
|
const NON_WORD_RE = /[^a-z0-9]+/gi;
|
||||||
|
|
||||||
|
function normalize(value: string | undefined | null): string {
|
||||||
|
if (!value) return "";
|
||||||
|
return String(value).toLowerCase().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokens(value: string): string[] {
|
||||||
|
return normalize(value)
|
||||||
|
.split(WORD_SPLIT_RE)
|
||||||
|
.map((t) => t.replace(NON_WORD_RE, ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score how strongly the query lexically matches the title-like fields of an
|
||||||
|
* IndexItem. Return value is a non-negative number — 0 means no useful match.
|
||||||
|
*
|
||||||
|
* Tiers (roughly):
|
||||||
|
* ~12 exact title equality
|
||||||
|
* ~10 title starts with full query string
|
||||||
|
* ~8 title contains full query string, on a word boundary
|
||||||
|
* ~7 ordered token-prefix match (e.g. `world w` vs `World War 2 Essay`)
|
||||||
|
* ~5 subject / metadata title contains query
|
||||||
|
* ~3 any token in title starts with query
|
||||||
|
* ~2 substring anywhere in title
|
||||||
|
* 0 no lexical signal
|
||||||
|
*
|
||||||
|
* The function is intentionally cheap (string ops only, no regex compilation
|
||||||
|
* per call beyond the constants above) because it is called for every item in
|
||||||
|
* the candidate pool.
|
||||||
|
*/
|
||||||
|
export function getLexicalMatchQuality(item: IndexItem, query: string): number {
|
||||||
|
const q = normalize(query);
|
||||||
|
if (!q) return 0;
|
||||||
|
|
||||||
|
const title = normalize(item.text);
|
||||||
|
if (!title) return 0;
|
||||||
|
|
||||||
|
if (title === q) return 12;
|
||||||
|
if (title.startsWith(q + " ") || title.startsWith(q)) return 10;
|
||||||
|
|
||||||
|
const queryTokens = tokens(q);
|
||||||
|
const titleTokens = tokens(title);
|
||||||
|
|
||||||
|
if (queryTokens.length > 0 && titleTokens.length >= queryTokens.length) {
|
||||||
|
let bestStreakStart = -1;
|
||||||
|
for (let i = 0; i <= titleTokens.length - queryTokens.length; i++) {
|
||||||
|
let ok = true;
|
||||||
|
for (let j = 0; j < queryTokens.length; j++) {
|
||||||
|
const tt = titleTokens[i + j];
|
||||||
|
const qt = queryTokens[j];
|
||||||
|
const isLast = j === queryTokens.length - 1;
|
||||||
|
if (isLast) {
|
||||||
|
if (!tt.startsWith(qt)) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (tt !== qt) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
bestStreakStart = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestStreakStart === 0) return 9;
|
||||||
|
if (bestStreakStart > 0) return 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title.includes(" " + q) || title.includes(q + " ")) return 8;
|
||||||
|
|
||||||
|
// Token starts-with anywhere
|
||||||
|
for (const t of titleTokens) {
|
||||||
|
if (t.startsWith(q)) return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject / curated metadata title
|
||||||
|
const md = (item.metadata ?? {}) as Record<string, unknown>;
|
||||||
|
const subjectName = normalize(
|
||||||
|
typeof md.subjectName === "string" ? md.subjectName : "",
|
||||||
|
);
|
||||||
|
const subjectCode = normalize(
|
||||||
|
typeof md.subjectCode === "string" ? md.subjectCode : "",
|
||||||
|
);
|
||||||
|
if (subjectName && (subjectName === q || subjectName.startsWith(q))) return 5;
|
||||||
|
if (subjectCode && (subjectCode === q || subjectCode.startsWith(q))) return 5;
|
||||||
|
|
||||||
|
if (title.includes(q)) return 2;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStrongLexicalMatch(item: IndexItem, query: string): boolean {
|
||||||
|
return getLexicalMatchQuality(item, query) >= STRONG_LEXICAL_THRESHOLD;
|
||||||
|
}
|
||||||
@@ -3,10 +3,64 @@ import { getStaticCommands, type StaticCommandItem } from "../core/commands";
|
|||||||
import { getDynamicItems } from "../utils/dynamicItems";
|
import { getDynamicItems } from "../utils/dynamicItems";
|
||||||
import type { CombinedResult } from "../core/types";
|
import type { CombinedResult } from "../core/types";
|
||||||
import type { IndexItem } from "../indexing/types";
|
import type { IndexItem } from "../indexing/types";
|
||||||
import { searchVectors } from "./vector/vectorSearch";
|
import { dedupeCombinedResultsByCourseNav, dedupeIndexItemsForSearch } from "./dedupeIndexItems";
|
||||||
import type { VectorSearchResult } from "./vector/vectorTypes";
|
|
||||||
import { jobs } from "../indexing/jobs";
|
|
||||||
import { hybridSearchWithExpansion } from "./hybridSearch";
|
import { hybridSearchWithExpansion } from "./hybridSearch";
|
||||||
|
import {
|
||||||
|
getLexicalMatchQuality,
|
||||||
|
isStrongLexicalMatch,
|
||||||
|
STRONG_LEXICAL_THRESHOLD,
|
||||||
|
} from "./lexicalMatch";
|
||||||
|
|
||||||
|
/** Same normalization as lexical matching (trim + lowercase). */
|
||||||
|
function normSearchKey(s: string): string {
|
||||||
|
return s.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact title tiers so palette navigation (e.g. "Home", "Assessments") always
|
||||||
|
* wins over hybrid-scored body matches. Higher = sort earlier.
|
||||||
|
*/
|
||||||
|
function exactTitleSortTier(r: CombinedResult, queryNorm: string): number {
|
||||||
|
if (!queryNorm) return 0;
|
||||||
|
if (r.type === "command") {
|
||||||
|
const cmd = r.item as StaticCommandItem;
|
||||||
|
if (normSearchKey(cmd.text) !== queryNorm) return 0;
|
||||||
|
return cmd.category === "navigation" ? 3 : 2;
|
||||||
|
}
|
||||||
|
const ix = r.item as IndexItem;
|
||||||
|
if (normSearchKey(ix.text) === queryNorm) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareCombinedSearchResults(
|
||||||
|
a: CombinedResult,
|
||||||
|
b: CombinedResult,
|
||||||
|
queryNorm: string,
|
||||||
|
): number {
|
||||||
|
const tierDiff = exactTitleSortTier(b, queryNorm) - exactTitleSortTier(a, queryNorm);
|
||||||
|
if (tierDiff !== 0) return tierDiff;
|
||||||
|
|
||||||
|
if (a.type === "command" && b.type === "dynamic") {
|
||||||
|
return b.score - a.score - 10;
|
||||||
|
}
|
||||||
|
if (a.type === "dynamic" && b.type === "command") {
|
||||||
|
return b.score - a.score + 10;
|
||||||
|
}
|
||||||
|
return b.score - a.score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syntheticIndexFromCommand(cmd: StaticCommandItem): IndexItem {
|
||||||
|
return {
|
||||||
|
id: cmd.id,
|
||||||
|
text: cmd.text,
|
||||||
|
category: cmd.category,
|
||||||
|
content: "",
|
||||||
|
dateAdded: 0,
|
||||||
|
metadata: {},
|
||||||
|
actionId: "",
|
||||||
|
renderComponentId: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Search result cache for better performance
|
// Search result cache for better performance
|
||||||
const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>();
|
const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>();
|
||||||
@@ -25,7 +79,9 @@ function setCachedResults(query: string, results: CombinedResult[]) {
|
|||||||
// Limit cache size
|
// Limit cache size
|
||||||
if (searchCache.size >= MAX_CACHE_SIZE) {
|
if (searchCache.size >= MAX_CACHE_SIZE) {
|
||||||
const firstKey = searchCache.keys().next().value;
|
const firstKey = searchCache.keys().next().value;
|
||||||
searchCache.delete(firstKey);
|
if (firstKey !== undefined) {
|
||||||
|
searchCache.delete(firstKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
searchCache.set(query, { results, timestamp: Date.now() });
|
searchCache.set(query, { results, timestamp: Date.now() });
|
||||||
}
|
}
|
||||||
@@ -46,8 +102,9 @@ if (typeof window !== 'undefined') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createSearchIndexes() {
|
export function createSearchIndexes() {
|
||||||
|
clearSearchCache();
|
||||||
const commands = getStaticCommands();
|
const commands = getStaticCommands();
|
||||||
const dynamicItems = getDynamicItems();
|
const dynamicItems = dedupeIndexItemsForSearch(getDynamicItems());
|
||||||
|
|
||||||
// Optimized command search options
|
// Optimized command search options
|
||||||
const commandOptions = {
|
const commandOptions = {
|
||||||
@@ -61,23 +118,40 @@ export function createSearchIndexes() {
|
|||||||
findAllMatches: false, // Performance optimization
|
findAllMatches: false, // Performance optimization
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optimized dynamic content search options
|
// Optimized dynamic content search options.
|
||||||
|
// The expanded corpus mixes structured entities (assessments, subjects)
|
||||||
|
// with free-form text (course content, notices, folio bodies, passive
|
||||||
|
// captures) so we list a broad set of metadata keys while keeping titles
|
||||||
|
// dominant in the ranking.
|
||||||
|
// NOTE: metadata.route is intentionally excluded. Raw API paths like
|
||||||
|
// `/seqta/student/load/message/people` should never influence ranking — they
|
||||||
|
// historically caused passive-capture support records to bubble up above
|
||||||
|
// real assessments when the user typed substrings that happened to appear in
|
||||||
|
// the path.
|
||||||
const dynamicOptions = {
|
const dynamicOptions = {
|
||||||
keys: [
|
keys: [
|
||||||
{ name: "text", weight: 3 }, // Increased weight for title matches
|
{ name: "text", weight: 3 }, // Title is king
|
||||||
{ name: "content", weight: 1 },
|
{ name: "content", weight: 1 },
|
||||||
{ name: "category", weight: 0.5 }, // Lower weight for category
|
{ name: "category", weight: 0.4 },
|
||||||
{ name: "metadata.subjectName", weight: 1.5 }, // Boost subject name matches
|
{ name: "metadata.subjectName", weight: 1.6 },
|
||||||
{ name: "metadata.subjectCode", weight: 1.5 }, // Boost subject code matches
|
{ name: "metadata.subjectCode", weight: 1.6 },
|
||||||
|
{ name: "metadata.subject", weight: 1.4 },
|
||||||
|
{ name: "metadata.courseCode", weight: 1.2 },
|
||||||
|
{ name: "metadata.filename", weight: 1.2 },
|
||||||
|
{ name: "metadata.author", weight: 0.8 },
|
||||||
|
{ name: "metadata.authorName", weight: 0.8 },
|
||||||
|
{ name: "metadata.label", weight: 0.6 },
|
||||||
|
{ name: "metadata.categoryName", weight: 0.6 },
|
||||||
|
{ name: "metadata.entityType", weight: 0.4 },
|
||||||
],
|
],
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
includeMatches: true,
|
includeMatches: true,
|
||||||
threshold: 0.5, // More permissive for better partial word matching (increased from 0.4)
|
threshold: 0.5,
|
||||||
minMatchCharLength: 2, // Minimum 2 characters for Fuse.js matches (substring fallback handles shorter queries)
|
minMatchCharLength: 2,
|
||||||
distance: 100, // Increased to allow matches across longer strings
|
distance: 100,
|
||||||
useExtendedSearch: true,
|
useExtendedSearch: true,
|
||||||
ignoreLocation: true, // Allow matches anywhere in the string for better partial word matching
|
ignoreLocation: true,
|
||||||
findAllMatches: true, // Enable to find all matches for better partial word support
|
findAllMatches: true,
|
||||||
shouldSort: true,
|
shouldSort: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,7 +191,19 @@ export function searchCommands(
|
|||||||
return searchResults.map((result: FuseResult<StaticCommandItem>) => {
|
return searchResults.map((result: FuseResult<StaticCommandItem>) => {
|
||||||
const item = result.item;
|
const item = result.item;
|
||||||
const fuseScore = 15 * (1 - (result.score || 0.5));
|
const fuseScore = 15 * (1 - (result.score || 0.5));
|
||||||
const score = fuseScore + (item.priority ?? 0);
|
let score = fuseScore + (item.priority ?? 0);
|
||||||
|
|
||||||
|
// Static palette titles share the same lexical tiers as index titles, but
|
||||||
|
// Fuse scores are tiny versus hybrid dynamic scores — scale title matches
|
||||||
|
// up so "Assessments" / prefix matches stay competitive with body hits.
|
||||||
|
const titleLex = getLexicalMatchQuality(syntheticIndexFromCommand(item), query);
|
||||||
|
if (titleLex >= 12) score += 240;
|
||||||
|
else if (titleLex >= 10) score += 195;
|
||||||
|
else if (titleLex >= 9) score += 165;
|
||||||
|
else if (titleLex >= 8) score += 140;
|
||||||
|
else if (titleLex >= 7) score += 120;
|
||||||
|
else if (titleLex >= 6) score += 100;
|
||||||
|
else if (titleLex > 0) score += titleLex * 14;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -197,15 +283,24 @@ export function searchDynamicItems(
|
|||||||
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
|
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
|
||||||
score += recencyBoost;
|
score += recencyBoost;
|
||||||
|
|
||||||
// Boost for exact text matches (especially at the start)
|
// Lexical title bonus — sticky across adjacent keystrokes so a strong
|
||||||
const textLower = item.text.toLowerCase();
|
// title prefix match like `world wa` doesn't disappear from the top once
|
||||||
if (textLower.startsWith(queryLower)) {
|
// vector reranking kicks in.
|
||||||
score += 5; // Strong boost for prefix matches
|
const lexicalQuality = getLexicalMatchQuality(item, queryLower);
|
||||||
} else if (textLower.includes(queryLower)) {
|
if (lexicalQuality > 0) {
|
||||||
score += 2; // Boost for substring matches
|
score += lexicalQuality;
|
||||||
|
// Curated-content boost: assessments and assignments with a strong
|
||||||
|
// title match should be elevated further, since they are the items
|
||||||
|
// users are most often hunting for.
|
||||||
|
if (
|
||||||
|
lexicalQuality >= STRONG_LEXICAL_THRESHOLD &&
|
||||||
|
(item.category === "assignments" || item.category === "assessments")
|
||||||
|
) {
|
||||||
|
score += 4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boost for category matches
|
// Category match (small nudge)
|
||||||
if (item.category.toLowerCase().includes(queryLower)) {
|
if (item.category.toLowerCase().includes(queryLower)) {
|
||||||
score += 1;
|
score += 1;
|
||||||
}
|
}
|
||||||
@@ -221,17 +316,12 @@ export function searchDynamicItems(
|
|||||||
|
|
||||||
// Add additional matches from simple substring search
|
// Add additional matches from simple substring search
|
||||||
additionalMatches.forEach((item) => {
|
additionalMatches.forEach((item) => {
|
||||||
// Check if already in results
|
|
||||||
if (!results.find(r => r.id === item.id)) {
|
if (!results.find(r => r.id === item.id)) {
|
||||||
const textLower = item.text.toLowerCase();
|
|
||||||
let score = 5; // Base score for substring matches
|
let score = 5; // Base score for substring matches
|
||||||
|
|
||||||
// Boost for prefix matches
|
const lexicalQuality = getLexicalMatchQuality(item, queryLower);
|
||||||
if (textLower.startsWith(queryLower)) {
|
score += lexicalQuality;
|
||||||
score += 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recency boost
|
|
||||||
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
|
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
|
||||||
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
|
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
|
||||||
score += recencyBoost;
|
score += recencyBoost;
|
||||||
@@ -241,6 +331,7 @@ export function searchDynamicItems(
|
|||||||
type: "dynamic" as const,
|
type: "dynamic" as const,
|
||||||
score,
|
score,
|
||||||
item,
|
item,
|
||||||
|
matches: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -249,6 +340,7 @@ export function searchDynamicItems(
|
|||||||
return results.sort((a, b) => b.score - a.score).slice(0, limit);
|
return results.sort((a, b) => b.score - a.score).slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function performSearch(
|
export async function performSearch(
|
||||||
query: string,
|
query: string,
|
||||||
commandsFuse: Fuse<StaticCommandItem>,
|
commandsFuse: Fuse<StaticCommandItem>,
|
||||||
@@ -286,12 +378,37 @@ export async function performSearch(
|
|||||||
sortByRecent,
|
sortByRecent,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Step 2b: Always include strong lexical title matches, even if Fuse
|
||||||
|
// missed them with the current threshold. This is the safety net that
|
||||||
|
// stops `world wa` from dropping a `World War 2 Essay` assessment that
|
||||||
|
// `world w` happily showed.
|
||||||
|
const allItems = Array.from(dynamicIdToItemMap.values());
|
||||||
|
const seen = new Set(bm25Results.map((r) => r.id));
|
||||||
|
const lexicalAdds: CombinedResult[] = [];
|
||||||
|
for (const item of allItems) {
|
||||||
|
if (seen.has(item.id)) continue;
|
||||||
|
if (!isStrongLexicalMatch(item, trimmedQuery)) continue;
|
||||||
|
const quality = getLexicalMatchQuality(item, trimmedQuery);
|
||||||
|
let score = 6 + quality;
|
||||||
|
if (item.category === "assignments" || item.category === "assessments") {
|
||||||
|
score += 4;
|
||||||
|
}
|
||||||
|
lexicalAdds.push({
|
||||||
|
id: item.id,
|
||||||
|
type: "dynamic" as const,
|
||||||
|
score,
|
||||||
|
item,
|
||||||
|
matches: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (lexicalAdds.length > 0) {
|
||||||
|
bm25Results.push(...lexicalAdds);
|
||||||
|
bm25Results.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
// Step 3: Apply hybrid search (BM25 + Vector reranking + boosting)
|
// Step 3: Apply hybrid search (BM25 + Vector reranking + boosting)
|
||||||
if (trimmedQuery.length > 2 && bm25Results.length > 0) {
|
if (trimmedQuery.length > 2 && bm25Results.length > 0) {
|
||||||
try {
|
try {
|
||||||
// Get all items for expansion
|
|
||||||
const allItems = Array.from(dynamicIdToItemMap.values());
|
|
||||||
|
|
||||||
// Apply hybrid search with expansion
|
// Apply hybrid search with expansion
|
||||||
dynamicResults = await hybridSearchWithExpansion(
|
dynamicResults = await hybridSearchWithExpansion(
|
||||||
bm25Results,
|
bm25Results,
|
||||||
@@ -320,22 +437,19 @@ export async function performSearch(
|
|||||||
// Step 4: Combine command and dynamic results
|
// Step 4: Combine command and dynamic results
|
||||||
const allResults = [...commandResults, ...dynamicResults];
|
const allResults = [...commandResults, ...dynamicResults];
|
||||||
|
|
||||||
// Sort by score (commands typically have higher priority)
|
allResults.sort((a, b) =>
|
||||||
allResults.sort((a, b) => {
|
compareCombinedSearchResults(a, b, trimmedQuery),
|
||||||
// Commands always come first if scores are similar
|
);
|
||||||
if (a.type === "command" && b.type === "dynamic") {
|
|
||||||
return b.score - a.score - 10; // Commands get +10 boost
|
const dedupedResults = dedupeCombinedResultsByCourseNav(allResults);
|
||||||
}
|
dedupedResults.sort((a, b) =>
|
||||||
if (a.type === "dynamic" && b.type === "command") {
|
compareCombinedSearchResults(a, b, trimmedQuery),
|
||||||
return b.score - a.score + 10; // Commands get +10 boost
|
);
|
||||||
}
|
|
||||||
return b.score - a.score;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cache results for queries longer than 2 chars
|
// Cache results for queries longer than 2 chars
|
||||||
if (trimmedQuery.length > 2) {
|
if (trimmedQuery.length > 2) {
|
||||||
setCachedResults(trimmedQuery, allResults);
|
setCachedResults(trimmedQuery, dedupedResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
return allResults;
|
return dedupedResults;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export interface VectorSearchResult extends SearchResult {
|
|||||||
|
|
||||||
// Cache for query embeddings to avoid recomputing
|
// Cache for query embeddings to avoid recomputing
|
||||||
const embeddingCache = new Map<string, number[]>();
|
const embeddingCache = new Map<string, number[]>();
|
||||||
const EMBEDDING_CACHE_TTL = 1000 * 60 * 30; // 30 minutes
|
|
||||||
const MAX_EMBEDDING_CACHE_SIZE = 50;
|
const MAX_EMBEDDING_CACHE_SIZE = 50;
|
||||||
|
|
||||||
function getCachedEmbedding(query: string): number[] | null {
|
function getCachedEmbedding(query: string): number[] | null {
|
||||||
@@ -55,7 +54,9 @@ function setCachedEmbedding(query: string, embedding: number[]) {
|
|||||||
// Limit cache size
|
// Limit cache size
|
||||||
if (embeddingCache.size >= MAX_EMBEDDING_CACHE_SIZE) {
|
if (embeddingCache.size >= MAX_EMBEDDING_CACHE_SIZE) {
|
||||||
const firstKey = embeddingCache.keys().next().value;
|
const firstKey = embeddingCache.keys().next().value;
|
||||||
embeddingCache.delete(firstKey);
|
if (firstKey !== undefined) {
|
||||||
|
embeddingCache.delete(firstKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
embeddingCache.set(query, embedding);
|
embeddingCache.set(query, embedding);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
import { resetSearchIndexes } from "../indexing/resetIndexes";
|
||||||
|
|
||||||
const VERSION_STORAGE_KEY = "betterseqta-global-search-version";
|
const VERSION_STORAGE_KEY = "betterseqta-global-search-version";
|
||||||
const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version";
|
const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version";
|
||||||
@@ -40,32 +41,51 @@ export function storeVersion(version: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the extension has been updated and clears caches if needed
|
* Checks if the extension has been updated and clears caches + resets the
|
||||||
* Returns true if an update was detected
|
* search index if needed.
|
||||||
|
*
|
||||||
|
* The reset is intentionally aggressive: every manifest version bump
|
||||||
|
* triggers a full IndexedDB wipe so changes to indexer extraction logic,
|
||||||
|
* job sets, or item shape can never serve stale results from an older
|
||||||
|
* build. The next indexing pass will repopulate from scratch in the
|
||||||
|
* background. Re-population is bounded by the per-job rate limits in
|
||||||
|
* `api.ts` so it can't hammer SEQTA after an update.
|
||||||
|
*
|
||||||
|
* Returns true if an update was detected.
|
||||||
*/
|
*/
|
||||||
export async function checkAndHandleUpdate(): Promise<boolean> {
|
export async function checkAndHandleUpdate(): Promise<boolean> {
|
||||||
const currentVersion = getCurrentVersion();
|
const currentVersion = getCurrentVersion();
|
||||||
const storedVersion = getStoredVersion();
|
const storedVersion = getStoredVersion();
|
||||||
|
|
||||||
// If no stored version, this is first run - store current version
|
// First run: just remember the version, don't reset (the user likely
|
||||||
|
// just installed the extension; the index is already empty).
|
||||||
if (!storedVersion) {
|
if (!storedVersion) {
|
||||||
console.debug(`[Version Check] First run detected, storing version ${currentVersion}`);
|
console.debug(
|
||||||
|
`[Version Check] First run detected, storing version ${currentVersion}`,
|
||||||
|
);
|
||||||
storeVersion(currentVersion);
|
storeVersion(currentVersion);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If versions match, no update
|
|
||||||
if (storedVersion === currentVersion) {
|
if (storedVersion === currentVersion) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version mismatch detected - extension was updated
|
console.log(
|
||||||
console.log(`[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, clearing caches...`);
|
`[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, resetting search index...`,
|
||||||
|
);
|
||||||
|
|
||||||
// Clear all caches
|
|
||||||
await clearAllCaches();
|
await clearAllCaches();
|
||||||
|
|
||||||
// Store new version
|
try {
|
||||||
|
await resetSearchIndexes();
|
||||||
|
console.log(
|
||||||
|
"[Version Check] Search index reset; next indexing pass will repopulate from scratch.",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Version Check] resetSearchIndexes failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
storeVersion(currentVersion);
|
storeVersion(currentVersion);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
|
||||||
|
import * as Chart from "./chart/index";
|
||||||
|
|
||||||
|
import { scaleUtc, scaleLinear } from "d3-scale";
|
||||||
|
|
||||||
|
import { Area, AreaChart, ChartClipPath } from "layerchart";
|
||||||
|
|
||||||
|
import { curveNatural } from "d3-shape";
|
||||||
|
|
||||||
|
import { cubicInOut } from "svelte/easing";
|
||||||
|
|
||||||
|
import type { Assessment } from "./types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
|
||||||
|
buildGradeTrendChart,
|
||||||
|
|
||||||
|
getTimeRangeLabel,
|
||||||
|
|
||||||
|
type TimeRange,
|
||||||
|
|
||||||
|
} from "./timeRange";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
|
||||||
|
data: Assessment[];
|
||||||
|
|
||||||
|
timeRange: TimeRange;
|
||||||
|
|
||||||
|
showSubjectTrends?: boolean;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let { data, timeRange, showSubjectTrends = false }: Props = $props();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const chartUid = `area-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const chartResult = $derived(() =>
|
||||||
|
|
||||||
|
buildGradeTrendChart(data, timeRange, {
|
||||||
|
|
||||||
|
showPerSubject: showSubjectTrends,
|
||||||
|
|
||||||
|
}),
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const filteredData = $derived(() => chartResult().points);
|
||||||
|
|
||||||
|
const chartSeries = $derived(() => chartResult().series);
|
||||||
|
|
||||||
|
const accentColor = $derived(() => chartResult().accentColor);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const chartConfig = $derived(() => {
|
||||||
|
|
||||||
|
const config: Chart.ChartConfig = {};
|
||||||
|
|
||||||
|
for (const s of chartSeries()) {
|
||||||
|
|
||||||
|
config[s.key] = { label: s.label, color: s.color };
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const yScale = $derived.by(() => {
|
||||||
|
|
||||||
|
const points = filteredData();
|
||||||
|
|
||||||
|
const series = chartSeries();
|
||||||
|
|
||||||
|
if (!points.length) return scaleLinear().domain([0, 100]);
|
||||||
|
|
||||||
|
const values: number[] = [];
|
||||||
|
|
||||||
|
for (const p of points) {
|
||||||
|
|
||||||
|
for (const s of series) {
|
||||||
|
|
||||||
|
const v = p[s.key];
|
||||||
|
|
||||||
|
if (typeof v === "number" && !Number.isNaN(v)) values.push(v);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.length) return scaleLinear().domain([0, 100]);
|
||||||
|
|
||||||
|
const min = Math.max(0, Math.min(...values) - 8);
|
||||||
|
|
||||||
|
const max = Math.min(100, Math.max(...values) + 8);
|
||||||
|
|
||||||
|
return scaleLinear().domain([min, max]).nice();
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const trend = $derived(() => {
|
||||||
|
|
||||||
|
const points = filteredData();
|
||||||
|
|
||||||
|
if (points.length < 2) return { percentage: "0", direction: "neutral" as const };
|
||||||
|
|
||||||
|
const recent = points.slice(-2);
|
||||||
|
|
||||||
|
const change = recent[1].average - recent[0].average;
|
||||||
|
|
||||||
|
return {
|
||||||
|
|
||||||
|
percentage: Math.abs(change).toFixed(1),
|
||||||
|
|
||||||
|
direction: change > 0 ? ("up" as const) : change < 0 ? ("down" as const) : ("neutral" as const),
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const areaSeries = $derived(() =>
|
||||||
|
|
||||||
|
chartSeries().map((s) => ({
|
||||||
|
|
||||||
|
key: s.key,
|
||||||
|
|
||||||
|
label: s.label,
|
||||||
|
|
||||||
|
color: s.color,
|
||||||
|
|
||||||
|
})),
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<article class="bsplus-analytics-card">
|
||||||
|
|
||||||
|
<header class="bsplus-analytics-card-header">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h3 class="bsplus-analytics-card-title">Grade trends</h3>
|
||||||
|
|
||||||
|
<p class="bsplus-analytics-card-desc">
|
||||||
|
|
||||||
|
{#if showSubjectTrends}
|
||||||
|
|
||||||
|
Overall and per-subject averages · {getTimeRangeLabel(timeRange)}
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
|
||||||
|
Average grades over time · {getTimeRangeLabel(timeRange)}
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="bsplus-analytics-card-body">
|
||||||
|
|
||||||
|
{#if filteredData().length > 0}
|
||||||
|
|
||||||
|
<Chart.Container config={chartConfig()} class="bsplus-chart-surface w-full">
|
||||||
|
|
||||||
|
<AreaChart
|
||||||
|
|
||||||
|
legend
|
||||||
|
|
||||||
|
data={filteredData()}
|
||||||
|
|
||||||
|
x="date"
|
||||||
|
|
||||||
|
xScale={scaleUtc()}
|
||||||
|
|
||||||
|
yScale={yScale()}
|
||||||
|
|
||||||
|
series={areaSeries()}
|
||||||
|
|
||||||
|
props={{
|
||||||
|
|
||||||
|
area: {
|
||||||
|
|
||||||
|
curve: curveNatural,
|
||||||
|
|
||||||
|
"fill-opacity": showSubjectTrends ? 0.12 : 0.35,
|
||||||
|
|
||||||
|
line: { class: "stroke-2" },
|
||||||
|
|
||||||
|
motion: "tween",
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
xAxis: {
|
||||||
|
|
||||||
|
ticks: timeRange === "7d" ? 7 : undefined,
|
||||||
|
|
||||||
|
format: (v: Date) =>
|
||||||
|
|
||||||
|
v.toLocaleDateString("en-US", {
|
||||||
|
|
||||||
|
month: "short",
|
||||||
|
|
||||||
|
day: timeRange === "7d" ? "numeric" : undefined,
|
||||||
|
|
||||||
|
}),
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
yAxis: {
|
||||||
|
|
||||||
|
format: (v: number) => `${v.toFixed(0)}%`,
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
}}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
{#snippet marks({ series, getAreaProps })}
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
|
||||||
|
<linearGradient id={chartUid} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
|
||||||
|
<stop offset="0%" stop-color={accentColor()} stop-opacity="0.55" />
|
||||||
|
|
||||||
|
<stop offset="100%" stop-color={accentColor()} stop-opacity="0.04" />
|
||||||
|
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<ChartClipPath
|
||||||
|
|
||||||
|
initialWidth={0}
|
||||||
|
|
||||||
|
motion={{
|
||||||
|
|
||||||
|
width: { type: "tween", duration: 900, easing: cubicInOut },
|
||||||
|
|
||||||
|
}}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
{#each series as s, i (s.key)}
|
||||||
|
|
||||||
|
{@const meta = chartSeries().find((c) => c.key === s.key)}
|
||||||
|
|
||||||
|
{@const isOverall = meta?.isOverall ?? s.key === "average"}
|
||||||
|
|
||||||
|
<Area
|
||||||
|
|
||||||
|
{...getAreaProps(s, i)}
|
||||||
|
|
||||||
|
fill={isOverall && !showSubjectTrends
|
||||||
|
|
||||||
|
? `url(#${chartUid})`
|
||||||
|
|
||||||
|
: isOverall
|
||||||
|
|
||||||
|
? accentColor()
|
||||||
|
|
||||||
|
: "transparent"}
|
||||||
|
|
||||||
|
fill-opacity={isOverall ? (showSubjectTrends ? 0.08 : 0.35) : 0}
|
||||||
|
|
||||||
|
stroke={meta?.color ?? s.color}
|
||||||
|
|
||||||
|
style={`stroke: ${meta?.color ?? s.color}`}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
</ChartClipPath>
|
||||||
|
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet tooltip()}
|
||||||
|
|
||||||
|
<Chart.Tooltip
|
||||||
|
|
||||||
|
labelFormatter={(v: Date) =>
|
||||||
|
|
||||||
|
v.toLocaleDateString("en-US", {
|
||||||
|
|
||||||
|
month: "long",
|
||||||
|
|
||||||
|
day: "numeric",
|
||||||
|
|
||||||
|
year: "numeric",
|
||||||
|
|
||||||
|
})}
|
||||||
|
|
||||||
|
indicator="line"
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
</AreaChart>
|
||||||
|
|
||||||
|
</Chart.Container>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
|
||||||
|
<div class="bsplus-analytics-card-empty">
|
||||||
|
|
||||||
|
<strong>No grade data for this range</strong>
|
||||||
|
|
||||||
|
<span>Complete assessments with released marks to see trends.</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<footer class="bsplus-analytics-card-footer">
|
||||||
|
|
||||||
|
{#if trend().direction === "up"}
|
||||||
|
|
||||||
|
<span class="bsplus-analytics-trend-up"
|
||||||
|
|
||||||
|
>Trending up · {trend().percentage}% vs previous period</span
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
{:else if trend().direction === "down"}
|
||||||
|
|
||||||
|
<span class="bsplus-analytics-trend-down"
|
||||||
|
|
||||||
|
>Trending down · {trend().percentage}% vs previous period</span
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
|
||||||
|
<span>Grades remain stable across this period</span>
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
|
||||||
|
{filteredData().length} data points · {getTimeRangeLabel(timeRange)}
|
||||||
|
|
||||||
|
{#if showSubjectTrends && chartSeries().length > 1}
|
||||||
|
|
||||||
|
· {chartSeries().length - 1} subject{chartSeries().length - 1 === 1 ? "" : "s"}
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
import { scaleBand, scaleLinear } from "d3-scale";
|
||||||
|
|
||||||
|
import { BarChart } from "layerchart";
|
||||||
|
|
||||||
|
import * as Chart from "./chart/index";
|
||||||
|
|
||||||
|
import { cubicInOut } from "svelte/easing";
|
||||||
|
|
||||||
|
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
|
||||||
|
|
||||||
|
import type { Assessment } from "./types";
|
||||||
|
|
||||||
|
import { getTimeRangeLabel, type TimeRange } from "./timeRange";
|
||||||
|
|
||||||
|
import {
|
||||||
|
|
||||||
|
buildGradeDistribution,
|
||||||
|
|
||||||
|
DISTRIBUTION_MODE_OPTIONS,
|
||||||
|
|
||||||
|
type DistributionMode,
|
||||||
|
|
||||||
|
} from "./gradeDistribution";
|
||||||
|
|
||||||
|
import { loadDistributionMode, saveDistributionMode } from "./storage";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
|
||||||
|
data: Assessment[];
|
||||||
|
|
||||||
|
timeRange: TimeRange;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let { data, timeRange }: Props = $props();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let distributionMode: DistributionMode = $state("auto");
|
||||||
|
|
||||||
|
let modeReady = $state(false);
|
||||||
|
|
||||||
|
let studentId: number | null = $state(null);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const accentColor =
|
||||||
|
|
||||||
|
"var(--bsplus-analytics-accent, var(--better-main, #007bff))";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const distribution = $derived(() =>
|
||||||
|
|
||||||
|
buildGradeDistribution(data, distributionMode),
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const chartData = $derived(() =>
|
||||||
|
|
||||||
|
distribution().buckets.map((b) => ({
|
||||||
|
|
||||||
|
grade: b.label,
|
||||||
|
|
||||||
|
count: b.count,
|
||||||
|
|
||||||
|
minPercent: b.minPercent,
|
||||||
|
|
||||||
|
maxPercent: b.maxPercent,
|
||||||
|
|
||||||
|
})),
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
const useLetterScaleLabels = $derived(() => distribution().modeUsed === "letter");
|
||||||
|
|
||||||
|
function formatXTick(label: string): string {
|
||||||
|
|
||||||
|
if (!useLetterScaleLabels()) return label;
|
||||||
|
|
||||||
|
const row = chartData().find((d) => d.grade === label);
|
||||||
|
|
||||||
|
if (
|
||||||
|
|
||||||
|
row?.minPercent !== undefined &&
|
||||||
|
|
||||||
|
row?.maxPercent !== undefined &&
|
||||||
|
|
||||||
|
!(row.minPercent === 0 && row.maxPercent === 100)
|
||||||
|
|
||||||
|
) {
|
||||||
|
|
||||||
|
return `${label}\n${Math.round(row.minPercent)}–${Math.round(row.maxPercent)}%`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return label;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const chartConfig = $derived(() => {
|
||||||
|
|
||||||
|
const config: Chart.ChartConfig = {
|
||||||
|
|
||||||
|
count: { label: "Assessments", color: accentColor },
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
return config;
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const yMax = $derived(Math.max(1, ...chartData().map((d) => d.count)));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const yScale = $derived(scaleLinear().domain([0, yMax]).nice());
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const totalAssessments = $derived(distribution().gradedCount);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const modeOptionLabel = $derived(
|
||||||
|
|
||||||
|
DISTRIBUTION_MODE_OPTIONS.find((o) => o.value === distributionMode)?.label ??
|
||||||
|
|
||||||
|
"Auto",
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const subtitle = $derived(() => {
|
||||||
|
|
||||||
|
const d = distribution();
|
||||||
|
|
||||||
|
if (d.modeUsed === "letter") {
|
||||||
|
|
||||||
|
return `Assessments per letter grade · ${getTimeRangeLabel(timeRange)}`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Assessments per grade band · ${getTimeRangeLabel(timeRange)}`;
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const info = await getUserInfo();
|
||||||
|
|
||||||
|
if (info?.id) {
|
||||||
|
|
||||||
|
studentId = info.id;
|
||||||
|
|
||||||
|
const saved = await loadDistributionMode(location.origin, info.id);
|
||||||
|
|
||||||
|
if (saved) distributionMode = saved;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
/* use default */
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
|
||||||
|
modeReady = true;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function onModeChange(next: DistributionMode) {
|
||||||
|
|
||||||
|
distributionMode = next;
|
||||||
|
|
||||||
|
if (studentId != null) {
|
||||||
|
|
||||||
|
await saveDistributionMode(location.origin, studentId, next);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<article class="bsplus-analytics-card">
|
||||||
|
|
||||||
|
<header class="bsplus-analytics-card-header bsplus-analytics-card-header-split">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h3 class="bsplus-analytics-card-title">Grade distribution</h3>
|
||||||
|
|
||||||
|
<p class="bsplus-analytics-card-desc">{subtitle()}</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bsplus-analytics-card-controls">
|
||||||
|
|
||||||
|
<label class="bsplus-analytics-card-control">
|
||||||
|
|
||||||
|
<span class="bsplus-analytics-field-label">Grouping</span>
|
||||||
|
|
||||||
|
<select
|
||||||
|
|
||||||
|
class="bsplus-analytics-select bsplus-analytics-select-compact"
|
||||||
|
|
||||||
|
value={distributionMode}
|
||||||
|
|
||||||
|
disabled={!modeReady}
|
||||||
|
|
||||||
|
aria-label="Grade distribution grouping"
|
||||||
|
|
||||||
|
onchange={(e) => onModeChange(e.currentTarget.value as DistributionMode)}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
{#each DISTRIBUTION_MODE_OPTIONS as option}
|
||||||
|
|
||||||
|
<option value={option.value} title={option.description}>{option.label}</option>
|
||||||
|
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="bsplus-analytics-card-body">
|
||||||
|
|
||||||
|
{#if totalAssessments > 0 && chartData().length > 0}
|
||||||
|
|
||||||
|
<Chart.Container config={chartConfig()} class="bsplus-chart-surface bsplus-chart-surface-bar w-full">
|
||||||
|
|
||||||
|
<BarChart
|
||||||
|
|
||||||
|
data={chartData()}
|
||||||
|
|
||||||
|
xScale={scaleBand().padding(distribution().modeUsed === "letter" ? 0.22 : 0.28)}
|
||||||
|
|
||||||
|
yScale={yScale()}
|
||||||
|
|
||||||
|
x="grade"
|
||||||
|
|
||||||
|
y="count"
|
||||||
|
|
||||||
|
axis={true}
|
||||||
|
|
||||||
|
grid={true}
|
||||||
|
|
||||||
|
series={[
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
key: "count",
|
||||||
|
|
||||||
|
label: "Assessments",
|
||||||
|
|
||||||
|
color: accentColor,
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
]}
|
||||||
|
|
||||||
|
props={{
|
||||||
|
|
||||||
|
bars: {
|
||||||
|
|
||||||
|
stroke: "none",
|
||||||
|
|
||||||
|
fill: accentColor,
|
||||||
|
|
||||||
|
rounded: "all",
|
||||||
|
|
||||||
|
radius: 10,
|
||||||
|
|
||||||
|
insets: { top: 4, bottom: 0, left: 4, right: 4 },
|
||||||
|
|
||||||
|
motion: {
|
||||||
|
|
||||||
|
y: { type: "tween", duration: 600, easing: cubicInOut },
|
||||||
|
|
||||||
|
height: { type: "tween", duration: 600, easing: cubicInOut },
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
highlight: { area: { fill: "none" } },
|
||||||
|
|
||||||
|
xAxis: {
|
||||||
|
|
||||||
|
format: (d: string) => formatXTick(d),
|
||||||
|
|
||||||
|
tickMultiline: useLetterScaleLabels(),
|
||||||
|
|
||||||
|
tickLabelProps: useLetterScaleLabels()
|
||||||
|
|
||||||
|
? { class: "bsplus-bar-tick-label" }
|
||||||
|
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
yAxis: {
|
||||||
|
|
||||||
|
label: "Assessments",
|
||||||
|
|
||||||
|
format: (d: number) => (Number.isInteger(d) ? String(d) : ""),
|
||||||
|
|
||||||
|
ticks: 5,
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
}}
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
{#snippet tooltip()}
|
||||||
|
|
||||||
|
<Chart.Tooltip hideLabel />
|
||||||
|
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
</BarChart>
|
||||||
|
|
||||||
|
</Chart.Container>
|
||||||
|
|
||||||
|
{#if distribution().modeUsed === "letter"}
|
||||||
|
|
||||||
|
<p class="bsplus-analytics-scale-hint">{distribution().scaleLabel}</p>
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
|
||||||
|
<div class="bsplus-analytics-card-empty">
|
||||||
|
|
||||||
|
<strong>No graded assessments</strong>
|
||||||
|
|
||||||
|
<span>for {getTimeRangeLabel(timeRange).toLowerCase()}</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<footer class="bsplus-analytics-card-footer">
|
||||||
|
|
||||||
|
{#if distribution().averagePercent !== null}
|
||||||
|
|
||||||
|
Average <strong>{distribution().averagePercent}%</strong>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
|
||||||
|
Average <strong>—</strong>
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
across {totalAssessments} assessment{totalAssessments === 1 ? "" : "s"}
|
||||||
|
|
||||||
|
{#if distributionMode === "auto" && distribution().modeUsed === "letter"}
|
||||||
|
|
||||||
|
<span class="bsplus-analytics-footer-muted"> · letter scale detected</span>
|
||||||
|
|
||||||
|
{:else if distributionMode !== "auto"}
|
||||||
|
|
||||||
|
<span class="bsplus-analytics-footer-muted"> · {modeOptionLabel} grouping</span>
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Assessment } from "./types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: Assessment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let currentPage = $state(0);
|
||||||
|
let itemsPerPage = $state(10);
|
||||||
|
let sortColumn = $state<keyof Assessment | null>("due");
|
||||||
|
let sortDirection = $state<"asc" | "desc">("desc");
|
||||||
|
|
||||||
|
const sortedData = $derived.by(() => {
|
||||||
|
const list = [...data];
|
||||||
|
if (!sortColumn) return list;
|
||||||
|
list.sort((a, b) => {
|
||||||
|
const av = a[sortColumn!];
|
||||||
|
const bv = b[sortColumn!];
|
||||||
|
if (av === bv) return 0;
|
||||||
|
if (av == null) return 1;
|
||||||
|
if (bv == null) return -1;
|
||||||
|
const cmp = av < bv ? -1 : 1;
|
||||||
|
return sortDirection === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageCount = $derived(Math.max(1, Math.ceil(sortedData.length / itemsPerPage)));
|
||||||
|
const pageData = $derived(
|
||||||
|
sortedData.slice(
|
||||||
|
currentPage * itemsPerPage,
|
||||||
|
(currentPage + 1) * itemsPerPage,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleSort(column: keyof Assessment) {
|
||||||
|
if (sortColumn === column) {
|
||||||
|
sortDirection = sortDirection === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
sortColumn = column;
|
||||||
|
sortDirection = "asc";
|
||||||
|
}
|
||||||
|
currentPage = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(status: string) {
|
||||||
|
return status.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function gradeDisplay(a: Assessment) {
|
||||||
|
if (a.finalGrade !== undefined) {
|
||||||
|
return a.letterGrade
|
||||||
|
? `${a.finalGrade}% (${a.letterGrade})`
|
||||||
|
: `${a.finalGrade}%`;
|
||||||
|
}
|
||||||
|
return a.letterGrade ?? "—";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="bsplus-analytics-table-wrap">
|
||||||
|
<header class="bsplus-analytics-table-header">
|
||||||
|
<h2>Assessment history</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="bsplus-analytics-table-scroll">
|
||||||
|
<table class="bsplus-analytics-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{#each [
|
||||||
|
["title", "Title"],
|
||||||
|
["subject", "Subject"],
|
||||||
|
["due", "Due"],
|
||||||
|
["status", "Status"],
|
||||||
|
["finalGrade", "Grade"],
|
||||||
|
] as [col, label]}
|
||||||
|
<th>
|
||||||
|
<button type="button" onclick={() => toggleSort(col as keyof Assessment)}>
|
||||||
|
{label}
|
||||||
|
{#if sortColumn === col}
|
||||||
|
{sortDirection === "asc" ? " ↑" : " ↓"}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each pageData as row (row.id)}
|
||||||
|
<tr>
|
||||||
|
<td class="cell-title" title={row.title}>{row.title}</td>
|
||||||
|
<td>{row.subject}</td>
|
||||||
|
<td style="white-space: nowrap">
|
||||||
|
{new Date(row.due).toLocaleDateString(undefined, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td>{formatStatus(row.status)}</td>
|
||||||
|
<td>
|
||||||
|
{#if row.finalGrade !== undefined}
|
||||||
|
<span class="bsplus-analytics-grade-pill">{gradeDisplay(row)}</span>
|
||||||
|
{:else}
|
||||||
|
{gradeDisplay(row)}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="text-align: center; padding: 2rem; color: var(--bsplus-analytics-muted)">
|
||||||
|
No assessments match your filters
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="bsplus-analytics-table-footer">
|
||||||
|
<label>
|
||||||
|
Rows per page
|
||||||
|
<select bind:value={itemsPerPage} onchange={() => (currentPage = 0)}>
|
||||||
|
{#each [5, 10, 20, 50] as n}
|
||||||
|
<option value={n}>{n}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bsplus-analytics-btn bsplus-analytics-btn-ghost"
|
||||||
|
style="padding: 0.4rem 0.85rem; font-size: 0.8125rem;"
|
||||||
|
disabled={currentPage === 0}
|
||||||
|
onclick={() => currentPage--}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span>Page {currentPage + 1} of {pageCount}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bsplus-analytics-btn bsplus-analytics-btn-ghost"
|
||||||
|
style="padding: 0.4rem 0.85rem; font-size: 0.8125rem;"
|
||||||
|
disabled={currentPage >= pageCount - 1}
|
||||||
|
onclick={() => currentPage++}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user