mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 19:54:39 +00:00
Compare commits
175 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fda0071251 | |||
| 8a5100c06f | |||
| cf2778951f | |||
| 6fa1af2f68 | |||
| b8286b6f22 | |||
| 8466ef7691 | |||
| d75959eeb1 | |||
| 94a2f4ac34 | |||
| 1647870186 | |||
| b332de52ff | |||
| daf7ea8e83 | |||
| 341087b6a0 | |||
| a0d8e05fd0 | |||
| 399f68c547 | |||
| 3ddf1d0c4f | |||
| 10a6c458b1 | |||
| 33825843b7 | |||
| 56dabc8fd5 | |||
| 0c1a71f398 | |||
| bb6ee72159 | |||
| d52a59ae48 | |||
| 1c9e361f78 | |||
| ec3396c52e | |||
| 5b5dba69dc | |||
| 449b54ae32 | |||
| c29dc45697 | |||
| d4127626b1 | |||
| 907f970018 | |||
| d9b1482255 | |||
| 454ab283ab | |||
| 0ef43eb9b5 | |||
| ecbdffbbde | |||
| 92344400e1 | |||
| ca20ba4e07 | |||
| 694d4ea0a1 | |||
| 72a529ee1d | |||
| 0a3ee5c666 | |||
| ef6176b6a4 | |||
| b3c395cca1 | |||
| 8c2539f130 | |||
| 442ea04a2f | |||
| bd812ffdae | |||
| 6377a0c909 | |||
| d8829d5716 | |||
| 7fd85a5529 | |||
| 9562368157 | |||
| ab867af57d | |||
| 886d0a95f1 | |||
| dd47deb954 | |||
| fbf066cea8 | |||
| eb2c665843 | |||
| 45a16de405 | |||
| 048ccb248e | |||
| 363fbfa3c8 | |||
| 0bf4ed8157 | |||
| 814647e835 | |||
| 07aa9524aa | |||
| 13f830ee16 | |||
| 1b4708261d | |||
| 6a556b6940 | |||
| d0edad8134 | |||
| 5e93ae6e4b | |||
| 0788b78e73 | |||
| e884b0526b | |||
| ea77224c75 | |||
| 18441712c9 | |||
| 3dc77dd398 | |||
| e7c5357c64 | |||
| 8df138a374 | |||
| 068e4ab778 | |||
| adbba730c4 | |||
| 1f3354c47b | |||
| 7a80dc2cc3 | |||
| 68e8c89b35 | |||
| 77582a4d00 | |||
| 3f97049451 | |||
| ebc7baaacc | |||
| 35ca292c04 | |||
| e928399066 | |||
| a4033862c9 | |||
| 22ddb4bc41 | |||
| b8d8b108c3 | |||
| aeaf5d9e59 | |||
| 1acda4f399 | |||
| 121888c1c3 | |||
| 647a32fbac | |||
| 19cc1a5600 | |||
| e3f4b59d9c | |||
| a07323499c | |||
| 600456f28e | |||
| 3ecd7205ed | |||
| 6147e96cc9 | |||
| 09855c9ef5 | |||
| 9542cb13f5 | |||
| d19f573093 | |||
| 7af6acaf38 | |||
| c4c50f2c30 | |||
| a33f4f3f00 | |||
| 1f023574b8 | |||
| dc4499e8a2 | |||
| ad2ad4d456 | |||
| 5413286f56 | |||
| f0c5b1dace | |||
| ad14dc3aa5 | |||
| 64bf1d88e8 | |||
| 7196a85f7d | |||
| f2b594a13b | |||
| a17a9a50c1 | |||
| 207832640f | |||
| b76999cb13 | |||
| fc0e491ea7 | |||
| 68159ddd0e | |||
| 4696529964 | |||
| a9e198ea68 | |||
| 620d168d28 | |||
| 1c63c06b72 | |||
| 7a76d3f4eb | |||
| 8e34db4a67 | |||
| 9fc24767ec | |||
| 331c9a9d81 | |||
| 74e92ddb53 | |||
| 1a6dc9ebb9 | |||
| be54816d83 | |||
| b644dbbbc7 | |||
| d06356101a | |||
| 7eacf345d0 | |||
| 9a71a5241a | |||
| f4ae9098d8 | |||
| 325f6c5f9b | |||
| ea46ab41ce | |||
| e6f36edabf | |||
| 587aa5eb89 | |||
| da3a680455 | |||
| 77c3761947 | |||
| 6fb4ea5372 | |||
| 5c0044a4d4 | |||
| dba688d3cd | |||
| 75446c6855 | |||
| fe2fa87cb5 | |||
| 9f7b46d2ad | |||
| ef890ee776 | |||
| d42dc79415 | |||
| e072b3f5c8 | |||
| e32218bf07 | |||
| 286375c662 | |||
| f2d197e8f0 | |||
| 85beb62a37 | |||
| 0b908cb251 | |||
| c9f0f9cf16 | |||
| 3c65e6d6c5 | |||
| 2cb607c5a9 | |||
| 695357a639 | |||
| 8cb052f2ff | |||
| 6b39f60db7 | |||
| 1638dd4989 | |||
| ca7e6b9137 | |||
| 1263c1c8ef | |||
| 5eb92bc87a | |||
| ecff10a991 | |||
| 4745df7ace | |||
| c7bdd86967 | |||
| f920980948 | |||
| 8c2f36033f | |||
| 75e687f934 | |||
| 5cd0f47fe5 | |||
| 84cfaccded | |||
| 0c55098bc7 | |||
| 50157f24fd | |||
| b77e2b2247 | |||
| 0c0fabe661 | |||
| f39bfce5c3 | |||
| 2d26f729e3 | |||
| d7b541c814 | |||
| 41bb5996df | |||
| d3d7a1199f |
@@ -1,23 +0,0 @@
|
|||||||
---
|
|
||||||
name: Report A bug.
|
|
||||||
about: Create a report of a present bug.
|
|
||||||
title: "[BUG]"
|
|
||||||
labels: bug
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Bug Description**
|
|
||||||
Please provide a clear and concise description of the bug.
|
|
||||||
|
|
||||||
**Steps to Reproduce**
|
|
||||||
Please list the steps taken to reproduce the issue.
|
|
||||||
|
|
||||||
**Expected Behavior**
|
|
||||||
Please describe the expected behaviour clearly and concisely.
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, please include any screenshots that may help clarify the issue.
|
|
||||||
|
|
||||||
**Additional Context**
|
|
||||||
Feel free to provide any additional context or information relevant to the problem.
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Report an issue with the modpack in its unmodified state. For other issues, use Discord.
|
||||||
|
labels: bug
|
||||||
|
title: "[BUG]"
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Before reporting an issue, [please search](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) to make sure it has not already been reported (make sure to search closed issues as well!).
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe the bug
|
||||||
|
description: Describe your issue. For general issues and questions you'll get a faster answer [from our Discord.](https://discord.gg/YzmbnCDkat)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Extension version
|
||||||
|
description: What version of the extension are you using?
|
||||||
|
placeholder: Find it by opening the config menu and clicking the about icon in the top right.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Browser
|
||||||
|
description: Which Browser are you using?
|
||||||
|
options:
|
||||||
|
- Chrome
|
||||||
|
- Firefox
|
||||||
|
- Brave
|
||||||
|
- Safari
|
||||||
|
- DuckDuckGO
|
||||||
|
- Microsoft Edge
|
||||||
|
- Other Chromium-Based Browser
|
||||||
|
- Other Non-Chromium-Based Browser
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Confirm
|
||||||
|
options:
|
||||||
|
- label: This bug report is about an issue with the extension itself. I have not modified the extension nor added any unsupported plugins. If this is not the case, I know that I should post the issue to the extension's Discord support channel instead.
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Screenshots, video or any other information. Include photos of the console if possible
|
||||||
|
placeholder: |
|
||||||
|
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
contact_links:
|
||||||
|
- name: BetterSEQTA Community Support
|
||||||
|
url: https://discord.gg/YzmbnCDkat
|
||||||
|
about: Join our discord for community updates, discussion, and more!
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: "[FR] "
|
|
||||||
labels: enhancement
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest a new Feature to be added or replaced in BetterSeqtaPLUS
|
||||||
|
labels: enhancement
|
||||||
|
title: "[FR]"
|
||||||
|
body:
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Confirm
|
||||||
|
options:
|
||||||
|
- label: "Is this feature request related to a Bug report?"
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Bug report link
|
||||||
|
description: "If this feature request is related to a bug report, please insert the link to the bug report here"
|
||||||
|
placeholder: "https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Feature details
|
||||||
|
Before you request a feature, [please search](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) if it has already been requested. (Make sure to check closed issues as well!)
|
||||||
|
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Feature type
|
||||||
|
multiple: false
|
||||||
|
options:
|
||||||
|
- Graphical
|
||||||
|
- Functional
|
||||||
|
- Not Sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Feature Details
|
||||||
|
description: Please write, with as much detail as possible, what you would like to see from this mod.
|
||||||
|
placeholder: I would like to see...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional details
|
||||||
|
description: Anything else you want to add?
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
---
|
|
||||||
name: Vulnerability
|
|
||||||
about: Report a vulnerability in this extension.
|
|
||||||
title: "[VUL] "
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**What is the vulnerability?**
|
|
||||||
Describe the vulnerability in concise language.
|
|
||||||
|
|
||||||
**Where is the vulnerability found?**
|
|
||||||
Describe where the vulnerability is found.
|
|
||||||
|
|
||||||
**What does this affect?**
|
|
||||||
Explain what it affects. E.G: It opens up my school email to the world. etc.
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
## Description
|
||||||
|
|
||||||
|
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
|
||||||
|
|
||||||
|
Fixes # (issue)
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
Please delete options that are not relevant.
|
||||||
|
|
||||||
|
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||||
|
- [ ] New feature (non-breaking change which adds functionality)
|
||||||
|
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||||
|
- [ ] This change requires a documentation update
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"plugins": {
|
|
||||||
"tailwindcss": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"semi": false
|
"semi": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,21 @@
|
|||||||
When contributing to this repository, please first discuss the change you wish to make via issue,
|
When contributing to this repository, please first discuss the change you wish to make via issue,
|
||||||
email, or any other method with the owners of this repository before making a change.
|
email, or any other method with the owners of this repository before making a change.
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
Join our community channels to discuss the project, get help, and connect with other contributors:
|
||||||
|
|
||||||
|
- **Discord Server**: [Join our Discord](https://discord.gg/betterseqta)
|
||||||
|
- **GitHub Discussions**: For longer-form conversations
|
||||||
|
- **GitHub Issues**: For bug reports and feature requests
|
||||||
|
|
||||||
|
## Creating Plugins
|
||||||
|
|
||||||
|
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
|
||||||
|
|
||||||
|
- [Creating Your First Plugin](./docs/plugins/creating-plugins.md)
|
||||||
|
- [Plugin API Reference](./docs/advanced/plugin-api.md)
|
||||||
|
|
||||||
## Pull Request Process
|
## Pull Request Process
|
||||||
|
|
||||||
1. It is recommended to start by opening an issue to discuss the change you wish to make. This will allow us to discuss the change and ensure it is a good fit for the project.
|
1. It is recommended to start by opening an issue to discuss the change you wish to make. This will allow us to discuss the change and ensure it is a good fit for the project.
|
||||||
|
|||||||
@@ -44,23 +44,18 @@
|
|||||||
- Assessments
|
- Assessments
|
||||||
- Options to remove certain items from the side menu
|
- Options to remove certain items from the side menu
|
||||||
- Grades calculator
|
- Grades calculator
|
||||||
- Fully customisable themes and an offical theme store
|
- Fully customisable themes and an official theme store
|
||||||
- Notification for next lesson (sent 5 minutes before end of the lesson)
|
- Notification for next lesson (sent 5 minutes before end of the lesson)
|
||||||
- Browser Support
|
- Browser Support
|
||||||
- Chrome Supported
|
- Chrome, Edge, Brave, Opera and other Chromium-Based browsers are supported
|
||||||
- Edge Supported
|
- Firefox Supported: [here](https://addons.mozilla.org/en-US/firefox/addon/betterseqta-plus/)!
|
||||||
- Brave Supported
|
- Safari (Experimental and not recommended - only available via compilation)
|
||||||
- Opera Supported
|
|
||||||
- Vivaldi Supported
|
|
||||||
- Chromium-based browsers are supported
|
|
||||||
- Firefox (Experimental - available [here](https://addons.mozilla.org/en-US/firefox/addon/betterseqta-plus/)
|
|
||||||
- Safari (Experimental - only available via compilation)
|
|
||||||
|
|
||||||
## 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://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).
|
||||||
|
|
||||||
Don't worry- if you get stuck feel free to ask around in the discord. 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 :)
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
@@ -70,20 +65,43 @@ Don't worry- if you get stuck feel free to ask around in the discord. We're open
|
|||||||
git clone https://github.com/BetterSEQTA/BetterSEQTA-Plus
|
git clone https://github.com/BetterSEQTA/BetterSEQTA-Plus
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running Development
|
|
||||||
|
|
||||||
1. Install dependencies
|
1. Install dependencies
|
||||||
|
|
||||||
|
You may install the dependencies like below:
|
||||||
|
|
||||||
```
|
```
|
||||||
npm install # or your preferred package manager like pnpm or yarn
|
npm install # or your preferred package manager like pnpm or yarn
|
||||||
```
|
```
|
||||||
|
|
||||||
|
But it is recommended to do it like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install --legacy-peer-deps # Only NPM supported
|
||||||
|
```
|
||||||
|
### Running Development
|
||||||
2. Run the dev script (it updates as you save files)
|
2. Run the dev script (it updates as you save files)
|
||||||
|
|
||||||
```
|
```
|
||||||
npm run dev
|
npm run dev # or use your perferred package manager
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Building for production
|
||||||
|
|
||||||
|
2. Run the build script
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run build # or use your perferred package manager
|
||||||
|
```
|
||||||
|
|
||||||
|
2.1. Package it up (optional)
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run zip # This REQUIRES 7-Zip to be installed in order to work. You can also use your perferred package manager
|
||||||
|
```
|
||||||
3. Load the extension into chrome
|
3. Load the extension into chrome
|
||||||
|
|
||||||
- Go to `chrome://extensions`
|
- Go to `chrome://extensions`
|
||||||
@@ -91,33 +109,15 @@ npm run dev
|
|||||||
- Click `Load unpacked`
|
- Click `Load unpacked`
|
||||||
- Select the `dist` folder
|
- Select the `dist` folder
|
||||||
|
|
||||||
Just remember, in order to update changes to the extension, you need to click the refresh button on the extension in `chrome://extensions` whenever anything's changed.
|
Just remember, in order to update changes to the extension if you are running in developer mode, you need to click the refresh button on the extension in `chrome://extensions` whenever anything's changed.
|
||||||
|
|
||||||
### Building for production
|
|
||||||
|
|
||||||
1. Install dependencies
|
|
||||||
|
|
||||||
```
|
|
||||||
npm install # or your preferred package manager like pnpm or yarn
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Run the build script
|
|
||||||
|
|
||||||
```
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Package it up (optional)
|
|
||||||
|
|
||||||
```
|
|
||||||
npm run zip # This requires 7-Zip to be installed in order to work
|
|
||||||
```
|
|
||||||
|
|
||||||
## Folder Structure
|
## Folder Structure
|
||||||
|
|
||||||
The folder structure is as follows:
|
The folder structure is as follows:
|
||||||
|
|
||||||
- The `src` folder contains source files that are compiled to the build directory.
|
- 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 `src/interface` folder contains source React & Svelte files that are required for the Settings page.
|
||||||
|
|
||||||
@@ -136,4 +136,4 @@ This extension was initially developed by [Nulkem](https://github.com/Nulkem/bet
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://star-history.com/#sethburkart123/EvenBetterSEQTA&Date)
|
[](https://star-history.com/#BetterSEQTA/BetterSEQTA-Plus&Date)
|
||||||
|
|||||||
+1
-1
@@ -12,4 +12,4 @@ Below here is the supported versions of BetterSEQTA+. Anything older than this i
|
|||||||
`*` May not work on other devices.
|
`*` May not work on other devices.
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you find vulnerabilities, REPORT IT IMMEDIATELY. Make an issue and use the template provided for vulnerabilities.
|
If you find vulnerabilities, REPORT IT IMMEDIATELY. open the [advisories tab](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories) on the left and click the green "report a vulnerability" button or use [this quick-link](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories/new) to create a new report
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# BetterSEQTA+ Documentation
|
||||||
|
|
||||||
|
🚧 DOCS UNDER CONSTRUCTION! 🚧
|
||||||
|
|
||||||
|
Welcome to the BetterSEQTA+ documentation! This documentation will help you understand how BetterSEQTA+ works and how to extend it with plugins and new features.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
- [Project Overview](./README.md) - This file
|
||||||
|
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+
|
||||||
|
- [Contributing Guide](../CONTRIBUTING.md) - How to contribute to BetterSEQTA+
|
||||||
|
|
||||||
|
### Plugin System
|
||||||
|
- [Creating Your First Plugin](./plugins/README.md) - A comprehensive, beginner-friendly guide to creating plugins
|
||||||
|
- [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs
|
||||||
|
|
||||||
|
## 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!
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
3. **Storage API**: Plugins can use the Storage API to persist data between sessions. The Storage API is also type-safe, ensuring that plugins can only access their own data.
|
||||||
|
|
||||||
|
4. **SEQTA Integration**: BetterSEQTA+ integrates with SEQTA Learn by injecting code into the page. This allows plugins to modify the SEQTA UI and add new features.
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
If you need help with BetterSEQTA+, you can:
|
||||||
|
|
||||||
|
- [Open an Issue](https://github.com/SeqtaLearning/betterseqta-plus/issues) - Report bugs or request features
|
||||||
|
- [Join the Discord](https://discord.gg/YzmbnCDkat) - Chat with the community
|
||||||
|
- [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly
|
||||||
|
|
||||||
|
## Contributing to the Documentation
|
||||||
|
|
||||||
|
We welcome contributions to the documentation! If you find something unclear or missing, please open an issue or submit a pull request.
|
||||||
|
|
||||||
|
To contribute to the documentation:
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Make your changes to the documentation files
|
||||||
|
3. Submit a pull request with a clear description of your changes
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
BetterSEQTA+ is licensed under the [MIT License](../LICENSE).
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# Contributing to BetterSEQTA+
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to BetterSEQTA+! This document provides guidelines and instructions for contributing to the project.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Code of Conduct](#code-of-conduct)
|
||||||
|
- [Getting Started](#getting-started)
|
||||||
|
- [Setting Up Your Development Environment](#setting-up-your-development-environment)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Contributing Code](#contributing-code)
|
||||||
|
- [Branching Strategy](#branching-strategy)
|
||||||
|
- [Pull Request Process](#pull-request-process)
|
||||||
|
- [Coding Standards](#coding-standards)
|
||||||
|
- [Reporting Bugs](#reporting-bugs)
|
||||||
|
- [Suggesting Features](#suggesting-features)
|
||||||
|
- [Writing Documentation](#writing-documentation)
|
||||||
|
- [Community](#community)
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
BetterSEQTA+ is committed to providing a welcoming and inclusive environment for all contributors. We expect all participants to adhere to our Code of Conduct, which promotes respectful and harassment-free interaction.
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- Be respectful and inclusive
|
||||||
|
- Focus on what is best for the community
|
||||||
|
- Show empathy towards other community members
|
||||||
|
- Be open to constructive feedback
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Setting Up Your Development Environment
|
||||||
|
|
||||||
|
1. **Fork the Repository**
|
||||||
|
|
||||||
|
Start by forking the BetterSEQTA+ repository to your GitHub account.
|
||||||
|
|
||||||
|
2. **Clone Your Fork**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/betterseqta-plus.git
|
||||||
|
cd betterseqta-plus
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Install Dependencies**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Set Up Development Environment**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Install in Chrome/Firefox**
|
||||||
|
|
||||||
|
Follow the [installation instructions](./installation.md#development-installation) to load the development version into your browser.
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
Understanding the project structure will help you navigate the codebase:
|
||||||
|
|
||||||
|
```
|
||||||
|
betterseqta-plus/
|
||||||
|
├── src/ # Source code
|
||||||
|
│ ├── plugins/ # Plugin system
|
||||||
|
│ │ ├── built-in/ # Built-in plugins
|
||||||
|
│ │ ├── core/ # Plugin core functionality
|
||||||
|
│ ├── settings/ # Settings system
|
||||||
|
│ ├── utils/ # Utility functions
|
||||||
|
│ ├── extension/ # Browser extension code
|
||||||
|
├── docs/ # Documentation
|
||||||
|
├── test/ # Test files
|
||||||
|
├── dist/ # Build output (generated)
|
||||||
|
├── package.json # Project dependencies
|
||||||
|
├── tsconfig.json # TypeScript configuration
|
||||||
|
└── README.md # Project README
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing Code
|
||||||
|
|
||||||
|
### Branching Strategy
|
||||||
|
|
||||||
|
We follow a simple branching strategy:
|
||||||
|
|
||||||
|
- `main` - The main development branch
|
||||||
|
- `feature/*` - Feature branches
|
||||||
|
- `bugfix/*` - Bug fix branches
|
||||||
|
- `docs/*` - Documentation branches
|
||||||
|
|
||||||
|
Always create a new branch for your changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/my-new-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pull Request Process
|
||||||
|
|
||||||
|
1. **Keep PRs Focused**
|
||||||
|
|
||||||
|
Each pull request should address a single concern. If you're working on multiple features, create separate PRs for each.
|
||||||
|
|
||||||
|
2. **Write Clear Commit Messages**
|
||||||
|
|
||||||
|
Follow the conventional commits format:
|
||||||
|
```
|
||||||
|
feat: add new feature
|
||||||
|
fix: resolve bug with timetable
|
||||||
|
docs: update installation instructions
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Update Documentation**
|
||||||
|
|
||||||
|
If your changes require documentation updates, include them in the same PR.
|
||||||
|
|
||||||
|
4. **Run Tests**
|
||||||
|
|
||||||
|
Make sure all tests pass before submitting your PR:
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Submit Your PR**
|
||||||
|
|
||||||
|
When you're ready, push your branch and create a pull request on GitHub.
|
||||||
|
|
||||||
|
6. **Code Review**
|
||||||
|
|
||||||
|
All PRs will be reviewed by maintainers. Be responsive to feedback and make requested changes.
|
||||||
|
|
||||||
|
7. **Merge**
|
||||||
|
|
||||||
|
Once approved, a maintainer will merge your PR.
|
||||||
|
|
||||||
|
### Coding Standards
|
||||||
|
|
||||||
|
We follow TypeScript best practices and have a consistent code style:
|
||||||
|
|
||||||
|
1. **Use TypeScript**
|
||||||
|
|
||||||
|
All new code should be written in TypeScript with proper typing.
|
||||||
|
|
||||||
|
2. **Follow Existing Patterns**
|
||||||
|
|
||||||
|
Match the coding style of the existing codebase.
|
||||||
|
|
||||||
|
3. **Write Tests**
|
||||||
|
|
||||||
|
Add tests for new features and bug fixes.
|
||||||
|
|
||||||
|
4. **Document Your Code**
|
||||||
|
|
||||||
|
Add comments for complex logic and JSDoc comments for functions.
|
||||||
|
|
||||||
|
5. **Use Linters**
|
||||||
|
|
||||||
|
We use ESLint and Prettier. Run them before submitting your PR:
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run format
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reporting Bugs
|
||||||
|
|
||||||
|
If you find a bug, please report it by creating an issue on GitHub:
|
||||||
|
|
||||||
|
1. **Search Existing Issues**
|
||||||
|
|
||||||
|
Check if the bug has already been reported.
|
||||||
|
|
||||||
|
2. **Use the Bug Report Template**
|
||||||
|
|
||||||
|
Fill in all sections of the bug report template:
|
||||||
|
- Description
|
||||||
|
- Steps to reproduce
|
||||||
|
- Expected behavior
|
||||||
|
- Actual behavior
|
||||||
|
- Screenshots (if applicable)
|
||||||
|
- Environment (browser, OS, etc.)
|
||||||
|
|
||||||
|
3. **Be Specific**
|
||||||
|
|
||||||
|
The more details you provide, the easier it will be to fix the bug.
|
||||||
|
|
||||||
|
## Suggesting Features
|
||||||
|
|
||||||
|
We welcome feature suggestions! To suggest a new feature:
|
||||||
|
|
||||||
|
1. **Search Existing Suggestions**
|
||||||
|
|
||||||
|
Check if your idea has already been suggested.
|
||||||
|
|
||||||
|
2. **Use the Feature Request Template**
|
||||||
|
|
||||||
|
Fill in all sections of the feature request template:
|
||||||
|
- Description
|
||||||
|
- Use case
|
||||||
|
- Potential implementation
|
||||||
|
- Alternatives considered
|
||||||
|
|
||||||
|
3. **Be Patient**
|
||||||
|
|
||||||
|
Feature requests are evaluated based on alignment with project goals, feasibility, and maintainer bandwidth.
|
||||||
|
|
||||||
|
## Writing Documentation
|
||||||
|
|
||||||
|
Good documentation is crucial for the project. To contribute to documentation:
|
||||||
|
|
||||||
|
1. **Identify Gaps**
|
||||||
|
|
||||||
|
Look for areas where documentation is missing or unclear.
|
||||||
|
|
||||||
|
2. **Follow Documentation Style**
|
||||||
|
|
||||||
|
Maintain a consistent style and format.
|
||||||
|
|
||||||
|
3. **Use Clear Language**
|
||||||
|
|
||||||
|
Write in simple, clear English. Avoid jargon when possible.
|
||||||
|
|
||||||
|
4. **Include Examples**
|
||||||
|
|
||||||
|
Code examples and screenshots help users understand.
|
||||||
|
|
||||||
|
5. **Submit a PR**
|
||||||
|
|
||||||
|
Follow the same process as code contributions, but create a branch with a `docs/` prefix.
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
Join our community channels to discuss the project, get help, and connect with other contributors:
|
||||||
|
|
||||||
|
- **Discord Server**: [Join our Discord](https://discord.gg/betterseqta)
|
||||||
|
- **GitHub Discussions**: For longer-form conversations
|
||||||
|
- **GitHub Issues**: For bug reports and feature requests
|
||||||
|
|
||||||
|
## Creating Plugins
|
||||||
|
|
||||||
|
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
|
||||||
|
|
||||||
|
- [Creating Your First Plugin](./plugins/creating-plugins.md)
|
||||||
|
- [Plugin API Reference](./advanced/plugin-api.md)
|
||||||
|
|
||||||
|
## Recognition
|
||||||
|
|
||||||
|
Contributors are recognized in several ways:
|
||||||
|
|
||||||
|
1. **CONTRIBUTORS.md**: All contributors are listed in this file
|
||||||
|
2. **Release Notes**: Significant contributions are highlighted in release notes
|
||||||
|
3. **Community Recognition**: Regular shout-outs in community channels
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
If you have any questions about contributing, please:
|
||||||
|
|
||||||
|
1. Check the documentation
|
||||||
|
2. Ask in the Discord server
|
||||||
|
3. Open a GitHub Discussion
|
||||||
|
|
||||||
|
Thank you for contributing to BetterSEQTA+! Your efforts help make SEQTA better for students and teachers everywhere.
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Installing BetterSEQTA+
|
||||||
|
|
||||||
|
This guide will walk you through the process of installing and setting up BetterSEQTA+ for development or usage.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before you begin, make sure you have the following installed:
|
||||||
|
|
||||||
|
- [npm](https://www.npmjs.com/) (v7 or higher) or [Bun](https://bun.sh/) (recommended)
|
||||||
|
- A modern web browser (Chrome, Firefox, Edge, etc.)
|
||||||
|
|
||||||
|
## Installation Methods
|
||||||
|
|
||||||
|
There are two ways to install BetterSEQTA+:
|
||||||
|
|
||||||
|
1. **For Users**: Install the browser extension
|
||||||
|
2. **For Developers**: Clone the repository and set up the development environment
|
||||||
|
|
||||||
|
## For Users: Installing the Browser Extension
|
||||||
|
|
||||||
|
BetterSEQTA+ is available as a browser extension for Chrome, Firefox, and Edge.
|
||||||
|
|
||||||
|
### Chrome/Edge
|
||||||
|
|
||||||
|
1. Visit the [Chrome Web Store page for BetterSEQTA+](https://chrome.google.com/webstore/detail/betterseqta)
|
||||||
|
2. Click the "Add to Chrome" button
|
||||||
|
3. Confirm the installation when prompted
|
||||||
|
4. The extension will be installed and ready to use
|
||||||
|
|
||||||
|
### Firefox
|
||||||
|
|
||||||
|
1. Visit the [Firefox Add-ons page for BetterSEQTA+](https://addons.mozilla.org/en-US/firefox/addon/betterseqta)
|
||||||
|
2. Click the "Add to Firefox" button
|
||||||
|
3. Confirm the installation when prompted
|
||||||
|
4. The extension will be installed and ready to use
|
||||||
|
|
||||||
|
## For Developers: Setting Up the Development Environment
|
||||||
|
|
||||||
|
If you want to develop for BetterSEQTA+ or modify the code, follow these steps:
|
||||||
|
|
||||||
|
### 1. Clone the Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/SeqtaLearning/betterseqta-plus.git
|
||||||
|
cd betterseqta-plus
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install Dependencies
|
||||||
|
|
||||||
|
Using npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --legacy-peer-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
Using Bun (recommended):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Set Up Environment Variables - Only required for pushing to extension stores from the command line
|
||||||
|
|
||||||
|
Copy the example environment file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.submit.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit the `.env` file with your SEQTA credentials and settings.
|
||||||
|
|
||||||
|
### 4. Start the Development Server
|
||||||
|
|
||||||
|
Using npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Using Bun:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start a development server and build the extension in watch mode.
|
||||||
|
|
||||||
|
### 5. Load the Extension in Your Browser
|
||||||
|
|
||||||
|
#### Chrome/Edge
|
||||||
|
|
||||||
|
1. Open Chrome/Edge and navigate to `chrome://extensions` or `edge://extensions`
|
||||||
|
2. Enable "Developer mode" using the toggle in the top right
|
||||||
|
3. Click "Load unpacked" and select the `dist` folder in your BetterSEQTA+ directory
|
||||||
|
4. The extension should now appear in your extensions list
|
||||||
|
|
||||||
|
#### Firefox
|
||||||
|
|
||||||
|
1. Open Firefox and navigate to `about:debugging#/runtime/this-firefox`
|
||||||
|
2. Click "Load Temporary Add-on..."
|
||||||
|
3. Select the `manifest.json` file in the `dist` folder
|
||||||
|
4. The extension should now appear in your add-ons list
|
||||||
|
|
||||||
|
### 6. Test Your Changes
|
||||||
|
|
||||||
|
After making changes to the code, the development server will automatically rebuild the extension. However, you may need to reload the extension in your browser to see the changes:
|
||||||
|
|
||||||
|
1. Go to the extensions page in your browser
|
||||||
|
2. Find BetterSEQTA+ and click the reload icon
|
||||||
|
3. Refresh any SEQTA Learn pages you have open
|
||||||
|
|
||||||
|
## Troubleshooting Installation
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### "Cannot find module" errors
|
||||||
|
|
||||||
|
If you see errors about missing modules, try:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Bun:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf node_modules
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Extension not appearing in SEQTA
|
||||||
|
|
||||||
|
Make sure:
|
||||||
|
- You're visiting a SEQTA Learn page
|
||||||
|
- The extension is enabled
|
||||||
|
- You've refreshed the page after installing the extension
|
||||||
|
|
||||||
|
#### Development build not updating
|
||||||
|
|
||||||
|
Try:
|
||||||
|
1. Stopping the development server
|
||||||
|
2. Clearing your browser cache
|
||||||
|
3. Removing the extension from your browser
|
||||||
|
4. Rebuilding the extension
|
||||||
|
5. Loading it again
|
||||||
|
|
||||||
|
## Updating BetterSEQTA+
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
|
||||||
|
Browser extensions update automatically, but you can manually check for updates:
|
||||||
|
|
||||||
|
- **Chrome/Edge**: Go to `chrome://extensions` or `edge://extensions`, enable Developer mode, and click "Update"
|
||||||
|
- **Firefox**: Go to `about:addons`, click the gear icon, and select "Check for Updates"
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
|
||||||
|
If you're working on the code, pull the latest changes and reinstall dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Bun:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
bun install
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Now that you have BetterSEQTA+ installed, you can:
|
||||||
|
|
||||||
|
- [Getting Started with Plugins](./plugins/getting-started.md)
|
||||||
|
- [Contribute to the project](../CONTRIBUTING.md)
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
# Creating Plugins for BetterSEQTA+
|
||||||
|
|
||||||
|
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?
|
||||||
|
|
||||||
|
In BetterSEQTA+, a plugin is like a mini-app that adds new features to SEQTA. Think of it as a piece of LEGO that you can snap onto SEQTA to make it do new things. For example, you could create a plugin that:
|
||||||
|
- Changes how SEQTA looks
|
||||||
|
- Adds new buttons or features
|
||||||
|
- Shows extra information on your timetable
|
||||||
|
- Collects notifications in a better way
|
||||||
|
- Really, anything you can imagine!
|
||||||
|
|
||||||
|
## Your First Plugin
|
||||||
|
|
||||||
|
Let's create a super simple plugin together. We'll make one that adds a friendly message to the SEQTA homepage. Here's what we'll need:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Plugin } from '@/plugins/core/types';
|
||||||
|
|
||||||
|
const myFirstPlugin: Plugin = {
|
||||||
|
// Every plugin needs these basic details
|
||||||
|
id: 'my-first-plugin',
|
||||||
|
name: 'My First Plugin',
|
||||||
|
description: 'Adds a friendly message to SEQTA',
|
||||||
|
version: '1.0.0',
|
||||||
|
|
||||||
|
// This tells BetterSEQTA+ that users can turn our plugin on/off
|
||||||
|
disableToggle: true,
|
||||||
|
|
||||||
|
// This is where the magic happens!
|
||||||
|
run: async (api) => {
|
||||||
|
// Wait for the homepage to load
|
||||||
|
api.seqta.onMount('.home-page', (homePage) => {
|
||||||
|
// Create our message
|
||||||
|
const message = document.createElement('div');
|
||||||
|
message.textContent = 'Hello from my first plugin! 🎉';
|
||||||
|
message.style.padding = '20px';
|
||||||
|
message.style.backgroundColor = '#e9f5ff';
|
||||||
|
message.style.borderRadius = '8px';
|
||||||
|
message.style.margin = '20px';
|
||||||
|
|
||||||
|
// Add it to the page
|
||||||
|
homePage.prepend(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return a cleanup function that removes our message when the plugin is disabled
|
||||||
|
return () => {
|
||||||
|
const message = document.querySelector('.home-page > div');
|
||||||
|
message?.remove();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default myFirstPlugin;
|
||||||
|
```
|
||||||
|
|
||||||
|
Let's break down what's happening here:
|
||||||
|
|
||||||
|
1. First, we import the `Plugin` type that tells TypeScript what a plugin should look like
|
||||||
|
2. We create our plugin object with some basic information:
|
||||||
|
- `id`: A unique name for your plugin (use lowercase and dashes)
|
||||||
|
- `name`: A friendly name that users will see
|
||||||
|
- `description`: Explain what your plugin does
|
||||||
|
- `version`: Your plugin's version number
|
||||||
|
3. We set `disableToggle: true` so users can turn our plugin on/off in settings
|
||||||
|
4. The `run` function is where we put our plugin's code
|
||||||
|
5. We use `api.seqta.onMount` to wait for the homepage to load
|
||||||
|
6. We create and style a message element
|
||||||
|
7. We return a cleanup function that removes our changes when the plugin is disabled
|
||||||
|
|
||||||
|
## The Plugin API
|
||||||
|
|
||||||
|
When your plugin runs, it gets access to a powerful API that lets you do all sorts of things. Let's look at what you can do:
|
||||||
|
|
||||||
|
### SEQTA API (`api.seqta`)
|
||||||
|
|
||||||
|
This helps you interact with SEQTA's pages:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Wait for an element to appear on the page
|
||||||
|
api.seqta.onMount('.some-class', (element) => {
|
||||||
|
// Do something with the element
|
||||||
|
});
|
||||||
|
|
||||||
|
// Know when the user changes pages
|
||||||
|
api.seqta.onPageChange((page) => {
|
||||||
|
console.log('User went to:', page);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the current page
|
||||||
|
const currentPage = api.seqta.getCurrentPage();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings API (`api.settings`)
|
||||||
|
|
||||||
|
Want to let users customize your plugin? Use settings!
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BasePlugin } from '@/plugins/core/settings';
|
||||||
|
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
|
||||||
|
|
||||||
|
// Define your settings
|
||||||
|
const settings = defineSettings({
|
||||||
|
showMessage: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Show Welcome Message",
|
||||||
|
description: "Show a friendly message on the homepage",
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a class for your plugin
|
||||||
|
class MyPluginClass extends BasePlugin<typeof settings> {
|
||||||
|
@Setting(settings.showMessage)
|
||||||
|
showMessage!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create your plugin
|
||||||
|
const settingsInstance = new MyPluginClass();
|
||||||
|
|
||||||
|
const myPlugin: Plugin<typeof settings> = {
|
||||||
|
// ... other plugin details ...
|
||||||
|
settings: settingsInstance.settings,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
// Use the setting
|
||||||
|
if (api.settings.showMessage) {
|
||||||
|
// Show the message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for setting changes
|
||||||
|
api.settings.onChange('showMessage', (newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
// Show the message
|
||||||
|
} else {
|
||||||
|
// Hide the message
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage API (`api.storage`)
|
||||||
|
|
||||||
|
Need to save some data? The storage API has got you covered:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Save some data
|
||||||
|
await api.storage.set('lastVisit', new Date().toISOString());
|
||||||
|
|
||||||
|
// Get it back later
|
||||||
|
const lastVisit = await api.storage.get('lastVisit');
|
||||||
|
|
||||||
|
// Listen for changes
|
||||||
|
api.storage.onChange('lastVisit', (newValue) => {
|
||||||
|
console.log('Last visit updated:', newValue);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Events API (`api.events`)
|
||||||
|
|
||||||
|
Want your plugin to be able to interface with other plugins? Then use events!
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Listen for an event
|
||||||
|
api.events.on('myCustomEvent', (data) => {
|
||||||
|
console.log('Got event:', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send an event
|
||||||
|
api.events.emit('myCustomEvent', { some: 'data' });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding Styles
|
||||||
|
|
||||||
|
Want to make your plugin look pretty? You can add CSS styles:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const myPlugin: Plugin = {
|
||||||
|
// ... other plugin details ...
|
||||||
|
|
||||||
|
// Add your CSS here
|
||||||
|
styles: `
|
||||||
|
.my-plugin-message {
|
||||||
|
background: linear-gradient(135deg, #6e8efb, #a777e3);
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
margin: 20px;
|
||||||
|
animation: slide-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in {
|
||||||
|
from { transform: translateY(-20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
// Your plugin code here
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
Here are some tips to make your plugin awesome:
|
||||||
|
|
||||||
|
1. **Always Clean Up**: When your plugin is disabled, clean up any changes you made:
|
||||||
|
```typescript
|
||||||
|
run: async (api) => {
|
||||||
|
// Add stuff to the page
|
||||||
|
const element = document.createElement('div');
|
||||||
|
document.body.appendChild(element);
|
||||||
|
|
||||||
|
// Return a cleanup function
|
||||||
|
return () => {
|
||||||
|
element.remove();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use TypeScript**: It helps catch errors before they happen and makes your code easier to understand.
|
||||||
|
|
||||||
|
3. **Test Your Plugin**: Make sure it works in different situations:
|
||||||
|
- When SEQTA is loading
|
||||||
|
- When the user switches pages
|
||||||
|
- When the plugin is enabled/disabled
|
||||||
|
- When settings are changed
|
||||||
|
|
||||||
|
4. **Keep It Fast**: Don't slow down SEQTA:
|
||||||
|
- Use `onMount` instead of intervals or timeouts
|
||||||
|
- Clean up event listeners when they're not needed
|
||||||
|
- Don't do heavy calculations on the main thread
|
||||||
|
|
||||||
|
5. **Make It User-Friendly**:
|
||||||
|
- Add clear settings with good descriptions
|
||||||
|
- Use `disableToggle: true` so users can turn it off if needed
|
||||||
|
- Add helpful error messages if something goes wrong
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Want to see more examples? Check out our built-in plugins:
|
||||||
|
- [themes](../../src/plugins/built-in/themes/index.ts): Shows how to change SEQTA's appearance
|
||||||
|
- [notificationCollector](../../src/plugins/built-in/notificationCollector/index.ts): Shows how to work with SEQTA's notifications
|
||||||
|
- [timetable](../../src/plugins/built-in/timetable/index.ts): Shows how to modify SEQTA's timetable view
|
||||||
|
- [assessmentsAverage](../../src/plugins/built-in/assessmentsAverage/index.ts): Shows how to add new features to existing pages
|
||||||
|
|
||||||
|
## Need Help?
|
||||||
|
|
||||||
|
Got stuck? No worries! Here's where you can get help:
|
||||||
|
- Join our [Discord server](https://discord.gg/YzmbnCDkat)
|
||||||
|
- Check out the built-in plugins in the `src/plugins/built-in` folder
|
||||||
|
- Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues)
|
||||||
|
|
||||||
|
Happy coding and feel free to checkout the api reference [here](./api-reference.md)
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
## Plugin Structure
|
||||||
|
|
||||||
|
Here's how a plugin is structured:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Plugin } from '@/plugins/core/types';
|
||||||
|
import { BasePlugin } from '@/plugins/core/settings';
|
||||||
|
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
|
||||||
|
|
||||||
|
// First, define your settings
|
||||||
|
const settings = defineSettings({
|
||||||
|
enabled: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Enable Feature",
|
||||||
|
description: "Turn this feature on or off",
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a class to handle your settings
|
||||||
|
class MyPluginClass extends BasePlugin<typeof settings> {
|
||||||
|
@Setting(settings.enabled)
|
||||||
|
enabled!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an instance of your settings
|
||||||
|
const settingsInstance = new MyPluginClass();
|
||||||
|
|
||||||
|
// Create your plugin
|
||||||
|
const myPlugin: Plugin<typeof settings> = {
|
||||||
|
id: 'my-plugin',
|
||||||
|
name: 'My Plugin',
|
||||||
|
description: 'A cool plugin that does things',
|
||||||
|
version: '1.0.0',
|
||||||
|
settings: settingsInstance.settings,
|
||||||
|
disableToggle: true,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
console.log('Plugin is running!');
|
||||||
|
|
||||||
|
// Do stuff when settings change
|
||||||
|
api.settings.onChange('enabled', (enabled) => {
|
||||||
|
if (enabled) {
|
||||||
|
console.log('Feature enabled!');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return a cleanup function
|
||||||
|
return () => {
|
||||||
|
console.log('Plugin cleanup');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default myPlugin;
|
||||||
|
```
|
||||||
|
|
||||||
|
## SEQTA API
|
||||||
|
|
||||||
|
The SEQTA API helps you interact with SEQTA's pages:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Plugin } from '@/plugins/core/types';
|
||||||
|
|
||||||
|
const seqtaPlugin: Plugin<typeof settings> = {
|
||||||
|
id: 'seqta-example',
|
||||||
|
name: 'SEQTA Example',
|
||||||
|
description: 'Shows how to use the SEQTA API',
|
||||||
|
version: '1.0.0',
|
||||||
|
settings: {},
|
||||||
|
disableToggle: true,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
// Wait for elements to appear
|
||||||
|
const { unregister: timetableUnregister } = api.seqta.onMount('.timetable', (timetable) => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.textContent = 'Export';
|
||||||
|
timetable.appendChild(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track page changes
|
||||||
|
const { unregister: pageUnregister } = api.seqta.onPageChange((page) => {
|
||||||
|
console.log('User went to:', page);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up when disabled
|
||||||
|
return () => {
|
||||||
|
timetableUnregister();
|
||||||
|
pageUnregister();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default seqtaPlugin;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Settings API
|
||||||
|
|
||||||
|
Here's how to add settings to your plugin:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Plugin } from '@/plugins/core/types';
|
||||||
|
import { BasePlugin } from '@/plugins/core/settings';
|
||||||
|
import { booleanSetting, stringSetting, numberSetting, selectSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
|
||||||
|
|
||||||
|
// Define your settings
|
||||||
|
const settings = defineSettings({
|
||||||
|
darkMode: booleanSetting({
|
||||||
|
default: false,
|
||||||
|
title: "Dark Mode",
|
||||||
|
description: "Enable dark mode"
|
||||||
|
}),
|
||||||
|
userName: stringSetting({
|
||||||
|
default: "",
|
||||||
|
title: "User Name",
|
||||||
|
description: "Your display name",
|
||||||
|
placeholder: "Enter your name..."
|
||||||
|
}),
|
||||||
|
theme: selectSetting({
|
||||||
|
default: "light",
|
||||||
|
title: "Theme",
|
||||||
|
description: "Choose your theme",
|
||||||
|
options: [
|
||||||
|
{ value: "light", label: "Light" },
|
||||||
|
{ value: "dark", label: "Dark" }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create your settings class
|
||||||
|
class ThemePluginClass extends BasePlugin<typeof settings> {
|
||||||
|
@Setting(settings.darkMode)
|
||||||
|
darkMode!: boolean;
|
||||||
|
|
||||||
|
@Setting(settings.userName)
|
||||||
|
userName!: string;
|
||||||
|
|
||||||
|
@Setting(settings.theme)
|
||||||
|
theme!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the plugin
|
||||||
|
const themePlugin: Plugin<typeof settings> = {
|
||||||
|
id: 'theme-example',
|
||||||
|
name: 'Theme Example',
|
||||||
|
description: 'Shows how to use settings',
|
||||||
|
version: '1.0.0',
|
||||||
|
settings: new ThemePluginClass().settings,
|
||||||
|
disableToggle: true,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
// Apply initial settings
|
||||||
|
if (api.settings.darkMode) {
|
||||||
|
document.body.classList.add('dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for changes
|
||||||
|
const { unregister } = api.settings.onChange('darkMode', (enabled) => {
|
||||||
|
document.body.classList.toggle('dark', enabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unregister();
|
||||||
|
document.body.classList.remove('dark');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default themePlugin;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage API
|
||||||
|
|
||||||
|
Here's how to use storage in your plugin:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Plugin } from '@/plugins/core/types';
|
||||||
|
|
||||||
|
const storagePlugin: Plugin<typeof settings> = {
|
||||||
|
id: 'storage-example',
|
||||||
|
name: 'Storage Example',
|
||||||
|
description: 'Shows how to use storage',
|
||||||
|
version: '1.0.0',
|
||||||
|
settings: {},
|
||||||
|
disableToggle: true,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
// Wait for storage to be ready
|
||||||
|
await api.storage.loaded;
|
||||||
|
|
||||||
|
// Save some data
|
||||||
|
await api.storage.set('lastVisit', new Date().toISOString());
|
||||||
|
|
||||||
|
// Get saved data
|
||||||
|
const lastVisit = await api.storage.get('lastVisit');
|
||||||
|
console.log('Last visit:', lastVisit);
|
||||||
|
|
||||||
|
// Listen for changes
|
||||||
|
const { unregister } = api.storage.onChange('lastVisit', (newValue) => {
|
||||||
|
console.log('Last visit updated:', newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unregister();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default storagePlugin;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Events API
|
||||||
|
|
||||||
|
Here's how to use events in your plugin:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Plugin } from '@/plugins/core/types';
|
||||||
|
|
||||||
|
const eventsPlugin: Plugin<typeof settings> = {
|
||||||
|
id: 'events-example',
|
||||||
|
name: 'Events Example',
|
||||||
|
description: 'Shows how to use events',
|
||||||
|
version: '1.0.0',
|
||||||
|
settings: {},
|
||||||
|
disableToggle: true,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
// Listen for theme changes
|
||||||
|
const { unregister: themeListener } = api.events.on('theme.changed', (theme) => {
|
||||||
|
console.log('Theme changed to:', theme);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for notifications
|
||||||
|
const { unregister: notifyListener } = api.events.on('notification.new', (notification) => {
|
||||||
|
console.log('New notification:', notification);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up listeners
|
||||||
|
return () => {
|
||||||
|
themeListener();
|
||||||
|
notifyListener();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default eventsPlugin;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
Here's how to write efficient plugins:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Plugin } from '@/plugins/core/types';
|
||||||
|
|
||||||
|
const efficientPlugin: Plugin<typeof settings> = {
|
||||||
|
id: 'efficient-example',
|
||||||
|
name: 'Efficient Example',
|
||||||
|
description: 'Shows performance best practices',
|
||||||
|
version: '1.0.0',
|
||||||
|
settings: {},
|
||||||
|
disableToggle: true,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
// ✅ Good: Use onMount
|
||||||
|
const { unregister } = api.seqta.onMount('.timetable', (el) => {
|
||||||
|
el.classList.add('enhanced');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ❌ Bad: Don't use intervals
|
||||||
|
// const interval = setInterval(() => {
|
||||||
|
// const el = document.querySelector('.timetable');
|
||||||
|
// if (el) el.classList.add('enhanced');
|
||||||
|
// }, 100);
|
||||||
|
|
||||||
|
// ✅ Good: Cache DOM elements
|
||||||
|
const header = document.querySelector('.header');
|
||||||
|
if (header) {
|
||||||
|
// Reuse header instead of querying again
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Good: Batch DOM updates
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
fragment.appendChild(div);
|
||||||
|
}
|
||||||
|
document.body.appendChild(fragment);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unregister();
|
||||||
|
// clearInterval(interval); // If you used the bad approach
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default efficientPlugin;
|
||||||
|
```
|
||||||
|
|
||||||
|
Each plugin should be in its own file and exported as the default export. The plugin should:
|
||||||
|
1. Import necessary types and helpers
|
||||||
|
2. Define settings if needed
|
||||||
|
3. Create a settings class if using settings
|
||||||
|
4. Create the plugin object with proper type annotation
|
||||||
|
5. Export the plugin as default
|
||||||
|
|
||||||
|
Remember to always:
|
||||||
|
- Use proper TypeScript types
|
||||||
|
- Clean up when your plugin is disabled
|
||||||
|
- Handle errors gracefully
|
||||||
|
- Follow the plugin structure shown above
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// vite-plugin-inline-worker-dev.ts
|
||||||
|
import { Plugin } from 'vite'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import { build, transform } from 'esbuild'
|
||||||
|
|
||||||
|
export default function InlineWorkerDevPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'vite:inline-worker-dev',
|
||||||
|
async load(id) {
|
||||||
|
if (id.includes('?inlineWorker')) {
|
||||||
|
const [cleanPath] = id.split('?')
|
||||||
|
console.log('cleanPath', cleanPath)
|
||||||
|
const code = await fs.readFile(cleanPath, 'utf-8')
|
||||||
|
const result = await build({
|
||||||
|
entryPoints: [cleanPath],
|
||||||
|
bundle: true,
|
||||||
|
write: false,
|
||||||
|
platform: 'browser',
|
||||||
|
format: 'iife',
|
||||||
|
target: 'esnext',
|
||||||
|
})
|
||||||
|
|
||||||
|
const workerCode = result.outputFiles[0].text
|
||||||
|
|
||||||
|
const workerBlobCode = `
|
||||||
|
const code = ${JSON.stringify(workerCode)};
|
||||||
|
export default function InlineWorker() {
|
||||||
|
const blob = new Blob([code], { type: 'application/javascript' });
|
||||||
|
return new Worker(URL.createObjectURL(blob), { type: 'module' });
|
||||||
|
}
|
||||||
|
`
|
||||||
|
return workerBlobCode
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,6 @@ export function updateManifestPlugin(): PluginOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs.watchFile(manifestPath, () => {
|
fs.watchFile(manifestPath, () => {
|
||||||
console.log('** watchFile **');
|
|
||||||
try {
|
try {
|
||||||
const manifestContents = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
const manifestContents = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||||
if (manifestContents.web_accessible_resources?.some((resource: any) => resource.use_dynamic_url)) {
|
if (manifestContents.web_accessible_resources?.some((resource: any) => resource.use_dynamic_url)) {
|
||||||
|
|||||||
+24
-6
@@ -5,26 +5,44 @@ const path = require('path');
|
|||||||
|
|
||||||
function getLatestVersion(files) {
|
function getLatestVersion(files) {
|
||||||
console.log('Files passed to getLatestVersion:', files);
|
console.log('Files passed to getLatestVersion:', files);
|
||||||
|
|
||||||
const versions = files.map(file => {
|
const versions = files.map(file => {
|
||||||
const match = file.match(/@(\d+\.\d+\.\d+)-/);
|
const match = file.match(/@([\d\.]+)-/);
|
||||||
console.log('Matching file:', file, 'Version found:', match ? match[1] : 'None');
|
console.log('Matching file:', file, 'Version found:', match ? match[1] : 'None');
|
||||||
return match ? match[1] : null;
|
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const fullVersion = match[1]; // Original version (e.g., 3.4.5.1)
|
||||||
|
const semverVersion = fullVersion.split('.').slice(0, 3).join('.'); // Trim to 3.4.5
|
||||||
|
|
||||||
|
return { fullVersion, semverVersion };
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
|
|
||||||
console.log('Extracted versions:', versions);
|
console.log('Extracted versions:', versions.map(v => v.semverVersion));
|
||||||
const latestVersion = semver.maxSatisfying(versions, '*');
|
|
||||||
console.log('Latest version:', latestVersion);
|
// Find latest version using the trimmed semver format
|
||||||
|
const latestSemver = semver.maxSatisfying(versions.map(v => v.semverVersion), '*');
|
||||||
|
console.log('Latest SemVer-compatible version:', latestSemver);
|
||||||
|
|
||||||
|
// Get the full version that matches the latest SemVer version
|
||||||
|
const latestVersion = versions.find(v => v.semverVersion === latestSemver)?.fullVersion || null;
|
||||||
|
|
||||||
|
console.log('Final selected latest version:', latestVersion);
|
||||||
return latestVersion;
|
return latestVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatestFiles(browser) {
|
function getLatestFiles(browser) {
|
||||||
const pattern = `dist/betterseqtaplus@*-*${browser}.zip`;
|
const pattern = `dist/betterseqtaplus@*-*${browser}.zip`;
|
||||||
console.log('Glob pattern:', pattern);
|
console.log('Glob pattern:', pattern);
|
||||||
|
|
||||||
const files = glob.sync(pattern);
|
const files = glob.sync(pattern);
|
||||||
console.log('Files found for browser', browser, ':', files);
|
console.log('Files found for browser', browser, ':', files);
|
||||||
|
|
||||||
const latestVersion = getLatestVersion(files);
|
const latestVersion = getLatestVersion(files);
|
||||||
|
|
||||||
const latestFile = files.find(file => file.includes(latestVersion));
|
// Find the exact file by matching the original full version
|
||||||
|
const latestFile = files.find(file => file.includes(`@${latestVersion}-`));
|
||||||
|
|
||||||
console.log('Latest file for browser', browser, ':', latestFile);
|
console.log('Latest file for browser', browser, ':', latestFile);
|
||||||
return latestFile;
|
return latestFile;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
export default function touchGlobalCSSPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'touch-global-css',
|
||||||
|
handleHotUpdate({ modules }) {
|
||||||
|
// log all of the staticImportedUrls
|
||||||
|
const importers = modules[0]._clientModule.importers
|
||||||
|
importers.forEach((importer) => {
|
||||||
|
if (importer.file.includes('.css')) {
|
||||||
|
console.log("touching", importer.file)
|
||||||
|
fs.utimesSync(importer.file, new Date(), new Date())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+49
-41
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "betterseqtaplus",
|
"name": "betterseqtaplus",
|
||||||
"version": "3.4.5",
|
"version": "3.4.6.1",
|
||||||
"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 add add heaps more features!",
|
||||||
"browserslist": "> 0.5%, last 2 versions, not dead",
|
"browserslist": "> 0.5%, last 2 versions, not dead",
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"build:chrome": "cross-env MODE=chrome vite build",
|
"build:chrome": "cross-env MODE=chrome vite build",
|
||||||
"build:firefox": "cross-env MODE=firefox vite build",
|
"build:firefox": "cross-env MODE=firefox vite build",
|
||||||
"build:safari": "cross-env MODE=safari vite build",
|
"build:safari": "cross-env MODE=safari vite build",
|
||||||
|
"build:dev": "cross-env MODE=chrome SOURCEMAP=true vite build && cross-env MODE=firefox SOURCEMAP=true vite build",
|
||||||
"convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari",
|
"convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari",
|
||||||
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
|
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
|
||||||
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
|
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
|
||||||
@@ -32,66 +33,73 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-transform-runtime": "^7.25.9",
|
"@babel/plugin-transform-runtime": "^7.26.9",
|
||||||
"@babel/runtime": "^7.26.7",
|
"@babel/runtime": "^7.26.9",
|
||||||
"@bedframe/cli": "^0.0.85",
|
"@bedframe/cli": "^0.0.91",
|
||||||
"@crxjs/vite-plugin": "2.0.0-beta.25",
|
"@crxjs/vite-plugin": "2.0.0-beta.25",
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^2.1.4",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
"@types/react": "^19.0.10",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"dependency-cruiser": "^16.10.0",
|
"dependency-cruiser": "^16.10.0",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "9.22.0",
|
||||||
"glob": "^11.0.1",
|
"glob": "^11.0.1",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.5.3",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
"publish-browser-extension": "^3.0.0",
|
"publish-browser-extension": "^3.0.0",
|
||||||
"sass": "^1.83.4",
|
"sass": "^1.85.1",
|
||||||
"sass-loader": "^13.3.3",
|
"sass-loader": "^16.0.5",
|
||||||
"semver": "^7.7.1",
|
"semver": "^7.7.1",
|
||||||
|
"tailwindcss": "3",
|
||||||
"url": "^0.11.4"
|
"url": "^0.11.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-css": "^6.3.0",
|
"@codemirror/autocomplete": "^6.18.6",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@codemirror/commands": "^6.8.0",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
|
"@codemirror/language": "^6.10.8",
|
||||||
|
"@codemirror/search": "^6.5.10",
|
||||||
|
"@codemirror/state": "^6.5.2",
|
||||||
|
"@codemirror/view": "^6.36.4",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tsconfig/svelte": "^5.0.4",
|
"@tsconfig/svelte": "^5.0.4",
|
||||||
"@types/chrome": "^0.0.270",
|
"@types/chrome": "^0.0.308",
|
||||||
"@types/color": "^3.0.6",
|
"@types/color": "^4.2.0",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/lodash": "^4.17.16",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/node": "^22.13.10",
|
||||||
"@types/node": "^20.17.17",
|
|
||||||
"@types/react": "^17.0.83",
|
|
||||||
"@types/react-dom": "^17.0.26",
|
|
||||||
"@types/sortablejs": "^1.15.8",
|
"@types/sortablejs": "^1.15.8",
|
||||||
"@types/uuid": "^9.0.8",
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/webextension-polyfill": "^0.10.7",
|
"@types/webextension-polyfill": "^0.12.3",
|
||||||
"@uiw/codemirror-extensions-color": "^4.23.8",
|
"@uiw/codemirror-extensions-color": "^4.23.10",
|
||||||
"@uiw/codemirror-theme-github": "^4.23.8",
|
"@uiw/codemirror-theme-github": "^4.23.10",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"autoprefixer": "^10.4.21",
|
||||||
"autoprefixer": "^10.4.20",
|
"client-vector-search": "../client-vector-search",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"color": "^4.2.3",
|
"color": "^5.0.0",
|
||||||
"dompurify": "^3.1.6",
|
"dompurify": "^3.2.4",
|
||||||
"embla-carousel-autoplay": "^8.3.1",
|
"embla-carousel-autoplay": "^8.5.2",
|
||||||
"embla-carousel-svelte": "^8.3.1",
|
"embla-carousel-svelte": "^8.5.2",
|
||||||
"fuse.js": "^7.0.0",
|
"events": "^3.3.0",
|
||||||
"idb": "^8.0.0",
|
"flexsearch": "^0.8.147",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
|
"idb": "^8.0.2",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"mathjs": "^14.4.0",
|
||||||
"million": "^3.1.11",
|
"million": "^3.1.11",
|
||||||
"motion": "^11.12.0",
|
"motion": "^12.4.12",
|
||||||
"postcss": "^8.4.45",
|
"postcss": "^8.5.3",
|
||||||
"react": "17",
|
"react": "17",
|
||||||
"react-best-gradient-color-picker": "^3.0.10",
|
"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.3",
|
"sortablejs": "^1.15.6",
|
||||||
"svelte": "^5.1.9",
|
"svelte": "^5.22.6",
|
||||||
"tailwindcss": "^3.4.11",
|
"typescript": "^5.8.2",
|
||||||
"typescript": "^5.6.2",
|
"uuid": "^11.1.0",
|
||||||
"uuid": "^9.0.1",
|
"vite": "^6.2.1",
|
||||||
"vite": "^5.4.14",
|
"webextension-polyfill": "^0.12.0"
|
||||||
"webextension-polyfill": "^0.10.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
--- a/Users/sethburkart/Documents/Coding/betterseqta-plus/src/plugins/core/settings.ts
|
||||||
|
+++ b/Users/sethburkart/Documents/Coding/betterseqta-plus/src/plugins/core/settings.ts
|
||||||
|
@@ -2,7 +2,7 @@
|
||||||
|
|
||||||
|
// Base interfaces for our settings
|
||||||
|
interface BaseSettingOptions {
|
||||||
|
- title: string;
|
||||||
|
+ readonly title: string; // Mark as readonly where appropriate
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -11,21 +11,21 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StringSettingOptions extends BaseSettingOptions {
|
||||||
|
- default: string;
|
||||||
|
+ readonly default: string;
|
||||||
|
maxLength?: number;
|
||||||
|
pattern?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NumberSettingOptions extends BaseSettingOptions {
|
||||||
|
- default: number;
|
||||||
|
+ readonly default: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectSettingOptions<T extends string> extends BaseSettingOptions {
|
||||||
|
- default: T;
|
||||||
|
- options: readonly T[];
|
||||||
|
+ readonly default: T;
|
||||||
|
+ readonly options: readonly T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The actual decorators
|
||||||
|
@@ -34,14 +34,16 @@
|
||||||
|
// Ensure the settings property exists on the constructor's prototype
|
||||||
|
const proto = target.constructor.prototype;
|
||||||
|
if (!proto.hasOwnProperty('settings')) {
|
||||||
|
- proto.settings = {};
|
||||||
|
+ // Initialize with a base type that can be extended
|
||||||
|
+ Object.defineProperty(proto, 'settings', {
|
||||||
|
+ value: {},
|
||||||
|
+ writable: true, // Allows adding properties
|
||||||
|
+ configurable: true,
|
||||||
|
+ enumerable: true
|
||||||
|
+ });
|
||||||
|
}
|
||||||
|
-
|
||||||
|
+
|
||||||
|
// Add the setting to the prototype's settings object with const assertion
|
||||||
|
proto.settings[propertyKey] = {
|
||||||
|
type: 'boolean' as const,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
- };
|
||||||
|
-}
|
||||||
|
-
|
||||||
|
-export function StringSetting(options: StringSettingOptions): PropertyDecorator {
|
||||||
|
- return (target: Object, propertyKey: string | symbol) => {
|
||||||
|
- // Ensure the settings property exists on the constructor's prototype
|
||||||
|
- const proto = target.constructor.prototype;
|
||||||
|
- if (!proto.hasOwnProperty('settings')) {
|
||||||
|
- proto.settings = {};
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
- // Add the setting to the prototype's settings object with const assertion
|
||||||
|
- proto.settings[propertyKey] = {
|
||||||
|
- type: 'string' as const,
|
||||||
|
- ...options
|
||||||
|
- };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -50,14 +52,16 @@
|
||||||
|
// Ensure the settings property exists on the constructor's prototype
|
||||||
|
const proto = target.constructor.prototype;
|
||||||
|
if (!proto.hasOwnProperty('settings')) {
|
||||||
|
- proto.settings = {};
|
||||||
|
+ Object.defineProperty(proto, 'settings', {
|
||||||
|
+ value: {},
|
||||||
|
+ writable: true,
|
||||||
|
+ configurable: true,
|
||||||
|
+ enumerable: true
|
||||||
|
+ });
|
||||||
|
}
|
||||||
|
-
|
||||||
|
+
|
||||||
|
// Add the setting to the prototype's settings object with const assertion
|
||||||
|
proto.settings[propertyKey] = {
|
||||||
|
type: 'number' as const,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
- };
|
||||||
|
-}
|
||||||
|
-
|
||||||
|
-export function SelectSetting<T extends string>(options: SelectSettingOptions<T>): PropertyDecorator {
|
||||||
|
- return (target: Object, propertyKey: string | symbol) => {
|
||||||
|
- // Ensure the settings property exists on the constructor's prototype
|
||||||
|
- const proto = target.constructor.prototype;
|
||||||
|
- if (!proto.hasOwnProperty('settings')) {
|
||||||
|
- proto.settings = {};
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
- // Add the setting to the prototype's settings object with const assertion
|
||||||
|
- proto.settings[propertyKey] = {
|
||||||
|
- type: 'select' as const,
|
||||||
|
- ...options
|
||||||
|
- };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base plugin class that handles settings
|
||||||
|
export abstract class BasePlugin<T extends PluginSettings = PluginSettings> {
|
||||||
|
// The settings property will be populated by decorators
|
||||||
|
- settings!: T;
|
||||||
|
-
|
||||||
|
+ // Keep the instance property and constructor logic as is,
|
||||||
|
+ // as changing it would require changing animated-background/index.ts
|
||||||
|
+ settings!: T; // Use definite assignment assertion
|
||||||
|
+
|
||||||
|
constructor() {
|
||||||
|
// Copy settings from the prototype to the instance
|
||||||
|
// This ensures that each instance has its own settings object
|
||||||
+32
-3106
File diff suppressed because it is too large
Load Diff
+50
-41
@@ -14,7 +14,7 @@ function reloadSeqtaPages() {
|
|||||||
result.then(open, console.error)
|
result.then(open, console.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main message listener
|
// @ts-ignore
|
||||||
browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (response?: any) => void) => {
|
browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (response?: any) => void) => {
|
||||||
|
|
||||||
switch (request.type) {
|
switch (request.type) {
|
||||||
@@ -38,7 +38,7 @@ browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (resp
|
|||||||
sendResponse(response);
|
sendResponse(response);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return true; // Keep message channel open for async response
|
return true;
|
||||||
|
|
||||||
case 'githubTab':
|
case 'githubTab':
|
||||||
browser.tabs.create({ url: 'github.com/BetterSEQTA/BetterSEQTA-Plus' });
|
browser.tabs.create({ url: 'github.com/BetterSEQTA/BetterSEQTA-Plus' });
|
||||||
@@ -49,13 +49,14 @@ browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (resp
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'sendNews':
|
case 'sendNews':
|
||||||
|
|
||||||
fetchNews(request.source ?? 'australia', sendResponse);
|
fetchNews(request.source ?? 'australia', sendResponse);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log('Unknown request type');
|
console.log('Unknown request type');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const DefaultValues: SettingsState = {
|
const DefaultValues: SettingsState = {
|
||||||
@@ -64,7 +65,6 @@ const DefaultValues: SettingsState = {
|
|||||||
bksliderinput: "50",
|
bksliderinput: "50",
|
||||||
transparencyEffects: false,
|
transparencyEffects: false,
|
||||||
lessonalert: true,
|
lessonalert: true,
|
||||||
notificationcollector: true,
|
|
||||||
defaultmenuorder: [],
|
defaultmenuorder: [],
|
||||||
menuitems: {
|
menuitems: {
|
||||||
assessments: { toggle: true },
|
assessments: { toggle: true },
|
||||||
@@ -154,54 +154,63 @@ function SetStorageValue(object: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function UpdateCurrentValues() {
|
function convertBksliderToSpeed(bksliderinput: number): number {
|
||||||
try {
|
const minBase = 50;
|
||||||
const items = await browser.storage.local.get();
|
const maxBase = 150;
|
||||||
const CurrentValues = items;
|
|
||||||
|
|
||||||
const NewValue = Object.assign({}, DefaultValues, CurrentValues);
|
const scaledValue = 2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4;
|
||||||
|
const baseSpeed = 3;
|
||||||
|
|
||||||
function CheckInnerElement(element: any) {
|
const speed = baseSpeed / scaledValue;
|
||||||
for (let i in element) {
|
return speed;
|
||||||
if (typeof element[i] === 'object') {
|
}
|
||||||
// @ts-expect-error
|
|
||||||
if (!Array.isArray(DefaultValues[i])) {
|
async function migrateLegacySettings() {
|
||||||
// @ts-expect-error
|
const storage = await browser.storage.local.get(null) as unknown as SettingsState;
|
||||||
NewValue[i] = Object.assign({}, DefaultValues[i], CurrentValues[i]);
|
|
||||||
|
// Animated Background Migration
|
||||||
|
if ('animatedbk' in storage || 'bksliderinput' in storage) {
|
||||||
|
const animatedSettings = {
|
||||||
|
enabled: storage.animatedbk ?? true,
|
||||||
|
speed: storage.bksliderinput ? convertBksliderToSpeed(parseFloat(storage.bksliderinput)) : 1
|
||||||
|
};
|
||||||
|
await browser.storage.local.set({ 'plugin.animated-background.settings': animatedSettings });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assessments Average Migration
|
||||||
|
if ('assessmentsAverage' in storage || 'lettergrade' in storage) {
|
||||||
|
const assessmentsSettings = {
|
||||||
|
enabled: storage.assessmentsAverage ?? true,
|
||||||
|
lettergrade: storage.lettergrade ?? false
|
||||||
|
};
|
||||||
|
await browser.storage.local.set({ 'plugin.assessments-average.settings': assessmentsSettings });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('selectedTheme' in storage) {
|
||||||
|
const themesSettings = { enabled: true };
|
||||||
|
await browser.storage.local.set({ 'plugin.themes.settings': themesSettings });
|
||||||
|
}
|
||||||
|
if (storage.notificationCollector !== false) {
|
||||||
|
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: true } });
|
||||||
} else {
|
} else {
|
||||||
// @ts-expect-error
|
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: false } });
|
||||||
const length = DefaultValues[i].length;
|
|
||||||
// @ts-expect-error
|
|
||||||
NewValue[i] = Object.assign({}, DefaultValues[i], CurrentValues[i]);
|
|
||||||
let NewArray = [];
|
|
||||||
for (let j = 0; j < length; j++) {
|
|
||||||
NewArray.push(NewValue[i][j]);
|
|
||||||
}
|
|
||||||
NewValue[i] = NewArray;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CheckInnerElement(DefaultValues);
|
const keysToRemove = [
|
||||||
|
'animatedbk',
|
||||||
if (items['customshortcuts']) {
|
'bksliderinput',
|
||||||
NewValue['customshortcuts'] = items['customshortcuts'];
|
'assessmentsAverage',
|
||||||
}
|
'lettergrade'
|
||||||
|
];
|
||||||
SetStorageValue(NewValue);
|
await browser.storage.local.remove(keysToRemove);
|
||||||
console.log('[BetterSEQTA+] Values updated successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[BetterSEQTA+] Error updating values:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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']);
|
||||||
|
|
||||||
UpdateCurrentValues();
|
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 });
|
||||||
|
migrateLegacySettings();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-10
@@ -19,20 +19,25 @@ const rssFeedsByCountry: Record<string, string[]> = {
|
|||||||
"https://www.npr.org/rss/rss.php",
|
"https://www.npr.org/rss/rss.php",
|
||||||
],
|
],
|
||||||
taiwan: [
|
taiwan: [
|
||||||
"https://focustaiwan.tw/rss",
|
"https://news.ltn.com.tw/rss/all.xml",
|
||||||
"https://www.taipeitimes.com/rss/all.xml",
|
"https://www.taipeitimes.com/xml/index.rss",
|
||||||
"https://international.thenewslens.com/rss",
|
"https://international.thenewslens.com/rss",
|
||||||
],
|
],
|
||||||
hong_kong: [
|
hong_kong: [
|
||||||
"https://news.rthk.hk/rthk/en/rss.htm",
|
"https://rthk9.rthk.hk/rthk/news/rss/e_expressnews_elocal.xml",
|
||||||
"https://www.scmp.com/rss/91/feed",
|
"https://www.scmp.com/rss/91/feed",
|
||||||
],
|
],
|
||||||
panama: [
|
panama: [
|
||||||
"http://www.panama-guide.com/backend.php",
|
"https://critica.com.pa/rss.xml",
|
||||||
|
"https://www.panamaamerica.com.pa/rss.xml",
|
||||||
|
"https://noticiassin.com/feed/",
|
||||||
|
"https://elcapitalfinanciero.com/feed/"
|
||||||
],
|
],
|
||||||
canada: [
|
canada: [
|
||||||
"https://www.cbc.ca/cmlink/rss-topstories",
|
"https://www.cbc.ca/cmlink/rss-topstories",
|
||||||
"https://www.theglobeandmail.com/?service=rss",
|
"https://calgaryherald.com/feed",
|
||||||
|
"https://ottawacitizen.com/feed",
|
||||||
|
"https://www.montrealgazette.com/feed"
|
||||||
],
|
],
|
||||||
singapore: [
|
singapore: [
|
||||||
"https://www.straitstimes.com/news/singapore/rss.xml",
|
"https://www.straitstimes.com/news/singapore/rss.xml",
|
||||||
@@ -43,19 +48,16 @@ const rssFeedsByCountry: Record<string, string[]> = {
|
|||||||
"https://www.theguardian.com/uk/rss",
|
"https://www.theguardian.com/uk/rss",
|
||||||
],
|
],
|
||||||
japan: [
|
japan: [
|
||||||
"https://www.japantimes.co.jp/feed/topstories.xml",
|
|
||||||
"https://www3.nhk.or.jp/nhkworld/en/news/feeds/",
|
"https://www3.nhk.or.jp/nhkworld/en/news/feeds/",
|
||||||
|
"https://news.livedoor.com/topics/rss/int.xml"
|
||||||
],
|
],
|
||||||
netherlands: [
|
netherlands: [
|
||||||
"https://www.dutchnews.nl/feed/",
|
"https://www.dutchnews.nl/feed/",
|
||||||
"http://feeds.nos.nl/nosnieuwsalgemeen",
|
"https://www.nrc.nl/rss/"
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchNews(source: string, sendResponse: any) {
|
export async function fetchNews(source: string, sendResponse: any) {
|
||||||
const parser = new Parser();
|
|
||||||
let feeds: string[];
|
|
||||||
|
|
||||||
if (source === "australia") {
|
if (source === "australia") {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
|
|
||||||
@@ -72,6 +74,10 @@ export async function fetchNews(source: string, sendResponse: any) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parser = new Parser();
|
||||||
|
let feeds: string[];
|
||||||
|
console.log('fetchNews', source)
|
||||||
|
|
||||||
if (rssFeedsByCountry[source.toLowerCase()]) {
|
if (rssFeedsByCountry[source.toLowerCase()]) {
|
||||||
// If the source is a country, fetch from predefined feeds
|
// If the source is a country, fetch from predefined feeds
|
||||||
feeds = rssFeedsByCountry[source.toLowerCase()];
|
feeds = rssFeedsByCountry[source.toLowerCase()];
|
||||||
|
|||||||
+261
-140
@@ -147,7 +147,7 @@ html {
|
|||||||
border-radius: 17px 17px 0px 0 !important;
|
border-radius: 17px 17px 0px 0 !important;
|
||||||
color: var(--text-color) !important;
|
color: var(--text-color) !important;
|
||||||
}
|
}
|
||||||
.LegacyModuleBody__LegacyModule___20YE2 {
|
[class*="LegacyModuleBody__LegacyModule___"] {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
#AddedSettings {
|
#AddedSettings {
|
||||||
@@ -192,17 +192,17 @@ html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.PillBox__PillBox___3GjAk {
|
[class*="PillBox__PillBox___"] {
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.PillBox__active___3Qpi9 {
|
[class*="PillBox__active___"] {
|
||||||
background: rgba(0, 0, 0, 0.2) !important;
|
background: rgba(0, 0, 0, 0.2) !important;
|
||||||
color: black !important;
|
color: black !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .PillBox__active___3Qpi9 {
|
.dark [class*="PillBox__active___"] {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +261,10 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ais-btnSearch {
|
.ais-btnSearch {
|
||||||
transition: background 200ms, color 200ms, box-shadow 200ms;
|
transition:
|
||||||
|
background 200ms,
|
||||||
|
color 200ms,
|
||||||
|
box-shadow 200ms;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.2) !important;
|
background: rgba(0, 0, 0, 0.2) !important;
|
||||||
@@ -274,7 +277,7 @@ html {
|
|||||||
font-family: Rubik, sans-serif !important;
|
font-family: Rubik, sans-serif !important;
|
||||||
&::before {
|
&::before {
|
||||||
font-size: 18px !important;
|
font-size: 18px !important;
|
||||||
content: 'Search' !important;
|
content: "Search" !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,8 +386,7 @@ ul.magicDelete > li.deleting {
|
|||||||
width: 28px !important;
|
width: 28px !important;
|
||||||
height: 28px !important;
|
height: 28px !important;
|
||||||
}
|
}
|
||||||
.notifications__items___2hCdv,
|
[class*="notifications__items___"] {
|
||||||
#menu ul {
|
|
||||||
-ms-overflow-style: none !important;
|
-ms-overflow-style: none !important;
|
||||||
scrollbar-width: none !important;
|
scrollbar-width: none !important;
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
@@ -468,7 +470,7 @@ html {
|
|||||||
[data-type="student"] .header {
|
[data-type="student"] .header {
|
||||||
color: black !important;
|
color: black !important;
|
||||||
}
|
}
|
||||||
ol:has(.MessageList__avatar___2wxyb svg) {
|
ol:has([class*="MessageList__avatar___"] svg) {
|
||||||
transition-duration: 150ms !important;
|
transition-duration: 150ms !important;
|
||||||
transition-delay: 0ms !important;
|
transition-delay: 0ms !important;
|
||||||
}
|
}
|
||||||
@@ -494,7 +496,7 @@ ol:has(.MessageList__avatar___2wxyb svg) {
|
|||||||
background: var(--background-primary) !important;
|
background: var(--background-primary) !important;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
.MessageList__MessageList___3DxoC .footer {
|
[class*="MessageList__MessageList___"] .footer {
|
||||||
background: var(--background-secondary) !important;
|
background: var(--background-secondary) !important;
|
||||||
}
|
}
|
||||||
.listWrapper {
|
.listWrapper {
|
||||||
@@ -756,15 +758,15 @@ ol > [data-label] {
|
|||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.Message__Message___3oJaU > .uiFrameWrapper .iframeWrapper {
|
[class*="Message__Message___"] > .uiFrameWrapper .iframeWrapper {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.Viewer__newMessage___3ToUb {
|
[class*="Viewer__newMessage___"] {
|
||||||
border-radius: 8px !important;
|
border-radius: 8px !important;
|
||||||
font-size: 12.8px !important;
|
font-size: 12.8px !important;
|
||||||
background: var(--background-primary) !important;
|
background: var(--background-primary) !important;
|
||||||
}
|
}
|
||||||
.MessageList__sender___32riy :last-child {
|
[class*="MessageList__sender___"] :last-child {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
[data-type="student"] [style="z-index: 30;"] .header:has(h1) {
|
[data-type="student"] [style="z-index: 30;"] .header:has(h1) {
|
||||||
@@ -863,7 +865,7 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.LabelList__LabelList___2RJFf > li {
|
[class*="LabelList__LabelList___"] > li {
|
||||||
border-radius: 8px !important;
|
border-radius: 8px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -883,10 +885,10 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
.welcome
|
.welcome
|
||||||
> .portalPageView
|
> .portalPageView
|
||||||
> .powerPortalPage
|
> .powerPortalPage
|
||||||
> .Body__body___3pGxr
|
> [class*="Body__body___"]
|
||||||
> .Container__container___33GlY
|
> [class*="Container__container___"]
|
||||||
> .Document__document___1KJCG
|
> [class*="Document__document___"]
|
||||||
> .Canvas__canvas___OBdCZ {
|
> [class*="Canvas__canvas___"] {
|
||||||
background-color: unset !important;
|
background-color: unset !important;
|
||||||
background-image: unset !important;
|
background-image: unset !important;
|
||||||
background-size: unset;
|
background-size: unset;
|
||||||
@@ -896,7 +898,7 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
color: var(--text-primary) !important;
|
color: var(--text-primary) !important;
|
||||||
}
|
}
|
||||||
.Module__wrapper___2sbOo {
|
[class*="Module__wrapper___"] {
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
background: var(--background-primary) !important;
|
background: var(--background-primary) !important;
|
||||||
border-radius: 16px !important;
|
border-radius: 16px !important;
|
||||||
@@ -908,10 +910,10 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.composer
|
.composer
|
||||||
> .Body__body___3pGxr
|
> [class*="Body__body___"]
|
||||||
> .Container__container___33GlY
|
> [class*="Container__container___"]
|
||||||
> .Document__document___1KJCG
|
> [class*="Document__document___"]
|
||||||
> .Canvas__canvas___OBdCZ {
|
> [class*="Canvas__canvas___"] {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
background-image: unset !important;
|
background-image: unset !important;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
@@ -1007,34 +1009,6 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
margin-right: 157.5px;
|
margin-right: 157.5px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.bg {
|
|
||||||
animation: slide 3s ease-in-out infinite alternate;
|
|
||||||
background: var(--better-main);
|
|
||||||
bottom: 0;
|
|
||||||
left: -50%;
|
|
||||||
opacity: 0.5;
|
|
||||||
position: fixed;
|
|
||||||
right: -50%;
|
|
||||||
top: 0;
|
|
||||||
z-index: 0 !important;
|
|
||||||
overflow: hidden;
|
|
||||||
scale: 1.5;
|
|
||||||
}
|
|
||||||
.bg2 {
|
|
||||||
animation-direction: alternate-reverse;
|
|
||||||
animation-duration: 4s;
|
|
||||||
}
|
|
||||||
.bg3 {
|
|
||||||
animation-duration: 5s;
|
|
||||||
}
|
|
||||||
@keyframes slide {
|
|
||||||
0% {
|
|
||||||
transform: translate(50%) rotate(-60deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(5%) rotate(-60deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.home-root {
|
.home-root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1199,7 +1173,7 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
box-shadow: inset 0px 5px 20px 1px rgba(0, 0, 0, 0.3);
|
box-shadow: inset 0px 5px 20px 1px rgba(0, 0, 0, 0.3);
|
||||||
background: var(--background-primary);
|
background: var(--background-primary);
|
||||||
}
|
}
|
||||||
.Empty__Empty___2F6rn {
|
[class*="Empty__Empty___"] {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.shortcut-container {
|
.shortcut-container {
|
||||||
@@ -1401,18 +1375,18 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
margin: 20px auto 0px;
|
margin: 20px auto 0px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.dark .notifications__detailsBody___2nU2k > .notifications__subtitle___1se8e {
|
.dark [class*="notifications__detailsBody___"] > [class*="notifications__subtitle___"] {
|
||||||
color: #c1bcbc;
|
color: #c1bcbc;
|
||||||
}
|
}
|
||||||
.notifications__detailsBody___2nU2k > .notifications__subtitle___1se8e {
|
[class*="notifications__detailsBody___"] > [class*="notifications__subtitle___"] {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.notifications__notifications___3mmLY.notifications__hasItems___gXxzx > button {
|
[class*="notifications__notifications___"] > button {
|
||||||
background: white;
|
background: white;
|
||||||
z-index: 21 !important;
|
z-index: 21 !important;
|
||||||
color: var(--better-sub);
|
color: var(--better-sub);
|
||||||
}
|
}
|
||||||
.notifications__notifications___3mmLY > button {
|
[class*="notifications__notifications___"] > button {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
.legacy-root button > svg,
|
.legacy-root button > svg,
|
||||||
@@ -1420,9 +1394,7 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
height: 25px;
|
height: 25px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
}
|
}
|
||||||
.notifications__notifications___3mmLY
|
[class*="notifications__notifications___"] > button > [class*="notifications__bubble___"] {
|
||||||
> button
|
|
||||||
> .notifications__bubble___1EkSQ {
|
|
||||||
background: var(--better-alert-highlight);
|
background: var(--better-alert-highlight);
|
||||||
width: 25px;
|
width: 25px;
|
||||||
height: 25px;
|
height: 25px;
|
||||||
@@ -1440,16 +1412,16 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
.legacy-root button:not([disabled]):focus {
|
.legacy-root button:not([disabled]):focus {
|
||||||
border-color: var(--better-sub);
|
border-color: var(--better-sub);
|
||||||
}
|
}
|
||||||
.notifications__list___rp2L2 {
|
[class*="notifications__list___"] {
|
||||||
border: 4px solid var(--auto-background);
|
border: 4px solid var(--auto-background);
|
||||||
background: var(--background-primary);
|
background: var(--background-primary);
|
||||||
}
|
}
|
||||||
.notifications__item___2ErJN {
|
[class*="notifications__item___"] {
|
||||||
background: var(--background-primary) !important;
|
background: var(--background-primary) !important;
|
||||||
border-left: 4px solid var(--better-main) !important;
|
border-left: 4px solid var(--better-main) !important;
|
||||||
margin-bottom: 4px !important;
|
margin-bottom: 4px !important;
|
||||||
|
|
||||||
> .notifications__dismiss___zveKV {
|
> [class*="notifications__dismiss___"] {
|
||||||
background: rgba(0, 0, 0, 0.1) !important;
|
background: rgba(0, 0, 0, 0.1) !important;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: auto 0;
|
margin: auto 0;
|
||||||
@@ -1474,7 +1446,7 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
#menu li:first-child {
|
#menu li:first-child {
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
.notifications__actions___1UX7r {
|
[class*="notifications__actions___"] {
|
||||||
background: var(--auto-background);
|
background: var(--auto-background);
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -1482,27 +1454,27 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
border: 1px solid white;
|
border: 1px solid white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.notifications__items___2hCdv {
|
[class*="notifications__items___"] {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
height: 540px;
|
height: 540px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.notifications__details___193F4 {
|
[class*="notifications__details___"] {
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
}
|
}
|
||||||
.notifications__details___193F4 div {
|
[class*="notifications__details___"] div {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
#main > .messages {
|
#main > .messages {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.Overview__details___2Zlnr {
|
[class*="Overview__details___"] {
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.Viewer__sidebar___1Btu4 {
|
[class*="Viewer__sidebar___"] {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-right: unset;
|
border-right: unset;
|
||||||
background: unset;
|
background: unset;
|
||||||
@@ -1511,14 +1483,14 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
background: unset;
|
background: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.MessageList__MessageList___3DxoC ::-webkit-scrollbar {
|
[class*="MessageList__MessageList___"] ::-webkit-scrollbar {
|
||||||
width: 0px;
|
width: 0px;
|
||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
.MessageList__primary___1zTHa > :last-child {
|
[class*="MessageList__primary___"] > :last-child {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
.MessageList__MessageList___3DxoC ol .Button__Button___3SRFo::before {
|
[class*="MessageList__MessageList___"] ol [class*="Button__Button___"]::before {
|
||||||
// plus icon
|
// plus icon
|
||||||
content: "";
|
content: "";
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -1527,7 +1499,7 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.MessageList__MessageList___3DxoC ol .Button__Button___3SRFo {
|
[class*="MessageList__MessageList___"] ol [class*="Button__Button___"] {
|
||||||
width: calc(100% - 32px);
|
width: calc(100% - 32px);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
margin: 8px 16px;
|
margin: 8px 16px;
|
||||||
@@ -1536,21 +1508,21 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .MessageList__MessageList___3DxoC .Button__Button___3SRFo {
|
.dark [class*="MessageList__MessageList___"] [class*="Button__Button___"] {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.MessageList__MessageList___3DxoC .Button__Button___3SRFo {
|
[class*="MessageList__MessageList___"] [class*="Button__Button___"] {
|
||||||
background: rgba(0, 0, 0, 0.1);
|
background: rgba(0, 0, 0, 0.1);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.MessageList__MessageList___3DxoC {
|
[class*="MessageList__MessageList___"] {
|
||||||
background: var(--background-primary);
|
background: var(--background-primary);
|
||||||
}
|
}
|
||||||
.Input__Input___3RSTI::before,
|
[class*="Input__Input___"]::before,
|
||||||
.ais-btnSearch::before {
|
.ais-btnSearch::before {
|
||||||
content: "";
|
content: "";
|
||||||
/* Unicode for the search icon */
|
/* Unicode for the search icon */
|
||||||
@@ -1562,7 +1534,7 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
font-family: "IconFamily";
|
font-family: "IconFamily";
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.Input__Input___3RSTI {
|
[class*="Input__Input___"] {
|
||||||
transition:
|
transition:
|
||||||
background-color 0.5s,
|
background-color 0.5s,
|
||||||
border-color 0.5s;
|
border-color 0.5s;
|
||||||
@@ -1587,15 +1559,15 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
height: 180px;
|
height: 180px;
|
||||||
background: var(--background-primary);
|
background: var(--background-primary);
|
||||||
}
|
}
|
||||||
.Avatar__Avatar___gE5kx.Avatar__staff___4gVLs {
|
[class*="Avatar__Avatar___"][class*="Avatar__staff___"] {
|
||||||
--person-colour: var(--better-light);
|
--person-colour: var(--better-light);
|
||||||
background: var(--person-colour, var(--navy));
|
background: var(--person-colour, var(--navy));
|
||||||
}
|
}
|
||||||
.LabelList__LabelList___2RJFf > li.LabelList__selected___3Egk7 {
|
[class*="LabelList__LabelList___"] > li[class*="LabelList__selected___"] {
|
||||||
background: var(--background-primary);
|
background: var(--background-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.Message__Message___3oJaU {
|
[class*="Message__Message___"] {
|
||||||
background: var(--background-primary);
|
background: var(--background-primary);
|
||||||
border-radius: 16px !important;
|
border-radius: 16px !important;
|
||||||
}
|
}
|
||||||
@@ -1615,29 +1587,31 @@ iframe.userHTML {
|
|||||||
background: var(--better-light);
|
background: var(--better-light);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
.Spinner__Spinner___CStEb > svg {
|
[class*="Spinner__Spinner___"] > svg {
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
}
|
}
|
||||||
.Spinner__Spinner___CStEb > svg > path {
|
[class*="Spinner__Spinner___"] > svg > path {
|
||||||
stroke: var(--text-primary) !important;
|
stroke: var(--text-primary) !important;
|
||||||
}
|
}
|
||||||
#main > .reports > .item > .report > .term {
|
#main > .reports > .item > .report > .term {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
background: var(--better-main);
|
background: var(--better-main);
|
||||||
}
|
}
|
||||||
.Collapsible__Collapsible___3O8P3 > .Collapsible__header___-Afvq {
|
[class*="Collapsible__Collapsible___"] > [class*="Collapsible__header__"] {
|
||||||
background: none;
|
background: none !important;
|
||||||
}
|
}
|
||||||
.Collapsible__Collapsible___3O8P3 > .Collapsible__content___2c6of.Collapsible__enterActive___3b2ow,
|
[class*="Collapsible__Collapsible___"]
|
||||||
.Collapsible__Collapsible___3O8P3 > .Collapsible__content___2c6of.Collapsible__exitActive___3rFL1 {
|
> [class*="Collapsible__content___"]
|
||||||
|
[class*="Collapsible__enterActive___"]
|
||||||
|
[class*="Collapsible__exitActive___"] {
|
||||||
animation-timing-function: ease-out !important;
|
animation-timing-function: ease-out !important;
|
||||||
}
|
}
|
||||||
.AssessmentList__AssessmentList___1GdCl
|
[class*="AssessmentList__AssessmentList___"]
|
||||||
> .AssessmentList__searchFilter___3N70o
|
> [class*="AssessmentList__searchFilter___"]
|
||||||
+ .AssessmentList__items___3LcmQ {
|
+ [class*="AssessmentList__items___"] {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.Thermoscore__Thermoscore___2tWMi {
|
[class*="Thermoscore__Thermoscore___"] {
|
||||||
background-image: unset;
|
background-image: unset;
|
||||||
background: var(--auto-background);
|
background: var(--auto-background);
|
||||||
}
|
}
|
||||||
@@ -1695,7 +1669,7 @@ body,
|
|||||||
div,
|
div,
|
||||||
ol,
|
ol,
|
||||||
ul {
|
ul {
|
||||||
scrollbar-width: thin !important;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #babac0 #fff !important;
|
scrollbar-color: #babac0 #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1704,7 +1678,7 @@ ul {
|
|||||||
div,
|
div,
|
||||||
ol,
|
ol,
|
||||||
ul {
|
ul {
|
||||||
scrollbar-width: thin !important;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #333 #111 !important;
|
scrollbar-color: #333 #111 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1724,33 +1698,31 @@ ul {
|
|||||||
#userActions > .details > .code {
|
#userActions > .details > .code {
|
||||||
text-transform: initial;
|
text-transform: initial;
|
||||||
}
|
}
|
||||||
.SelectedAssessment__SelectedAssessment___3Bu5D {
|
[class*="SelectedAssessment__SelectedAssessment___"] {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.SelectedAssessment__SelectedAssessment___3Bu5D
|
[class*="SelectedAssessment__SelectedAssessment___"]
|
||||||
> .SelectedAssessment__meta___1gq_y
|
> [class*="SelectedAssessment__meta___"]
|
||||||
> .SelectedAssessment__clearBtn___21D85 {
|
> [class*="SelectedAssessment__clearBtn___"] {
|
||||||
background: var(--better-main);
|
background: var(--better-main);
|
||||||
}
|
}
|
||||||
.SelectedAssessment__SelectedAssessment___3Bu5D
|
[class*="SelectedAssessment__SelectedAssessment___"]
|
||||||
> .SelectedAssessment__meta___1gq_y {
|
> [class*="SelectedAssessment__meta___"] {
|
||||||
border-bottom: 1px solid var(--better-main);
|
border-bottom: 1px solid var(--better-main);
|
||||||
}
|
}
|
||||||
.TabSet__TabSet___Vo-SZ
|
[class*="TabSet__TabSet___"] > ol[class*="TabSet__tabs___"] > li[class*="TabSet__selected___"] {
|
||||||
> ol.TabSet__tabs___1RRZk
|
|
||||||
> li.TabSet__selected___1psfF {
|
|
||||||
border-bottom-color: var(--better-main);
|
border-bottom-color: var(--better-main);
|
||||||
}
|
}
|
||||||
.TabSet__TabSet___Vo-SZ > ol.TabSet__tabs___1RRZk {
|
[class*="TabSet__TabSet___"] > ol[class*="TabSet__tabs___"] {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.TabSet__TabSet___Vo-SZ > ol.TabSet__tabs___1RRZk > li:hover {
|
[class*="TabSet__TabSet___"] > ol[class*="TabSet__tabs___"] > li:hover {
|
||||||
box-shadow: inset 0 -1px var(--better-main);
|
box-shadow: inset 0 -1px var(--better-main);
|
||||||
}
|
}
|
||||||
.TabSet__TabSet___Vo-SZ > .TabSet__tabContainer___3iIRe {
|
[class*="TabSet__TabSet___"] > [class*="TabSet__tabContainer___"] {
|
||||||
background: unset;
|
background: unset;
|
||||||
}
|
}
|
||||||
.BasicPanel__BasicPanel___1GP6s {
|
[class*="BasicPanel__BasicPanel___"] {
|
||||||
background: var(--background-primary);
|
background: var(--background-primary);
|
||||||
}
|
}
|
||||||
.back > svg {
|
.back > svg {
|
||||||
@@ -1774,25 +1746,25 @@ ul {
|
|||||||
}
|
}
|
||||||
.mediaWrapper,
|
.mediaWrapper,
|
||||||
.mediaRecorder,
|
.mediaRecorder,
|
||||||
.MediaRecorder__MediaRecorder___2c2_M {
|
[class*="MediaRecorder__MediaRecorder___"] {
|
||||||
border-top-left-radius: 16px;
|
border-top-left-radius: 16px;
|
||||||
border-top-right-radius: 16px;
|
border-top-right-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.MediaRecorder__MediaRecorder___2c2_M {
|
[class*="MediaRecorder__MediaRecorder___"] {
|
||||||
background: var(--background-primary);
|
background: var(--background-primary);
|
||||||
}
|
}
|
||||||
.legacy-root .uiFileHandler {
|
.legacy-root .uiFileHandler {
|
||||||
background: var(--auto-background);
|
background: var(--auto-background);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
.ResourceList__ResourceList___2z-c1 .legacy-root .uiFileHandler {
|
[class*="ResourceList__ResourceList___"] .legacy-root .uiFileHandler {
|
||||||
background: var(--background-primary);
|
background: var(--background-primary);
|
||||||
}
|
}
|
||||||
.legacy-root .uiFileHandler.dragTarget {
|
.legacy-root .uiFileHandler.dragTarget {
|
||||||
background: var(--better-main);
|
background: var(--better-main);
|
||||||
}
|
}
|
||||||
.MenuButton__MenuPanel___2q42B {
|
[class*="MenuButton__MenuPanel___"] {
|
||||||
background: var(--background-primary);
|
background: var(--background-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -1920,11 +1892,13 @@ div.entry.class[style*="width: 46.5%"] {
|
|||||||
.sources .uiButton {
|
.sources .uiButton {
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
.MediaRecorder__preview___1hQqY,
|
[class*="MediaRecorder__preview___"] {
|
||||||
.MediaRecorder__actions___3Jjvp {
|
|
||||||
background: var(--background-primary);
|
background: var(--background-primary);
|
||||||
}
|
}
|
||||||
.Rubric__Rubric___2AAKS > .Rubric__line___JCC3Y {
|
[class*="MediaRecorder__actions___"] {
|
||||||
|
background: var(--background-primary);
|
||||||
|
}
|
||||||
|
[class*="Rubric__Rubric___"] > [class*="Rubric__line___"] {
|
||||||
background: unset;
|
background: unset;
|
||||||
}
|
}
|
||||||
#main > .course > .content > .header > .coverImage.blurred {
|
#main > .course > .content > .header > .coverImage.blurred {
|
||||||
@@ -1990,6 +1964,22 @@ div.bar.flat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cke_toolbox > .cke_toolbar > .cke_combo > .cke_combo_button {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cke_toolbox > .cke_toolbar > .cke_toolgroup > .cke_button {
|
||||||
|
&:last-child {
|
||||||
|
border-top-right-radius: 8px !important;
|
||||||
|
border-bottom-right-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top-left-radius: 8px !important;
|
||||||
|
border-bottom-left-radius: 8px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.formattedText > .wrapper > .cke > .cke_inner > .cke_contents {
|
.formattedText > .wrapper > .cke > .cke_inner > .cke_contents {
|
||||||
background: var(--background-primary);
|
background: var(--background-primary);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -2026,8 +2016,8 @@ div.bar.flat {
|
|||||||
border-radius: 16px !important;
|
border-radius: 16px !important;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
.document-width-micro .RootModule__root-module___2wT52,
|
.document-width-micro [class*="RootModule__root-module___"],
|
||||||
.document-width-nano .RootModule__root-module___2wT52 {
|
.document-width-nano [class*="RootModule__root-module___"] {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -2068,9 +2058,11 @@ div.bar.flat {
|
|||||||
background: black !important;
|
background: black !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.quicktable {
|
.quicktable {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
.cke_toolbox > .cke_toolbar .cke_combo_on > .cke_combo_button,
|
.cke_toolbox > .cke_toolbar .cke_combo_on > .cke_combo_button,
|
||||||
.cke_toolbox > .cke_toolbar .cke_button_on {
|
.cke_toolbox > .cke_toolbar .cke_button_on {
|
||||||
@@ -2081,6 +2073,7 @@ div.bar.flat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.legacy-root input.singleSelect {
|
.legacy-root input.singleSelect {
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
|
|
||||||
@@ -2150,8 +2143,9 @@ body {
|
|||||||
> .entriesWrapper
|
> .entriesWrapper
|
||||||
> .entry {
|
> .entry {
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
.Viewer__Viewer___32BH- {
|
[class*="Viewer__Viewer___"] {
|
||||||
background: unset;
|
background: unset;
|
||||||
}
|
}
|
||||||
.weekend {
|
.weekend {
|
||||||
@@ -2170,16 +2164,16 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
li.MessageList__unread___3imtO {
|
[class*="MessageList__unread___"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: rgb(228 225 225);
|
background: rgb(228 225 225);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark li.MessageList__unread___3imtO {
|
.dark [class*="MessageList__unread___"] {
|
||||||
background: rgba(0, 0, 0, 0.1);
|
background: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.MessageList__MessageList___3DxoC > ol > li:hover {
|
[class*="MessageList__MessageList___"] > ol > li:hover {
|
||||||
background: var(--theme-offset-bg-more);
|
background: var(--theme-offset-bg-more);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2187,18 +2181,17 @@ li.MessageList__unread___3imtO {
|
|||||||
border-radius: 1600px;
|
border-radius: 1600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.MessageList__MessageList___3DxoC
|
[class*="MessageList__MessageList___"] > ol > li[class*="MessageList__selected___"]
|
||||||
> ol
|
[class*="MessageList__unread___"] {
|
||||||
> li.MessageList__selected___1SJNz.MessageList__unread___3imtO {
|
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Message__Message___3oJaU.Message__unread___23XIq > header {
|
[class*="Message__Message___"] [class*="Message__unread___"] > header {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.MessageList__MessageList___3DxoC > ol > li.MessageList__unread___3imtO::before,
|
[class*="MessageList__MessageList___"] > ol > li[class*="MessageList__unread___"]::before,
|
||||||
.MessageList__MessageList___3DxoC > ol > li::before {
|
[class*="MessageList__MessageList___"] > ol > li::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -2209,9 +2202,7 @@ li.MessageList__unread___3imtO {
|
|||||||
transition: width 0.1s;
|
transition: width 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.MessageList__MessageList___3DxoC
|
[class*="MessageList__MessageList___"] > ol > li[class*="MessageList__unread___"]::before {
|
||||||
> ol
|
|
||||||
> li.MessageList__unread___3imtO::before {
|
|
||||||
width: 3px;
|
width: 3px;
|
||||||
}
|
}
|
||||||
.connectedNotificationsWrapper > div > button {
|
.connectedNotificationsWrapper > div > button {
|
||||||
@@ -2286,13 +2277,13 @@ li.MessageList__unread___3imtO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark
|
.dark
|
||||||
.MessageList__MessageList___3DxoC
|
[class*="MessageList__MessageList___"]
|
||||||
> ol
|
> ol
|
||||||
> li.MessageList__selected___1SJNz {
|
> li[class*="MessageList__selected___"] {
|
||||||
background: var(--background-secondary);
|
background: var(--background-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.MessageList__MessageList___3DxoC > ol > li.MessageList__selected___1SJNz {
|
[class*="MessageList__MessageList___"] > ol > li[class*="MessageList__selected___"] {
|
||||||
background: rgb(228 225 225);
|
background: rgb(228 225 225);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
@@ -2429,14 +2420,14 @@ li.MessageList__unread___3imtO {
|
|||||||
animation: spin 3s linear infinite;
|
animation: spin 3s linear infinite;
|
||||||
-moz-animation: spin 3s linear infinite;
|
-moz-animation: spin 3s linear infinite;
|
||||||
}
|
}
|
||||||
.dark .LabelList__name___-CHgq {
|
.dark [class*="LabelList__name___"] {
|
||||||
text-shadow: 0 0 5px black;
|
text-shadow: 0 0 5px black;
|
||||||
}
|
}
|
||||||
.LabelList__name___-CHgq {
|
[class*="LabelList__name___"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
[data-label="inbox"] > .LabelList__name___-CHgq::before {
|
[data-label="inbox"] > [class*="LabelList__name___"]::before {
|
||||||
content: "\eb70";
|
content: "\eb70";
|
||||||
/* Unicode for the search icon */
|
/* Unicode for the search icon */
|
||||||
color: currentColor;
|
color: currentColor;
|
||||||
@@ -2446,7 +2437,7 @@ li.MessageList__unread___3imtO {
|
|||||||
font-family: "IconFamily";
|
font-family: "IconFamily";
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
[data-label="outbox"] > .LabelList__name___-CHgq::before {
|
[data-label="outbox"] > [class*="LabelList__name___"]::before {
|
||||||
content: "\eca6";
|
content: "\eca6";
|
||||||
/* Unicode for the search icon */
|
/* Unicode for the search icon */
|
||||||
color: currentColor;
|
color: currentColor;
|
||||||
@@ -2456,7 +2447,7 @@ li.MessageList__unread___3imtO {
|
|||||||
font-family: "IconFamily";
|
font-family: "IconFamily";
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
[data-label="starred"] > .LabelList__name___-CHgq::before {
|
[data-label="starred"] > [class*="LabelList__name___"]::before {
|
||||||
content: "\ece8";
|
content: "\ece8";
|
||||||
color: currentColor;
|
color: currentColor;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -2465,7 +2456,7 @@ li.MessageList__unread___3imtO {
|
|||||||
font-family: "IconFamily";
|
font-family: "IconFamily";
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
[data-label="trash"] > .LabelList__name___-CHgq::before {
|
[data-label="trash"] > [class*="LabelList__name___"]::before {
|
||||||
content: "\ed2c";
|
content: "\ed2c";
|
||||||
/* Unicode for the search icon */
|
/* Unicode for the search icon */
|
||||||
color: currentColor;
|
color: currentColor;
|
||||||
@@ -2762,7 +2753,7 @@ li.MessageList__unread___3imtO {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
|
||||||
&.container {
|
&.container {
|
||||||
box-shadow: -2px 2px 30px 0px rgba(0,0,0,0.3) !important;
|
box-shadow: -2px 2px 30px 0px rgba(0, 0, 0, 0.3) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
@@ -2775,7 +2766,7 @@ li.MessageList__unread___3imtO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.MessageList__MessageList___3DxoC > header {
|
[class*="MessageList__MessageList___"] > header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
@@ -2840,7 +2831,9 @@ li.MessageList__unread___3imtO {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: 200ms, background-color 0s;
|
transition:
|
||||||
|
200ms,
|
||||||
|
background-color 0s;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
.dark .upcoming-items {
|
.dark .upcoming-items {
|
||||||
@@ -3221,7 +3214,8 @@ li.MessageList__unread___3imtO {
|
|||||||
.loading {
|
.loading {
|
||||||
&.upcoming-items,
|
&.upcoming-items,
|
||||||
&.day-container {
|
&.day-container {
|
||||||
background: linear-gradient(90deg,
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
var(--background-primary) 0%,
|
var(--background-primary) 0%,
|
||||||
var(--background-secondary) 50%,
|
var(--background-secondary) 50%,
|
||||||
var(--background-primary) 100%
|
var(--background-primary) 100%
|
||||||
@@ -3234,3 +3228,130 @@ li.MessageList__unread___3imtO {
|
|||||||
height: 35em;
|
height: 35em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pane .formattedText > .wrapper {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto collapsing alignment toolbar
|
||||||
|
.cke_toolbar:has(.cke_button__seqta-align-left) {
|
||||||
|
overflow: visible !important;
|
||||||
|
|
||||||
|
.cke_toolgroup {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 32px;
|
||||||
|
|
||||||
|
.cke_button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
z-index: 100;
|
||||||
|
width: 32px;
|
||||||
|
margin: 0;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease-out,
|
||||||
|
visibility 0s linear,
|
||||||
|
background 0.3s ease,
|
||||||
|
border-radius 0.3s ease !important;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
visibility: visible !important;
|
||||||
|
z-index: 101;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cke_button_on {
|
||||||
|
visibility: visible;
|
||||||
|
position: absolute;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
z-index: 101;
|
||||||
|
|
||||||
|
& + .cke_button:first-child {
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button icons
|
||||||
|
.cke_button_icon {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// menu background
|
||||||
|
&:before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
left: -4px;
|
||||||
|
right: -4px;
|
||||||
|
bottom: calc(-300% - 10px);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||||
|
background: var(--background-primary) !important;
|
||||||
|
z-index: 100;
|
||||||
|
transform: scale(0.65, 0.2);
|
||||||
|
transform-origin: 50% 6px;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropdown behavior on hover
|
||||||
|
&:hover {
|
||||||
|
&:hover:before {
|
||||||
|
transform: scale(1);
|
||||||
|
border-radius: 16px;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.cke_button {
|
||||||
|
visibility: visible;
|
||||||
|
transition-delay: 0s;
|
||||||
|
|
||||||
|
// Stack buttons in dropdown with spacing
|
||||||
|
&:first-child {
|
||||||
|
transform: translateY(0);
|
||||||
|
border-top-left-radius: 12px !important;
|
||||||
|
border-top-right-radius: 12px !important;
|
||||||
|
}
|
||||||
|
&:nth-child(2) {
|
||||||
|
transform: translateY(calc(100% + 2px));
|
||||||
|
}
|
||||||
|
&:nth-child(3) {
|
||||||
|
transform: translateY(calc(200% + 4px));
|
||||||
|
}
|
||||||
|
&:nth-child(4) {
|
||||||
|
transform: translateY(calc(300% + 6px));
|
||||||
|
}
|
||||||
|
&:nth-child(5) {
|
||||||
|
transform: translateY(calc(400% + 6px));
|
||||||
|
}
|
||||||
|
&:nth-child(6) {
|
||||||
|
transform: translateY(calc(500% + 6px));
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
border-bottom-left-radius: 12px !important;
|
||||||
|
border-bottom-right-radius: 12px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subtle animation when closing dropdown
|
||||||
|
&:not(:hover)
|
||||||
|
.cke_button:not(.cke_button_on):not(
|
||||||
|
.cke_button__seqta-align-left:first-child
|
||||||
|
) {
|
||||||
|
transform: translateY(0);
|
||||||
|
visibility: hidden;
|
||||||
|
transition:
|
||||||
|
transform 0.3s ease,
|
||||||
|
visibility 0s linear 0.3s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.noscroll * {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ html.transparencyEffects:not(.dark) {
|
|||||||
|
|
||||||
html.transparencyEffects {
|
html.transparencyEffects {
|
||||||
/* Background Fixes */
|
/* Background Fixes */
|
||||||
.notifications__item___2ErJN,
|
[class*="notifications__item___"],
|
||||||
#shortcuts {
|
#shortcuts {
|
||||||
backdrop-filter: unset !important;
|
backdrop-filter: unset !important;
|
||||||
}
|
}
|
||||||
@@ -24,21 +24,21 @@ html.transparencyEffects {
|
|||||||
/* Blurs */
|
/* Blurs */
|
||||||
.draggable,
|
.draggable,
|
||||||
.notice,
|
.notice,
|
||||||
.BasicPanel__BasicPanel___1GP6s,
|
[class*="BasicPanel__BasicPanel___"],
|
||||||
.message.addMessage,
|
.message.addMessage,
|
||||||
.singleSelect,
|
.singleSelect,
|
||||||
.uiFileHandlerPanel,
|
.uiFileHandlerPanel,
|
||||||
.Module__wrapper___2sbOo,
|
[class*="Module__wrapper___"],
|
||||||
.notifications__list___rp2L2,
|
[class*="notifications__list___"],
|
||||||
.thread,
|
.thread,
|
||||||
.calendar,
|
.calendar,
|
||||||
.navigator,
|
.navigator,
|
||||||
#title,
|
#title,
|
||||||
.LabelList__selected___3Egk7,
|
[class*="LabelList__selected___"],
|
||||||
.buttonChecklist,
|
.buttonChecklist,
|
||||||
.pane,
|
.pane,
|
||||||
.legacy-root button, .legacy-root a,
|
.legacy-root button, .legacy-root a,
|
||||||
.MessageList__MessageList___3DxoC {
|
[class*="MessageList__MessageList___"] {
|
||||||
backdrop-filter: blur(80px);
|
backdrop-filter: blur(80px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ html.transparencyEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.whatsnewContainer,
|
.whatsnewContainer,
|
||||||
.Message__Message___3oJaU {
|
[class*="Message__Message___"] {
|
||||||
backdrop-filter: blur(50px);
|
backdrop-filter: blur(50px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
@@ -5,6 +5,11 @@ declare module '*.png';
|
|||||||
declare module '*.html';
|
declare module '*.html';
|
||||||
declare module '*.svelte';
|
declare module '*.svelte';
|
||||||
|
|
||||||
|
declare module '*?inlineWorker' {
|
||||||
|
const value: () => Worker;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
declare module "*.png?base64" {
|
declare module "*.png?base64" {
|
||||||
const value: string;
|
const value: string;
|
||||||
export default value;
|
export default value;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
aria-label="Color Picker Swatch"
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
style="background: {$settingsState.selectedColor}"
|
style="background: {$settingsState.selectedColor}"
|
||||||
class="w-16 h-8 rounded-md"
|
class="w-16 h-8 rounded-md"
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { state, onChange } = $props<{ state: number, onChange: (value: number) => void }>();
|
let { state, onChange, min = 0, max = 100, step = 1 } = $props<{
|
||||||
let percentage = $derived((state / 100) * 100);
|
state: number,
|
||||||
|
onChange: (value: number) => void,
|
||||||
|
min?: number,
|
||||||
|
max?: number,
|
||||||
|
step?: number
|
||||||
|
}>();
|
||||||
|
let percentage = $derived(((state - min) / (max - min)) * 100);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative w-full max-w-lg mx-auto">
|
<div class="relative mx-auto w-full max-w-lg">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min={min}
|
||||||
max="100"
|
max={max}
|
||||||
|
step={step}
|
||||||
bind:value={state}
|
bind:value={state}
|
||||||
style={`background: linear-gradient(to right, #30D259 ${percentage}%, #dddddd ${percentage}%)`}
|
style={`background: linear-gradient(to right, #30D259 ${percentage}%, #dddddd ${percentage}%)`}
|
||||||
onchange={(e) => onChange(Number(e.currentTarget.value))}
|
onchange={(e) => onChange(Number(e.currentTarget.value))}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>();
|
let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>();
|
||||||
let activeTab = $state(0);
|
let activeTab = $state(0);
|
||||||
let hoveredTab = $state<number | null>(null);
|
|
||||||
let containerRef: HTMLElement | null = null;
|
let containerRef: HTMLElement | null = null;
|
||||||
let tabWidth = $state(0);
|
let tabWidth = $state(0);
|
||||||
|
|
||||||
@@ -24,10 +23,6 @@
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
calcXPos(hoveredTab);
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
updateTabWidth();
|
updateTabWidth();
|
||||||
|
|
||||||
@@ -45,26 +40,24 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<div bind:this={containerRef} class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 tab-width-container">
|
<div class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container">
|
||||||
<div class="relative flex">
|
<div bind:this={containerRef} class="flex relative">
|
||||||
<MotionDiv
|
<MotionDiv
|
||||||
class="absolute top-0 left-0 z-0 h-full bg-[#DDDDDD] dark:bg-[#38373D] rounded-full opacity-40 tab-width"
|
class="absolute top-0 left-0 z-0 h-full bg-[#DDDDDD] dark:bg-[#38373D] rounded-full opacity-40 tab-width"
|
||||||
animate={{ x: calcXPos(hoveredTab) }}
|
animate={{ x: calcXPos(activeTab) }}
|
||||||
transition={springTransition}
|
transition={springTransition}
|
||||||
/>
|
/>
|
||||||
{#each tabs as { title }, index}
|
{#each tabs as { title }, index}
|
||||||
<button
|
<button
|
||||||
class="relative z-10 flex-1 px-4 py-2 focus-visible:outline-none"
|
class="relative z-10 flex-1 px-4 py-2 focus-visible:outline-none"
|
||||||
onclick={() => activeTab = index}
|
onclick={() => activeTab = index}
|
||||||
onmouseenter={() => hoveredTab = index}
|
|
||||||
onmouseleave={() => hoveredTab = null}
|
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full px-4 overflow-hidden">
|
<div class="overflow-hidden px-4 h-full">
|
||||||
<MotionDiv
|
<MotionDiv
|
||||||
class="h-full"
|
class="h-full"
|
||||||
animate={{ x: `${-activeTab * 100}%` }}
|
animate={{ x: `${-activeTab * 100}%` }}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader';
|
import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader';
|
||||||
import { setTheme } from '@/seqta/ui/themes/setTheme';
|
|
||||||
import Spinner from '../Spinner.svelte';
|
import Spinner from '../Spinner.svelte';
|
||||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||||
import Fuse from 'fuse.js';
|
import { Index } from 'flexsearch';
|
||||||
import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates'
|
import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates'
|
||||||
|
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
||||||
|
|
||||||
|
const themeManager = ThemeManager.getInstance();
|
||||||
|
|
||||||
type Background = { id: string; category: string; type: string; lowResUrl: string; highResUrl: string; name: string; description: string; featured?: boolean };
|
type Background = { id: string; category: string; type: string; lowResUrl: string; highResUrl: string; name: string; description: string; featured?: boolean };
|
||||||
let { searchTerm } = $props<{ searchTerm: string }>();
|
let { searchTerm } = $props<{ searchTerm: string }>();
|
||||||
@@ -18,19 +20,12 @@
|
|||||||
let savedBackgrounds = $state<string[]>([]);
|
let savedBackgrounds = $state<string[]>([]);
|
||||||
let installingBackgrounds = $state<Set<string>>(new Set());
|
let installingBackgrounds = $state<Set<string>>(new Set());
|
||||||
let debugInfo = $state<string>('');
|
let debugInfo = $state<string>('');
|
||||||
|
let searchIndex = $state<Index | null>(null);
|
||||||
|
|
||||||
// New state variables
|
// New state variables
|
||||||
let activeTab = $state<'all' | 'installed' | 'photos' | 'videos'>('all');
|
let activeTab = $state<'all' | 'installed' | 'photos' | 'videos'>('all');
|
||||||
let sortBy = $state<'newest' | 'popular' | 'name'>('newest');
|
let sortBy = $state<'newest' | 'popular' | 'name'>('newest');
|
||||||
|
|
||||||
// Add Fuse.js options
|
|
||||||
const fuseOptions = {
|
|
||||||
keys: ['name', 'description'],
|
|
||||||
threshold: 0.4,
|
|
||||||
ignoreLocation: true
|
|
||||||
};
|
|
||||||
let fuse: Fuse<Background>;
|
|
||||||
|
|
||||||
// Existing functions
|
// Existing functions
|
||||||
const loadStore = async () => {
|
const loadStore = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -41,7 +36,19 @@
|
|||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
backgrounds = data.backgrounds;
|
backgrounds = data.backgrounds;
|
||||||
fuse = new Fuse(backgrounds, fuseOptions);
|
|
||||||
|
// Initialize FlexSearch index
|
||||||
|
const index = new Index({
|
||||||
|
tokenize: "forward",
|
||||||
|
preset: "score"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add backgrounds to the index
|
||||||
|
backgrounds.forEach((bg, i) => {
|
||||||
|
index.add(i, bg.name + " " + bg.description);
|
||||||
|
});
|
||||||
|
|
||||||
|
searchIndex = index;
|
||||||
debugInfo = `Loaded ${backgrounds.length} backgrounds`;
|
debugInfo = `Loaded ${backgrounds.length} backgrounds`;
|
||||||
await loadSavedBackgrounds();
|
await loadSavedBackgrounds();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -72,14 +79,10 @@
|
|||||||
let filteredBackgrounds = $derived((() => {
|
let filteredBackgrounds = $derived((() => {
|
||||||
let filtered = backgrounds;
|
let filtered = backgrounds;
|
||||||
|
|
||||||
// Use Fuse.js search if there's a search term
|
// Use FlexSearch if there's a search term
|
||||||
if (searchTerm.trim()) {
|
if (searchTerm.trim() && searchIndex) {
|
||||||
// @ts-ignore
|
const results = searchIndex.search(searchTerm) as number[];
|
||||||
if (fuse) {
|
filtered = results.map(i => backgrounds[i]);
|
||||||
filtered = fuse.search(searchTerm).map((result: any) => result.item) ?? [];
|
|
||||||
} else {
|
|
||||||
filtered = backgrounds.filter(bg => bg.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply category filtering
|
// Apply category filtering
|
||||||
@@ -170,13 +173,13 @@
|
|||||||
|
|
||||||
function selectNoBackground() {
|
function selectNoBackground() {
|
||||||
selectedBackground = null;
|
selectedBackground = null;
|
||||||
setTheme('');
|
themeManager.setTheme('');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<div class="w-64 h-full p-4 border-r border-zinc-200 dark:border-zinc-700">
|
<div class="p-4 w-64 h-full border-r border-zinc-200 dark:border-zinc-700">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="mb-4 text-lg font-semibold">Categories</h2>
|
<h2 class="mb-4 text-lg font-semibold">Categories</h2>
|
||||||
<nav class="space-y-2">
|
<nav class="space-y-2">
|
||||||
@@ -208,15 +211,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="overflow-auto flex-1">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="sticky top-0 z-10 p-4 border-b bg-[#F1F1F3] dark:bg-zinc-900 dark:border-zinc-700">
|
<div class="sticky top-0 z-10 p-4 border-b bg-[#F1F1F3] dark:bg-zinc-900 dark:border-zinc-700">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h1 class="text-2xl font-bold">Explore Backgrounds {searchTerm ? `- "${searchTerm}"` : ''}</h1>
|
<h1 class="text-2xl font-bold">Explore Backgrounds {searchTerm ? `- "${searchTerm}"` : ''}</h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex gap-4 items-center">
|
||||||
<select
|
<select
|
||||||
bind:value={sortBy}
|
bind:value={sortBy}
|
||||||
class="p-2 border rounded-lg border-zinc-200 dark:border-zinc-700 dark:bg-zinc-800"
|
class="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 dark:bg-zinc-800"
|
||||||
>
|
>
|
||||||
<option value="newest">Newest</option>
|
<option value="newest">Newest</option>
|
||||||
<option value="name">Name</option>
|
<option value="name">Name</option>
|
||||||
@@ -230,7 +233,7 @@
|
|||||||
<button
|
<button
|
||||||
class={`px-4 py-2 text-sm font-medium transition-colors rounded-full
|
class={`px-4 py-2 text-sm font-medium transition-colors rounded-full
|
||||||
${activeTab === tab.toLowerCase() ? 'bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700' :
|
${activeTab === tab.toLowerCase() ? 'bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700' :
|
||||||
'bg-zinc-100 dark:bg-transparent dark:outline dark:outline-1 dark:outline-zinc-700 hover:bg-zinc-200 dark:hover:bg-zinc-700/20'}`}
|
'bg-zinc-100 dark:bg-transparent dark:outline dark:outline-zinc-700 hover:bg-zinc-200 dark:hover:bg-zinc-700/20'}`}
|
||||||
onclick={() => activeTab = tab.toLowerCase() as typeof activeTab}
|
onclick={() => activeTab = tab.toLowerCase() as typeof activeTab}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
@@ -244,15 +247,15 @@
|
|||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each Array(9) as _}
|
{#each Array(9) as _}
|
||||||
<div class="relative overflow-hidden rounded-lg animate-pulse">
|
<div class="overflow-hidden relative rounded-lg animate-pulse">
|
||||||
<!-- Image placeholder -->
|
<!-- Image placeholder -->
|
||||||
<div class="w-full h-48 bg-zinc-200 dark:bg-zinc-800"></div>
|
<div class="w-full h-48 bg-zinc-200 dark:bg-zinc-800"></div>
|
||||||
<!-- Gradient overlay -->
|
<!-- Gradient overlay -->
|
||||||
<div class="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-zinc-300 dark:from-zinc-700 to-transparent">
|
<div class="absolute right-0 bottom-0 left-0 h-16 to-transparent bg-linear-to-t from-zinc-300 dark:from-zinc-700">
|
||||||
<!-- Title placeholder -->
|
<!-- Title placeholder -->
|
||||||
<div class="absolute bottom-2 left-2 right-2">
|
<div class="absolute right-2 bottom-2 left-2">
|
||||||
<div class="w-2/3 h-4 rounded-full bg-zinc-200 dark:bg-zinc-800"></div>
|
<div class="w-2/3 h-4 rounded-full bg-zinc-200 dark:bg-zinc-800"></div>
|
||||||
<div class="w-1/2 h-3 mt-2 rounded-full bg-zinc-200 dark:bg-zinc-800"></div>
|
<div class="mt-2 w-1/2 h-3 rounded-full bg-zinc-200 dark:bg-zinc-800"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -271,7 +274,7 @@
|
|||||||
return true;
|
return true;
|
||||||
}) as background (background.id)}
|
}) as background (background.id)}
|
||||||
<div
|
<div
|
||||||
class="relative overflow-hidden rounded-lg shadow-lg cursor-pointer group"
|
class="overflow-hidden relative rounded-lg shadow-lg cursor-pointer group"
|
||||||
onclick={() => toggleBackgroundInstallation(background)}
|
onclick={() => toggleBackgroundInstallation(background)}
|
||||||
onkeydown={(event) => {
|
onkeydown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
@@ -286,7 +289,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<video src={background.lowResUrl} class="object-cover w-full h-48" muted loop autoplay></video>
|
<video src={background.lowResUrl} class="object-cover w-full h-48" muted loop autoplay></video>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="absolute inset-0 flex items-center justify-center transition-opacity duration-300 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100">
|
<div class={`flex absolute inset-0 justify-center items-center opacity-0 transition-opacity duration-300 bg-black/50 group-hover:opacity-100 ${installingBackgrounds.has(background.id) ? 'opacity-100' : ''}`}>
|
||||||
{#if installingBackgrounds.has(background.id)}
|
{#if installingBackgrounds.has(background.id)}
|
||||||
<Spinner />
|
<Spinner />
|
||||||
{:else if savedBackgrounds.includes(background.id)}
|
{:else if savedBackgrounds.includes(background.id)}
|
||||||
|
|||||||
@@ -27,9 +27,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if coverThemes.length > 0}
|
{#if coverThemes.length > 0}
|
||||||
<div class="relative w-full transition-opacity rounded-xl overflow-clip" transition:fade>
|
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
|
||||||
<div
|
<div
|
||||||
class="w-full aspect-[8/3]"
|
class="w-full aspect-8/3"
|
||||||
use:emblaCarouselSvelte={{ options, plugins }}
|
use:emblaCarouselSvelte={{ options, plugins }}
|
||||||
onemblaInit={onInit}
|
onemblaInit={onInit}
|
||||||
>
|
>
|
||||||
@@ -47,20 +47,20 @@
|
|||||||
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
|
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
|
||||||
<p class='text-lg text-white'>{theme.description}</p>
|
<p class='text-lg text-white'>{theme.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class='absolute bottom-0 left-0 w-full h-1/2 bg-gradient-to-t from-black/80 to-transparent'></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 -->
|
<!-- Navigation buttons -->
|
||||||
<div class='absolute z-10 flex gap-2 bottom-2 right-2'>
|
<div class='flex absolute right-2 bottom-2 z-10 gap-2'>
|
||||||
<button aria-label="Previous" onclick={slidePrev} class='flex items-center justify-center w-8 h-8 text-white bg-black bg-opacity-50 rounded-full dark:bg-zinc-800 dark:bg-opacity-50'>
|
<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'>
|
||||||
<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 items-center justify-center w-8 h-8 text-white bg-black bg-opacity-50 rounded-full dark:bg-zinc-800 dark:bg-opacity-50'>
|
<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'>
|
||||||
<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>
|
||||||
|
|||||||
@@ -48,8 +48,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add similar sections for color, resolution, and orientation -->
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 mt-4 text-white bg-red-500 rounded hover:bg-red-600"
|
class="px-4 py-2 mt-4 text-white bg-red-500 rounded hover:bg-red-600"
|
||||||
onclick={clearFilters}
|
onclick={clearFilters}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="fixed top-0 z-50 w-full h-[4.25rem] bg-white border-b shadow-md border-b-white/10 dark:bg-zinc-950/90 backdrop-blur-xl dark:text-white">
|
<header class="fixed top-0 z-50 w-full h-[4.25rem] bg-white border-b shadow-md border-b-white/10 dark:bg-zinc-950/90 backdrop-blur-xl dark:text-white">
|
||||||
<div class="flex items-center justify-between px-4 py-1">
|
<div class="flex justify-between items-center px-4 py-1">
|
||||||
<div class="flex gap-4 cursor-pointer place-items-center" onkeydown={(e) => { if (e.key === 'Enter') clearSearch() }} onclick={clearSearch} role="button" tabindex="0">
|
<div class="flex gap-4 place-items-center cursor-pointer" onkeydown={(e) => { if (e.key === 'Enter') clearSearch() }} onclick={clearSearch} role="button" tabindex="0">
|
||||||
<img src={browser.runtime.getURL(logo)} class="h-14 {darkMode ? 'hidden' : ''}" alt="Logo" />
|
<img src={browser.runtime.getURL(logo)} class="h-14 {darkMode ? 'hidden' : ''}" alt="Logo" />
|
||||||
<img src={browser.runtime.getURL(logoDark)} class="h-14 {darkMode ? '' : 'hidden'}" alt="Dark Logo" />
|
<img src={browser.runtime.getURL(logoDark)} class="h-14 {darkMode ? '' : 'hidden'}" alt="Dark Logo" />
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative flex gap-2">
|
<div class="flex relative gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search themes..."
|
placeholder="Search themes..."
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
oninput={(e: any) => setSearchTerm(e.target.value)}
|
oninput={(e: any) => setSearchTerm(e.target.value)}
|
||||||
class="px-4 py-2 pl-10 text-lg transition bg-gray-100/80 rounded-lg ring-0 focus:bg-gray-100/0 dark:focus:bg-zinc-700/50 focus:ring-[1px] ring-zinc-200 dark:ring-zinc-600 dark:bg-zinc-700/80 dark:text-gray-100 focus:outline-none focus:border-transparent" />
|
class="px-4 py-2 pl-10 text-lg transition bg-gray-100/80 rounded-lg ring-0 focus:bg-gray-100/0 dark:focus:bg-zinc-700/50 focus:ring-[1px] ring-zinc-200 dark:ring-zinc-600 dark:bg-zinc-700/80 dark:text-gray-100 focus:outline-none focus:border-transparent" />
|
||||||
<svg
|
<svg
|
||||||
class="absolute w-5 h-5 text-gray-400 transform -translate-y-1/2 left-3 top-1/2 dark:text-gray-200"
|
class="absolute left-3 top-1/2 w-5 h-5 text-gray-400 transform -translate-y-1/2 dark:text-gray-200"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white">
|
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white">
|
||||||
{theme.name}
|
{theme.name}
|
||||||
</div>
|
</div>
|
||||||
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-gradient-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'>
|
<div class='w-full'>
|
||||||
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
|
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex fixed inset-0 z-50 justify-center items-end bg-black bg-opacity-70"
|
class="flex fixed inset-0 z-50 justify-center items-end bg-black/70"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
if (e.target === e.currentTarget) hideModal();
|
if (e.target === e.currentTarget) hideModal();
|
||||||
}}
|
}}
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white transition-all duration-500 group-hover:-translate-y-0.5">
|
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white transition-all duration-500 group-hover:-translate-y-0.5">
|
||||||
{relatedTheme.name}
|
{relatedTheme.name}
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 z-0 w-full h-3/4 bg-gradient-to-t to-transparent from-black/80"></div>
|
<div class="absolute bottom-0 z-0 w-full h-3/4 to-transparent from-black/80 bg-linear-to-t"></div>
|
||||||
<img src={relatedTheme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48" />
|
<img src={relatedTheme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
onkeydown={onClick}
|
onkeydown={onClick}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
role="button"
|
role="button"
|
||||||
class="relative w-16 h-16 cursor-pointer rounded-xl transition ring dark:ring-zinc-500/50 ring-zinc-300 {isEditMode ? 'animate-shake' : ''} {isSelected ? 'dark:ring-4 ring-4' : 'ring-0'}"
|
class="relative w-16 h-16 cursor-pointer rounded-xl transition ring-3 dark:ring-zinc-500/50 ring-zinc-300 {isEditMode ? 'animate-shake' : ''} {isSelected ? 'dark:ring-4 ring-4' : 'ring-0'}"
|
||||||
>
|
>
|
||||||
{#if isEditMode}
|
{#if isEditMode}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
|
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
|
||||||
import { getAvailableThemes } from '@/seqta/ui/themes/getAvailableThemes'
|
|
||||||
import { onDestroy, onMount } from 'svelte'
|
import { onDestroy, onMount } from 'svelte'
|
||||||
import { OpenThemeCreator } from '@/seqta/ui/ThemeCreator'
|
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
||||||
import shareTheme from '@/seqta/ui/themes/shareTheme'
|
|
||||||
import { InstallTheme } from '@/seqta/ui/themes/downloadTheme'
|
|
||||||
import { disableTheme } from '@/seqta/ui/themes/disableTheme'
|
|
||||||
import { setTheme } from '@/seqta/ui/themes/setTheme'
|
|
||||||
import { deleteTheme } from '@/seqta/ui/themes/deleteTheme'
|
|
||||||
import { OpenStorePage } from '@/seqta/ui/renderStore'
|
import { OpenStorePage } from '@/seqta/ui/renderStore'
|
||||||
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
|
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
|
||||||
import { closeExtensionPopup } from '@/SEQTA'
|
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
|
||||||
|
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
||||||
|
|
||||||
|
const themeManager = ThemeManager.getInstance();
|
||||||
|
|
||||||
let themes = $state<ThemeList | null>(null);
|
let themes = $state<ThemeList | null>(null);
|
||||||
let { isEditMode } = $props<{ isEditMode: boolean }>();
|
let { isEditMode } = $props<{ isEditMode: boolean }>();
|
||||||
@@ -20,10 +17,10 @@
|
|||||||
const handleThemeClick = async (theme: CustomTheme) => {
|
const handleThemeClick = async (theme: CustomTheme) => {
|
||||||
if (isEditMode) return;
|
if (isEditMode) return;
|
||||||
if (theme.id === themes?.selectedTheme) {
|
if (theme.id === themes?.selectedTheme) {
|
||||||
await disableTheme();
|
await themeManager.disableTheme();
|
||||||
themes.selectedTheme = '';
|
themes.selectedTheme = '';
|
||||||
} else {
|
} else {
|
||||||
await setTheme(theme.id);
|
await themeManager.setTheme(theme.id);
|
||||||
if (!themes) return;
|
if (!themes) return;
|
||||||
themes.selectedTheme = theme.id;
|
themes.selectedTheme = theme.id;
|
||||||
}
|
}
|
||||||
@@ -31,13 +28,13 @@
|
|||||||
|
|
||||||
const handleThemeDelete = async (themeId: string) => {
|
const handleThemeDelete = async (themeId: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteTheme(themeId);
|
await themeManager.deleteTheme(themeId);
|
||||||
if (!themes) return;
|
if (!themes) return;
|
||||||
|
|
||||||
themes.themes = themes.themes.filter(theme => theme.id !== themeId);
|
themes.themes = themes.themes.filter(theme => theme.id !== themeId);
|
||||||
if (themeId === themes.selectedTheme) {
|
if (themeId === themes.selectedTheme) {
|
||||||
themes.selectedTheme = '';
|
themes.selectedTheme = '';
|
||||||
await disableTheme();
|
await themeManager.disableTheme();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting theme:', error);
|
console.error('Error deleting theme:', error);
|
||||||
@@ -46,7 +43,7 @@
|
|||||||
|
|
||||||
const handleShareTheme = async (theme: CustomTheme) => {
|
const handleShareTheme = async (theme: CustomTheme) => {
|
||||||
try {
|
try {
|
||||||
await shareTheme(theme.id);
|
await themeManager.shareTheme(theme.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sharing theme:', error);
|
console.error('Error sharing theme:', error);
|
||||||
}
|
}
|
||||||
@@ -72,9 +69,10 @@
|
|||||||
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 InstallTheme(result);
|
await themeManager.installTheme(result);
|
||||||
await fetchThemes();
|
await fetchThemes();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error parsing file:', error);
|
||||||
alert('Error parsing file. Please upload a valid JSON theme file.');
|
alert('Error parsing file. Please upload a valid JSON theme file.');
|
||||||
}
|
}
|
||||||
tempTheme = null;
|
tempTheme = null;
|
||||||
@@ -83,7 +81,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fetchThemes = async () => {
|
const fetchThemes = async () => {
|
||||||
themes = await getAvailableThemes();
|
themes = {
|
||||||
|
themes: await themeManager.getAvailableThemes(),
|
||||||
|
selectedTheme: themeManager.getSelectedThemeId() || '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
|||||||
@@ -4,14 +4,8 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
button {
|
||||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
@apply cursor-pointer;
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>BetterSEQTA+ Settings</title>
|
<title>BetterSEQTA+ Settings</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="h-[600px]">
|
||||||
<div id="app"></div>
|
<div id="app" style="height: 100%;"></div>
|
||||||
<script type="module" src="./index.ts"></script>
|
<script type="module" src="./index.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+2
-19
@@ -1,25 +1,8 @@
|
|||||||
import "./index.css"
|
import "./index.css"
|
||||||
import { mount } from "svelte"
|
|
||||||
import type { ComponentType } from "svelte"
|
|
||||||
import Settings from "./pages/settings.svelte"
|
import Settings from "./pages/settings.svelte"
|
||||||
import IconFamily from '@/resources/fonts/IconFamily.woff'
|
import IconFamily from '@/resources/fonts/IconFamily.woff'
|
||||||
import browser from "webextension-polyfill"
|
import browser from "webextension-polyfill"
|
||||||
|
import renderSvelte from "./main"
|
||||||
export default function renderSvelte(
|
|
||||||
Component: ComponentType | any,
|
|
||||||
mountPoint: ShadowRoot | HTMLElement,
|
|
||||||
props: Record<string, any> = {},
|
|
||||||
) {
|
|
||||||
const app = mount(Component, {
|
|
||||||
target: mountPoint,
|
|
||||||
props: {
|
|
||||||
standalone: true,
|
|
||||||
...props,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
|
|
||||||
function InjectCustomIcons() {
|
function InjectCustomIcons() {
|
||||||
console.info('[BetterSEQTA+] Injecting Icons')
|
console.info('[BetterSEQTA+] Injecting Icons')
|
||||||
@@ -43,4 +26,4 @@ if (!mountPoint) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
InjectCustomIcons()
|
InjectCustomIcons()
|
||||||
renderSvelte(Settings, mountPoint)
|
renderSvelte(Settings, mountPoint, { standalone: true })
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import styles from "./index.css?inline"
|
|
||||||
import { mount } from "svelte"
|
import { mount } from "svelte"
|
||||||
import type { ComponentType } from "svelte"
|
import type { SvelteComponent } from "svelte"
|
||||||
|
import style from './index.css?inline'
|
||||||
|
|
||||||
export default function renderSvelte(
|
export default function renderSvelte(
|
||||||
Component: ComponentType | any,
|
Component: SvelteComponent | any,
|
||||||
mountPoint: ShadowRoot | HTMLElement,
|
mountPoint: ShadowRoot | HTMLElement,
|
||||||
props: Record<string, any> = {},
|
props: Record<string, any> = {},
|
||||||
) {
|
) {
|
||||||
@@ -15,10 +15,9 @@ export default function renderSvelte(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const style = document.createElement("style")
|
const styleElement = document.createElement('style')
|
||||||
style.setAttribute("type", "text/css")
|
styleElement.textContent = style
|
||||||
style.innerHTML = styles
|
mountPoint.appendChild(styleElement)
|
||||||
mountPoint.appendChild(style)
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,10 @@
|
|||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { initializeSettingsState, settingsState } from '@/seqta/utils/listeners/SettingsState'
|
import { initializeSettingsState, settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||||
|
|
||||||
import { closeExtensionPopup, OpenAboutPage, OpenWhatsNewPopup } from "@/SEQTA"
|
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
||||||
|
import { OpenAboutPage } from "@/seqta/utils/Openers/OpenAboutPage"
|
||||||
|
import { OpenWhatsNewPopup } from "@/seqta/utils/Whatsnew"
|
||||||
|
|
||||||
import ColourPicker from '../components/ColourPicker.svelte'
|
import ColourPicker from '../components/ColourPicker.svelte'
|
||||||
import { settingsPopup } from '../hooks/SettingsPopup'
|
import { settingsPopup } from '../hooks/SettingsPopup'
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@
|
|||||||
|
|
||||||
if (!standalone) return;
|
if (!standalone) return;
|
||||||
initializeSettingsState();
|
initializeSettingsState();
|
||||||
|
console.log('settingsState', $settingsState);
|
||||||
StandaloneStore.setStandalone(true);
|
StandaloneStore.setStandalone(true);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,6 +11,67 @@
|
|||||||
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
|
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
|
||||||
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent"
|
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent"
|
||||||
|
|
||||||
|
import { getAllPluginSettings } from "@/plugins"
|
||||||
|
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting } from "@/plugins/core/types"
|
||||||
|
|
||||||
|
// Union type representing all possible settings
|
||||||
|
type SettingType =
|
||||||
|
(Omit<BooleanSetting, 'type'> & { type: 'boolean', id: string }) |
|
||||||
|
(Omit<StringSetting, 'type'> & { type: 'string', id: string }) |
|
||||||
|
(Omit<NumberSetting, 'type'> & { type: 'number', id: string }) |
|
||||||
|
(Omit<SelectSetting<string>, 'type'> & {
|
||||||
|
type: 'select',
|
||||||
|
id: string,
|
||||||
|
options: string[]
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Plugin {
|
||||||
|
pluginId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
settings: Record<string, SettingType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginSettings = getAllPluginSettings() as Plugin[];
|
||||||
|
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({});
|
||||||
|
|
||||||
|
async function loadPluginSettings() {
|
||||||
|
for (const plugin of pluginSettings) {
|
||||||
|
if (Object.keys(plugin.settings).length === 0) continue;
|
||||||
|
|
||||||
|
const storageKey = `plugin.${plugin.pluginId}.settings`;
|
||||||
|
const stored = await browser.storage.local.get(storageKey);
|
||||||
|
|
||||||
|
pluginSettingsValues[plugin.pluginId] = stored[storageKey] || {};
|
||||||
|
|
||||||
|
for (const [key, setting] of Object.entries(plugin.settings)) {
|
||||||
|
if (pluginSettingsValues[plugin.pluginId][key] === undefined) {
|
||||||
|
pluginSettingsValues[plugin.pluginId][key] = setting.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePluginSetting(pluginId: string, key: string, value: any) {
|
||||||
|
const storageKey = `plugin.${pluginId}.settings`;
|
||||||
|
|
||||||
|
if (!pluginSettingsValues[pluginId]) {
|
||||||
|
pluginSettingsValues[pluginId] = {};
|
||||||
|
}
|
||||||
|
pluginSettingsValues[pluginId][key] = value;
|
||||||
|
|
||||||
|
const stored = await browser.storage.local.get(storageKey);
|
||||||
|
const currentSettings = (stored[storageKey] || {}) as Record<string, any>;
|
||||||
|
|
||||||
|
currentSettings[key] = value;
|
||||||
|
|
||||||
|
await browser.storage.local.set({ [storageKey]: currentSettings });
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadPluginSettings();
|
||||||
|
})
|
||||||
|
|
||||||
const { showColourPicker } = $props<{ showColourPicker: () => void }>();
|
const { showColourPicker } = $props<{ showColourPicker: () => void }>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -28,7 +89,6 @@
|
|||||||
|
|
||||||
<div class="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700">
|
<div class="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700">
|
||||||
{#each [
|
{#each [
|
||||||
|
|
||||||
{
|
{
|
||||||
title: "Transparency Effects",
|
title: "Transparency Effects",
|
||||||
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)",
|
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)",
|
||||||
@@ -39,26 +99,6 @@
|
|||||||
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
|
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Animated Background",
|
|
||||||
description: "Adds an animated background to BetterSEQTA. (May impact battery life)",
|
|
||||||
id: 2,
|
|
||||||
Component: Switch,
|
|
||||||
props: {
|
|
||||||
state: $settingsState.animatedbk,
|
|
||||||
onChange: (isOn: boolean) => settingsState.animatedbk = isOn
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Animated Background Speed",
|
|
||||||
description: "Controls the speed of the animated background.",
|
|
||||||
id: 3,
|
|
||||||
Component: Slider,
|
|
||||||
props: {
|
|
||||||
state: $settingsState.bksliderinput,
|
|
||||||
onChange: (value: number) => settingsState.bksliderinput = `${value}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Custom Theme Colour",
|
title: "Custom Theme Colour",
|
||||||
description: "Customise the overall theme colour of SEQTA Learn.",
|
description: "Customise the overall theme colour of SEQTA Learn.",
|
||||||
@@ -88,46 +128,6 @@
|
|||||||
onChange: (isOn: boolean) => settingsState.animations = isOn
|
onChange: (isOn: boolean) => settingsState.animations = isOn
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Notification Collector",
|
|
||||||
description: "Uncaps the 9+ limit for notifications, showing the real number.",
|
|
||||||
id: 7,
|
|
||||||
Component: Switch,
|
|
||||||
props: {
|
|
||||||
state: $settingsState.notificationcollector,
|
|
||||||
onChange: (isOn: boolean) => settingsState.notificationcollector = isOn
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Assessment Average",
|
|
||||||
description: "Shows your subject average for assessments.",
|
|
||||||
id: 8,
|
|
||||||
Component: Switch,
|
|
||||||
props: {
|
|
||||||
state: $settingsState.assessmentsAverage,
|
|
||||||
onChange: (isOn: boolean) => settingsState.assessmentsAverage = isOn
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Letter Grade Averages",
|
|
||||||
description: "Shows the letter grade instead of the percentage in subject averages.",
|
|
||||||
id: 8,
|
|
||||||
Component: Switch,
|
|
||||||
props: {
|
|
||||||
state: $settingsState.lettergrade,
|
|
||||||
onChange: (isOn: boolean) => settingsState.lettergrade = isOn
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Lesson Alerts",
|
|
||||||
description: "Sends a native browser notification ~5 minutes prior to lessons.",
|
|
||||||
id: 8,
|
|
||||||
Component: Switch,
|
|
||||||
props: {
|
|
||||||
state: $settingsState.lessonalert,
|
|
||||||
onChange: (isOn: boolean) => settingsState.lessonalert = isOn
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "12 Hour Time",
|
title: "12 Hour Time",
|
||||||
description: "Prefer 12 hour time format for SEQTA",
|
description: "Prefer 12 hour time format for SEQTA",
|
||||||
@@ -178,8 +178,79 @@
|
|||||||
{ value: "netherlands", label: "Netherlands" }
|
{ value: "netherlands", label: "Netherlands" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
{
|
] as option}
|
||||||
|
{@render Setting(option)}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#each pluginSettings as plugin}
|
||||||
|
<div>
|
||||||
|
<!-- Always show enable toggle if disableToggle is true -->
|
||||||
|
{#if (plugin as any).disableToggle}
|
||||||
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Enable {plugin.name}</h2>
|
||||||
|
<p class="text-xs">{plugin.description}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Switch
|
||||||
|
state={pluginSettingsValues[plugin.pluginId]?.enabled ?? true}
|
||||||
|
onChange={(value) => updatePluginSetting(plugin.pluginId, 'enabled', value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Only show other settings if plugin is enabled or has no disableToggle -->
|
||||||
|
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
|
||||||
|
{#each Object.entries(plugin.settings) as [key, setting]}
|
||||||
|
<!-- Skip the 'enabled' setting if it's part of the settings object -->
|
||||||
|
{#if key !== 'enabled'}
|
||||||
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">{setting.title || key}</h2>
|
||||||
|
<p class="text-xs">{setting.description || ''}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{#if setting.type === 'boolean'}
|
||||||
|
<Switch
|
||||||
|
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
|
||||||
|
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
||||||
|
/>
|
||||||
|
{:else if setting.type === 'number'}
|
||||||
|
<Slider
|
||||||
|
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
|
||||||
|
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
||||||
|
min={setting.min}
|
||||||
|
max={setting.max}
|
||||||
|
step={setting.step}
|
||||||
|
/>
|
||||||
|
{:else if setting.type === 'string'}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="px-2 py-1 text-sm rounded-md dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white"
|
||||||
|
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
|
||||||
|
oninput={(e) => updatePluginSetting(plugin.pluginId, key, e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{:else if setting.type === 'select'}
|
||||||
|
<Select
|
||||||
|
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
|
||||||
|
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
||||||
|
options={(setting.options as string[]).map(opt => ({
|
||||||
|
value: opt,
|
||||||
|
label: opt.charAt(0).toUpperCase() + opt.slice(1)
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{@render Setting({
|
||||||
title: "BetterSEQTA+",
|
title: "BetterSEQTA+",
|
||||||
description: "Enables BetterSEQTA+ features",
|
description: "Enables BetterSEQTA+ features",
|
||||||
id: 12,
|
id: 12,
|
||||||
@@ -188,10 +259,7 @@
|
|||||||
state: $settingsState.onoff,
|
state: $settingsState.onoff,
|
||||||
onChange: (isOn: boolean) => settingsState.onoff = isOn
|
onChange: (isOn: boolean) => settingsState.onoff = isOn
|
||||||
}
|
}
|
||||||
}
|
})}
|
||||||
] as option}
|
|
||||||
{@render Setting(option)}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if $settingsState.devMode}
|
{#if $settingsState.devMode}
|
||||||
<div class="flex items-center justify-between px-4 py-3 mt-4 pt-[1.75rem]">
|
<div class="flex items-center justify-between px-4 py-3 mt-4 pt-[1.75rem]">
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet Shortcuts([index, Shortcut]: [string, { name: string, enabled: boolean }]) }
|
{#snippet Shortcuts([index, Shortcut]: [string, { name: string, enabled: boolean }]) }
|
||||||
<div class="flex items-center justify-between 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">{Shortcut.name}</h2>
|
<h2 class="text-sm">{Shortcut.name}</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
class="w-full p-2 transition border-0 rounded-lg placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
|
class="p-2 w-full rounded-lg border-0 transition placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Shortcut Name"
|
placeholder="Shortcut Name"
|
||||||
bind:value={newTitle}
|
bind:value={newTitle}
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
class="w-full p-2 my-2 transition border-0 rounded-lg placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
|
class="p-2 my-2 w-full rounded-lg border-0 transition placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="URL eg. https://google.com"
|
placeholder="URL eg. https://google.com"
|
||||||
bind:value={newURL}
|
bind:value={newURL}
|
||||||
@@ -142,9 +142,9 @@
|
|||||||
|
|
||||||
<!-- Custom Shortcuts Section -->
|
<!-- Custom Shortcuts Section -->
|
||||||
{#each $settingsState.customshortcuts as shortcut, index}
|
{#each $settingsState.customshortcuts as shortcut, index}
|
||||||
<div class="flex items-center justify-between px-4 py-3">
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
{shortcut.name}
|
{shortcut.name}
|
||||||
<button onclick={() => deleteCustomShortcut(index)}>
|
<button aria-label="Delete Shortcut" onclick={() => deleteCustomShortcut(index)}>
|
||||||
<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="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -9,16 +9,15 @@
|
|||||||
import type { Theme } from '../types/Theme'
|
import type { Theme } from '../types/Theme'
|
||||||
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 { StoreDownloadTheme } from '@/seqta/ui/themes/downloadTheme'
|
|
||||||
import { setTheme } from '@/seqta/ui/themes/setTheme'
|
|
||||||
import Header from '../components/store/Header.svelte'
|
import Header from '../components/store/Header.svelte'
|
||||||
import { deleteTheme } from '@/seqta/ui/themes/deleteTheme'
|
|
||||||
import { getAvailableThemes } from '@/seqta/ui/themes/getAvailableThemes'
|
|
||||||
import { themeUpdates } from '../hooks/ThemeUpdates'
|
import { themeUpdates } from '../hooks/ThemeUpdates'
|
||||||
|
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
||||||
|
|
||||||
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'
|
||||||
|
|
||||||
|
const themeManager = ThemeManager.getInstance();
|
||||||
|
|
||||||
// State variables
|
// State variables
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
let themes = $state<Theme[]>([]);
|
let themes = $state<Theme[]>([]);
|
||||||
@@ -33,8 +32,8 @@
|
|||||||
let selectedBackground = $state<string | null>(null);
|
let selectedBackground = $state<string | null>(null);
|
||||||
|
|
||||||
const fetchCurrentThemes = async () => {
|
const fetchCurrentThemes = async () => {
|
||||||
const themes = await getAvailableThemes();
|
const themes = await themeManager.getAvailableThemes();
|
||||||
currentThemes = themes.themes.filter(theme => theme !== null).map(theme => theme.id);
|
currentThemes = themes.filter(theme => theme !== null).map(theme => theme.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setDisplayTheme = (theme: Theme | null) => {
|
const setDisplayTheme = (theme: Theme | null) => {
|
||||||
@@ -123,8 +122,8 @@
|
|||||||
{setDisplayTheme}
|
{setDisplayTheme}
|
||||||
onInstall={async () => {
|
onInstall={async () => {
|
||||||
if (displayTheme) {
|
if (displayTheme) {
|
||||||
await StoreDownloadTheme({themeContent: displayTheme})
|
await themeManager.downloadTheme(displayTheme);
|
||||||
setTheme(displayTheme.id);
|
await themeManager.setTheme(displayTheme.id);
|
||||||
themeUpdates.triggerUpdate();
|
themeUpdates.triggerUpdate();
|
||||||
await fetchCurrentThemes();
|
await fetchCurrentThemes();
|
||||||
}
|
}
|
||||||
@@ -132,7 +131,7 @@
|
|||||||
onRemove={async () => {
|
onRemove={async () => {
|
||||||
if (displayTheme?.id) {
|
if (displayTheme?.id) {
|
||||||
console.debug('deleting theme', displayTheme.id);
|
console.debug('deleting theme', displayTheme.id);
|
||||||
deleteTheme(displayTheme.id)
|
await themeManager.deleteTheme(displayTheme.id);
|
||||||
themeUpdates.triggerUpdate();
|
themeUpdates.triggerUpdate();
|
||||||
await fetchCurrentThemes();
|
await fetchCurrentThemes();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import { type LoadedCustomTheme } from '@/types/CustomThemes'
|
import { type LoadedCustomTheme } from '@/types/CustomThemes'
|
||||||
|
|
||||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||||
import { getTheme } from '@/seqta/ui/themes/getTheme'
|
|
||||||
|
|
||||||
import Divider from '@/interface/components/themeCreator/divider.svelte'
|
import Divider from '@/interface/components/themeCreator/divider.svelte'
|
||||||
import Switch from '@/interface/components/Switch.svelte'
|
import Switch from '@/interface/components/Switch.svelte'
|
||||||
@@ -22,14 +21,13 @@
|
|||||||
handleImageVariableChange,
|
handleImageVariableChange,
|
||||||
handleCoverImageUpload
|
handleCoverImageUpload
|
||||||
} from '../utils/themeImageHandlers';
|
} from '../utils/themeImageHandlers';
|
||||||
import { ClearThemePreview, UpdateThemePreview } from '@/seqta/ui/themes/UpdateThemePreview'
|
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
||||||
import { saveTheme } from '@/seqta/ui/themes/saveTheme'
|
|
||||||
import { CloseThemeCreator } from '@/seqta/ui/ThemeCreator'
|
|
||||||
import { themeUpdates } from '../hooks/ThemeUpdates'
|
import { themeUpdates } from '../hooks/ThemeUpdates'
|
||||||
import { disableTheme } from '@/seqta/ui/themes/disableTheme'
|
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
||||||
import { setTheme } from '@/seqta/ui/themes/setTheme'
|
|
||||||
|
|
||||||
const { themeID } = $props<{ themeID: string }>()
|
const { themeID } = $props<{ themeID: string }>()
|
||||||
|
const themeManager = ThemeManager.getInstance();
|
||||||
|
|
||||||
let theme = $state<LoadedCustomTheme>({
|
let theme = $state<LoadedCustomTheme>({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name: '',
|
name: '',
|
||||||
@@ -53,7 +51,12 @@
|
|||||||
codeEditorFullscreen = !codeEditorFullscreen;
|
codeEditorFullscreen = !codeEditorFullscreen;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAccordion(title: string) {
|
function toggleAccordion(title: string, e: MouseEvent | KeyboardEvent) {
|
||||||
|
// if the target is the fullscreen button return
|
||||||
|
if (e.target instanceof HTMLButtonElement && e.target.classList.contains('fullscreen-toggle')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (closedAccordions.includes(title)) {
|
if (closedAccordions.includes(title)) {
|
||||||
closedAccordions = closedAccordions.filter(t => t !== title);
|
closedAccordions = closedAccordions.filter(t => t !== title);
|
||||||
} else {
|
} else {
|
||||||
@@ -62,10 +65,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await disableTheme();
|
await themeManager.disableTheme();
|
||||||
|
|
||||||
if (themeID) {
|
if (themeID) {
|
||||||
const tempTheme = await getTheme(themeID)
|
const tempTheme = await themeManager.getTheme(themeID)
|
||||||
|
|
||||||
if (!tempTheme) return
|
if (!tempTheme) return
|
||||||
|
|
||||||
@@ -73,16 +76,12 @@
|
|||||||
const loadedTheme = {
|
const loadedTheme = {
|
||||||
...tempTheme,
|
...tempTheme,
|
||||||
CustomImages: tempTheme.CustomImages.map(image => ({
|
CustomImages: tempTheme.CustomImages.map(image => ({
|
||||||
...image,
|
...image
|
||||||
url: image.blob ? URL.createObjectURL(image.blob) : null
|
}))
|
||||||
})),
|
|
||||||
coverImageUrl: tempTheme.coverImage ? URL.createObjectURL(tempTheme.coverImage) : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tempTheme) {
|
|
||||||
theme = loadedTheme
|
theme = loadedTheme
|
||||||
themeLoaded = true
|
themeLoaded = true
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
themeLoaded = true
|
themeLoaded = true
|
||||||
}
|
}
|
||||||
@@ -106,7 +105,7 @@
|
|||||||
theme = await handleCoverImageUpload(event, theme);
|
theme = await handleCoverImageUpload(event, theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitTheme() {
|
async function submitTheme() {
|
||||||
const themeClone = JSON.parse(JSON.stringify(theme));
|
const themeClone = JSON.parse(JSON.stringify(theme));
|
||||||
|
|
||||||
// re-insert blobs into themeClone
|
// re-insert blobs into themeClone
|
||||||
@@ -116,15 +115,17 @@
|
|||||||
}))
|
}))
|
||||||
themeClone.coverImage = theme.coverImage
|
themeClone.coverImage = theme.coverImage
|
||||||
|
|
||||||
ClearThemePreview();
|
themeManager.clearPreview();
|
||||||
saveTheme(themeClone);
|
await themeManager.saveTheme(themeClone);
|
||||||
setTheme(themeClone.id);
|
await themeManager.setTheme(themeClone.id);
|
||||||
themeUpdates.triggerUpdate();
|
themeUpdates.triggerUpdate();
|
||||||
CloseThemeCreator();
|
CloseThemeCreator();
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
UpdateThemePreview(theme);
|
if (themeLoaded) {
|
||||||
|
void themeManager.updatePreviewDebounced(theme);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
type SettingType = 'switch' | 'button' | 'slider' | 'colourPicker' | 'select' | 'codeEditor' | 'imageUpload' | 'conditional' | 'lightDarkToggle';
|
type SettingType = 'switch' | 'button' | 'slider' | 'colourPicker' | 'select' | 'codeEditor' | 'imageUpload' | 'conditional' | 'lightDarkToggle';
|
||||||
@@ -164,8 +165,8 @@
|
|||||||
<div class="flex justify-between {item.direction === 'vertical' ? 'flex-col items-start' : 'items-center'} py-3">
|
<div class="flex justify-between {item.direction === 'vertical' ? 'flex-col items-start' : 'items-center'} py-3">
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
onclick={() => { item.direction === 'vertical' && toggleAccordion(item.title) }}
|
onclick={(e) => { item.direction === 'vertical' && toggleAccordion(item.title, e) }}
|
||||||
onkeydown={(e) => { e.key === 'Enter' && item.direction === 'vertical' && toggleAccordion(item.title) }}
|
onkeydown={(e) => { e.key === 'Enter' && item.direction === 'vertical' && toggleAccordion(item.title, e) }}
|
||||||
class="flex justify-between pr-4 {item.direction === 'vertical' ? 'cursor-pointer w-full select-none' : ''}">
|
class="flex justify-between pr-4 {item.direction === 'vertical' ? 'cursor-pointer w-full select-none' : ''}">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -177,7 +178,7 @@
|
|||||||
<div class="flex justify-center items-center h-full text-xl font-light text-zinc-500 dark:text-zinc-300">
|
<div class="flex justify-center items-center h-full text-xl font-light text-zinc-500 dark:text-zinc-300">
|
||||||
{#if item.type === 'codeEditor'}
|
{#if item.type === 'codeEditor'}
|
||||||
<!-- Fullscreen toggle button -->
|
<!-- Fullscreen toggle button -->
|
||||||
<button onclick={toggleCodeEditorFullscreen} class="mr-2 text-lg font-IconFamily">
|
<button onclick={toggleCodeEditorFullscreen} class="px-2 mr-2 text-lg font-IconFamily fullscreen-toggle">
|
||||||
{'\uebdb'}
|
{'\uebdb'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -210,14 +211,14 @@
|
|||||||
{#each theme.CustomImages as image (image.id)}
|
{#each theme.CustomImages as image (image.id)}
|
||||||
<div class="flex gap-2 items-center px-2 py-2 mb-4 h-16 bg-white rounded-lg shadow-lg dark:bg-zinc-700">
|
<div class="flex gap-2 items-center px-2 py-2 mb-4 h-16 bg-white rounded-lg shadow-lg dark:bg-zinc-700">
|
||||||
<div class="h-full">
|
<div class="h-full">
|
||||||
<img src={image.url} alt={image.variableName} class="object-contain h-full rounded" />
|
<img src={URL.createObjectURL(image.blob)} alt={image.variableName} class="object-contain h-full rounded" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={image.variableName}
|
bind:value={image.variableName}
|
||||||
oninput={(e) => onImageVariableChange(image.id, e.currentTarget.value)}
|
oninput={(e) => onImageVariableChange(image.id, e.currentTarget.value)}
|
||||||
placeholder="CSS Variable Name"
|
placeholder="CSS Variable Name"
|
||||||
class="flex-grow flex-[3] w-full p-2 transition border-0 rounded-lg dark:placeholder-zinc-300 bg-zinc-200 dark:bg-zinc-600/50 focus:bg-zinc-300/50 dark:focus:bg-zinc-600"
|
class="p-2 w-full rounded-lg border-0 transition grow flex-3 dark:placeholder-zinc-300 bg-zinc-200 dark:bg-zinc-600/50 focus:bg-zinc-300/50 dark:focus:bg-zinc-600"
|
||||||
/>
|
/>
|
||||||
<button onclick={() => onRemoveImage(image.id)} class="p-2 transition dark:text-white">
|
<button onclick={() => onRemoveImage(image.id)} class="p-2 transition dark:text-white">
|
||||||
<span class='text-xl font-IconFamily'>{'\ued8c'}</span>
|
<span class='text-xl font-IconFamily'>{'\ued8c'}</span>
|
||||||
@@ -255,7 +256,7 @@
|
|||||||
|
|
||||||
<div class='h-screen overflow-y-scroll {$settingsState.DarkMode && "dark"} no-scrollbar'>
|
<div class='h-screen overflow-y-scroll {$settingsState.DarkMode && "dark"} no-scrollbar'>
|
||||||
{#if codeEditorFullscreen}
|
{#if codeEditorFullscreen}
|
||||||
<div class="absolute inset-0 z-[10000] bg-white dark:bg-zinc-900 dark:text-white">
|
<div class="absolute inset-0 bg-white z-[10000] dark:bg-zinc-900 dark:text-white">
|
||||||
<div class="sticky top-0 px-2 h-screen">
|
<div class="sticky top-0 px-2 h-screen">
|
||||||
<div class="flex justify-between items-center my-4">
|
<div class="flex justify-between items-center my-4">
|
||||||
<h2 class="text-xl font-bold">Custom CSS</h2>
|
<h2 class="text-xl font-bold">Custom CSS</h2>
|
||||||
@@ -310,7 +311,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if theme.coverImage}
|
{#if theme.coverImage}
|
||||||
<div class="absolute z-20 w-full h-full opacity-0 transition-opacity pointer-events-none group-hover:opacity-100 bg-black/20"></div>
|
<div class="absolute z-20 w-full h-full opacity-0 transition-opacity pointer-events-none group-hover:opacity-100 bg-black/20"></div>
|
||||||
<img src={theme.coverImageUrl} alt='Cover' class="object-cover absolute z-0 w-full h-full rounded" />
|
<img src="{typeof theme.coverImage === 'string' ? theme.coverImage : URL.createObjectURL(theme.coverImage)}" alt='Cover' class="object-cover absolute z-0 w-full h-full rounded" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function handleImageUpload(event: Event, theme: LoadedCustomTheme): Promi
|
|||||||
const variableName = `custom-image-${theme.CustomImages.length}`;
|
const variableName = `custom-image-${theme.CustomImages.length}`;
|
||||||
resolve({
|
resolve({
|
||||||
...theme,
|
...theme,
|
||||||
CustomImages: [...theme.CustomImages, { id: imageId, blob: imageBlob, variableName, url: URL.createObjectURL(imageBlob) }],
|
CustomImages: [...theme.CustomImages, { id: imageId, blob: imageBlob, variableName, url: null }],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
@@ -51,7 +51,7 @@ export function handleCoverImageUpload(event: Event, theme: LoadedCustomTheme):
|
|||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = async () => {
|
reader.onload = async () => {
|
||||||
const imageBlob = await fetch(reader.result as string).then(res => res.blob());
|
const imageBlob = await fetch(reader.result as string).then(res => res.blob());
|
||||||
resolve({ ...theme, coverImage: imageBlob, coverImageUrl: URL.createObjectURL(imageBlob) });
|
resolve({ ...theme, coverImage: imageBlob });
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,15 +32,7 @@
|
|||||||
],
|
],
|
||||||
"web_accessible_resources": [
|
"web_accessible_resources": [
|
||||||
{
|
{
|
||||||
"resources": ["*://*/*"],
|
"resources": ["*/*", "resources/*", "seqta/utils/migration/migrate.html", "plugins/built-in/globalSearch/*"],
|
||||||
"matches": ["*://*/*"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resources": ["resources/icons/*"],
|
|
||||||
"matches": ["*://*/*"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resources": ["seqta/utils/migration/migrate.html"],
|
|
||||||
"matches": ["*://*/*"]
|
"matches": ["*://*/*"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { BasePlugin } from '../../core/settings';
|
||||||
|
import { type Plugin } from '@/plugins/core/types';
|
||||||
|
import { defineSettings, numberSetting, Setting } from '@/plugins/core/settingsHelpers';
|
||||||
|
import styles from './styles.css?inline';
|
||||||
|
|
||||||
|
const settings = defineSettings({
|
||||||
|
speed: numberSetting({
|
||||||
|
default: 1,
|
||||||
|
title: "Animation Speed",
|
||||||
|
description: "Controls how fast the background moves",
|
||||||
|
min: 0.1,
|
||||||
|
max: 2,
|
||||||
|
step: 0.05
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
|
||||||
|
@Setting(settings.speed)
|
||||||
|
speed!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new AnimatedBackgroundPluginClass();
|
||||||
|
|
||||||
|
const animatedBackgroundPlugin: Plugin<typeof settings> = {
|
||||||
|
id: 'animated-background',
|
||||||
|
name: 'Animated Background',
|
||||||
|
description: 'Adds an animated background to BetterSEQTA+',
|
||||||
|
version: '1.0.0',
|
||||||
|
disableToggle: true,
|
||||||
|
styles: styles,
|
||||||
|
settings: instance.settings,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
// Create the background elements
|
||||||
|
const container = document.getElementById("container");
|
||||||
|
const menu = document.getElementById("menu");
|
||||||
|
|
||||||
|
if (!container || !menu) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const backgrounds = [
|
||||||
|
{ classes: ["bg"] },
|
||||||
|
{ classes: ["bg", "bg2"] },
|
||||||
|
{ classes: ["bg", "bg3"] }
|
||||||
|
];
|
||||||
|
|
||||||
|
backgrounds.forEach(({ classes }) => {
|
||||||
|
const bk = document.createElement("div");
|
||||||
|
classes.forEach(cls => bk.classList.add(cls));
|
||||||
|
container.insertBefore(bk, menu);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial speed
|
||||||
|
updateAnimationSpeed(api.settings.speed);
|
||||||
|
|
||||||
|
// Listen for speed changes
|
||||||
|
const speedUnregister = api.settings.onChange('speed', updateAnimationSpeed);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
speedUnregister.unregister();
|
||||||
|
// Remove background elements
|
||||||
|
const backgrounds = document.getElementsByClassName('bg');
|
||||||
|
Array.from(backgrounds).forEach(element => element.remove());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateAnimationSpeed(speed: number) {
|
||||||
|
const bgElements = document.getElementsByClassName('bg');
|
||||||
|
Array.from(bgElements).forEach((element, index) => {
|
||||||
|
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
|
||||||
|
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default animatedBackgroundPlugin;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
.bg {
|
||||||
|
animation: slide 3s ease-in-out infinite alternate;
|
||||||
|
background: var(--better-main);
|
||||||
|
bottom: 0;
|
||||||
|
left: -50%;
|
||||||
|
opacity: 0.5;
|
||||||
|
position: fixed;
|
||||||
|
right: -50%;
|
||||||
|
top: 0;
|
||||||
|
z-index: 0 !important;
|
||||||
|
overflow: hidden;
|
||||||
|
scale: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg2 {
|
||||||
|
animation-direction: alternate-reverse;
|
||||||
|
animation-duration: 4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg3 {
|
||||||
|
animation-duration: 5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide {
|
||||||
|
0% {
|
||||||
|
transform: translate(50%) rotate(-60deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(5%) rotate(-60deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export function CreateBackground() {
|
||||||
|
const bkCheck = document.getElementsByClassName("bg");
|
||||||
|
if (bkCheck.length !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creating and inserting 3 divs containing the background applied to the pages
|
||||||
|
const container = document.getElementById("container");
|
||||||
|
const menu = document.getElementById("menu");
|
||||||
|
|
||||||
|
if (!container || !menu) return;
|
||||||
|
|
||||||
|
const backgrounds = [
|
||||||
|
{ classes: ["bg"] },
|
||||||
|
{ classes: ["bg", "bg2"] },
|
||||||
|
{ classes: ["bg", "bg3"] }
|
||||||
|
];
|
||||||
|
|
||||||
|
backgrounds.forEach(({ classes }) => {
|
||||||
|
const bk = document.createElement("div");
|
||||||
|
classes.forEach(cls => bk.classList.add(cls));
|
||||||
|
container.insertBefore(bk, menu);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function RemoveBackground() {
|
||||||
|
const backgrounds = document.getElementsByClassName("bg");
|
||||||
|
|
||||||
|
// Convert HTMLCollection to Array and remove each element
|
||||||
|
Array.from(backgrounds).forEach(element => element.remove());
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { BasePlugin } from "@/plugins/core/settings";
|
||||||
|
import { booleanSetting, defineSettings, Setting } from "@/plugins/core/settingsHelpers";
|
||||||
|
import { type Plugin } from "@/plugins/core/types";
|
||||||
|
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||||
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
|
||||||
|
const settings = defineSettings({
|
||||||
|
lettergrade: booleanSetting({
|
||||||
|
default: false,
|
||||||
|
title: "Letter Grades",
|
||||||
|
description: "Display the average as a letter instead of a percentage"
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
|
||||||
|
@Setting(settings.lettergrade)
|
||||||
|
lettergrade!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new AssessmentsAveragePluginClass();
|
||||||
|
|
||||||
|
const assessmentsAveragePlugin: Plugin<typeof settings> = {
|
||||||
|
id: "assessments-average",
|
||||||
|
name: "Assessment Averages",
|
||||||
|
description: "Adds an average grade to the Assessments page",
|
||||||
|
version: "1.0.0",
|
||||||
|
disableToggle: true,
|
||||||
|
settings: instance.settings,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
api.seqta.onMount(".assessmentsWrapper", async () => {
|
||||||
|
// Wait for any assessment item to load first
|
||||||
|
await waitForElm(
|
||||||
|
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
|
||||||
|
true,
|
||||||
|
10,
|
||||||
|
1000
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper function to find actual class names by their base pattern
|
||||||
|
const getClassByPattern = (element: Element | Document, basePattern: string): string => {
|
||||||
|
// Find all classes on the element
|
||||||
|
const classes = Array.from(element.querySelectorAll('*'))
|
||||||
|
.flatMap(el => Array.from(el.classList))
|
||||||
|
.filter(className => className.startsWith(basePattern));
|
||||||
|
|
||||||
|
return classes.length ? classes[0] : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find actual class names from the DOM
|
||||||
|
const sampleAssessmentItem = document.querySelector("[class*='AssessmentItem__AssessmentItem___']");
|
||||||
|
if (!sampleAssessmentItem) return;
|
||||||
|
|
||||||
|
// Extract all necessary class patterns from a sample assessment item
|
||||||
|
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___');
|
||||||
|
|
||||||
|
// Get Thermoscore classes
|
||||||
|
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___');
|
||||||
|
|
||||||
|
// Find assessment list
|
||||||
|
const assessmentsList = document.querySelector("#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']");
|
||||||
|
if (!assessmentsList) return;
|
||||||
|
|
||||||
|
const gradeElements = document.querySelectorAll("[class*='Thermoscore__text___']");
|
||||||
|
if (!gradeElements.length) return;
|
||||||
|
|
||||||
|
// Parse and average grades
|
||||||
|
const letterToNumber: Record<string, number> = {
|
||||||
|
"A+": 100, A: 95, "A-": 90,
|
||||||
|
"B+": 85, B: 80, "B-": 75,
|
||||||
|
"C+": 70, C: 65, "C-": 60,
|
||||||
|
"D+": 55, D: 50, "D-": 45,
|
||||||
|
"E+": 40, E: 35, "E-": 30,
|
||||||
|
F: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseGrade(text: string): number {
|
||||||
|
const str = text.trim().toUpperCase();
|
||||||
|
if (str.includes("/")) {
|
||||||
|
const [raw, max] = str.split("/").map(n => parseFloat(n));
|
||||||
|
return (raw / max) * 100;
|
||||||
|
}
|
||||||
|
if (str.includes("%")) {
|
||||||
|
return parseFloat(str.replace("%", "")) || 0;
|
||||||
|
}
|
||||||
|
return letterToNumber[str] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
let count = 0;
|
||||||
|
gradeElements.forEach((el) => {
|
||||||
|
const grade = parseGrade(el.textContent || "");
|
||||||
|
if (grade > 0) {
|
||||||
|
total += grade;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!count) return;
|
||||||
|
|
||||||
|
const avg = total / count;
|
||||||
|
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)}%`;
|
||||||
|
|
||||||
|
// Prevent duplicate
|
||||||
|
const existing = assessmentsList.querySelector(`[class*='AssessmentItem__title___']`);
|
||||||
|
if (existing?.textContent === "Subject Average") return;
|
||||||
|
|
||||||
|
// Use the dynamic class names in the HTML template
|
||||||
|
const averageElement = stringToHTML(/* html */ `
|
||||||
|
<div class="${assessmentItemClass}">
|
||||||
|
<div class="${metaContainerClass}">
|
||||||
|
<div class="${metaClass}">
|
||||||
|
<div class="${simpleResultClass}">
|
||||||
|
<div class="${titleClass}">Subject Average</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="${thermoscoreClass}">
|
||||||
|
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
|
||||||
|
<div class="${textClass}" title="${display}">${display}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).firstChild;
|
||||||
|
|
||||||
|
assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default assessmentsAveragePlugin;
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
# client-vector-search
|
||||||
|
|
||||||
|
A client side vector search library that can embed, search, and cache. Works on the browser and server side.
|
||||||
|
|
||||||
|
It outperforms OpenAI's text-embedding-ada-002 and is way faster than Pinecone and other VectorDBs.
|
||||||
|
|
||||||
|
I'm the founder of [searchbase.app](https://searchbase.app) and we needed this for our product and customers. We'll be using this library in production. You can be sure it'll be maintained and improved.
|
||||||
|
|
||||||
|
- Embed documents using transformers by default: gte-small (~30mb).
|
||||||
|
- Calculate cosine similarity between embeddings.
|
||||||
|
- Create an index and search on the client side
|
||||||
|
- Cache vectors with browser caching support.
|
||||||
|
|
||||||
|
Lots of improvements are coming!
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Our goal is to build a super simple, fast vector search that works with couple hundred to thousands vectors. ~1k vectors per user covers 99% of the use cases.
|
||||||
|
|
||||||
|
We'll initially keep things super simple and sub 100ms
|
||||||
|
|
||||||
|
### TODOs
|
||||||
|
|
||||||
|
- [ ] add HNSW index that works on node and browser env, don't rely on hnsw binder libs
|
||||||
|
- [ ] add a proper testing suite and ci/cd for the lib
|
||||||
|
- [ ] simple health tests
|
||||||
|
- [ ] mock the @xenova/transformers for jest, it's not happy with it
|
||||||
|
- [ ] performance tests, recall, memory usage, cpu usage etc.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i client-vector-search
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
This library provides a plug-and-play solution for embedding and vector search. It's designed to be easy to use, efficient, and versatile. Here's a quick start guide:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { getEmbedding, EmbeddingIndex } from "client-vector-search";
|
||||||
|
|
||||||
|
// getEmbedding is an async function, so you need to use 'await' or '.then()' to get the result
|
||||||
|
const embedding = await getEmbedding("Apple"); // Returns embedding as number[]
|
||||||
|
|
||||||
|
// Each object should have an 'embedding' property of type number[]
|
||||||
|
const initialObjects = [
|
||||||
|
{ id: 1, name: "Apple", embedding: embedding },
|
||||||
|
{ id: 2, name: "Banana", embedding: await getEmbedding("Banana") },
|
||||||
|
{ id: 3, name: "Cheddar", embedding: await getEmbedding("Cheddar") },
|
||||||
|
{ id: 4, name: "Space", embedding: await getEmbedding("Space") },
|
||||||
|
{ id: 5, name: "database", embedding: await getEmbedding("database") },
|
||||||
|
];
|
||||||
|
const index = new EmbeddingIndex(initialObjects); // Creates an index
|
||||||
|
|
||||||
|
// The query should be an embedding of type number[]
|
||||||
|
const queryEmbedding = await getEmbedding("Fruit"); // Query embedding
|
||||||
|
const results = await index.search(queryEmbedding, { topK: 5 }); // Returns top similar objects
|
||||||
|
|
||||||
|
// specify the storage type
|
||||||
|
await index.saveIndex("indexedDB");
|
||||||
|
const results = await index.search([1, 2, 3], {
|
||||||
|
topK: 5,
|
||||||
|
useStorage: "indexedDB",
|
||||||
|
// storageOptions: { // use only if you overrode the defaults
|
||||||
|
// indexedDBName: 'clientVectorDB',
|
||||||
|
// indexedDBObjectStoreName: 'ClientEmbeddingStore',
|
||||||
|
// },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(results);
|
||||||
|
|
||||||
|
await index.deleteIndexedDB(); // if you overrode default, specify db name
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trouble-shooting
|
||||||
|
|
||||||
|
### NextJS
|
||||||
|
|
||||||
|
To use it inside NextJS projects you'll need to update the `next.config.js` file to include the following:
|
||||||
|
|
||||||
|
```js
|
||||||
|
module.exports = {
|
||||||
|
// Override the default webpack configuration
|
||||||
|
webpack: (config) => {
|
||||||
|
// See https://webpack.js.org/configuration/resolve/#resolvealias
|
||||||
|
config.resolve.alias = {
|
||||||
|
...config.resolve.alias,
|
||||||
|
sharp$: false,
|
||||||
|
"onnxruntime-node$": false,
|
||||||
|
};
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Model load after page is loaded
|
||||||
|
|
||||||
|
You can initialize the model before using it to generate embeddings. This will ensure that the model is loaded before you use it and provide a better UX.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { initializeModel } from "client-vector-search"
|
||||||
|
...
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
initializeModel();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Guide
|
||||||
|
|
||||||
|
This guide provides a step-by-step walkthrough of the library's main features. It covers everything from generating embeddings for a string to performing operations on the index such as adding, updating, and removing objects. It also includes instructions on how to save the index to a database and perform search operations within it.
|
||||||
|
|
||||||
|
Until we have a reference documentation, you can find all the methods and their usage in this guide. Each step is accompanied by a code snippet to illustrate the usage of the method in question. Make sure to follow along and try out the examples in your own environment to get a better understanding of how everything works.
|
||||||
|
|
||||||
|
Let's get started!
|
||||||
|
|
||||||
|
### Step 1: Generate Embeddings for String
|
||||||
|
|
||||||
|
Generate embeddings for a given string using the `getEmbedding` method.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const embedding = await getEmbedding("Apple"); // Returns embedding as number[]
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: `getEmbedding` is asynchronous; make sure to use `await`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Calculate Cosine Similarity
|
||||||
|
|
||||||
|
Calculate the cosine similarity between two embeddings.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const similarity = cosineSimilarity(embedding1, embedding2, 6);
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: Both embeddings should be of the same length.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: Create an Index
|
||||||
|
|
||||||
|
Create an index with an initial array of objects. Each object must have an 'embedding' property.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const initialObjects = [...];
|
||||||
|
const index = new EmbeddingIndex(initialObjects);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: Add to Index
|
||||||
|
|
||||||
|
Add an object to the index.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const objectToAdd = {
|
||||||
|
id: 6,
|
||||||
|
name: "Cat",
|
||||||
|
embedding: await getEmbedding("Cat"),
|
||||||
|
};
|
||||||
|
index.add(objectToAdd);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5: Update Index
|
||||||
|
|
||||||
|
Update an existing object in the index.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const vectorToUpdate = {
|
||||||
|
id: 6,
|
||||||
|
name: "Dog",
|
||||||
|
embedding: await getEmbedding("Dog"),
|
||||||
|
};
|
||||||
|
index.update({ id: 6 }, vectorToUpdate);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6: Remove from Index
|
||||||
|
|
||||||
|
Remove an object from the index.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
index.remove({ id: 6 });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7: Retrieve from Index
|
||||||
|
|
||||||
|
Retrieve an object from the index.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const vector = index.get({ id: 1 });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 8: Search the Index
|
||||||
|
|
||||||
|
Search the index with a query embedding.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const queryEmbedding = await getEmbedding("Fruit");
|
||||||
|
const results = await index.search(queryEmbedding, { topK: 5 });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 9: Print the Index
|
||||||
|
|
||||||
|
Print the entire index to the console.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
index.printIndex();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 10: Save Index to IndexedDB (for browser)
|
||||||
|
|
||||||
|
Save the index to a persistent IndexedDB database. Note
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await index.saveIndex("indexedDB", {
|
||||||
|
DBName: "clientVectorDB",
|
||||||
|
objectStoreName: "ClientEmbeddingStore",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Important: Search in indexedDB
|
||||||
|
|
||||||
|
Perform a search operation in the IndexedDB.
|
||||||
|
|
||||||
|
````ts
|
||||||
|
const results = await index.search(queryEmbedding, {
|
||||||
|
topK: 5,
|
||||||
|
useStorage: "indexedDB",
|
||||||
|
storageOptions: { // only if you want to override the default options, defaults are below
|
||||||
|
indexedDBName: 'clientVectorDB',
|
||||||
|
indexedDBObjectStoreName: 'ClientEmbeddingStore'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Delete Database
|
||||||
|
To delete an entire database.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await IndexedDbManager.deleteIndexedDB("clientVectorDB");
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Delete Object Store
|
||||||
|
|
||||||
|
To delete an object store from a database.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await IndexedDbManager.deleteIndexedDBObjectStore(
|
||||||
|
"clientVectorDB",
|
||||||
|
"ClientEmbeddingStore",
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Retrieve All Objects
|
||||||
|
|
||||||
|
To retrieve all objects from a specific object store.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const allObjects = await IndexedDbManager.getAllObjectsFromIndexedDB(
|
||||||
|
"clientVectorDB",
|
||||||
|
"ClientEmbeddingStore",
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
# THE MAIN INDEX.TS FILE THAT YOU ARE IMPORTING FROM
|
||||||
|
|
||||||
|
```index.ts
|
||||||
|
const DEFAULT_TOP_K = 3;
|
||||||
|
|
||||||
|
interface Filter {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
import Cache from './cache';
|
||||||
|
import { IndexedDbManager } from './indexedDB';
|
||||||
|
import { cosineSimilarity } from './utils';
|
||||||
|
export { ExperimentalHNSWIndex } from './hnsw';
|
||||||
|
|
||||||
|
// uncomment if you want to test indexedDB implementation in node env for faster dev cycle
|
||||||
|
// import { IDBFactory } from 'fake-indexeddb';
|
||||||
|
// const indexedDB = new IDBFactory();
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
similarity: number;
|
||||||
|
object: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageOptions = 'indexedDB' | 'localStorage' | 'none';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for search options in the EmbeddingIndex class.
|
||||||
|
* topK: The number of top similar items to return.
|
||||||
|
* filter: An optional filter to apply to the objects before searching.
|
||||||
|
* useStorage: A flag to indicate whether to use storage options like indexedDB or localStorage.
|
||||||
|
*/
|
||||||
|
interface SearchOptions {
|
||||||
|
topK?: number;
|
||||||
|
filter?: Filter;
|
||||||
|
useStorage?: StorageOptions;
|
||||||
|
storageOptions?: { indexedDBName: string; indexedDBObjectStoreName: string }; // TODO: generalize it to localStorage as well
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheInstance = Cache.getInstance();
|
||||||
|
|
||||||
|
let pipe: any;
|
||||||
|
let currentModel: string;
|
||||||
|
|
||||||
|
export const initializeModel = async (
|
||||||
|
model: string = 'Xenova/gte-small',
|
||||||
|
): Promise<void> => {
|
||||||
|
if (model !== currentModel) {
|
||||||
|
const transformersModule = await import('@xenova/transformers');
|
||||||
|
const pipeline = transformersModule.pipeline;
|
||||||
|
pipe = await pipeline('feature-extraction', model);
|
||||||
|
currentModel = model;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEmbedding = async (
|
||||||
|
text: string,
|
||||||
|
precision: number = 7,
|
||||||
|
options = { pooling: 'mean', normalize: false },
|
||||||
|
model = 'Xenova/gte-small',
|
||||||
|
): Promise<number[]> => {
|
||||||
|
const cachedEmbedding = cacheInstance.get(text);
|
||||||
|
if (cachedEmbedding) {
|
||||||
|
return Promise.resolve(cachedEmbedding);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model !== currentModel) {
|
||||||
|
await initializeModel(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await pipe(text, options);
|
||||||
|
const roundedOutput = Array.from(output.data as number[]).map(
|
||||||
|
(value: number) => parseFloat(value.toFixed(precision)),
|
||||||
|
);
|
||||||
|
cacheInstance.set(text, roundedOutput);
|
||||||
|
return Array.from(roundedOutput);
|
||||||
|
};
|
||||||
|
|
||||||
|
export class EmbeddingIndex {
|
||||||
|
private objects: Filter[];
|
||||||
|
private keys: string[];
|
||||||
|
|
||||||
|
constructor(initialObjects?: Filter[]) {
|
||||||
|
// TODO: add support for options while creating index such as {... indexedDB: true, ...}
|
||||||
|
this.objects = [];
|
||||||
|
this.keys = [];
|
||||||
|
if (initialObjects && initialObjects.length > 0) {
|
||||||
|
initialObjects.forEach((obj) => this.validateAndAdd(obj));
|
||||||
|
if (initialObjects[0]) {
|
||||||
|
this.keys = Object.keys(initialObjects[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findVectorIndex(filter: Filter): number {
|
||||||
|
return this.objects.findIndex((object) =>
|
||||||
|
Object.keys(filter).every((key) => object[key] === filter[key]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateAndAdd(obj: Filter) {
|
||||||
|
if (!Array.isArray(obj.embedding) || obj.embedding.some(isNaN)) {
|
||||||
|
throw new Error(
|
||||||
|
'Object must have an embedding property of type number[]',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.keys.length === 0) {
|
||||||
|
this.keys = Object.keys(obj);
|
||||||
|
} else if (!this.keys.every((key) => key in obj)) {
|
||||||
|
throw new Error(
|
||||||
|
'Object must have the same properties as the initial objects',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.objects.push(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
add(obj: Filter) {
|
||||||
|
this.validateAndAdd(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to update an existing vector in the index
|
||||||
|
update(filter: Filter, vector: Filter) {
|
||||||
|
const index = this.findVectorIndex(filter);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Vector not found');
|
||||||
|
}
|
||||||
|
if (vector.hasOwnProperty('embedding')) {
|
||||||
|
// Validate and add the new vector
|
||||||
|
this.validateAndAdd(vector);
|
||||||
|
}
|
||||||
|
// Replace the old vector with the new one
|
||||||
|
this.objects[index] = Object.assign(this.objects[index] as Filter, vector);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to remove a vector from the index
|
||||||
|
remove(filter: Filter) {
|
||||||
|
const index = this.findVectorIndex(filter);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Vector not found');
|
||||||
|
}
|
||||||
|
// Remove the vector from the index
|
||||||
|
this.objects.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to remove multiple vectors from the index
|
||||||
|
removeBatch(filters: Filter[]) {
|
||||||
|
filters.forEach((filter) => {
|
||||||
|
const index = this.findVectorIndex(filter);
|
||||||
|
if (index !== -1) {
|
||||||
|
// Remove the vector from the index
|
||||||
|
this.objects.splice(index, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to retrieve a vector from the index
|
||||||
|
get(filter: Filter) {
|
||||||
|
const vector = this.objects[this.findVectorIndex(filter)];
|
||||||
|
return vector || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
size(): number {
|
||||||
|
// Returns the size of the index
|
||||||
|
return this.objects.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.objects = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(
|
||||||
|
queryEmbedding: number[],
|
||||||
|
options: SearchOptions = {
|
||||||
|
topK: 3,
|
||||||
|
useStorage: 'none',
|
||||||
|
storageOptions: {
|
||||||
|
indexedDBName: 'clientVectorDB',
|
||||||
|
indexedDBObjectStoreName: 'ClientEmbeddingStore',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
): Promise<SearchResult[]> {
|
||||||
|
const topK = options.topK || DEFAULT_TOP_K;
|
||||||
|
const filter = options.filter || {};
|
||||||
|
const useStorage = options.useStorage || 'none';
|
||||||
|
|
||||||
|
if (useStorage === 'indexedDB') {
|
||||||
|
const DBname = options.storageOptions?.indexedDBName || 'clientVectorDB';
|
||||||
|
const objectStoreName =
|
||||||
|
options.storageOptions?.indexedDBObjectStoreName ||
|
||||||
|
'ClientEmbeddingStore';
|
||||||
|
|
||||||
|
if (typeof indexedDB === 'undefined') {
|
||||||
|
console.error('IndexedDB is not supported');
|
||||||
|
throw new Error('IndexedDB is not supported');
|
||||||
|
}
|
||||||
|
const results = await this.loadAndSearchFromIndexedDB(
|
||||||
|
DBname,
|
||||||
|
objectStoreName,
|
||||||
|
queryEmbedding,
|
||||||
|
topK,
|
||||||
|
filter,
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
} else {
|
||||||
|
// Compute similarities
|
||||||
|
const similarities = this.objects
|
||||||
|
.filter((object) =>
|
||||||
|
Object.keys(filter).every((key) => object[key] === filter[key]),
|
||||||
|
)
|
||||||
|
.map((obj) => ({
|
||||||
|
similarity: cosineSimilarity(queryEmbedding, obj.embedding),
|
||||||
|
object: obj,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sort by similarity and return topK results
|
||||||
|
return similarities
|
||||||
|
.sort((a, b) => b.similarity - a.similarity)
|
||||||
|
.slice(0, topK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printIndex() {
|
||||||
|
console.log('Index Content:');
|
||||||
|
this.objects.forEach((obj, idx) => {
|
||||||
|
console.log(`Item ${idx + 1}:`, obj);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveIndex(
|
||||||
|
storageType: string,
|
||||||
|
options: { DBName: string; objectStoreName: string } = {
|
||||||
|
DBName: 'clientVectorDB',
|
||||||
|
objectStoreName: 'ClientEmbeddingStore',
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (storageType === 'indexedDB') {
|
||||||
|
await this.saveToIndexedDB(options.DBName, options.objectStoreName);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported storage type: ${storageType} \n Supported storage types: "indexedDB"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveToIndexedDB(
|
||||||
|
DBname: string = 'clientVectorDB',
|
||||||
|
objectStoreName: string = 'ClientEmbeddingStore',
|
||||||
|
): Promise<void> {
|
||||||
|
if (typeof indexedDB === 'undefined') {
|
||||||
|
console.error('IndexedDB is not defined');
|
||||||
|
throw new Error('IndexedDB is not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.objects || this.objects.length === 0) {
|
||||||
|
throw new Error('Index is empty. Nothing to save');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await IndexedDbManager.create(DBname, objectStoreName);
|
||||||
|
await db.addToIndexedDB(this.objects);
|
||||||
|
console.log(
|
||||||
|
`Index saved to database '${DBname}' object store '${objectStoreName}'`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving index to database:', error);
|
||||||
|
throw new Error('Error saving index to database');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAndSearchFromIndexedDB(
|
||||||
|
DBname: string = 'clientVectorDB',
|
||||||
|
objectStoreName: string = 'ClientEmbeddingStore',
|
||||||
|
queryEmbedding: number[],
|
||||||
|
topK: number,
|
||||||
|
filter: { [key: string]: any },
|
||||||
|
): Promise<SearchResult[]> {
|
||||||
|
const db = await IndexedDbManager.create(DBname, objectStoreName);
|
||||||
|
const generator = db.dbGenerator();
|
||||||
|
const results: { similarity: number; object: any }[] = [];
|
||||||
|
|
||||||
|
for await (const record of generator) {
|
||||||
|
if (Object.keys(filter).every((key) => record[key] === filter[key])) {
|
||||||
|
const similarity = cosineSimilarity(queryEmbedding, record.embedding);
|
||||||
|
results.push({ similarity, object: record });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.sort((a, b) => b.similarity - a.similarity);
|
||||||
|
return results.slice(0, topK);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIndexedDB(DBname: string = 'clientVectorDB'): Promise<void> {
|
||||||
|
if (typeof indexedDB === 'undefined') {
|
||||||
|
console.error('IndexedDB is not defined');
|
||||||
|
throw new Error('IndexedDB is not supported');
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.deleteDatabase(DBname);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log(`Database '${DBname}' deleted`);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
request.onerror = (event) => {
|
||||||
|
console.error('Failed to delete database', event);
|
||||||
|
reject(new Error('Failed to delete database'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIndexedDBObjectStore(
|
||||||
|
DBname: string = 'clientVectorDB',
|
||||||
|
objectStoreName: string = 'ClientEmbeddingStore',
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await IndexedDbManager.create(DBname, objectStoreName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.deleteIndexedDBObjectStoreFromDB(DBname, objectStoreName);
|
||||||
|
console.log(
|
||||||
|
`Object store '${objectStoreName}' deleted from database '${DBname}'`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting object store:', error);
|
||||||
|
throw new Error('Error deleting object store');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllObjectsFromIndexedDB(
|
||||||
|
DBname: string = 'clientVectorDB',
|
||||||
|
objectStoreName: string = 'ClientEmbeddingStore',
|
||||||
|
): Promise<any[]> {
|
||||||
|
const db = await IndexedDbManager.create(DBname, objectStoreName);
|
||||||
|
const objects: any[] = [];
|
||||||
|
for await (const record of db.dbGenerator()) {
|
||||||
|
objects.push(record);
|
||||||
|
}
|
||||||
|
return objects;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { highlightMatch, highlightSnippet, stripHtmlButKeepHighlights } from '../utils/highlight';
|
||||||
|
import type { DynamicContentItem } from '../utils/dynamicItems';
|
||||||
|
import type { FuseResultMatch } from '../core/types';
|
||||||
|
|
||||||
|
const { item, isSelected, searchTerm, matches } = $props<{
|
||||||
|
item: DynamicContentItem;
|
||||||
|
isSelected: boolean;
|
||||||
|
searchTerm: string;
|
||||||
|
matches?: readonly FuseResultMatch[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group
|
||||||
|
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center w-full">
|
||||||
|
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{item.metadata?.icon || '\ue924'}</div>
|
||||||
|
<span class="ml-4 text-lg truncate">
|
||||||
|
{@html stripHtmlButKeepHighlights(highlightMatch(item.text, searchTerm, matches))}
|
||||||
|
</span>
|
||||||
|
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{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">
|
||||||
|
{@html stripHtmlButKeepHighlights(highlightSnippet(item.content, searchTerm, matches))}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.highlight) {
|
||||||
|
background-color: rgba(255, 213, 0, 0.3);
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 1px;
|
||||||
|
margin: 0 -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark :global(.highlight) {
|
||||||
|
background-color: rgba(255, 230, 100, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||||
|
import { unitFullNames } from './unitMap';
|
||||||
|
import * as math from 'mathjs';
|
||||||
|
|
||||||
|
let { searchTerm = '', isSelected = false } = $props<{ searchTerm: string, isSelected: boolean }>();
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
hasResult: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let result = $state<string | null>(null);
|
||||||
|
let isCalculating = $state(false);
|
||||||
|
let inputUnit = $state<string>('');
|
||||||
|
let outputUnit = $state<string>('');
|
||||||
|
|
||||||
|
function detectUnit(expression: string): string {
|
||||||
|
try {
|
||||||
|
const unit = math.unit(expression);
|
||||||
|
if (unit) {
|
||||||
|
// Get the base unit name
|
||||||
|
const unitStr = unit.formatUnits();
|
||||||
|
return unitFullNames[unitStr] || unitStr;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Not a unit or invalid expression
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the input with debounce to avoid unnecessary calculations
|
||||||
|
const processInput = (input: string) => {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
!input.trim() ||
|
||||||
|
(input.trim().length <= 2 && !/\d/.test(input))
|
||||||
|
) {
|
||||||
|
result = null;
|
||||||
|
inputUnit = '';
|
||||||
|
outputUnit = '';
|
||||||
|
dispatch('hasResult', null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCalculating = true;
|
||||||
|
|
||||||
|
// Let mathjs handle everything
|
||||||
|
const evaluated = math.evaluate(input.replace('**', '^'));
|
||||||
|
|
||||||
|
// Format the result
|
||||||
|
if (evaluated !== undefined) {
|
||||||
|
if (math.typeOf(evaluated) === 'Unit') {
|
||||||
|
// Handle unit conversion results
|
||||||
|
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||||
|
inputUnit = detectUnit(input);
|
||||||
|
outputUnit = detectUnit(result);
|
||||||
|
} else if (typeof evaluated === 'number') {
|
||||||
|
// Handle regular numbers
|
||||||
|
if (math.round(evaluated) === evaluated) {
|
||||||
|
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||||
|
} else {
|
||||||
|
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||||
|
}
|
||||||
|
inputUnit = '';
|
||||||
|
outputUnit = '';
|
||||||
|
} else {
|
||||||
|
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||||
|
inputUnit = '';
|
||||||
|
outputUnit = '';
|
||||||
|
}
|
||||||
|
dispatch('hasResult', result);
|
||||||
|
} else {
|
||||||
|
result = null;
|
||||||
|
inputUnit = '';
|
||||||
|
outputUnit = '';
|
||||||
|
dispatch('hasResult', null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If mathjs throws an error, this isn't a valid expression
|
||||||
|
result = null;
|
||||||
|
inputUnit = '';
|
||||||
|
outputUnit = '';
|
||||||
|
dispatch('hasResult', null);
|
||||||
|
} finally {
|
||||||
|
isCalculating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
processInput(searchTerm);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
dispatch('hasResult', null);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if result !== null}
|
||||||
|
<div class="p-2">
|
||||||
|
<p class="text-[0.85rem] p-1 pb-0.5 pt-0 font-semibold text-zinc-500 dark:text-zinc-400">Calculator</p>
|
||||||
|
<div class="flex items-center justify-between gap-8 rounded-lg border border-transparent {isSelected ? 'bg-zinc-900/5 dark:bg-white/10 border-zinc-900/5 dark:border-zinc-100/5' : ''}">
|
||||||
|
<div class="flex flex-col flex-1 items-center py-4 pl-4 min-w-0">
|
||||||
|
<div class="overflow-hidden py-2 w-full font-semibold text-center whitespace-nowrap text-zinc-900 dark:text-white text-ellipsis"
|
||||||
|
style="--char-count: {searchTerm?.length || 10}; font-size: min(2.5rem, max(1rem, calc(35vw / var(--char-count, 10))))">
|
||||||
|
{searchTerm}
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-1 mt-1 text-sm rounded-md text-zinc-900 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-100/10">
|
||||||
|
{inputUnit || 'Question'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col flex-shrink-0 justify-center items-center w-12">
|
||||||
|
<div class="h-8 w-[1px] bg-zinc-900/5 dark:bg-zinc-100/5"></div>
|
||||||
|
<div class="text-2xl text-zinc-900 dark:text-zinc-100">
|
||||||
|
→
|
||||||
|
</div>
|
||||||
|
<div class="h-8 w-[1px] bg-zinc-900/5 dark:bg-zinc-100/5"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !isCalculating}
|
||||||
|
<div class="flex flex-col flex-1 items-center py-4 pr-4 min-w-0">
|
||||||
|
<div class="overflow-hidden py-2 w-full font-semibold text-center whitespace-nowrap text-zinc-900 dark:text-white text-ellipsis"
|
||||||
|
style="--char-count: {result?.length || 10}; font-size: min(2.5rem, max(1rem, calc(30vw / var(--char-count, 10))))">
|
||||||
|
{result}
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-1 mt-1 text-sm rounded-md text-zinc-900 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-100/10">
|
||||||
|
{outputUnit || 'Result'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="w-6 h-6 rounded-full border-2 animate-spin border-zinc-300 dark:border-zinc-700 border-t-zinc-600 dark:border-t-zinc-300"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||||
|
import { fade, scale } from 'svelte/transition';
|
||||||
|
import { circOut, quintOut } from 'svelte/easing';
|
||||||
|
import { type StaticCommandItem } from '../core/commands';
|
||||||
|
import type { CombinedResult } from '../core/types';
|
||||||
|
import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils';
|
||||||
|
import { highlightMatch, highlightSnippet, stripHtmlButKeepHighlights } from '../utils/highlight';
|
||||||
|
import Fuse from 'fuse.js';
|
||||||
|
import Calculator from './Calculator.svelte';
|
||||||
|
import { actionMap } from '../indexing/actions';
|
||||||
|
import type { IndexItem, HydratedIndexItem } from '../indexing/types';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
|
||||||
|
const {
|
||||||
|
transparencyEffects,
|
||||||
|
showRecentFirst
|
||||||
|
} = $props<{
|
||||||
|
transparencyEffects: boolean,
|
||||||
|
showRecentFirst: boolean
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let commandsFuse = $state<Fuse<StaticCommandItem>>();
|
||||||
|
let dynamicContentFuse = $state<Fuse<HydratedIndexItem>>();
|
||||||
|
|
||||||
|
const dynamicIdToItemMap = $state(new Map<string, HydratedIndexItem>());
|
||||||
|
const commandIdToItemMap = $state(new Map<string, StaticCommandItem>());
|
||||||
|
|
||||||
|
let isIndexing = $state(false);
|
||||||
|
let completedJobs = $state(0);
|
||||||
|
let totalJobs = $state(0);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const progressHandler = (event: CustomEvent) => {
|
||||||
|
const { completed, total, indexing } = event.detail;
|
||||||
|
completedJobs = completed;
|
||||||
|
totalJobs = total;
|
||||||
|
isIndexing = indexing;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
||||||
|
|
||||||
|
const itemsUpdatedHandler = () => {
|
||||||
|
console.log('Search Bar received items-updated event, re-indexing...');
|
||||||
|
setupSearchIndexes();
|
||||||
|
performSearch();
|
||||||
|
};
|
||||||
|
window.addEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('indexing-progress', progressHandler as EventListener);
|
||||||
|
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupSearchIndexes() {
|
||||||
|
const { commandsFuse: cfuse, dynamicContentFuse: dfuse, commands, dynamicItems } = createSearchIndexes();
|
||||||
|
|
||||||
|
commandsFuse = cfuse;
|
||||||
|
dynamicContentFuse = dfuse;
|
||||||
|
|
||||||
|
dynamicIdToItemMap.clear();
|
||||||
|
commandIdToItemMap.clear();
|
||||||
|
|
||||||
|
dynamicItems.forEach(item => dynamicIdToItemMap.set(item.id, item));
|
||||||
|
commands.forEach(item => commandIdToItemMap.set(item.id, item));
|
||||||
|
|
||||||
|
console.debug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`);
|
||||||
|
}
|
||||||
|
let commandPalleteOpen = $state(false);
|
||||||
|
let searchTerm = $state('');
|
||||||
|
let selectedIndex = $state(0);
|
||||||
|
let searchbar = $state<HTMLInputElement>();
|
||||||
|
let combinedResults = $state<CombinedResult[]>([]);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let prevSearchTerm = $state('');
|
||||||
|
let calculatorResult = $state<string | null>(null);
|
||||||
|
|
||||||
|
const updateCalculatorState = (hasResult: string | null) => {
|
||||||
|
calculatorResult = hasResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setupSearchIndexes();
|
||||||
|
|
||||||
|
// @ts-ignore - Intentionally adding to window
|
||||||
|
window.setCommandPalleteOpen = (open: boolean) => {
|
||||||
|
commandPalleteOpen = open;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
commandPalleteOpen = true;
|
||||||
|
tick().then(() => searchbar?.focus());
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
commandPalleteOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const performSearch = async () => {
|
||||||
|
isLoading = true;
|
||||||
|
selectedIndex = 0;
|
||||||
|
|
||||||
|
const term = searchTerm.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (commandsFuse && dynamicContentFuse) {
|
||||||
|
combinedResults = await doSearch(
|
||||||
|
term,
|
||||||
|
commandsFuse,
|
||||||
|
dynamicContentFuse,
|
||||||
|
commandIdToItemMap,
|
||||||
|
dynamicIdToItemMap,
|
||||||
|
showRecentFirst
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
combinedResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedPerformSearch = debounce(performSearch, 10);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (commandPalleteOpen) {
|
||||||
|
if (searchTerm === '') {
|
||||||
|
performSearch();
|
||||||
|
} else {
|
||||||
|
debouncedPerformSearch();
|
||||||
|
}
|
||||||
|
tick().then(() => searchbar?.focus());
|
||||||
|
} else {
|
||||||
|
searchTerm = '';
|
||||||
|
selectedIndex = 0;
|
||||||
|
prevSearchTerm = '';
|
||||||
|
combinedResults = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (combinedResults.length === 0 && calculatorResult && commandPalleteOpen) {
|
||||||
|
selectedIndex = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectNext = () => {
|
||||||
|
const maxIndex = (calculatorResult ? 1 : 0) + combinedResults.length - 1;
|
||||||
|
if (selectedIndex < maxIndex) {
|
||||||
|
selectedIndex++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectPrev = () => {
|
||||||
|
if (selectedIndex > 0) {
|
||||||
|
selectedIndex--;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function executeItemAction(item: StaticCommandItem | HydratedIndexItem) {
|
||||||
|
if ('action' in item && typeof item.action === 'function') {
|
||||||
|
(item as StaticCommandItem).action();
|
||||||
|
} else if ('actionId' in item && item.actionId && actionMap[item.actionId]) {
|
||||||
|
actionMap[item.actionId](item as IndexItem);
|
||||||
|
}
|
||||||
|
commandPalleteOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeSelected = () => {
|
||||||
|
if (calculatorResult && selectedIndex === 0) {
|
||||||
|
navigator.clipboard.writeText(calculatorResult);
|
||||||
|
commandPalleteOpen = false;
|
||||||
|
} else {
|
||||||
|
const resultIndex = calculatorResult ? selectedIndex - 1 : selectedIndex;
|
||||||
|
const result = combinedResults[resultIndex];
|
||||||
|
if (result?.item) {
|
||||||
|
executeItemAction(result.item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyNav = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectNext();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectPrev();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
executeSelected();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
commandPalleteOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if commandPalleteOpen}
|
||||||
|
<div role="dialog" aria-modal="true" class={settingsState.DarkMode ? 'dark' : ''}>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[50000] bg-zinc-900/40 dark:bg-black/60"
|
||||||
|
transition:fade={{ duration: 150, easing: quintOut }}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="fixed inset-0 z-[50000] flex justify-center place-items-start p-8 sm:p-6 md:p-8 select-none"
|
||||||
|
onclick={() => commandPalleteOpen = false}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && (commandPalleteOpen = false)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0">
|
||||||
|
<div
|
||||||
|
class="w-full max-w-2xl overflow-clip rounded-xl ring-1 shadow-2xl ring-black/5 dark:ring-white/10 { transparencyEffects ? 'bg-white/80 dark:bg-zinc-900/80 backdrop-blur' : 'bg-white dark:bg-zinc-900' }"
|
||||||
|
transition:scale={{ duration: 100, start: 0.95, opacity: 0, easing: circOut }}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
commandPalleteOpen = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabindex="0">
|
||||||
|
|
||||||
|
<div class="relative p-2 border-b border-zinc-900/5 dark:border-zinc-100/5">
|
||||||
|
<div class="absolute top-1/2 translate-y-[calc(-50%-3px)] scale-105 left-5 w-6 h-6 text-[1.3rem] text-zinc-900 dark:text-zinc-400 text-opacity-40 pointer-events-none font-IconFamily">
|
||||||
|
{'\ueca5'}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
bind:this={searchbar}
|
||||||
|
bind:value={searchTerm}
|
||||||
|
onkeydown={handleKeyNav}
|
||||||
|
class="pr-4 pl-12 w-full h-10 text-lg bg-transparent border-0 outline-none placeholder-zinc-400 text-zinc-700 dark:placeholder-zinc-500 dark:text-white focus:ring-0 sm:text-xl"
|
||||||
|
placeholder="Search..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="overflow-y-auto max-h-[24rem] text-base scroll-py-2 p-1 gap-0.5 flex flex-col">
|
||||||
|
<Calculator
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
isSelected={selectedIndex === 0}
|
||||||
|
on:hasResult={(e) => updateCalculatorState(e.detail)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if combinedResults.length > 0}
|
||||||
|
{#each combinedResults as result, i (result.id)}
|
||||||
|
{@const isSelected = selectedIndex === (calculatorResult ? i + 1 : i)}
|
||||||
|
{@const item = result.item}
|
||||||
|
<li>
|
||||||
|
{#if result.type === 'command'}
|
||||||
|
{@const staticItem = item as StaticCommandItem}
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center px-2 py-1.5 rounded-lg select-none cursor-pointer group
|
||||||
|
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
|
||||||
|
onclick={() => executeItemAction(staticItem)}
|
||||||
|
>
|
||||||
|
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{staticItem.icon}</div>
|
||||||
|
<span class="ml-4 text-lg truncate">
|
||||||
|
{@html highlightMatch(staticItem.text, searchTerm, result.matches)}
|
||||||
|
</span>
|
||||||
|
{#if staticItem.keybindLabel}
|
||||||
|
<div class="flex-none ml-auto">
|
||||||
|
{@render Shortcut({ text: '', keybind: staticItem.keybindLabel })}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else if result.type === 'dynamic'}
|
||||||
|
{@const dynamicItem = item as HydratedIndexItem}
|
||||||
|
{#if dynamicItem.renderComponent}
|
||||||
|
<dynamicItem.renderComponent
|
||||||
|
item={dynamicItem}
|
||||||
|
isSelected={isSelected}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
matches={result.matches}
|
||||||
|
on:click={() => executeItemAction(dynamicItem)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group
|
||||||
|
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
|
||||||
|
onclick={() => executeItemAction(dynamicItem)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center w-full">
|
||||||
|
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{dynamicItem.metadata?.icon || '\ue924'}</div>
|
||||||
|
<span class="ml-4 text-lg truncate">
|
||||||
|
{@html stripHtmlButKeepHighlights(highlightMatch(dynamicItem.text, searchTerm, result.matches))}
|
||||||
|
</span>
|
||||||
|
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{dynamicItem.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if dynamicItem.content}
|
||||||
|
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
|
||||||
|
{@html stripHtmlButKeepHighlights(highlightSnippet(dynamicItem.content, searchTerm, result.matches))}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{:else if !calculatorResult}
|
||||||
|
<div class="px-8 py-16 text-center text-zinc-900 dark:text-zinc-200 sm:px-16">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="mx-auto w-8 h-8 rounded-full border-2 animate-spin border-zinc-300 dark:border-zinc-700 border-t-zinc-600 dark:border-t-zinc-300"></div>
|
||||||
|
<p class="mt-4 text-lg dark:text-zinc-300">Searching...</p>
|
||||||
|
{:else}
|
||||||
|
<svg class="mx-auto w-8 h-8 text-opacity-40 dark:text-opacity-60" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
||||||
|
</svg>
|
||||||
|
<p class="mt-6 text-lg dark:text-zinc-300">No matches found. Try something else.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
<div class="px-3 py-2 w-full border-t border-zinc-900/5 dark:border-zinc-100/5 bg-white/5">
|
||||||
|
{#if combinedResults.length > 0 || calculatorResult}
|
||||||
|
<div class="flex justify-between items-center h-5 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
<div class="flex gap-4 items-center">
|
||||||
|
{#if !calculatorResult}
|
||||||
|
{#if selectedIndex >= 0 && selectedIndex < combinedResults.length}
|
||||||
|
{@const item = combinedResults[selectedIndex].item}
|
||||||
|
{#if 'keybind' in item && item.keybind}
|
||||||
|
{@render Shortcut({ text: 'Shortcut', keybind: [ ...(item?.keybindLabel ?? []) ] })}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex gap-4 items-center">
|
||||||
|
{@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})}
|
||||||
|
{#if calculatorResult && selectedIndex === 0}
|
||||||
|
{@render Shortcut({ text: 'Copy result', keybind: ['↵']})}
|
||||||
|
{:else}
|
||||||
|
{@render Shortcut({ text: 'Select', keybind: ['↵']})}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if isIndexing}
|
||||||
|
<div class="inset-x-0 top-0">
|
||||||
|
<div class="absolute right-2 -bottom-4 text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||||
|
Indexing
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden h-0.5 bg-zinc-200 dark:bg-zinc-700">
|
||||||
|
<div
|
||||||
|
class="h-full bg-blue-500 transition-all duration-300 ease-out"
|
||||||
|
style="width: {(completedJobs / totalJobs) * 100}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet Shortcut({ text, keybind }: { text: string, keybind: string[] }) }
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div class="flex gap-1 items-center">
|
||||||
|
{#each keybind as key}
|
||||||
|
<kbd class="px-1 py-0.5 text-[0.8rem] text-center align-middle rounded min-w-6 bg-zinc-100 dark:bg-zinc-100/10">{key}</kbd>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<span>{text}</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.highlight) {
|
||||||
|
background-color: rgba(200, 200, 200, 0.3);
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 2px;
|
||||||
|
margin: 0 -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark :global(.highlight) {
|
||||||
|
background-color: rgba(79, 79, 79, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
export const unitFullNames: Record<string, string> = {
|
||||||
|
// --- Length ---
|
||||||
|
m: "Meters",
|
||||||
|
km: "Kilometers",
|
||||||
|
cm: "Centimeters",
|
||||||
|
mm: "Millimeters",
|
||||||
|
µm: "Micrometers",
|
||||||
|
nm: "Nanometers",
|
||||||
|
pm: "Picometers",
|
||||||
|
fm: "Femtometers",
|
||||||
|
am: "Attometers",
|
||||||
|
zm: "Zeptometers",
|
||||||
|
ym: "Yoctometers",
|
||||||
|
mi: "Miles",
|
||||||
|
yd: "Yards",
|
||||||
|
ft: "Feet",
|
||||||
|
in: "Inches",
|
||||||
|
nmi: "Nautical Miles",
|
||||||
|
angstrom: "Angstroms",
|
||||||
|
au: "Astronomical Units",
|
||||||
|
ly: "Light Years",
|
||||||
|
pc: "Parsecs",
|
||||||
|
|
||||||
|
// --- Mass ---
|
||||||
|
kg: "Kilograms",
|
||||||
|
g: "Grams",
|
||||||
|
mg: "Milligrams",
|
||||||
|
µg: "Micrograms",
|
||||||
|
ng: "Nanograms",
|
||||||
|
lb: "Pounds",
|
||||||
|
oz: "Ounces",
|
||||||
|
ton: "Tons (Imperial)",
|
||||||
|
tonne: "Tonnes (Metric)",
|
||||||
|
slug: "Slugs",
|
||||||
|
stone: "Stones",
|
||||||
|
|
||||||
|
// --- Time ---
|
||||||
|
s: "Seconds",
|
||||||
|
ms: "Milliseconds",
|
||||||
|
µs: "Microseconds",
|
||||||
|
ns: "Nanoseconds",
|
||||||
|
ps: "Picoseconds",
|
||||||
|
min: "Minutes",
|
||||||
|
h: "Hours",
|
||||||
|
day: "Days",
|
||||||
|
week: "Weeks",
|
||||||
|
month: "Months (30 days)",
|
||||||
|
year: "Years (365 days)",
|
||||||
|
fortnight: "Fortnights",
|
||||||
|
|
||||||
|
// --- Temperature ---
|
||||||
|
K: "Kelvin",
|
||||||
|
degC: "Degrees Celsius",
|
||||||
|
degF: "Degrees Fahrenheit",
|
||||||
|
degR: "Degrees Rankine",
|
||||||
|
|
||||||
|
// --- Volume ---
|
||||||
|
"m³": "Cubic Meters",
|
||||||
|
"cm³": "Cubic Centimeters",
|
||||||
|
"mm³": "Cubic Millimeters",
|
||||||
|
l: "Liters",
|
||||||
|
ml: "Milliliters",
|
||||||
|
gal: "Gallons (US)",
|
||||||
|
qt: "Quarts (US)",
|
||||||
|
pt: "Pints (US)",
|
||||||
|
cup: "Cups (US)",
|
||||||
|
floz: "Fluid Ounces (US)",
|
||||||
|
tbsp: "Tablespoons (US)",
|
||||||
|
tsp: "Teaspoons (US)",
|
||||||
|
|
||||||
|
// --- Area ---
|
||||||
|
"m²": "Square Meters",
|
||||||
|
"km²": "Square Kilometers",
|
||||||
|
"cm²": "Square Centimeters",
|
||||||
|
"mm²": "Square Millimeters",
|
||||||
|
ha: "Hectares",
|
||||||
|
acre: "Acres",
|
||||||
|
"ft²": "Square Feet",
|
||||||
|
"in²": "Square Inches",
|
||||||
|
"mi²": "Square Miles",
|
||||||
|
|
||||||
|
// --- Speed ---
|
||||||
|
"m/s": "Meters per Second",
|
||||||
|
"km/h": "Kilometers per Hour",
|
||||||
|
mph: "Miles per Hour",
|
||||||
|
knot: "Knots",
|
||||||
|
|
||||||
|
// --- Acceleration ---
|
||||||
|
"m/s²": "Meters per Second Squared",
|
||||||
|
|
||||||
|
// --- Force ---
|
||||||
|
N: "Newtons",
|
||||||
|
lbf: "Pound-Force",
|
||||||
|
dyn: "Dynes",
|
||||||
|
|
||||||
|
// --- Energy ---
|
||||||
|
J: "Joules",
|
||||||
|
kJ: "Kilojoules",
|
||||||
|
cal: "Calories",
|
||||||
|
kcal: "Kilocalories",
|
||||||
|
Wh: "Watt Hours",
|
||||||
|
kWh: "Kilowatt Hours",
|
||||||
|
BTU: "British Thermal Units",
|
||||||
|
erg: "Ergs",
|
||||||
|
eV: "Electronvolts",
|
||||||
|
|
||||||
|
// --- Power ---
|
||||||
|
W: "Watts",
|
||||||
|
kW: "Kilowatts",
|
||||||
|
MW: "Megawatts",
|
||||||
|
GW: "Gigawatts",
|
||||||
|
hp: "Horsepower",
|
||||||
|
|
||||||
|
// --- Pressure ---
|
||||||
|
Pa: "Pascals",
|
||||||
|
kPa: "Kilopascals",
|
||||||
|
bar: "Bar",
|
||||||
|
atm: "Atmospheres",
|
||||||
|
psi: "Pounds per Square Inch",
|
||||||
|
torr: "Torr",
|
||||||
|
mmHg: "Millimeters of Mercury",
|
||||||
|
|
||||||
|
// --- Frequency ---
|
||||||
|
Hz: "Hertz",
|
||||||
|
kHz: "Kilohertz",
|
||||||
|
MHz: "Megahertz",
|
||||||
|
GHz: "Gigahertz",
|
||||||
|
THz: "Terahertz",
|
||||||
|
|
||||||
|
// --- Electric ---
|
||||||
|
V: "Volts",
|
||||||
|
A: "Amperes",
|
||||||
|
C: "Coulombs",
|
||||||
|
Ω: "Ohms",
|
||||||
|
F: "Farads",
|
||||||
|
S: "Siemens",
|
||||||
|
H: "Henries",
|
||||||
|
Wb: "Webers",
|
||||||
|
T: "Teslas",
|
||||||
|
lx: "Lux",
|
||||||
|
|
||||||
|
// --- Angle & Rotation ---
|
||||||
|
rad: "Radians",
|
||||||
|
deg: "Degrees",
|
||||||
|
grad: "Gradians",
|
||||||
|
cycle: "Cycles",
|
||||||
|
turn: "Turns",
|
||||||
|
rev: "Revolutions",
|
||||||
|
|
||||||
|
// --- Charge & Capacitance ---
|
||||||
|
e: "Elementary Charges",
|
||||||
|
|
||||||
|
// --- Magnetic & Light ---
|
||||||
|
lm: "Lumens",
|
||||||
|
ph: "Photons",
|
||||||
|
|
||||||
|
// --- Miscellaneous / Dimensionless ---
|
||||||
|
"%": "Percent",
|
||||||
|
ppm: "Parts per Million",
|
||||||
|
ppb: "Parts per Billion",
|
||||||
|
pptr: "Parts per Trillion",
|
||||||
|
dB: "Decibels",
|
||||||
|
bit: "Bits",
|
||||||
|
byte: "Bytes",
|
||||||
|
|
||||||
|
// --- Digital Storage ---
|
||||||
|
b: "Bits",
|
||||||
|
B: "Bytes",
|
||||||
|
kb: "Kilobits",
|
||||||
|
kB: "Kilobytes",
|
||||||
|
Mb: "Megabits",
|
||||||
|
MB: "Megabytes",
|
||||||
|
Gb: "Gigabits",
|
||||||
|
GB: "Gigabytes",
|
||||||
|
Tb: "Terabits",
|
||||||
|
TB: "Terabytes",
|
||||||
|
|
||||||
|
// --- Currency ---
|
||||||
|
USD: "US Dollars",
|
||||||
|
EUR: "Euros",
|
||||||
|
GBP: "British Pounds",
|
||||||
|
AUD: "Australian Dollars",
|
||||||
|
CAD: "Canadian Dollars",
|
||||||
|
CHF: "Swiss Francs",
|
||||||
|
JPY: "Japanese Yen",
|
||||||
|
CNY: "Chinese Yuan",
|
||||||
|
INR: "Indian Rupees",
|
||||||
|
NZD: "New Zealand Dollars",
|
||||||
|
SEK: "Swedish Krona",
|
||||||
|
NOK: "Norwegian Krone",
|
||||||
|
SGD: "Singapore Dollars",
|
||||||
|
HKD: "Hong Kong Dollars",
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||||
|
|
||||||
|
export interface BaseCommandItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
category: string;
|
||||||
|
icon: string;
|
||||||
|
action: () => void;
|
||||||
|
keywords?: string[];
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaticCommandItem extends BaseCommandItem {
|
||||||
|
keybind?: string[];
|
||||||
|
keybindLabel?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticCommands: StaticCommandItem[] = [
|
||||||
|
{
|
||||||
|
id: "home",
|
||||||
|
icon: "\ueb4c",
|
||||||
|
category: "navigation",
|
||||||
|
text: "Home",
|
||||||
|
keybind: ["alt+h"],
|
||||||
|
keybindLabel: ["Alt", "H"],
|
||||||
|
action: () => {
|
||||||
|
window.location.hash = "?page=/home";
|
||||||
|
loadHomePage();
|
||||||
|
},
|
||||||
|
priority: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "messages",
|
||||||
|
icon: "\uebfd",
|
||||||
|
category: "navigation",
|
||||||
|
text: "Direct Messages",
|
||||||
|
keybind: ["alt+m"],
|
||||||
|
keybindLabel: ["Alt", "M"],
|
||||||
|
action: () => {
|
||||||
|
window.location.hash = "?page=/messages";
|
||||||
|
},
|
||||||
|
priority: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "timetable",
|
||||||
|
icon: "\ue9cd",
|
||||||
|
category: "navigation",
|
||||||
|
text: "Timetable",
|
||||||
|
keybind: ["alt+t"],
|
||||||
|
keybindLabel: ["Alt", "T"],
|
||||||
|
action: () => {
|
||||||
|
window.location.hash = "?page=/timetable";
|
||||||
|
},
|
||||||
|
priority: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "assessments",
|
||||||
|
icon: "\ueac3",
|
||||||
|
category: "navigation",
|
||||||
|
text: "Assessments",
|
||||||
|
keybind: ["alt+a"],
|
||||||
|
keybindLabel: ["Alt", "A"],
|
||||||
|
action: () => {
|
||||||
|
window.location.hash = "?page=/assessments";
|
||||||
|
},
|
||||||
|
priority: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "toggle-dark-mode",
|
||||||
|
icon: "\uecfe",
|
||||||
|
category: "action",
|
||||||
|
text: "Toggle Dark Mode",
|
||||||
|
action: () => (settingsState.DarkMode = !settingsState.DarkMode),
|
||||||
|
priority: 2,
|
||||||
|
keywords: ["theme", "appearance"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the predefined list of static commands.
|
||||||
|
*/
|
||||||
|
export const getStaticCommands = (): StaticCommandItem[] => {
|
||||||
|
return [...staticCommands];
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Plugin } from "@/plugins/core/types";
|
||||||
|
import { BasePlugin } from "@/plugins/core/settings";
|
||||||
|
import {
|
||||||
|
booleanSetting,
|
||||||
|
defineSettings,
|
||||||
|
Setting,
|
||||||
|
stringSetting,
|
||||||
|
} from "@/plugins/core/settingsHelpers";
|
||||||
|
import styles from "./styles.css?inline";
|
||||||
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
import { runIndexing } from "../indexing/indexer";
|
||||||
|
import { initVectorSearch } from "../search/vector/vectorSearch";
|
||||||
|
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
|
||||||
|
|
||||||
|
const settings = defineSettings({
|
||||||
|
searchHotkey: stringSetting({
|
||||||
|
default: "ctrl+k",
|
||||||
|
title: "Search Hotkey",
|
||||||
|
description: "Keyboard shortcut to open the search (cmd on Mac)",
|
||||||
|
}),
|
||||||
|
showRecentFirst: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Show Recent First",
|
||||||
|
description: "Sort dynamic content by most recent first",
|
||||||
|
}),
|
||||||
|
transparencyEffects: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Transparency Effects",
|
||||||
|
description: "Enable transparency effects for the search bar",
|
||||||
|
}),
|
||||||
|
runIndexingOnLoad: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Index on Page Load",
|
||||||
|
description: "Run content indexing when SEQTA loads",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
class GlobalSearchPlugin extends BasePlugin<typeof settings> {
|
||||||
|
@Setting(settings.searchHotkey)
|
||||||
|
searchHotkey!: string;
|
||||||
|
|
||||||
|
@Setting(settings.showRecentFirst)
|
||||||
|
showRecentFirst!: boolean;
|
||||||
|
|
||||||
|
@Setting(settings.transparencyEffects)
|
||||||
|
transparencyEffects!: boolean;
|
||||||
|
|
||||||
|
@Setting(settings.runIndexingOnLoad)
|
||||||
|
runIndexingOnLoad!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsInstance = new GlobalSearchPlugin();
|
||||||
|
|
||||||
|
const globalSearchPlugin: Plugin<typeof settings> = {
|
||||||
|
id: "global-search",
|
||||||
|
name: "Global Search",
|
||||||
|
description: "Quick search for everything in SEQTA",
|
||||||
|
version: "1.0.0",
|
||||||
|
settings: settingsInstance.settings,
|
||||||
|
disableToggle: true,
|
||||||
|
styles: styles,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
const appRef = { current: null };
|
||||||
|
|
||||||
|
initVectorSearch();
|
||||||
|
|
||||||
|
if (api.settings.runIndexingOnLoad) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
await runIndexing();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = document.querySelector("#title");
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
mountSearchBar(title, api, appRef);
|
||||||
|
} else {
|
||||||
|
await waitForElm("#title", true, 100, 60);
|
||||||
|
mountSearchBar(document.querySelector("#title") as Element, api, appRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanupSearchBar(appRef);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default globalSearchPlugin;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import renderSvelte from "@/interface/main";
|
||||||
|
import SearchBar from "../components/SearchBar.svelte";
|
||||||
|
import { unmount } from "svelte";
|
||||||
|
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
|
||||||
|
|
||||||
|
export function mountSearchBar(
|
||||||
|
titleElement: Element,
|
||||||
|
api: any,
|
||||||
|
appRef: { current: any }
|
||||||
|
) {
|
||||||
|
if (titleElement.querySelector(".search-trigger")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchButton = document.createElement("div");
|
||||||
|
searchButton.className = "search-trigger";
|
||||||
|
searchButton.innerHTML = /* html */ `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
|
</svg>
|
||||||
|
<p>Quick search...</p>
|
||||||
|
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">⌘K</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
titleElement.appendChild(searchButton);
|
||||||
|
|
||||||
|
const searchRoot = document.createElement("div");
|
||||||
|
document.body.appendChild(searchRoot);
|
||||||
|
const searchRootShadow = searchRoot.attachShadow({ mode: "open" });
|
||||||
|
|
||||||
|
searchButton.addEventListener("click", () => {
|
||||||
|
// @ts-ignore - Intentionally adding to window
|
||||||
|
window.setCommandPalleteOpen(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
|
||||||
|
transparencyEffects: api.settings.transparencyEffects ? true : false,
|
||||||
|
showRecentFirst: api.settings.showRecentFirst,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error rendering Svelte component:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupSearchBar(appRef: { current: any }) {
|
||||||
|
const searchButton = document.querySelector(".search-trigger");
|
||||||
|
const searchRoot = document.querySelector(".global-search-root");
|
||||||
|
if (searchButton) searchButton.remove();
|
||||||
|
if (searchRoot) searchRoot.remove();
|
||||||
|
|
||||||
|
// Clean up workers
|
||||||
|
VectorWorkerManager.getInstance().terminate();
|
||||||
|
unmount(appRef.current);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
.search-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 32px;
|
||||||
|
margin-left: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-right: auto !important;
|
||||||
|
padding: 3px 12px;
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-right: 48px;
|
||||||
|
height: 100%;
|
||||||
|
margin-bottom: 0;
|
||||||
|
line-height: 32px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode styles */
|
||||||
|
.search-trigger {
|
||||||
|
background-color: rgba(248, 250, 252, 0.05) !important;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||||
|
color: #555 !important;
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #555 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-trigger {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||||
|
color: #aaa !important;
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #aaa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { StaticCommandItem } from "./commands";
|
||||||
|
import type { HydratedIndexItem } from "../indexing/types";
|
||||||
|
|
||||||
|
export interface MatchIndices {
|
||||||
|
readonly 0: number;
|
||||||
|
readonly 1: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FuseResultMatch {
|
||||||
|
key?: string;
|
||||||
|
value?: string;
|
||||||
|
indices: readonly MatchIndices[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombinedResult {
|
||||||
|
id: string;
|
||||||
|
type: "command" | "dynamic";
|
||||||
|
score: number;
|
||||||
|
item: StaticCommandItem | HydratedIndexItem;
|
||||||
|
matches?: readonly FuseResultMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FuseResult<T> {
|
||||||
|
item: T;
|
||||||
|
refIndex: number;
|
||||||
|
score?: number;
|
||||||
|
matches?: readonly FuseResultMatch[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { IndexItem } from "./types";
|
||||||
|
|
||||||
|
interface MessageMetadata {
|
||||||
|
messageId: number;
|
||||||
|
author: string;
|
||||||
|
senderId: number;
|
||||||
|
senderType: string;
|
||||||
|
timestamp: string;
|
||||||
|
hasAttachments: boolean;
|
||||||
|
attachmentCount: number;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssessmentMetadata {
|
||||||
|
assessmentId?: number;
|
||||||
|
messageId?: number;
|
||||||
|
subject?: string;
|
||||||
|
term?: string;
|
||||||
|
programmeId?: number;
|
||||||
|
metaclassId?: number;
|
||||||
|
timestamp: string;
|
||||||
|
isMessageBased?: boolean;
|
||||||
|
author?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionHandler<T = any> = (item: IndexItem & { metadata: T }) => void;
|
||||||
|
|
||||||
|
export const actionMap: Record<string, ActionHandler<any>> = {
|
||||||
|
message: ((item: IndexItem & { metadata: MessageMetadata }) => {
|
||||||
|
window.location.hash = `#?page=/messages&id=${item.metadata.messageId}`;
|
||||||
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
|
assessment: ((item: IndexItem & { metadata: AssessmentMetadata }) => {
|
||||||
|
if (item.metadata.isMessageBased) {
|
||||||
|
window.location.hash = `#?page=/messages&id=${item.metadata.messageId}`;
|
||||||
|
} else {
|
||||||
|
window.location.hash = `#?page=/assessments&id=${item.metadata.assessmentId}`;
|
||||||
|
}
|
||||||
|
}) as ActionHandler<any>,
|
||||||
|
};
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
const DB_NAME = "betterseqta-index";
|
||||||
|
const META_STORE = "meta";
|
||||||
|
const VERSION_KEY = "betterseqta-index-version";
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||||
|
|
||||||
|
// Get the current version from localStorage or start at 1
|
||||||
|
function getCurrentVersion(): number {
|
||||||
|
const storedVersion = localStorage.getItem(VERSION_KEY);
|
||||||
|
return storedVersion ? parseInt(storedVersion, 10) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the version in localStorage
|
||||||
|
function updateVersion(version: number) {
|
||||||
|
localStorage.setItem(VERSION_KEY, version.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
if (dbPromise) return dbPromise;
|
||||||
|
|
||||||
|
const currentVersion = getCurrentVersion();
|
||||||
|
|
||||||
|
dbPromise = new Promise((resolve, reject) => {
|
||||||
|
let request: IDBOpenDBRequest;
|
||||||
|
|
||||||
|
try {
|
||||||
|
request = indexedDB.open(DB_NAME, currentVersion);
|
||||||
|
} catch (e) {
|
||||||
|
// If there's a version error, try to delete the database and start fresh
|
||||||
|
console.warn("Database version conflict, recreating database...");
|
||||||
|
indexedDB.deleteDatabase(DB_NAME);
|
||||||
|
localStorage.removeItem(VERSION_KEY);
|
||||||
|
request = indexedDB.open(DB_NAME, 1);
|
||||||
|
updateVersion(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = request.result;
|
||||||
|
const existingStores = Array.from(db.objectStoreNames);
|
||||||
|
|
||||||
|
// Always ensure META_STORE exists
|
||||||
|
if (!existingStores.includes(META_STORE)) {
|
||||||
|
db.createObjectStore(META_STORE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update version in localStorage to match the database
|
||||||
|
updateVersion(event.newVersion || 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error("Error opening database:", request.error);
|
||||||
|
// If there's an error, try to recover by deleting and recreating
|
||||||
|
indexedDB.deleteDatabase(DB_NAME);
|
||||||
|
localStorage.removeItem(VERSION_KEY);
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStore(store: string, mode: IDBTransactionMode = "readonly") {
|
||||||
|
const db = await openDB();
|
||||||
|
|
||||||
|
// Create store dynamically if needed
|
||||||
|
if (!db.objectStoreNames.contains(store)) {
|
||||||
|
db.close();
|
||||||
|
await upgradeDB(store);
|
||||||
|
return getStore(store, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = db.transaction(store, mode);
|
||||||
|
return tx.objectStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeDB(newStore: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const currentVersion = getCurrentVersion();
|
||||||
|
const newVersion = currentVersion + 1;
|
||||||
|
|
||||||
|
// Close any existing connections
|
||||||
|
if (dbPromise) {
|
||||||
|
dbPromise.then((db) => db.close());
|
||||||
|
dbPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = indexedDB.open(DB_NAME, newVersion);
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains(newStore)) {
|
||||||
|
db.createObjectStore(newStore);
|
||||||
|
}
|
||||||
|
// Update version in localStorage
|
||||||
|
updateVersion(event.newVersion || newVersion);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
dbPromise = Promise.resolve(request.result);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error("Error upgrading database:", request.error);
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAll(store: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = s.getAll();
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in getAll for store ${store}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get(store: string, key: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = s.get(key);
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in get for store ${store}, key ${key}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function put(
|
||||||
|
store: string,
|
||||||
|
value: any,
|
||||||
|
key?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store, "readwrite");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = key ? s.put(value, key) : s.put(value);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in put for store ${store}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(store: string, key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store, "readwrite");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = s.delete(key);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in remove for store ${store}, key ${key}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clear(store: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = await getStore(store, "readwrite");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = s.clear();
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in clear for store ${store}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to reset the database if needed
|
||||||
|
export async function resetDatabase(): Promise<void> {
|
||||||
|
if (dbPromise) {
|
||||||
|
const db = await dbPromise;
|
||||||
|
db.close();
|
||||||
|
dbPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.deleteDatabase(DB_NAME);
|
||||||
|
req.onsuccess = () => {
|
||||||
|
localStorage.removeItem(VERSION_KEY);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { clear, getAll, put, remove } from "./db";
|
||||||
|
import { jobs } from "./jobs";
|
||||||
|
import { renderComponentMap } from "./renderComponents";
|
||||||
|
import type { HydratedIndexItem, IndexItem, Job, JobContext } from "./types";
|
||||||
|
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
||||||
|
|
||||||
|
const META_STORE = "meta";
|
||||||
|
const LOCK_KEY = "bsq-indexer-lock";
|
||||||
|
const HEARTBEAT_INTERVAL = 10000;
|
||||||
|
const LOCK_TIMEOUT = 20000;
|
||||||
|
|
||||||
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function shouldRun(job: Job, lastRun?: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (job.frequency === "pageLoad") return true;
|
||||||
|
if (!lastRun) return true;
|
||||||
|
|
||||||
|
if (job.frequency.type === "interval") {
|
||||||
|
return now - lastRun >= job.frequency.ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.frequency.type === "expiry") {
|
||||||
|
return now - lastRun >= job.frequency.afterMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastRunMeta(jobId: string): Promise<number | undefined> {
|
||||||
|
return getAll(META_STORE).then((metaItems) => {
|
||||||
|
const match = metaItems.find((m: any) => m.jobId === jobId);
|
||||||
|
return match?.lastRun;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLastRunMeta(jobId: string): Promise<void> {
|
||||||
|
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIndex(): boolean {
|
||||||
|
const last = parseInt(localStorage.getItem(LOCK_KEY) || "0", 10);
|
||||||
|
return isNaN(last) || Date.now() - last > LOCK_TIMEOUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHeartbeat() {
|
||||||
|
localStorage.setItem(LOCK_KEY, `${Date.now()}`);
|
||||||
|
heartbeatTimer = setInterval(() => {
|
||||||
|
localStorage.setItem(LOCK_KEY, `${Date.now()}`);
|
||||||
|
}, HEARTBEAT_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHeartbeat() {
|
||||||
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||||
|
localStorage.removeItem(LOCK_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchProgress(completed: number, total: number, indexing: boolean, status?: string, detail?: string) {
|
||||||
|
const event = new CustomEvent("indexing-progress", {
|
||||||
|
detail: { completed, total, indexing, status, detail },
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAllStoredItems(): Promise<HydratedIndexItem[]> {
|
||||||
|
const all: HydratedIndexItem[] = [];
|
||||||
|
const jobIds = Object.keys(jobs);
|
||||||
|
|
||||||
|
for (const jobId of jobIds) {
|
||||||
|
try {
|
||||||
|
const items = await getAll(jobId) as IndexItem[];
|
||||||
|
const job = jobs[jobId];
|
||||||
|
const renderComponent = renderComponentMap[job.renderComponentId];
|
||||||
|
|
||||||
|
if (!renderComponent) {
|
||||||
|
console.warn(`Render component not found for job ${jobId} (ID: ${job.renderComponentId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
// Ensure item has all required fields before pushing
|
||||||
|
if (item && item.id && item.text && item.category && item.actionId && job.renderComponentId) {
|
||||||
|
all.push({
|
||||||
|
...item,
|
||||||
|
renderComponent: renderComponent || undefined, // Assign undefined if not found
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(`Skipping invalid item from job ${jobId}:`, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading items for job ${jobId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.debug(`[Indexer] Loaded ${all.length} items from non-vector storage.`);
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runIndexing(): Promise<void> {
|
||||||
|
if (!shouldIndex()) {
|
||||||
|
console.debug(
|
||||||
|
"%c[Indexer] Skipping indexing (another tab has the lock)",
|
||||||
|
"color: gray",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startHeartbeat();
|
||||||
|
console.debug("%c[Indexer] Starting indexing...", "color: green");
|
||||||
|
|
||||||
|
const jobIds = Object.keys(jobs);
|
||||||
|
let completedJobs = 0;
|
||||||
|
// Add an extra step for vectorization
|
||||||
|
const totalSteps = jobIds.length + 1;
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs");
|
||||||
|
|
||||||
|
const allItemsFromJobs: HydratedIndexItem[] = [];
|
||||||
|
|
||||||
|
// --- Step 1: Run Fetching/Storing Jobs (Main Thread) ---
|
||||||
|
for (const jobId of jobIds) {
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, `Running job: ${jobs[jobId].label}`);
|
||||||
|
const job = jobs[jobId];
|
||||||
|
const lastRun = await getLastRunMeta(jobId);
|
||||||
|
|
||||||
|
if (!shouldRun(job, lastRun)) {
|
||||||
|
console.debug(
|
||||||
|
`%c[Indexer] Skipping job "${jobId}" (not due)`,
|
||||||
|
"color: gray",
|
||||||
|
);
|
||||||
|
completedJobs++;
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, `Skipped job: ${job.label}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// These DB operations happen on the main thread (acceptable per request)
|
||||||
|
const getStoredItems = async () => await getAll(jobId);
|
||||||
|
const setStoredItems = async (items: IndexItem[]) => {
|
||||||
|
await clear(jobId);
|
||||||
|
// Add validation before putting
|
||||||
|
const validItems = items.filter(i => i && i.id);
|
||||||
|
if (validItems.length !== items.length) {
|
||||||
|
console.warn(`[Indexer Job ${jobId}] Filtered out ${items.length - validItems.length} invalid items before storing.`);
|
||||||
|
}
|
||||||
|
await Promise.all(validItems.map((i) => put(jobId, i, i.id)));
|
||||||
|
};
|
||||||
|
const addItem = async (item: IndexItem) => {
|
||||||
|
if (item && item.id) { // Add validation
|
||||||
|
await put(jobId, item, item.id);
|
||||||
|
} else {
|
||||||
|
console.warn(`[Indexer Job ${jobId}] Attempted to add invalid item:`, item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const removeItem = async (id: string) => {
|
||||||
|
await remove(jobId, id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx: JobContext = {
|
||||||
|
getStoredItems,
|
||||||
|
setStoredItems,
|
||||||
|
addItem,
|
||||||
|
removeItem,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.debug(`%c[Indexer] Running job "${jobId}"...`, "color: #4ea1ff");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newItemsRaw = await job.run(ctx);
|
||||||
|
const stored = await getStoredItems();
|
||||||
|
|
||||||
|
let merged = mergeItems(stored, newItemsRaw);
|
||||||
|
if (job.purge) merged = job.purge(merged);
|
||||||
|
|
||||||
|
await setStoredItems(merged); // Store merged non-vector data
|
||||||
|
await updateLastRunMeta(jobId);
|
||||||
|
|
||||||
|
// Hydrate items for vector processing
|
||||||
|
const renderComponent = renderComponentMap[job.renderComponentId];
|
||||||
|
if (!renderComponent) {
|
||||||
|
console.warn(`Render component not found for job ${jobId} (ID: ${job.renderComponentId}) during hydration`);
|
||||||
|
}
|
||||||
|
const hydratedItems = merged
|
||||||
|
.filter(item => item && item.id && item.text && item.category && item.actionId && job.renderComponentId) // Filter invalid before hydrating
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
renderComponent: renderComponent || undefined, // Assign undefined if not found
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (hydratedItems.length !== merged.length) {
|
||||||
|
console.warn(`[Indexer Job ${jobId}] Filtered out ${merged.length - hydratedItems.length} invalid items during hydration.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
allItemsFromJobs.push(...hydratedItems);
|
||||||
|
|
||||||
|
console.debug(
|
||||||
|
`%c[Indexer] ✅ ${job.label}: ${newItemsRaw.length} new items fetched, ${merged.length} total stored (non-vector).`,
|
||||||
|
"color: #00c46f",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.debug(`%c[Indexer] ❌ ${job.label} failed:`, "color: red");
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
completedJobs++;
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, `Finished job: ${job.label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 2: Delegate Vectorization to Worker (Off Main Thread) ---
|
||||||
|
if (allItemsFromJobs.length > 0) {
|
||||||
|
console.debug(
|
||||||
|
`%c[Indexer] Sending ${allItemsFromJobs.length} items to worker for vectorization...`,
|
||||||
|
"color: #4ea1ff",
|
||||||
|
);
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, "Starting vectorization");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workerManager = VectorWorkerManager.getInstance();
|
||||||
|
// Pass a progress callback to the worker manager
|
||||||
|
await workerManager.processItems(allItemsFromJobs, (progress) => {
|
||||||
|
// Update overall progress based on worker feedback
|
||||||
|
let detailMessage = progress.message || '';
|
||||||
|
if (progress.status === 'processing' && progress.total && progress.processed !== undefined) {
|
||||||
|
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
|
||||||
|
// You could potentially update the 'completed' count more granularly here
|
||||||
|
// For simplicity, we'll just update the detail message
|
||||||
|
} else if (progress.status === 'complete') {
|
||||||
|
detailMessage = "Vectorization complete";
|
||||||
|
// Mark the vectorization step as complete
|
||||||
|
dispatchProgress(totalSteps, totalSteps, true, "Vectorization finished");
|
||||||
|
} else if (progress.status === 'error') {
|
||||||
|
detailMessage = `Vectorization error: ${progress.message}`;
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, "Vectorization failed", detailMessage); // Show error
|
||||||
|
} else if (progress.status === 'started') {
|
||||||
|
detailMessage = `Vectorization started for ${progress.total} items`;
|
||||||
|
} else if (progress.status === 'cancelled') {
|
||||||
|
detailMessage = `Vectorization cancelled: ${progress.message}`;
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, "Vectorization cancelled", detailMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the status detail
|
||||||
|
dispatchProgress(completedJobs, totalSteps, true, "Vectorization in progress", detailMessage);
|
||||||
|
|
||||||
|
// When worker signals completion of *its* task, mark the final step complete
|
||||||
|
if (progress.status === 'complete') {
|
||||||
|
completedJobs++; // Increment completion count *after* vectorization finishes
|
||||||
|
dispatchProgress(completedJobs, totalSteps, false, "Indexing finished"); // Set indexing to false
|
||||||
|
} else if (progress.status === 'error' || progress.status === 'cancelled') {
|
||||||
|
// Don't increment completed count on failure/cancel, just stop indexing indicator
|
||||||
|
dispatchProgress(completedJobs, totalSteps, false, "Indexing stopped due to error/cancel");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.debug("%c[Indexer] Vectorization task sent to worker.", "color: green");
|
||||||
|
// Note: runIndexing might return *before* vectorization is complete now.
|
||||||
|
// The progress updates will signal the true end state.
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`%c[Indexer] ❌ Failed to send items to vector worker:`, "color: red", error);
|
||||||
|
dispatchProgress(completedJobs, totalSteps, false, "Vectorization failed", String(error)); // Stop indexing indicator
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.debug("%c[Indexer] No items to send for vectorization.", "color: gray");
|
||||||
|
// If no vectorization needed, indexing is done here.
|
||||||
|
completedJobs++; // Count the "skipped" vectorization step
|
||||||
|
dispatchProgress(completedJobs, totalSteps, false, "Indexing finished (no vectorization needed)");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Stop heartbeat ONLY when all jobs *and* the vectorization dispatch are done.
|
||||||
|
// The actual *completion* of vectorization is now asynchronous.
|
||||||
|
stopHeartbeat();
|
||||||
|
// Final progress update might be handled by the worker callback now.
|
||||||
|
// dispatchProgress(completedJobs, totalSteps, false); // This might be premature
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] {
|
||||||
|
const map = new Map<string, IndexItem>();
|
||||||
|
// Prioritize incoming items if IDs clash
|
||||||
|
for (const item of existing) {
|
||||||
|
if (item && item.id) map.set(item.id, item);
|
||||||
|
}
|
||||||
|
for (const item of incoming) {
|
||||||
|
if (item && item.id) map.set(item.id, item);
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import type { Job } from "./types";
|
||||||
|
import type { IndexItem } from "./types";
|
||||||
|
|
||||||
|
interface MessageNotification {
|
||||||
|
notificationID: number;
|
||||||
|
type: "message";
|
||||||
|
message: {
|
||||||
|
subtitle: string;
|
||||||
|
messageID: number;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssessmentNotification {
|
||||||
|
notificationID: number;
|
||||||
|
type: "coneqtassessments";
|
||||||
|
coneqtAssessments: {
|
||||||
|
programmeID: number;
|
||||||
|
metaclassID: number;
|
||||||
|
subtitle: string;
|
||||||
|
term: string;
|
||||||
|
title: string;
|
||||||
|
assessmentID: number;
|
||||||
|
subjectCode: string;
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notification = MessageNotification | AssessmentNotification;
|
||||||
|
|
||||||
|
interface MessageListResponse {
|
||||||
|
payload: {
|
||||||
|
hasMore: boolean;
|
||||||
|
messages: {
|
||||||
|
date: string;
|
||||||
|
attachments: boolean;
|
||||||
|
attachmentCount: number;
|
||||||
|
read: number;
|
||||||
|
sender: string;
|
||||||
|
sender_id: number;
|
||||||
|
sender_type: string;
|
||||||
|
subject: string;
|
||||||
|
id: number;
|
||||||
|
participants: Array<{
|
||||||
|
name: string;
|
||||||
|
photo: string;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
}[];
|
||||||
|
ts: string;
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageContentResponse {
|
||||||
|
payload: {
|
||||||
|
date: string;
|
||||||
|
blind: boolean;
|
||||||
|
read: boolean;
|
||||||
|
subject: string;
|
||||||
|
sender_type: string;
|
||||||
|
sender_id: number;
|
||||||
|
starred: boolean;
|
||||||
|
contents: string;
|
||||||
|
sender: string;
|
||||||
|
files: any[];
|
||||||
|
id: number;
|
||||||
|
participants: Array<{
|
||||||
|
read: number;
|
||||||
|
name: string;
|
||||||
|
photo: string;
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to strip HTML tags from text
|
||||||
|
function stripHtmlTags(html: string): string {
|
||||||
|
return html.replace(/<[^>]*>/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to fetch messages with pagination
|
||||||
|
async function fetchMessages(
|
||||||
|
offset: number = 0,
|
||||||
|
limit: number = 100,
|
||||||
|
): Promise<MessageListResponse> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${location.origin}/seqta/student/load/message`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
searchValue: "",
|
||||||
|
sortBy: "date",
|
||||||
|
sortOrder: "desc",
|
||||||
|
action: "list",
|
||||||
|
label: "inbox",
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
datetimeUntil: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to fetch message content
|
||||||
|
async function fetchMessageContent(
|
||||||
|
messageId: number,
|
||||||
|
): Promise<MessageContentResponse> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${location.origin}/seqta/student/load/message`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "message",
|
||||||
|
id: messageId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to fetch notifications
|
||||||
|
async function fetchNotifications(): Promise<Notification[]> {
|
||||||
|
const response = await fetch(`${location.origin}/seqta/student/heartbeat?`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
timestamp: "1970-01-01 00:00:00.0",
|
||||||
|
hash: "#?page=/notifications",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
return json.notifications ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const jobs: Record<string, Job> = {
|
||||||
|
messages: {
|
||||||
|
id: "messages",
|
||||||
|
label: "Messages",
|
||||||
|
renderComponentId: "message",
|
||||||
|
frequency: { type: "expiry", afterMs: 1000 * 60 * 5 }, // every 5 minutes
|
||||||
|
|
||||||
|
run: async (ctx) => {
|
||||||
|
// Get existing items first
|
||||||
|
const existing = await ctx.getStoredItems();
|
||||||
|
const existingIds = new Set(existing.map((i) => i.id));
|
||||||
|
const newItems: IndexItem[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
const limit = 100;
|
||||||
|
let hasMore = true;
|
||||||
|
let consecutiveExisting = 0;
|
||||||
|
|
||||||
|
// Fetch all messages with pagination
|
||||||
|
while (hasMore) {
|
||||||
|
try {
|
||||||
|
const response = await fetchMessages(offset, limit);
|
||||||
|
|
||||||
|
if (response.status !== "200") {
|
||||||
|
console.error("Failed to fetch messages:", response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = response.payload.messages;
|
||||||
|
hasMore = response.payload.hasMore;
|
||||||
|
|
||||||
|
// Process each message
|
||||||
|
for (const message of messages) {
|
||||||
|
const id = message.id.toString();
|
||||||
|
|
||||||
|
// Skip if we already have this message
|
||||||
|
if (existingIds.has(id)) {
|
||||||
|
consecutiveExisting++;
|
||||||
|
// If we've found 20 consecutive existing messages, assume we've caught up
|
||||||
|
if (consecutiveExisting >= 20) {
|
||||||
|
console.debug(
|
||||||
|
"[Messages Job] Found 20 consecutive existing messages, stopping fetch",
|
||||||
|
);
|
||||||
|
hasMore = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset consecutive counter when we find a new message
|
||||||
|
consecutiveExisting = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch message content
|
||||||
|
const contentResponse = await fetchMessageContent(message.id);
|
||||||
|
|
||||||
|
if (contentResponse.status !== "200") {
|
||||||
|
console.error(
|
||||||
|
"Failed to fetch message content:",
|
||||||
|
contentResponse,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = stripHtmlTags(contentResponse.payload.contents);
|
||||||
|
|
||||||
|
newItems.push({
|
||||||
|
id,
|
||||||
|
text: message.subject,
|
||||||
|
category: "messages",
|
||||||
|
content: `From: ${message.sender}\n\n${content}`,
|
||||||
|
dateAdded: new Date(message.date).getTime(),
|
||||||
|
metadata: {
|
||||||
|
messageId: message.id,
|
||||||
|
author: message.sender,
|
||||||
|
senderId: message.sender_id,
|
||||||
|
senderType: message.sender_type,
|
||||||
|
timestamp: message.date,
|
||||||
|
hasAttachments: message.attachments,
|
||||||
|
attachmentCount: message.attachmentCount,
|
||||||
|
read: message.read === 1,
|
||||||
|
},
|
||||||
|
actionId: "message",
|
||||||
|
renderComponentId: "message",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to existingIds as we process to prevent duplicates in the same run
|
||||||
|
existingIds.add(id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching message content:", error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += limit;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching messages:", error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to avoid overwhelming the server
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`[Messages Job] Found ${newItems.length} new messages`);
|
||||||
|
return newItems;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => {
|
||||||
|
// Keep messages from the last 30 days
|
||||||
|
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||||
|
return items.filter((i) => i.dateAdded >= cutoff);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
assessments: {
|
||||||
|
id: "assessments",
|
||||||
|
label: "Assessments",
|
||||||
|
renderComponentId: "assessment",
|
||||||
|
frequency: { type: "expiry", afterMs: 1000 * 60 * 15 }, // every 15 minutes
|
||||||
|
|
||||||
|
run: async (ctx) => {
|
||||||
|
const notifications = await fetchNotifications();
|
||||||
|
const assessmentNotifications = notifications.filter(
|
||||||
|
(n): n is MessageNotification | AssessmentNotification =>
|
||||||
|
n.type === "coneqtassessments" ||
|
||||||
|
(n.type === "message" &&
|
||||||
|
n.message.title.toLowerCase().includes("assessment")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const existing = await ctx.getStoredItems();
|
||||||
|
const existingIds = new Set(existing.map((i) => i.id));
|
||||||
|
const newItems: IndexItem[] = [];
|
||||||
|
|
||||||
|
for (const notification of assessmentNotifications) {
|
||||||
|
const id = notification.notificationID.toString();
|
||||||
|
if (existingIds.has(id)) continue;
|
||||||
|
|
||||||
|
if (notification.type === "coneqtassessments") {
|
||||||
|
const { coneqtAssessments: assessment } = notification;
|
||||||
|
newItems.push({
|
||||||
|
id,
|
||||||
|
text: assessment.title,
|
||||||
|
category: "assessments",
|
||||||
|
content: assessment.subtitle,
|
||||||
|
dateAdded: new Date(notification.timestamp).getTime(),
|
||||||
|
metadata: {
|
||||||
|
assessmentId: assessment.assessmentID,
|
||||||
|
subject: assessment.subjectCode,
|
||||||
|
term: assessment.term,
|
||||||
|
programmeId: assessment.programmeID,
|
||||||
|
metaclassId: assessment.metaclassID,
|
||||||
|
timestamp: notification.timestamp,
|
||||||
|
},
|
||||||
|
actionId: "assessment",
|
||||||
|
renderComponentId: "assessment",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Handle message-based assessments
|
||||||
|
const { message } = notification;
|
||||||
|
newItems.push({
|
||||||
|
id,
|
||||||
|
text: message.title,
|
||||||
|
category: "assessments",
|
||||||
|
content: `From: ${message.subtitle}`,
|
||||||
|
dateAdded: new Date(notification.timestamp).getTime(),
|
||||||
|
metadata: {
|
||||||
|
messageId: message.messageID,
|
||||||
|
author: message.subtitle,
|
||||||
|
timestamp: notification.timestamp,
|
||||||
|
isMessageBased: true,
|
||||||
|
},
|
||||||
|
actionId: "assessment",
|
||||||
|
renderComponentId: "assessment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newItems;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => {
|
||||||
|
// Keep assessments from the current year
|
||||||
|
const date = new Date();
|
||||||
|
date.setMonth(0); // January
|
||||||
|
date.setDate(1);
|
||||||
|
date.setHours(0);
|
||||||
|
date.setMinutes(0);
|
||||||
|
date.setSeconds(0);
|
||||||
|
const cutoff = date.getTime();
|
||||||
|
return items.filter((i) => i.dateAdded >= cutoff);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// We can add more job types here as needed:
|
||||||
|
// - notices
|
||||||
|
// - timetable changes
|
||||||
|
// - homework
|
||||||
|
// etc.
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { SvelteComponent } from "svelte";
|
||||||
|
import AssessmentComponent from "../components/AssessmentItem.svelte";
|
||||||
|
// import other components as needed
|
||||||
|
|
||||||
|
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
|
||||||
|
assessment: AssessmentComponent as unknown as typeof SvelteComponent,
|
||||||
|
// messages: MessageComponent,
|
||||||
|
// subject: SubjectComponent,
|
||||||
|
// etc...
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { SvelteComponent } from "svelte";
|
||||||
|
|
||||||
|
export interface IndexItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
category: string;
|
||||||
|
content: string;
|
||||||
|
dateAdded: number;
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
actionId: string;
|
||||||
|
renderComponentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydratedIndexItem extends IndexItem {
|
||||||
|
renderComponent: typeof SvelteComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Frequency =
|
||||||
|
| "pageLoad"
|
||||||
|
| { type: "interval"; ms: number }
|
||||||
|
| { type: "expiry"; afterMs: number };
|
||||||
|
|
||||||
|
export interface JobContext {
|
||||||
|
getStoredItems: () => Promise<IndexItem[]>;
|
||||||
|
setStoredItems: (items: IndexItem[]) => Promise<void>;
|
||||||
|
addItem: (item: IndexItem) => Promise<void>;
|
||||||
|
removeItem: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Job {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
frequency: Frequency;
|
||||||
|
renderComponentId: string;
|
||||||
|
run: (ctx: JobContext) => Promise<IndexItem[]>;
|
||||||
|
purge?: (items: IndexItem[]) => IndexItem[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import {
|
||||||
|
EmbeddingIndex,
|
||||||
|
getEmbedding,
|
||||||
|
initializeModel,
|
||||||
|
} from "client-vector-search";
|
||||||
|
import type { HydratedIndexItem } from "../types";
|
||||||
|
|
||||||
|
let vectorIndex: EmbeddingIndex | null = null;
|
||||||
|
let isInitialized = false;
|
||||||
|
let currentAbortController: AbortController | null = null;
|
||||||
|
|
||||||
|
async function initWorker() {
|
||||||
|
if (isInitialized) {
|
||||||
|
console.debug("Vector worker already initialized.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug("Initializing vector worker...");
|
||||||
|
try {
|
||||||
|
await initializeModel();
|
||||||
|
vectorIndex = new EmbeddingIndex([]);
|
||||||
|
|
||||||
|
const stored = await vectorIndex.getAllObjectsFromIndexedDB();
|
||||||
|
if (stored.length > 0) {
|
||||||
|
stored.forEach((item) => vectorIndex!.add(item));
|
||||||
|
console.debug(
|
||||||
|
`Vector index loaded ${stored.length} items from IndexedDB.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.debug("No existing vector index found in IndexedDB.");
|
||||||
|
}
|
||||||
|
isInitialized = true;
|
||||||
|
console.debug("Vector worker initialized successfully.");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to initialize vector worker:", e);
|
||||||
|
// Set as initialized even on error to prevent retries, but index will be null
|
||||||
|
isInitialized = true;
|
||||||
|
vectorIndex = null; // Ensure index is null on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vectorizeItem(
|
||||||
|
item: HydratedIndexItem,
|
||||||
|
): Promise<(HydratedIndexItem & { embedding: number[] }) | null> {
|
||||||
|
// Simplified for brevity - assumes embedding function doesn't need cancellation signal
|
||||||
|
try {
|
||||||
|
const textToEmbed = [
|
||||||
|
item.text,
|
||||||
|
item.content,
|
||||||
|
item.category,
|
||||||
|
item.metadata?.author,
|
||||||
|
item.metadata?.subject,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const embedding = await getEmbedding(textToEmbed);
|
||||||
|
return { ...item, embedding };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error vectorizing item ${item.id}:`, error);
|
||||||
|
return null; // Return null if vectorization fails for an item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processItems(items: HydratedIndexItem[], signal: AbortSignal) {
|
||||||
|
console.debug("Worker received process request.");
|
||||||
|
if (!vectorIndex) {
|
||||||
|
console.warn(
|
||||||
|
"Processing requested but vector index not ready. Attempting init.",
|
||||||
|
);
|
||||||
|
await initWorker(); // Attempt initialization if not ready
|
||||||
|
if (!vectorIndex) {
|
||||||
|
// Check again after attempt
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "error",
|
||||||
|
message:
|
||||||
|
"Vector index not available for processing after init attempt.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find items we haven't processed yet by checking against the index instance
|
||||||
|
const unprocessedItems = items.filter((item) => {
|
||||||
|
if (signal.aborted) return false; // Check cancellation during filtering
|
||||||
|
try {
|
||||||
|
// Check if the item ID already exists in the index (loaded or added)
|
||||||
|
return !vectorIndex!.get({ id: item.id });
|
||||||
|
} catch (e) {
|
||||||
|
// If get throws (e.g., item not found), it means it's unprocessed
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
console.debug("Processing cancelled before starting.");
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "cancelled",
|
||||||
|
message: "Processing cancelled before start",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unprocessedItems.length === 0) {
|
||||||
|
console.debug("No new items to process.");
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: { status: "complete", message: "No new items to process" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`Starting processing of ${unprocessedItems.length} items.`);
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "started",
|
||||||
|
total: unprocessedItems.length,
|
||||||
|
processed: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const BATCH_SIZE = 5;
|
||||||
|
let processedCount = 0;
|
||||||
|
for (let i = 0; i < unprocessedItems.length; i += BATCH_SIZE) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
console.debug("Processing cancelled during batching.");
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "cancelled",
|
||||||
|
message: "Processing cancelled during batching",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = unprocessedItems.slice(i, i + BATCH_SIZE);
|
||||||
|
// Vectorize batch
|
||||||
|
const vectorizationResults = await Promise.all(batch.map(vectorizeItem));
|
||||||
|
const successfullyVectorized = vectorizationResults.filter(
|
||||||
|
(result) => result !== null,
|
||||||
|
) as (HydratedIndexItem & { embedding: number[] })[];
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
console.debug("Processing cancelled after vectorization batch.");
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "cancelled",
|
||||||
|
message: "Processing cancelled after vectorization",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add successfully vectorized items to index
|
||||||
|
if (successfullyVectorized.length > 0) {
|
||||||
|
try {
|
||||||
|
successfullyVectorized.forEach((item) => vectorIndex!.add(item));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error adding batch to index:", e);
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: { status: "error", message: `Error adding to index: ${e}` },
|
||||||
|
});
|
||||||
|
// Decide whether to continue or stop on error
|
||||||
|
// return; // Example: Stop processing if adding fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
console.debug("Processing cancelled before saving batch.");
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "cancelled",
|
||||||
|
message: "Processing cancelled before saving",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save index after processing the batch
|
||||||
|
try {
|
||||||
|
await vectorIndex!.saveIndex("indexedDB");
|
||||||
|
console.debug(`Saved index after processing batch ${i / BATCH_SIZE + 1}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error saving index batch:", e);
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: { status: "error", message: `Error saving index batch: ${e}` },
|
||||||
|
});
|
||||||
|
// Continue processing next batch even if saving failed? Or stop?
|
||||||
|
// return; // Example: Stop if saving fails
|
||||||
|
}
|
||||||
|
|
||||||
|
processedCount = Math.min(i + BATCH_SIZE, unprocessedItems.length);
|
||||||
|
|
||||||
|
// Report progress
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "processing",
|
||||||
|
total: unprocessedItems.length,
|
||||||
|
processed: processedCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Yield control briefly to allow other messages (like cancellation) to be processed
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signal.aborted) {
|
||||||
|
console.debug("Processing completed successfully.");
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: { status: "complete", message: "All items processed successfully" },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.debug("Processing completed, but was cancelled.");
|
||||||
|
// No need to send 'cancelled' again if already sent during batching
|
||||||
|
// self.postMessage({ type: 'progress', data: { status: 'cancelled', message: 'Processing finished but was cancelled' }});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(
|
||||||
|
query: string,
|
||||||
|
topK: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
messageId: string,
|
||||||
|
) {
|
||||||
|
console.debug(
|
||||||
|
`Worker received search request (ID: ${messageId}): "${query}"`,
|
||||||
|
);
|
||||||
|
if (!vectorIndex) {
|
||||||
|
console.warn(
|
||||||
|
`Search (ID: ${messageId}) requested but vector index not ready. Attempting init.`,
|
||||||
|
);
|
||||||
|
await initWorker(); // Attempt initialization
|
||||||
|
// Re-check after waiting/init attempt
|
||||||
|
if (!vectorIndex) {
|
||||||
|
console.error(
|
||||||
|
`Search (ID: ${messageId}) failed: Vector index unavailable after init attempt.`,
|
||||||
|
);
|
||||||
|
self.postMessage({
|
||||||
|
type: "searchError",
|
||||||
|
data: { messageId, error: "Vector index not available." },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug(
|
||||||
|
`Vector index ready after init for search (ID: ${messageId}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
console.debug(`Search (ID: ${messageId}) cancelled before starting.`);
|
||||||
|
self.postMessage({ type: "searchCancelled", data: { messageId } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.debug(`Getting embedding for query (ID: ${messageId})...`);
|
||||||
|
const queryEmbedding = await getEmbedding(query);
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
console.debug(`Search (ID: ${messageId}) cancelled after embedding.`);
|
||||||
|
self.postMessage({ type: "searchCancelled", data: { messageId } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`Performing vector search (ID: ${messageId})...`);
|
||||||
|
// Await the search and let TypeScript infer the type
|
||||||
|
const results = await vectorIndex!.search(queryEmbedding, {
|
||||||
|
topK,
|
||||||
|
useStorage: "indexedDB", // Ensure we search the stored index
|
||||||
|
});
|
||||||
|
|
||||||
|
console.debug(
|
||||||
|
`Vector search (ID: ${messageId}) completed with ${results.length} results.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
console.debug(
|
||||||
|
`Search (ID: ${messageId}) cancelled after search completed, discarding results.`,
|
||||||
|
);
|
||||||
|
self.postMessage({ type: "searchCancelled", data: { messageId } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post results back to the main thread
|
||||||
|
self.postMessage({ type: "searchResults", data: { messageId, results } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Vector search error in worker (ID: ${messageId}):`, error);
|
||||||
|
// Ensure signal isn't checked *after* an error occurred before posting error message
|
||||||
|
if (!signal.aborted) {
|
||||||
|
// Only post error if not cancelled
|
||||||
|
self.postMessage({
|
||||||
|
type: "searchError",
|
||||||
|
data: {
|
||||||
|
messageId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.debug(
|
||||||
|
`Search (ID: ${messageId}) encountered error but was cancelled, suppressing error message.`,
|
||||||
|
);
|
||||||
|
self.postMessage({ type: "searchCancelled", data: { messageId } }); // Still notify of cancellation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle messages from the main thread
|
||||||
|
self.addEventListener("message", async (e) => {
|
||||||
|
// Make sure data and type exist
|
||||||
|
if (!e.data || !e.data.type) {
|
||||||
|
console.warn("Worker received message with no data or type.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, data, messageId } = e.data; // messageId used for requests needing response/cancellation tracking
|
||||||
|
|
||||||
|
// Cancel previous long-running operation (process or search) if a new one starts
|
||||||
|
if (type === "process" || type === "search") {
|
||||||
|
if (currentAbortController) {
|
||||||
|
console.debug(
|
||||||
|
`Worker cancelling previous operation due to new '${type}' request.`,
|
||||||
|
);
|
||||||
|
currentAbortController.abort(`New '${type}' operation requested`);
|
||||||
|
}
|
||||||
|
currentAbortController = new AbortController();
|
||||||
|
console.debug(`Worker starting new '${type}' operation.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the signal from the *current* controller for the task being started
|
||||||
|
const signal = currentAbortController?.signal;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "process":
|
||||||
|
if (signal && data?.items) {
|
||||||
|
await processItems(data.items, signal);
|
||||||
|
} else if (!signal) {
|
||||||
|
console.error(
|
||||||
|
"Process message received but no abort signal available.",
|
||||||
|
);
|
||||||
|
} else if (!data?.items) {
|
||||||
|
console.error("Process message received without 'items' data.");
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "error",
|
||||||
|
message: "Process command received without items.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "search":
|
||||||
|
if (signal && messageId && typeof data?.query === "string") {
|
||||||
|
await search(data.query, data.topK ?? 10, signal, messageId);
|
||||||
|
} else {
|
||||||
|
const errorReason = !signal
|
||||||
|
? "Missing signal"
|
||||||
|
: !messageId
|
||||||
|
? "Missing messageId"
|
||||||
|
: "Missing or invalid query";
|
||||||
|
console.error(`Search message received invalid: ${errorReason}.`, {
|
||||||
|
data,
|
||||||
|
messageId,
|
||||||
|
signalExists: !!signal,
|
||||||
|
});
|
||||||
|
// Send an error back if messageId exists
|
||||||
|
if (messageId) {
|
||||||
|
self.postMessage({
|
||||||
|
type: "searchError",
|
||||||
|
data: { messageId, error: `Worker internal error: ${errorReason}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "init":
|
||||||
|
// Init should not be cancellable in the same way, it's foundational
|
||||||
|
// Check if already initialized before potentially running it again
|
||||||
|
if (!isInitialized) {
|
||||||
|
await initWorker();
|
||||||
|
self.postMessage({ type: "ready" }); // Signal ready *after* init attempt
|
||||||
|
} else {
|
||||||
|
console.debug("Received init message, but worker already initialized.");
|
||||||
|
self.postMessage({ type: "ready" }); // Signal ready anyway
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// No explicit 'cancel' case needed as new tasks auto-cancel previous ones
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn("Unknown message type received by vector worker:", type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial check or trigger for initialization when the worker starts
|
||||||
|
initWorker()
|
||||||
|
.then(() => {
|
||||||
|
self.postMessage({ type: "ready" });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Initial worker initialization failed:", err);
|
||||||
|
// Still need to signal readiness, perhaps with an error state?
|
||||||
|
// Or rely on the first 'process' or 'search' to retry init.
|
||||||
|
// For now, just signal ready, but the index might be null.
|
||||||
|
self.postMessage({ type: "ready" });
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import type { HydratedIndexItem } from '../types';
|
||||||
|
import vectorWorker from './vectorWorker.ts?inlineWorker';
|
||||||
|
import type { SearchResult } from 'client-vector-search';
|
||||||
|
|
||||||
|
export type ProgressCallback = (data: {
|
||||||
|
status: 'started' | 'processing' | 'complete' | 'error' | 'cancelled';
|
||||||
|
total?: number;
|
||||||
|
processed?: number;
|
||||||
|
message?: string;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export class VectorWorkerManager {
|
||||||
|
private static instance: VectorWorkerManager;
|
||||||
|
private worker: Worker | null = null;
|
||||||
|
private isInitialized = false;
|
||||||
|
private readyPromise: Promise<void> | null = null; // To await initialization
|
||||||
|
private progressCallback: ProgressCallback | null = null;
|
||||||
|
private searchPromises = new Map<string, { resolve: (value: SearchResult[]) => void, reject: (reason?: any) => void, timer: NodeJS.Timeout }>();
|
||||||
|
private debounceTimer: NodeJS.Timeout | null = null;
|
||||||
|
private lastSearchParams: { query: string; topK: number; resolve: (results: SearchResult[]) => void, reject: (reason?: any) => void } | null = null;
|
||||||
|
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
// Start initialization immediately, but allow awaiting it
|
||||||
|
this.readyPromise = this.initWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): VectorWorkerManager {
|
||||||
|
if (!VectorWorkerManager.instance) {
|
||||||
|
VectorWorkerManager.instance = new VectorWorkerManager();
|
||||||
|
}
|
||||||
|
return VectorWorkerManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initWorker(): Promise<void> {
|
||||||
|
// If already initialized or initializing, return the existing promise
|
||||||
|
if (this.isInitialized) return Promise.resolve();
|
||||||
|
if (this.readyPromise) return this.readyPromise;
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
// Create the worker
|
||||||
|
this.worker = vectorWorker();
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
console.error('Vector worker initialization timed out');
|
||||||
|
this.worker?.terminate(); // Clean up worker if it exists
|
||||||
|
this.worker = null;
|
||||||
|
this.isInitialized = false; // Ensure state reflects failure
|
||||||
|
this.readyPromise = null; // Allow retrying init later
|
||||||
|
reject(new Error('Worker initialization timed out'));
|
||||||
|
}, 10000); // Increased timeout
|
||||||
|
|
||||||
|
// Set up message handling
|
||||||
|
this.worker!.addEventListener('message', (e) => {
|
||||||
|
const { type, data } = e.data;
|
||||||
|
console.debug("Message from vector worker:", type, data);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'ready':
|
||||||
|
this.isInitialized = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.debug('Vector worker initialized and ready.');
|
||||||
|
resolve(); // Resolve the init promise
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'progress':
|
||||||
|
if (this.progressCallback) {
|
||||||
|
this.progressCallback(data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'searchResults':
|
||||||
|
const searchInfo = this.searchPromises.get(data.messageId);
|
||||||
|
if (searchInfo) {
|
||||||
|
clearTimeout(searchInfo.timer); // Clear timeout on success
|
||||||
|
searchInfo.resolve(data.results);
|
||||||
|
this.searchPromises.delete(data.messageId);
|
||||||
|
} else {
|
||||||
|
console.warn('Received search results for unknown messageId:', data.messageId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'searchError':
|
||||||
|
const errorInfo = this.searchPromises.get(data.messageId);
|
||||||
|
if (errorInfo) {
|
||||||
|
clearTimeout(errorInfo.timer); // Clear timeout on error
|
||||||
|
errorInfo.reject(new Error(data.error));
|
||||||
|
this.searchPromises.delete(data.messageId);
|
||||||
|
} else {
|
||||||
|
console.warn('Received search error for unknown messageId:', data.messageId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'searchCancelled':
|
||||||
|
const cancelledInfo = this.searchPromises.get(data.messageId);
|
||||||
|
if (cancelledInfo) {
|
||||||
|
clearTimeout(cancelledInfo.timer); // Clear timeout on cancel
|
||||||
|
// Reject with a specific cancellation error or resolve with empty? Let's reject.
|
||||||
|
cancelledInfo.reject(new Error('Search cancelled by worker'));
|
||||||
|
this.searchPromises.delete(data.messageId);
|
||||||
|
} else {
|
||||||
|
console.debug('Received cancellation for unknown messageId:', data.messageId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('Unknown message from worker:', type, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize the worker
|
||||||
|
this.worker!.postMessage({ type: 'init' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensures worker is ready before proceeding
|
||||||
|
private async ensureReady() {
|
||||||
|
if (!this.readyPromise) {
|
||||||
|
// If init wasn't called or failed, try again
|
||||||
|
console.warn("Worker not initialized, attempting init...");
|
||||||
|
this.readyPromise = this.initWorker();
|
||||||
|
}
|
||||||
|
await this.readyPromise;
|
||||||
|
if (!this.isInitialized || !this.worker) {
|
||||||
|
throw new Error("Vector Worker is not available after initialization attempt.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async processItems(items: HydratedIndexItem[], onProgress?: ProgressCallback) {
|
||||||
|
await this.ensureReady(); // Wait for worker to be ready
|
||||||
|
|
||||||
|
this.progressCallback = onProgress || null;
|
||||||
|
|
||||||
|
// Cancel any ongoing search when starting processing
|
||||||
|
this.cancelAllSearches("Processing started");
|
||||||
|
|
||||||
|
console.debug(`Sending ${items.length} items to worker for processing.`);
|
||||||
|
this.worker!.postMessage({
|
||||||
|
type: 'process',
|
||||||
|
data: { items }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public search method
|
||||||
|
public async search(query: string, topK: number = 10): Promise<SearchResult[]> {
|
||||||
|
await this.ensureReady();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.lastSearchParams = { query, topK, resolve, reject };
|
||||||
|
|
||||||
|
const messageId = crypto.randomUUID();
|
||||||
|
if (this.lastSearchParams && this.worker) {
|
||||||
|
const currentParams = this.lastSearchParams; // Capture current params
|
||||||
|
this.lastSearchParams = null; // Clear last params *before* posting
|
||||||
|
this.debounceTimer = null;
|
||||||
|
|
||||||
|
// Set a timeout for the search operation itself
|
||||||
|
const searchTimeout = 10000; // e.g., 10 seconds
|
||||||
|
const searchTimer = setTimeout(() => {
|
||||||
|
if (this.searchPromises.has(messageId)) {
|
||||||
|
console.error(`Search timed out for messageId: ${messageId}`);
|
||||||
|
currentParams.reject(new Error(`Search timed out after ${searchTimeout}ms`));
|
||||||
|
this.searchPromises.delete(messageId);
|
||||||
|
}
|
||||||
|
}, searchTimeout);
|
||||||
|
|
||||||
|
|
||||||
|
this.searchPromises.set(messageId, { resolve: currentParams.resolve, reject: currentParams.reject, timer: searchTimer });
|
||||||
|
|
||||||
|
console.debug(`Sending search request (ID: ${messageId}) to worker: "${currentParams.query}"`);
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "search",
|
||||||
|
data: { query: currentParams.query, topK: currentParams.topK },
|
||||||
|
messageId
|
||||||
|
});
|
||||||
|
} else if (this.lastSearchParams) {
|
||||||
|
// This case might happen if ensureReady failed but didn't throw
|
||||||
|
console.error("Worker unavailable when trying to send search request.");
|
||||||
|
this.lastSearchParams.reject(new Error("Worker unavailable for search"));
|
||||||
|
this.lastSearchParams = null;
|
||||||
|
this.debounceTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to cancel all pending/debounced searches
|
||||||
|
private cancelAllSearches(reason: string = "Cancelled") {
|
||||||
|
if (this.debounceTimer) {
|
||||||
|
clearTimeout(this.debounceTimer);
|
||||||
|
this.debounceTimer = null;
|
||||||
|
if (this.lastSearchParams) {
|
||||||
|
this.lastSearchParams.reject(new Error(`Search cancelled: ${reason}`));
|
||||||
|
this.lastSearchParams = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We might also want to tell the worker to cancel its *current* search
|
||||||
|
// if it supports it, but this requires worker modification.
|
||||||
|
// For now, just reject pending promises in the manager.
|
||||||
|
for (const [messageId, promiseInfo] of this.searchPromises.entries()) {
|
||||||
|
clearTimeout(promiseInfo.timer);
|
||||||
|
promiseInfo.reject(new Error(`Search cancelled: ${reason}`));
|
||||||
|
this.searchPromises.delete(messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
terminate() {
|
||||||
|
console.debug("Terminating Vector Worker Manager...");
|
||||||
|
this.cancelAllSearches("Worker terminated"); // Cancel pending searches
|
||||||
|
|
||||||
|
if (this.worker) {
|
||||||
|
this.worker.terminate();
|
||||||
|
this.worker = null;
|
||||||
|
}
|
||||||
|
this.isInitialized = false;
|
||||||
|
this.readyPromise = null; // Reset init promise
|
||||||
|
this.progressCallback = null;
|
||||||
|
// Clear the static instance? Or assume app lifecycle handles this?
|
||||||
|
// VectorWorkerManager.instance = null; // Uncomment if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import Fuse, { type FuseResult } from "fuse.js";
|
||||||
|
import { getStaticCommands, type StaticCommandItem } from "../core/commands";
|
||||||
|
import { getDynamicItems } from "../utils/dynamicItems";
|
||||||
|
import type { CombinedResult } from "../core/types";
|
||||||
|
import type { HydratedIndexItem } from "../indexing/types";
|
||||||
|
import { searchVectors } from "./vector/vectorSearch";
|
||||||
|
import type { VectorSearchResult } from "./vector/vectorTypes";
|
||||||
|
|
||||||
|
export function createSearchIndexes() {
|
||||||
|
const commands = getStaticCommands();
|
||||||
|
const dynamicItems = getDynamicItems();
|
||||||
|
|
||||||
|
const commandOptions = {
|
||||||
|
keys: ["text", "category", "keywords"],
|
||||||
|
includeScore: true,
|
||||||
|
includeMatches: true,
|
||||||
|
threshold: 0.6,
|
||||||
|
minMatchCharLength: 1,
|
||||||
|
ignoreLocation: true,
|
||||||
|
useExtendedSearch: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dynamicOptions = {
|
||||||
|
keys: [
|
||||||
|
"text",
|
||||||
|
"content",
|
||||||
|
"category",
|
||||||
|
"metadata.author",
|
||||||
|
"metadata.subject",
|
||||||
|
],
|
||||||
|
includeScore: true,
|
||||||
|
includeMatches: true,
|
||||||
|
threshold: 0.6,
|
||||||
|
minMatchCharLength: 3,
|
||||||
|
distance: 50,
|
||||||
|
useExtendedSearch: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
|
||||||
|
dynamicContentFuse: new Fuse(
|
||||||
|
dynamicItems,
|
||||||
|
dynamicOptions,
|
||||||
|
) as Fuse<HydratedIndexItem>,
|
||||||
|
commands,
|
||||||
|
dynamicItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchCommands(
|
||||||
|
commandsFuse: Fuse<StaticCommandItem>,
|
||||||
|
query: string,
|
||||||
|
commandIdToItemMap: Map<string, StaticCommandItem>,
|
||||||
|
limit = 10,
|
||||||
|
): CombinedResult[] {
|
||||||
|
if (!commandsFuse) return [];
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
return Array.from(commandIdToItemMap.values())
|
||||||
|
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) // Sort by priority when no query
|
||||||
|
.slice(0, limit) // Limit results even when no query
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
type: "command" as const,
|
||||||
|
score: 100 + (item.priority ?? 0),
|
||||||
|
item,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResults = commandsFuse.search(query, { limit });
|
||||||
|
|
||||||
|
return searchResults.map((result: FuseResult<StaticCommandItem>) => {
|
||||||
|
const item = result.item;
|
||||||
|
const fuseScore = 15 * (1 - (result.score || 0.5));
|
||||||
|
const score = fuseScore + (item.priority ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
type: "command" as const,
|
||||||
|
score,
|
||||||
|
item,
|
||||||
|
matches: result.matches,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchDynamicItems(
|
||||||
|
dynamicContentFuse: Fuse<HydratedIndexItem>,
|
||||||
|
query: string,
|
||||||
|
dynamicIdToItemMap: Map<string, HydratedIndexItem>,
|
||||||
|
limit = 10,
|
||||||
|
sortByRecent: boolean = true, // Added option to control sorting
|
||||||
|
): CombinedResult[] {
|
||||||
|
if (!dynamicContentFuse) return [];
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
let items = Array.from(dynamicIdToItemMap.values());
|
||||||
|
if (sortByRecent) {
|
||||||
|
items = items.sort((a, b) => b.dateAdded - a.dateAdded);
|
||||||
|
}
|
||||||
|
return items.slice(0, limit).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
type: "dynamic" as const,
|
||||||
|
score: 80, // Assign a default score for non-searched items
|
||||||
|
item,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const searchResults = dynamicContentFuse.search(query, { limit });
|
||||||
|
|
||||||
|
return searchResults.map((result: FuseResult<HydratedIndexItem>) => {
|
||||||
|
const item = result.item;
|
||||||
|
const fuseScore = 10 * (1 - (result.score || 0.5));
|
||||||
|
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
|
||||||
|
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0; // Apply boost only if sorting by recent
|
||||||
|
const score = fuseScore + recencyBoost;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
type: "dynamic" as const,
|
||||||
|
score,
|
||||||
|
item,
|
||||||
|
matches: result.matches,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performSearch(
|
||||||
|
query: string,
|
||||||
|
commandsFuse: Fuse<StaticCommandItem>,
|
||||||
|
dynamicContentFuse: Fuse<HydratedIndexItem>,
|
||||||
|
commandIdToItemMap: Map<string, StaticCommandItem>,
|
||||||
|
dynamicIdToItemMap: Map<string, HydratedIndexItem>,
|
||||||
|
showRecentFirst: boolean,
|
||||||
|
): Promise<CombinedResult[]> {
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
// Get all results first
|
||||||
|
const commandResults = searchCommands(
|
||||||
|
commandsFuse,
|
||||||
|
query,
|
||||||
|
commandIdToItemMap,
|
||||||
|
);
|
||||||
|
const commandEndTime = performance.now();
|
||||||
|
const dynamicResults = searchDynamicItems(
|
||||||
|
dynamicContentFuse,
|
||||||
|
query,
|
||||||
|
dynamicIdToItemMap,
|
||||||
|
10,
|
||||||
|
showRecentFirst,
|
||||||
|
);
|
||||||
|
const fuseEndTime = performance.now();
|
||||||
|
|
||||||
|
// Get vector results in parallel
|
||||||
|
let vectorResults: VectorSearchResult[] = [];
|
||||||
|
try {
|
||||||
|
vectorResults = await searchVectors(query, 10);
|
||||||
|
} catch (e) {}
|
||||||
|
const vectorEndTime = performance.now();
|
||||||
|
|
||||||
|
console.log("Vector results:", vectorResults);
|
||||||
|
|
||||||
|
// Log timings
|
||||||
|
console.log(`Command search took ${commandEndTime - startTime} milliseconds`);
|
||||||
|
console.log(
|
||||||
|
`Dynamic search took ${fuseEndTime - commandEndTime} milliseconds`,
|
||||||
|
);
|
||||||
|
console.log(`Vector search took ${vectorEndTime - fuseEndTime} milliseconds`);
|
||||||
|
|
||||||
|
// Create a map to store our final results, using ID as key to avoid duplicates
|
||||||
|
const resultMap = new Map<string, CombinedResult>();
|
||||||
|
|
||||||
|
// Add command results first (they keep their original scores)
|
||||||
|
commandResults.forEach((r) => resultMap.set(r.id, r));
|
||||||
|
|
||||||
|
// Process dynamic results and vector results together
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
|
||||||
|
// Add dynamic results first
|
||||||
|
dynamicResults.forEach((r) => {
|
||||||
|
seenIds.add(r.id);
|
||||||
|
const vectorMatch = vectorResults.find((v) => v.object.id === r.id);
|
||||||
|
if (vectorMatch) {
|
||||||
|
// If we found it in both searches, combine the scores
|
||||||
|
resultMap.set(r.id, {
|
||||||
|
...r,
|
||||||
|
score: r.score + vectorMatch.similarity * 0.6, // Boost exact matches
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If only in Fuse results, keep as is
|
||||||
|
resultMap.set(r.id, r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now add any vector results we haven't seen yet
|
||||||
|
vectorResults.forEach((v) => {
|
||||||
|
const id = v.object.id;
|
||||||
|
if (!seenIds.has(id)) {
|
||||||
|
// This is a semantic match that Fuse missed - add it with the vector similarity as score
|
||||||
|
resultMap.set(id, {
|
||||||
|
id,
|
||||||
|
type: "dynamic" as const,
|
||||||
|
score: v.similarity * 0.9, // High base score for semantic matches
|
||||||
|
item: v.object,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array and sort by score
|
||||||
|
const results = Array.from(resultMap.values());
|
||||||
|
results.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { EmbeddingIndex, getEmbedding, initializeModel } from 'client-vector-search';
|
||||||
|
import type { HydratedIndexItem } from '../../indexing/types';
|
||||||
|
import type { SearchResult } from 'client-vector-search';
|
||||||
|
|
||||||
|
let vectorIndex: EmbeddingIndex | null = null;
|
||||||
|
|
||||||
|
export async function initVectorSearch() {
|
||||||
|
try {
|
||||||
|
await initializeModel();
|
||||||
|
vectorIndex = new EmbeddingIndex([]);
|
||||||
|
vectorIndex.preloadIndexedDB();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error initializing vector search', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VectorSearchResult extends SearchResult {
|
||||||
|
object: HydratedIndexItem & { embedding: number[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchVectors(query: string, topK: number = 10): Promise<VectorSearchResult[]> {
|
||||||
|
if (!vectorIndex) await initVectorSearch();
|
||||||
|
|
||||||
|
const queryEmbedding = await getEmbedding(query.slice(0, 100));
|
||||||
|
|
||||||
|
const results = await vectorIndex!.search(queryEmbedding, {
|
||||||
|
topK,
|
||||||
|
useStorage: 'indexedDB',
|
||||||
|
dedupeEntries: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return results as VectorSearchResult[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { SearchResult } from "client-vector-search";
|
||||||
|
import type { HydratedIndexItem } from "../../indexing/types";
|
||||||
|
|
||||||
|
export interface VectorSearchResult extends SearchResult {
|
||||||
|
object: HydratedIndexItem & { embedding: number[] };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { SvelteComponent } from "svelte";
|
||||||
|
import type { HydratedIndexItem } from "./indexing/types";
|
||||||
|
|
||||||
|
export interface DynamicContentItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
category: string;
|
||||||
|
content: string;
|
||||||
|
dateAdded: number;
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
actionId: string;
|
||||||
|
renderComponentId: string;
|
||||||
|
renderComponent?: typeof SvelteComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dynamicItems: HydratedIndexItem[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a new set of dynamic items.
|
||||||
|
*/
|
||||||
|
export function loadDynamicItems(items: HydratedIndexItem[]) {
|
||||||
|
dynamicItems = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all currently loaded dynamic items.
|
||||||
|
*/
|
||||||
|
export function getDynamicItems(): HydratedIndexItem[] {
|
||||||
|
return dynamicItems;
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import type { FuseResultMatch, MatchIndices } from "./core/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple utility to remove HTML tags from a string.
|
||||||
|
*/
|
||||||
|
export function stripHtmlTags(html: string): string {
|
||||||
|
if (!html) return "";
|
||||||
|
return html.replace(/<[^>]*>/g, "").replace("\n", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes HTML tags from a string, but preserves <span class="highlight"> tags.
|
||||||
|
*/
|
||||||
|
export function stripHtmlButKeepHighlights(html: string): string {
|
||||||
|
if (!html) return "";
|
||||||
|
// Use a placeholder for highlight tags, strip others, then restore placeholders.
|
||||||
|
const highlightOpenPlaceholder = "__HIGHLIGHT_OPEN__";
|
||||||
|
const highlightClosePlaceholder = "__HIGHLIGHT_CLOSE__";
|
||||||
|
|
||||||
|
let processed = html.replace(
|
||||||
|
/<span class="highlight">/g,
|
||||||
|
highlightOpenPlaceholder,
|
||||||
|
);
|
||||||
|
processed = processed.replace(/<\/span>/g, (match, offset, fullString) => {
|
||||||
|
// Only replace </span> if it likely corresponds to our highlight span
|
||||||
|
// This is imperfect but helps avoid replacing unrelated spans.
|
||||||
|
// Look backwards for the nearest opening placeholder.
|
||||||
|
const lastPlaceholder = fullString.lastIndexOf(
|
||||||
|
highlightOpenPlaceholder,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
if (lastPlaceholder !== -1) {
|
||||||
|
// Check if there's another opening tag between the placeholder and the closing span
|
||||||
|
const interveningContent = fullString.substring(
|
||||||
|
lastPlaceholder + highlightOpenPlaceholder.length,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
if (!/<span/i.test(interveningContent)) {
|
||||||
|
return highlightClosePlaceholder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return match; // Keep the original </span> if unsure
|
||||||
|
});
|
||||||
|
|
||||||
|
// Strip all remaining HTML tags
|
||||||
|
processed = processed.replace(/<[^>]*>/g, "");
|
||||||
|
|
||||||
|
// Restore the highlight tags
|
||||||
|
processed = processed.replace(
|
||||||
|
new RegExp(highlightOpenPlaceholder, "g"),
|
||||||
|
'<span class="highlight">',
|
||||||
|
);
|
||||||
|
processed = processed.replace(
|
||||||
|
new RegExp(highlightClosePlaceholder, "g"),
|
||||||
|
"</span>",
|
||||||
|
);
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightMatch(
|
||||||
|
text: string,
|
||||||
|
term: string,
|
||||||
|
matches?: readonly FuseResultMatch[],
|
||||||
|
): string {
|
||||||
|
if (!term.trim() || !matches || matches.length === 0) return text;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find matches for the text field or allContent that contains the text
|
||||||
|
const fieldMatches = matches.find(
|
||||||
|
(match) =>
|
||||||
|
match.key === "text" ||
|
||||||
|
(match.key === "allContent" && match.value?.includes(text)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!fieldMatches ||
|
||||||
|
!fieldMatches.indices ||
|
||||||
|
fieldMatches.indices.length === 0
|
||||||
|
) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of character positions to mark which ones need highlighting
|
||||||
|
const highlightMap = new Array(text.length).fill(false);
|
||||||
|
|
||||||
|
fieldMatches.indices.forEach((indices: MatchIndices) => {
|
||||||
|
const start = indices[0];
|
||||||
|
const end = indices[1];
|
||||||
|
|
||||||
|
if (fieldMatches.key === "allContent") {
|
||||||
|
// Find where our text appears in the allContent
|
||||||
|
const allContent = fieldMatches.value;
|
||||||
|
const textPos = allContent?.indexOf(text) ?? -1;
|
||||||
|
|
||||||
|
// Only highlight if the match overlaps with our text
|
||||||
|
if (textPos >= 0) {
|
||||||
|
// Adjust start and end to be relative to our text field
|
||||||
|
const relStart = start - textPos;
|
||||||
|
const relEnd = end - textPos;
|
||||||
|
|
||||||
|
// Only highlight if the match actually overlaps with our text field
|
||||||
|
if (relEnd >= 0 && relStart < text.length) {
|
||||||
|
// Mark the overlapping characters
|
||||||
|
for (
|
||||||
|
let i = Math.max(0, relStart);
|
||||||
|
i <= Math.min(text.length - 1, relEnd);
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
highlightMap[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular text field match - ensure indices are within bounds
|
||||||
|
if (start >= 0 && end < text.length) {
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
highlightMap[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
let inHighlight = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (highlightMap[i] && !inHighlight) {
|
||||||
|
result += '<span class="highlight">';
|
||||||
|
inHighlight = true;
|
||||||
|
} else if (!highlightMap[i] && inHighlight) {
|
||||||
|
result += "</span>";
|
||||||
|
inHighlight = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += text.charAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inHighlight) {
|
||||||
|
result += "</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error highlighting match:", e);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to extract and highlight content snippet using Fuse matches
|
||||||
|
export function highlightSnippet(
|
||||||
|
content: string,
|
||||||
|
term: string,
|
||||||
|
matches?: readonly FuseResultMatch[],
|
||||||
|
): string {
|
||||||
|
if (!content || !term.trim() || !matches || matches.length === 0)
|
||||||
|
return content;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find matches for content field or allContent that contains the content
|
||||||
|
const contentMatches = matches.find(
|
||||||
|
(match) =>
|
||||||
|
match.key === "content" ||
|
||||||
|
(match.key === "allContent" && match.value?.includes(content)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!contentMatches ||
|
||||||
|
!contentMatches.indices ||
|
||||||
|
contentMatches.indices.length === 0
|
||||||
|
) {
|
||||||
|
// No content matches, return plain content
|
||||||
|
return content.length > 100 ? content.substring(0, 100) + "..." : content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the match indices
|
||||||
|
let allIndices: MatchIndices[] = contentMatches.indices as MatchIndices[];
|
||||||
|
|
||||||
|
// If matching against allContent, adjust indices to be relative to content
|
||||||
|
if (contentMatches.key === "allContent") {
|
||||||
|
const allContent = contentMatches.value;
|
||||||
|
const contentPos = allContent?.indexOf(content) ?? -1;
|
||||||
|
|
||||||
|
if (contentPos >= 0) {
|
||||||
|
// Adjust indices to be relative to the content field
|
||||||
|
allIndices = allIndices
|
||||||
|
.map(
|
||||||
|
(indices) =>
|
||||||
|
[
|
||||||
|
indices[0] - contentPos,
|
||||||
|
indices[1] - contentPos,
|
||||||
|
] as MatchIndices,
|
||||||
|
)
|
||||||
|
.filter((indices) => indices[1] >= 0 && indices[0] < content.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allIndices.length === 0) {
|
||||||
|
return content.length > 100 ? content.substring(0, 100) + "..." : content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a good center point for our snippet (average of first match)
|
||||||
|
const firstMatch = allIndices[0];
|
||||||
|
const matchCenter = Math.floor((firstMatch[0] + firstMatch[1]) / 2);
|
||||||
|
|
||||||
|
// Extract a window around the match
|
||||||
|
const windowSize = 100;
|
||||||
|
const start = Math.max(0, matchCenter - windowSize / 2);
|
||||||
|
const end = Math.min(content.length, matchCenter + windowSize / 2);
|
||||||
|
|
||||||
|
// Create the basic snippet
|
||||||
|
let snippet = content.substring(start, end);
|
||||||
|
if (start > 0) snippet = "..." + snippet;
|
||||||
|
if (end < content.length) snippet += "...";
|
||||||
|
|
||||||
|
// Create a highlighting map for the snippet
|
||||||
|
const snippetLength = snippet.length;
|
||||||
|
const highlightMap = new Array(snippetLength).fill(false);
|
||||||
|
|
||||||
|
// Calculate offset for the highlighting
|
||||||
|
const startOffset = start > 0 ? start - 3 : start; // Account for '...' if present
|
||||||
|
|
||||||
|
// Mark each matched character in the snippet
|
||||||
|
allIndices.forEach((indices: MatchIndices) => {
|
||||||
|
const matchStart = indices[0];
|
||||||
|
const matchEnd = indices[1];
|
||||||
|
|
||||||
|
// Skip matches outside our snippet window
|
||||||
|
if (matchEnd < start || matchStart > end) return;
|
||||||
|
|
||||||
|
// Adjust match indices to be relative to snippet
|
||||||
|
const snippetMatchStart = Math.max(0, matchStart - startOffset);
|
||||||
|
const snippetMatchEnd = Math.min(
|
||||||
|
snippetLength - 1,
|
||||||
|
matchEnd - startOffset,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark characters for highlighting
|
||||||
|
for (let i = snippetMatchStart; i <= snippetMatchEnd; i++) {
|
||||||
|
if (i >= 0 && i < snippetLength) {
|
||||||
|
highlightMap[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the highlighted snippet
|
||||||
|
let result = "";
|
||||||
|
let inHighlight = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < snippetLength; i++) {
|
||||||
|
// If highlighting state changes, add appropriate tags
|
||||||
|
if (highlightMap[i] && !inHighlight) {
|
||||||
|
result += '<span class="highlight">';
|
||||||
|
inHighlight = true;
|
||||||
|
} else if (!highlightMap[i] && inHighlight) {
|
||||||
|
result += "</span>";
|
||||||
|
inHighlight = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the current character
|
||||||
|
result += snippet.charAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close highlight tag if we're still in one at the end
|
||||||
|
if (inHighlight) {
|
||||||
|
result += "</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error highlighting snippet:", e);
|
||||||
|
return content.length > 100 ? content.substring(0, 100) + "..." : content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Plugin } from '../../core/types';
|
||||||
|
|
||||||
|
interface NotificationCollectorStorage {
|
||||||
|
lastNotificationCount: number;
|
||||||
|
lastCheckedTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
|
||||||
|
id: 'notificationCollector',
|
||||||
|
name: 'Notification Collector',
|
||||||
|
description: 'Collects and displays SEQTA notifications',
|
||||||
|
version: '1.0.0',
|
||||||
|
settings: {},
|
||||||
|
disableToggle: true,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
let pollInterval: number | null = null;
|
||||||
|
|
||||||
|
// Store last notification count in storage
|
||||||
|
if (!api.storage.lastNotificationCount) {
|
||||||
|
api.storage.lastNotificationCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkNotifications = async () => {
|
||||||
|
try {
|
||||||
|
const alertDiv = document.querySelector("[class*='notifications__bubble___']") as HTMLElement;
|
||||||
|
|
||||||
|
if (api.storage.lastNotificationCount !== 0) {
|
||||||
|
alertDiv.textContent = api.storage.lastNotificationCount.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${location.origin}/seqta/student/heartbeat?`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
timestamp: "1970-01-01 00:00:00.0",
|
||||||
|
hash: "#?page=/home",
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Store notification count for history
|
||||||
|
const notificationCount = data.payload.notifications.length;
|
||||||
|
api.storage.lastNotificationCount = notificationCount;
|
||||||
|
api.storage.lastCheckedTime = new Date().toISOString();
|
||||||
|
|
||||||
|
if (alertDiv) {
|
||||||
|
alertDiv.textContent = notificationCount.toString();
|
||||||
|
} else {
|
||||||
|
console.info("[BetterSEQTA+] No notifications currently");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[BetterSEQTA+] Error fetching notifications:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPolling = () => {
|
||||||
|
if (pollInterval) return; // Already polling
|
||||||
|
checkNotifications();
|
||||||
|
pollInterval = window.setInterval(checkNotifications, 30000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollInterval) {
|
||||||
|
window.clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
const alertDiv = document.querySelector("[class*='notifications__bubble___']") as HTMLElement;
|
||||||
|
if (alertDiv) {
|
||||||
|
if (api.storage.lastNotificationCount > 9) {
|
||||||
|
alertDiv.textContent = "9+";
|
||||||
|
} else {
|
||||||
|
alertDiv.textContent = api.storage.lastNotificationCount.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
api.seqta.onMount("[class*='notifications__bubble___']", (_) => {
|
||||||
|
startPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopPolling();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default notificationCollectorPlugin;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Plugin } from '@/plugins/core/types';
|
||||||
|
import { BasePlugin } from '@/plugins/core/settings';
|
||||||
|
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
|
||||||
|
|
||||||
|
// Step 1: Define settings with proper typing
|
||||||
|
const settings = defineSettings({
|
||||||
|
someSetting: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Test Plugin",
|
||||||
|
description: "Some random setting",
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Create the plugin class with @Setting decorators
|
||||||
|
class TestPluginClass extends BasePlugin<typeof settings> {
|
||||||
|
@Setting(settings.someSetting)
|
||||||
|
someSetting!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Instantiate and plug it in
|
||||||
|
const settingsInstance = new TestPluginClass();
|
||||||
|
|
||||||
|
const testPlugin: Plugin<typeof settings> = {
|
||||||
|
id: 'test',
|
||||||
|
name: 'Test Plugin',
|
||||||
|
description: 'A test plugin for BetterSEQTA+',
|
||||||
|
version: '1.0.0',
|
||||||
|
settings: settingsInstance.settings,
|
||||||
|
disableToggle: true,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
console.log('Test plugin running');
|
||||||
|
|
||||||
|
api.events.on('ping', (data) => {
|
||||||
|
console.log('Ping received! Page changed to: ', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unregister } = api.seqta.onPageChange((page) => {
|
||||||
|
//console.log('Page changed to', page);
|
||||||
|
api.events.emit('ping', page);
|
||||||
|
|
||||||
|
console.log('Current setting value:', api.settings.someSetting);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log('Test plugin stopped');
|
||||||
|
unregister();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default testPlugin;
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import renderSvelte from "@/interface/main"
|
import renderSvelte from "@/interface/main"
|
||||||
import themeCreator from "@/interface/pages/themeCreator.svelte"
|
import themeCreator from "@/interface/pages/themeCreator.svelte"
|
||||||
import { unmount } from "svelte"
|
import { unmount } from "svelte"
|
||||||
import { ClearThemePreview } from "./themes/UpdateThemePreview"
|
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager"
|
||||||
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||||
|
|
||||||
let themeCreatorSvelteApp: any = null
|
let themeCreatorSvelteApp: any = null
|
||||||
|
const themeManager = ThemeManager.getInstance();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the Theme Creator sidebar, it is an embedded page loaded similar to the extension popup
|
* Open the Theme Creator sidebar, it is an embedded page loaded similar to the extension popup
|
||||||
@@ -13,6 +15,12 @@ let themeCreatorSvelteApp: any = null
|
|||||||
export function OpenThemeCreator(themeID: string = "") {
|
export function OpenThemeCreator(themeID: string = "") {
|
||||||
CloseThemeCreator()
|
CloseThemeCreator()
|
||||||
|
|
||||||
|
// Only store original color if we're not editing an existing theme
|
||||||
|
localStorage.setItem('themeCreatorOpen', 'true');
|
||||||
|
if (!themeID) {
|
||||||
|
localStorage.setItem('originalPreviewColor', settingsState.selectedColor);
|
||||||
|
}
|
||||||
|
|
||||||
const width = "310px"
|
const width = "310px"
|
||||||
|
|
||||||
const themeCreatorDiv: HTMLDivElement = document.createElement("div")
|
const themeCreatorDiv: HTMLDivElement = document.createElement("div")
|
||||||
@@ -33,7 +41,7 @@ export function OpenThemeCreator(themeID: string = "") {
|
|||||||
closeButton.textContent = "×"
|
closeButton.textContent = "×"
|
||||||
closeButton.addEventListener("click", () => {
|
closeButton.addEventListener("click", () => {
|
||||||
CloseThemeCreator()
|
CloseThemeCreator()
|
||||||
ClearThemePreview()
|
themeManager.clearPreview()
|
||||||
})
|
})
|
||||||
|
|
||||||
document.body.appendChild(closeButton)
|
document.body.appendChild(closeButton)
|
||||||
@@ -82,6 +90,9 @@ export function OpenThemeCreator(themeID: string = "") {
|
|||||||
* @returns void
|
* @returns void
|
||||||
*/
|
*/
|
||||||
export function CloseThemeCreator() {
|
export function CloseThemeCreator() {
|
||||||
|
// Remove the stored flag
|
||||||
|
localStorage.removeItem('themeCreatorOpen');
|
||||||
|
|
||||||
const themeCreator = document.getElementById("themeCreator")
|
const themeCreator = document.getElementById("themeCreator")
|
||||||
const closeButton = document.querySelector(
|
const closeButton = document.querySelector(
|
||||||
".themeCloseButton",
|
".themeCloseButton",
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Plugin } from '../../core/types';
|
||||||
|
import { ThemeManager } from './theme-manager';
|
||||||
|
|
||||||
|
const themesPlugin: Plugin = {
|
||||||
|
id: 'themes',
|
||||||
|
name: 'Themes',
|
||||||
|
description: 'Adds a theme selector to the settings page',
|
||||||
|
version: '1.0.0',
|
||||||
|
settings: {},
|
||||||
|
|
||||||
|
run: async (_) => {
|
||||||
|
const themeManager = ThemeManager.getInstance();
|
||||||
|
await themeManager.initialize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default themesPlugin;
|
||||||
@@ -0,0 +1,738 @@
|
|||||||
|
import localforage from 'localforage';
|
||||||
|
import type { CustomTheme, LoadedCustomTheme } from '@/types/CustomThemes';
|
||||||
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState';
|
||||||
|
import debounce from '@/seqta/utils/debounce';
|
||||||
|
|
||||||
|
type ThemeContent = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
coverImage?: string; // base64, optional
|
||||||
|
description: string;
|
||||||
|
defaultColour?: string;
|
||||||
|
CanChangeColour?: boolean;
|
||||||
|
CustomCSS?: string;
|
||||||
|
hideThemeName?: boolean;
|
||||||
|
forceDark?: boolean;
|
||||||
|
images: { id: string, variableName: string, data: string }[]; // data: base64
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ThemeManager {
|
||||||
|
private static instance: ThemeManager;
|
||||||
|
private currentTheme: CustomTheme | null = null;
|
||||||
|
private styleElement: HTMLStyleElement | null = null;
|
||||||
|
private previewStyleElement: HTMLStyleElement | null = null;
|
||||||
|
private previousImageVariableNames: string[] = [];
|
||||||
|
private originalPreviewColor: string | null = null;
|
||||||
|
private originalPreviewTheme: boolean | null = null;
|
||||||
|
private imageUrlCache: Map<string, string> = new Map();
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
console.debug('[ThemeManager] Initializing...');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): ThemeManager {
|
||||||
|
if (!ThemeManager.instance) {
|
||||||
|
ThemeManager.instance = new ThemeManager();
|
||||||
|
}
|
||||||
|
return ThemeManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently active theme
|
||||||
|
*/
|
||||||
|
public getCurrentTheme(): CustomTheme | null {
|
||||||
|
return this.currentTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a theme by ID from storage
|
||||||
|
*/
|
||||||
|
public async getTheme(themeId: string): Promise<CustomTheme | null> {
|
||||||
|
console.debug('[ThemeManager] Getting theme:', themeId);
|
||||||
|
try {
|
||||||
|
const theme = await localforage.getItem(themeId) as CustomTheme;
|
||||||
|
return theme;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error getting theme:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ID of the currently selected theme
|
||||||
|
*/
|
||||||
|
public getSelectedThemeId(): string {
|
||||||
|
return settingsState.selectedTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable the current theme without deleting it
|
||||||
|
*/
|
||||||
|
public async disableTheme(): Promise<void> {
|
||||||
|
console.debug('[ThemeManager] Disabling current theme');
|
||||||
|
try {
|
||||||
|
if (!this.currentTheme) {
|
||||||
|
console.debug('[ThemeManager] No theme to disable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.removeTheme(this.currentTheme);
|
||||||
|
this.currentTheme = null;
|
||||||
|
settingsState.selectedTheme = '';
|
||||||
|
console.debug('[ThemeManager] Theme disabled successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error disabling theme:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the theme system and restore previous state
|
||||||
|
*/
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
console.debug('[ThemeManager] Starting initialization');
|
||||||
|
try {
|
||||||
|
// Check if theme creator was open during reload
|
||||||
|
const themeCreatorOpen = localStorage.getItem('themeCreatorOpen');
|
||||||
|
if (themeCreatorOpen === 'true') {
|
||||||
|
console.debug('[ThemeManager] Theme creator was open, clearing preview state');
|
||||||
|
this.clearPreview();
|
||||||
|
// Clean up the flag
|
||||||
|
localStorage.removeItem('themeCreatorOpen');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsState.selectedTheme) {
|
||||||
|
console.debug('[ThemeManager] Found selected theme, restoring:', settingsState.selectedTheme);
|
||||||
|
await this.setTheme(settingsState.selectedTheme);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error during initialization:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up theme system resources
|
||||||
|
*/
|
||||||
|
public async cleanup(): Promise<void> {
|
||||||
|
console.debug('[ThemeManager] Cleaning up resources');
|
||||||
|
try {
|
||||||
|
if (this.currentTheme) {
|
||||||
|
await this.removeTheme(this.currentTheme, false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error during cleanup:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set and apply a theme by ID
|
||||||
|
*/
|
||||||
|
public async setTheme(themeId: string): Promise<void> {
|
||||||
|
console.debug('[ThemeManager] Setting theme:', themeId);
|
||||||
|
try {
|
||||||
|
const theme = await localforage.getItem(themeId) as CustomTheme;
|
||||||
|
if (!theme) {
|
||||||
|
console.error('[ThemeManager] Theme not found:', themeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store original settings before applying new theme
|
||||||
|
if (!settingsState.selectedTheme) {
|
||||||
|
console.debug('[ThemeManager] Storing original settings');
|
||||||
|
settingsState.originalSelectedColor = settingsState.selectedColor;
|
||||||
|
settingsState.originalDarkMode = settingsState.DarkMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove current theme if exists
|
||||||
|
if (this.currentTheme) {
|
||||||
|
console.debug('[ThemeManager] Removing current theme');
|
||||||
|
|
||||||
|
await this.removeTheme(this.currentTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply new theme
|
||||||
|
await this.applyTheme(theme);
|
||||||
|
this.currentTheme = theme;
|
||||||
|
settingsState.selectedTheme = themeId;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error setting theme:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply theme components (CSS, images, settings)
|
||||||
|
*/
|
||||||
|
private async applyTheme(theme: CustomTheme): Promise<void> {
|
||||||
|
console.debug('[ThemeManager] Applying theme:', theme.name);
|
||||||
|
try {
|
||||||
|
// Apply custom CSS
|
||||||
|
if (theme.CustomCSS) {
|
||||||
|
console.debug('[ThemeManager] Applying custom CSS');
|
||||||
|
this.applyCustomCSS(theme.CustomCSS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply custom images
|
||||||
|
if (theme.CustomImages) {
|
||||||
|
console.debug('[ThemeManager] Applying custom images');
|
||||||
|
theme.CustomImages.forEach((image) => {
|
||||||
|
const imageUrl = URL.createObjectURL(image.blob);
|
||||||
|
document.documentElement.style.setProperty('--' + image.variableName, `url(${imageUrl})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme settings
|
||||||
|
if (theme.forceDark !== undefined) {
|
||||||
|
console.debug('[ThemeManager] Setting dark mode:', theme.forceDark);
|
||||||
|
settingsState.DarkMode = theme.forceDark;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the stored selected color if available, otherwise use the default
|
||||||
|
if (theme.selectedColor) {
|
||||||
|
console.debug('[ThemeManager] Restoring saved color:', theme.selectedColor);
|
||||||
|
settingsState.selectedColor = theme.selectedColor;
|
||||||
|
} else if (theme.defaultColour) {
|
||||||
|
console.debug('[ThemeManager] Using default color:', theme.defaultColour);
|
||||||
|
settingsState.selectedColor = theme.defaultColour;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error applying theme:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove theme and restore original settings
|
||||||
|
*/
|
||||||
|
private async removeTheme(theme: CustomTheme, clearSelectedTheme: boolean = true): Promise<void> {
|
||||||
|
console.debug('[ThemeManager] Removing theme:', theme.name);
|
||||||
|
try {
|
||||||
|
// Remove custom CSS
|
||||||
|
if (this.styleElement) {
|
||||||
|
console.debug('[ThemeManager] Removing custom CSS');
|
||||||
|
this.styleElement.remove();
|
||||||
|
this.styleElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove custom images
|
||||||
|
if (theme.CustomImages) {
|
||||||
|
console.debug('[ThemeManager] Removing custom images');
|
||||||
|
theme.CustomImages.forEach((image) => {
|
||||||
|
const value = document.documentElement.style.getPropertyValue('--' + image.variableName);
|
||||||
|
if (value) {
|
||||||
|
URL.revokeObjectURL(value.slice(4, -1)); // Remove url() wrapper
|
||||||
|
}
|
||||||
|
document.documentElement.style.removeProperty('--' + image.variableName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentTheme) {
|
||||||
|
// Store the current color with the theme before removing it
|
||||||
|
await localforage.setItem(this.currentTheme.id, {
|
||||||
|
...this.currentTheme,
|
||||||
|
selectedColor: settingsState.selectedColor
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original settings
|
||||||
|
if (settingsState.originalSelectedColor) {
|
||||||
|
console.debug('[ThemeManager] Restoring original color:', settingsState.originalSelectedColor);
|
||||||
|
settingsState.selectedColor = settingsState.originalSelectedColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsState.originalDarkMode !== undefined) {
|
||||||
|
console.debug('[ThemeManager] Restoring original dark mode:', settingsState.originalDarkMode);
|
||||||
|
settingsState.DarkMode = settingsState.originalDarkMode;
|
||||||
|
settingsState.originalDarkMode = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentTheme = null;
|
||||||
|
if (clearSelectedTheme) {
|
||||||
|
settingsState.selectedTheme = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error removing theme:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply custom CSS to the document
|
||||||
|
*/
|
||||||
|
private applyCustomCSS(css: string): void {
|
||||||
|
console.debug('[ThemeManager] Applying custom CSS');
|
||||||
|
try {
|
||||||
|
if (!this.styleElement) {
|
||||||
|
this.styleElement = document.createElement('style');
|
||||||
|
this.styleElement.id = 'custom-theme';
|
||||||
|
document.head.appendChild(this.styleElement);
|
||||||
|
}
|
||||||
|
this.styleElement.textContent = css;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error applying custom CSS:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of available themes
|
||||||
|
*/
|
||||||
|
public async getAvailableThemes(): Promise<CustomTheme[]> {
|
||||||
|
console.debug('[ThemeManager] Getting available themes');
|
||||||
|
try {
|
||||||
|
const themeIds = await localforage.getItem('customThemes') as string[] | null;
|
||||||
|
if (!themeIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const themes = await Promise.all(
|
||||||
|
themeIds.map(async (id) => {
|
||||||
|
return await localforage.getItem(id) as CustomTheme;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return themes.filter(theme => theme !== null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error getting available themes:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save or update a theme
|
||||||
|
*/
|
||||||
|
public async saveTheme(theme: LoadedCustomTheme): Promise<void> {
|
||||||
|
console.debug('[ThemeManager] Saving theme:', theme.name);
|
||||||
|
try {
|
||||||
|
await localforage.setItem(theme.id, theme);
|
||||||
|
const themeIds = await localforage.getItem('customThemes') as string[] | null;
|
||||||
|
|
||||||
|
if (themeIds) {
|
||||||
|
if (!themeIds.includes(theme.id)) {
|
||||||
|
themeIds.push(theme.id);
|
||||||
|
await localforage.setItem('customThemes', themeIds);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await localforage.setItem('customThemes', [theme.id]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error saving theme:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a theme
|
||||||
|
*/
|
||||||
|
public async deleteTheme(themeId: string): Promise<void> {
|
||||||
|
console.debug('[ThemeManager] Deleting theme:', themeId);
|
||||||
|
try {
|
||||||
|
const theme = await localforage.getItem(themeId) as CustomTheme;
|
||||||
|
if (theme) {
|
||||||
|
if (this.currentTheme?.id === themeId) {
|
||||||
|
await this.removeTheme(theme);
|
||||||
|
}
|
||||||
|
await localforage.removeItem(themeId);
|
||||||
|
|
||||||
|
const themeIds = await localforage.getItem('customThemes') as string[] | null;
|
||||||
|
if (themeIds) {
|
||||||
|
const updatedThemeIds = themeIds.filter(id => id !== themeId);
|
||||||
|
await localforage.setItem('customThemes', updatedThemeIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error deleting theme:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download and install a theme from the store
|
||||||
|
*/
|
||||||
|
public async downloadTheme(themeContent: { id: string; name: string; description: string; coverImage: string; }): Promise<void> {
|
||||||
|
console.debug('[ThemeManager] Downloading theme:', themeContent.name);
|
||||||
|
try {
|
||||||
|
if (!themeContent.id) return;
|
||||||
|
|
||||||
|
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes/${themeContent.id}/theme.json`);
|
||||||
|
const themeData = await response.json() as ThemeContent;
|
||||||
|
|
||||||
|
await this.installTheme(themeData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error downloading theme:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a theme from theme data
|
||||||
|
*/
|
||||||
|
public async installTheme(themeData: ThemeContent): Promise<void> {
|
||||||
|
console.debug('[ThemeManager] Installing theme:', themeData.name);
|
||||||
|
try {
|
||||||
|
// Validate required fields
|
||||||
|
if (!themeData.id || !themeData.name) {
|
||||||
|
throw new Error('Theme is missing required fields (id or name)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cover image (optional)
|
||||||
|
let coverImageBlob = null;
|
||||||
|
if (themeData.coverImage) {
|
||||||
|
try {
|
||||||
|
const strippedCoverImage = this.stripBase64Prefix(themeData.coverImage);
|
||||||
|
coverImageBlob = this.base64ToBlob(strippedCoverImage);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ThemeManager] Failed to process cover image:', e);
|
||||||
|
// Continue without cover image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle images (optional)
|
||||||
|
const images = themeData.images?.map((image) => {
|
||||||
|
try {
|
||||||
|
if (!image.id || !image.variableName || !image.data) {
|
||||||
|
console.warn('[ThemeManager] Skipping invalid image:', image);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...image,
|
||||||
|
blob: this.base64ToBlob(this.stripBase64Prefix(image.data))
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ThemeManager] Failed to process image:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}).filter(img => img !== null) ?? [];
|
||||||
|
|
||||||
|
// Create theme with defaults for optional fields
|
||||||
|
const theme: LoadedCustomTheme = {
|
||||||
|
id: themeData.id,
|
||||||
|
name: themeData.name,
|
||||||
|
description: themeData.description || '',
|
||||||
|
webURL: themeData.id,
|
||||||
|
coverImage: coverImageBlob,
|
||||||
|
CustomImages: images,
|
||||||
|
CustomCSS: themeData.CustomCSS || '',
|
||||||
|
defaultColour: themeData.defaultColour || 'rgba(0, 123, 255, 1)',
|
||||||
|
CanChangeColour: themeData.CanChangeColour ?? true,
|
||||||
|
allowBackgrounds: true,
|
||||||
|
isEditable: false,
|
||||||
|
hideThemeName: themeData.hideThemeName ?? false,
|
||||||
|
forceDark: themeData.forceDark
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.saveTheme(theme);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error installing theme:', error);
|
||||||
|
throw error; // Re-throw to handle in UI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share a theme by exporting it
|
||||||
|
*/
|
||||||
|
public async shareTheme(themeId: string): Promise<void> {
|
||||||
|
console.debug('[ThemeManager] Sharing theme:', themeId);
|
||||||
|
try {
|
||||||
|
const theme = await localforage.getItem(themeId) as LoadedCustomTheme;
|
||||||
|
if (!theme) {
|
||||||
|
console.error('[ThemeManager] Theme not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract only the fields we want to share
|
||||||
|
const {
|
||||||
|
CustomImages = [],
|
||||||
|
coverImage,
|
||||||
|
webURL,
|
||||||
|
isEditable,
|
||||||
|
selectedColor,
|
||||||
|
allowBackgrounds,
|
||||||
|
...themeBasics
|
||||||
|
} = theme;
|
||||||
|
|
||||||
|
// Convert images to base64
|
||||||
|
const finalImages = await Promise.all(CustomImages.map(async (image) => ({
|
||||||
|
id: image.id,
|
||||||
|
variableName: image.variableName,
|
||||||
|
data: await this.blobToBase64(image.blob)
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Convert cover image to base64
|
||||||
|
const coverImageBase64 = coverImage ? await this.blobToBase64(coverImage) : null;
|
||||||
|
|
||||||
|
// Create shareable theme data with only necessary fields
|
||||||
|
const shareableTheme = {
|
||||||
|
...themeBasics,
|
||||||
|
images: finalImages,
|
||||||
|
coverImage: coverImageBase64
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save theme file
|
||||||
|
this.saveThemeFile(shareableTheme, theme.name || 'Unnamed_Theme');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error sharing theme:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview a theme without applying it
|
||||||
|
*/
|
||||||
|
public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
|
||||||
|
console.debug('[ThemeManager] Previewing theme:', theme.name);
|
||||||
|
try {
|
||||||
|
const { CustomCSS, CustomImages, defaultColour, forceDark } = theme;
|
||||||
|
|
||||||
|
// Store original settings only if this is a new theme
|
||||||
|
if (!theme.webURL) {
|
||||||
|
if (this.originalPreviewColor === null) {
|
||||||
|
this.originalPreviewColor = settingsState.selectedColor;
|
||||||
|
localStorage.setItem('originalPreviewColor', settingsState.selectedColor);
|
||||||
|
}
|
||||||
|
if (this.originalPreviewTheme === null) {
|
||||||
|
this.originalPreviewTheme = settingsState.DarkMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply custom CSS
|
||||||
|
if (CustomCSS) {
|
||||||
|
this.applyPreviewCSS(CustomCSS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply custom images
|
||||||
|
const newImageVariableNames = CustomImages.map(image => image.variableName);
|
||||||
|
|
||||||
|
// Remove old preview images
|
||||||
|
this.previousImageVariableNames.forEach(variableName => {
|
||||||
|
if (!newImageVariableNames.includes(variableName)) {
|
||||||
|
this.removeImageFromDocument(variableName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply new images
|
||||||
|
CustomImages.forEach((image) => {
|
||||||
|
const imageUrl = URL.createObjectURL(image.blob);
|
||||||
|
document.documentElement.style.setProperty(`--${image.variableName}`, `url(${imageUrl})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update previousImageVariableNames
|
||||||
|
this.previousImageVariableNames = newImageVariableNames;
|
||||||
|
|
||||||
|
// Apply theme settings
|
||||||
|
if (forceDark !== undefined) {
|
||||||
|
settingsState.DarkMode = forceDark;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultColour) {
|
||||||
|
settingsState.selectedColor = defaultColour;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error previewing theme:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the preview of a theme in real-time (for theme creator)
|
||||||
|
*/
|
||||||
|
public async updatePreview(theme: Partial<LoadedCustomTheme>): Promise<void> {
|
||||||
|
console.debug('[ThemeManager] Updating theme preview');
|
||||||
|
try {
|
||||||
|
// Only store original settings if this is a new theme (not editing)
|
||||||
|
// We can tell it's a new theme if it has no webURL (which is set when a theme is saved/loaded)
|
||||||
|
if (!theme.webURL) {
|
||||||
|
if (this.originalPreviewColor === null) {
|
||||||
|
this.originalPreviewColor = settingsState.selectedColor;
|
||||||
|
}
|
||||||
|
if (this.originalPreviewTheme === null) {
|
||||||
|
this.originalPreviewTheme = settingsState.DarkMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply CSS if changed
|
||||||
|
if (theme.CustomCSS !== undefined) {
|
||||||
|
this.applyPreviewCSS(theme.CustomCSS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle images if present
|
||||||
|
if (theme.CustomImages) {
|
||||||
|
const newImageVariableNames = theme.CustomImages.map(image => image.variableName);
|
||||||
|
|
||||||
|
// Remove old preview images that are no longer present
|
||||||
|
this.previousImageVariableNames.forEach(variableName => {
|
||||||
|
if (!newImageVariableNames.includes(variableName)) {
|
||||||
|
this.removeImageFromDocument(variableName);
|
||||||
|
// Clean up cached URL
|
||||||
|
this.imageUrlCache.delete(variableName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply or update images
|
||||||
|
theme.CustomImages.forEach((image) => {
|
||||||
|
const existingUrl = this.imageUrlCache.get(image.variableName);
|
||||||
|
if (!existingUrl) {
|
||||||
|
// Only create new URL if one doesn't exist
|
||||||
|
const imageUrl = URL.createObjectURL(image.blob);
|
||||||
|
this.imageUrlCache.set(image.variableName, imageUrl);
|
||||||
|
document.documentElement.style.setProperty(`--${image.variableName}`, `url(${imageUrl})`);
|
||||||
|
} else {
|
||||||
|
// Reuse existing URL
|
||||||
|
document.documentElement.style.setProperty(`--${image.variableName}`, `url(${existingUrl})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.previousImageVariableNames = newImageVariableNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always apply dark mode setting
|
||||||
|
if (theme.forceDark !== undefined) {
|
||||||
|
settingsState.DarkMode = theme.forceDark;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only apply color if this is a new theme
|
||||||
|
if (!theme.webURL && theme.defaultColour) {
|
||||||
|
settingsState.selectedColor = theme.defaultColour;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error updating theme preview:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the preview of a theme (debounced)
|
||||||
|
* @param theme - The theme to update the preview of
|
||||||
|
*/
|
||||||
|
public updatePreviewDebounced = debounce((theme: Partial<LoadedCustomTheme>): void => {
|
||||||
|
this.updatePreview(theme);
|
||||||
|
}, 2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear theme preview
|
||||||
|
*/
|
||||||
|
public clearPreview(): void {
|
||||||
|
console.debug('[ThemeManager] Clearing theme preview');
|
||||||
|
try {
|
||||||
|
// Remove preview images and revoke URLs
|
||||||
|
this.previousImageVariableNames.forEach(variableName => {
|
||||||
|
this.removeImageFromDocument(variableName);
|
||||||
|
});
|
||||||
|
// Clear all cached URLs
|
||||||
|
this.imageUrlCache.forEach(url => URL.revokeObjectURL(url));
|
||||||
|
this.imageUrlCache.clear();
|
||||||
|
this.previousImageVariableNames = [];
|
||||||
|
|
||||||
|
// Remove preview CSS
|
||||||
|
if (this.previewStyleElement) {
|
||||||
|
this.previewStyleElement.remove();
|
||||||
|
this.previewStyleElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original settings
|
||||||
|
const storedColor = localStorage.getItem('originalPreviewColor');
|
||||||
|
|
||||||
|
if (storedColor) {
|
||||||
|
settingsState.selectedColor = storedColor;
|
||||||
|
localStorage.removeItem('originalPreviewColor');
|
||||||
|
} else if (this.originalPreviewColor !== null) {
|
||||||
|
console.debug('[ThemeManager] Restoring color from memory:', this.originalPreviewColor);
|
||||||
|
settingsState.selectedColor = this.originalPreviewColor;
|
||||||
|
console.debug('[ThemeManager] Color after restore:', settingsState.selectedColor);
|
||||||
|
} else {
|
||||||
|
console.debug('[ThemeManager] No color to restore found');
|
||||||
|
}
|
||||||
|
this.originalPreviewColor = null;
|
||||||
|
|
||||||
|
if (this.originalPreviewTheme !== null) {
|
||||||
|
console.debug('[ThemeManager] Restoring dark mode:', this.originalPreviewTheme);
|
||||||
|
settingsState.DarkMode = this.originalPreviewTheme;
|
||||||
|
this.originalPreviewTheme = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error clearing preview:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility methods
|
||||||
|
private stripBase64Prefix(base64String: string): string {
|
||||||
|
if (!base64String) return '';
|
||||||
|
|
||||||
|
const prefixRegex = /^data:[^;]+;base64,/;
|
||||||
|
try {
|
||||||
|
return prefixRegex.test(base64String) ? base64String.replace(prefixRegex, '') : base64String;
|
||||||
|
} catch(err) {
|
||||||
|
console.error('[ThemeManager] Error stripping base64 prefix:', err);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private base64ToBlob(base64: string): Blob {
|
||||||
|
try {
|
||||||
|
const byteString = atob(base64);
|
||||||
|
const ab = new ArrayBuffer(byteString.length);
|
||||||
|
const ia = new Uint8Array(ab);
|
||||||
|
|
||||||
|
for (let i = 0; i < byteString.length; i++) {
|
||||||
|
ia[i] = byteString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([ab], { type: 'image/png' });
|
||||||
|
} catch(err) {
|
||||||
|
console.error('[ThemeManager] Error converting base64 to blob:', err);
|
||||||
|
return new Blob();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
const base64String = reader.result as string;
|
||||||
|
const base64Data = base64String.split(',')[1];
|
||||||
|
resolve(base64Data);
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveThemeFile(data: object, fileName: string): void {
|
||||||
|
try {
|
||||||
|
const fileData = JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([fileData], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${fileName}.theme.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch(err) {
|
||||||
|
console.error('[ThemeManager] Error saving theme file:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeImageFromDocument(variableName: string): void {
|
||||||
|
try {
|
||||||
|
const value = document.documentElement.style.getPropertyValue('--' + variableName);
|
||||||
|
if (value) {
|
||||||
|
const url = this.imageUrlCache.get(variableName);
|
||||||
|
if (url) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
this.imageUrlCache.delete(variableName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.documentElement.style.removeProperty('--' + variableName);
|
||||||
|
} catch(err) {
|
||||||
|
console.error('[ThemeManager] Error removing image from document:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyPreviewCSS(css: string): void {
|
||||||
|
console.debug('[ThemeManager] Applying preview CSS');
|
||||||
|
try {
|
||||||
|
if (!this.previewStyleElement) {
|
||||||
|
this.previewStyleElement = document.createElement('style');
|
||||||
|
this.previewStyleElement.id = 'custom-theme-preview';
|
||||||
|
document.head.appendChild(this.previewStyleElement);
|
||||||
|
}
|
||||||
|
this.previewStyleElement.textContent = css;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ThemeManager] Error applying preview CSS:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState';
|
||||||
|
import type { Plugin } from '../../core/types';
|
||||||
|
import { convertTo12HourFormat } from '@/seqta/utils/convertTo12HourFormat';
|
||||||
|
import { waitForElm } from '@/seqta/utils/waitForElm';
|
||||||
|
|
||||||
|
const timetablePlugin: Plugin<{}, {}> = {
|
||||||
|
id: 'timetable',
|
||||||
|
name: 'Timetable Enhancer',
|
||||||
|
description: 'Adds extra features to the timetable view',
|
||||||
|
version: '1.0.0',
|
||||||
|
settings: {},
|
||||||
|
disableToggle: true,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
const { unregister } = api.seqta.onMount('.timetablepage', handleTimetable)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Call the unregister function to remove the mount listener
|
||||||
|
unregister();
|
||||||
|
|
||||||
|
const timetablePage = document.querySelector('.timetablepage')
|
||||||
|
if (timetablePage) {
|
||||||
|
const zoomControls = document.querySelector('.timetable-zoom-controls')
|
||||||
|
if (zoomControls) zoomControls.remove()
|
||||||
|
|
||||||
|
const hideControls = document.querySelector('.timetable-hide-controls')
|
||||||
|
if (hideControls) hideControls.remove()
|
||||||
|
|
||||||
|
resetTimetableStyles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store event handlers globally for cleanup
|
||||||
|
const zoomHandlers = new WeakMap<Element, { zoomIn: () => void; zoomOut: () => void }>()
|
||||||
|
|
||||||
|
function resetTimetableStyles(): void {
|
||||||
|
const firstDayColumn = document.querySelector(".dailycal .content .days td") as HTMLElement
|
||||||
|
if (!firstDayColumn) return
|
||||||
|
|
||||||
|
const baseContainerHeight = parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight
|
||||||
|
|
||||||
|
const dayColumns = document.querySelectorAll(".dailycal .content .days td")
|
||||||
|
dayColumns.forEach((td: Element) => {
|
||||||
|
(td as HTMLElement).style.height = `${baseContainerHeight}px`
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeColumn = document.querySelector(".times")
|
||||||
|
if (timeColumn) {
|
||||||
|
const times = timeColumn.querySelectorAll(".time")
|
||||||
|
const timeHeight = baseContainerHeight / times.length
|
||||||
|
times.forEach((time: Element) => {
|
||||||
|
(time as HTMLElement).style.height = `${timeHeight}px`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const lessons = document.querySelectorAll(".dailycal .lesson")
|
||||||
|
lessons.forEach((lesson: Element) => {
|
||||||
|
const lessonEl = lesson as HTMLElement
|
||||||
|
const originalHeight = lessonEl.getAttribute('data-original-height')
|
||||||
|
if (originalHeight) {
|
||||||
|
lessonEl.style.height = `${originalHeight}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const entries = document.querySelectorAll(".entry")
|
||||||
|
entries.forEach((entry: Element) => {
|
||||||
|
const entryEl = entry as HTMLElement
|
||||||
|
entryEl.style.opacity = '1'
|
||||||
|
})
|
||||||
|
|
||||||
|
const zoomControls = document.querySelector('.timetable-zoom-controls')
|
||||||
|
if (zoomControls) {
|
||||||
|
const handlers = zoomHandlers.get(zoomControls)
|
||||||
|
if (handlers) {
|
||||||
|
const zoomIn = zoomControls.querySelector('.timetable-zoom:nth-child(2)')
|
||||||
|
const zoomOut = zoomControls.querySelector('.timetable-zoom:nth-child(1)')
|
||||||
|
if (zoomIn) zoomIn.removeEventListener('click', handlers.zoomIn)
|
||||||
|
if (zoomOut) zoomOut.removeEventListener('click', handlers.zoomOut)
|
||||||
|
zoomHandlers.delete(zoomControls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTimetable(): Promise<void> {
|
||||||
|
await waitForElm(".time", true, 10)
|
||||||
|
|
||||||
|
// Store original heights when timetable loads
|
||||||
|
const lessons = document.querySelectorAll(".dailycal .lesson")
|
||||||
|
lessons.forEach((lesson: Element) => {
|
||||||
|
const lessonEl = lesson as HTMLElement
|
||||||
|
lessonEl.setAttribute(
|
||||||
|
"data-original-height",
|
||||||
|
lessonEl.offsetHeight.toString(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Existing time format code
|
||||||
|
if (settingsState.timeFormat == "12") {
|
||||||
|
const times = document.querySelectorAll(".timetablepage .times .time")
|
||||||
|
for (const time of times) {
|
||||||
|
if (!time.textContent) continue
|
||||||
|
time.textContent = convertTo12HourFormat(time.textContent, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTimetableZoom()
|
||||||
|
handleTimetableAssessmentHide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimetableZoom(): void {
|
||||||
|
console.log("Initializing timetable zoom controls")
|
||||||
|
|
||||||
|
// Lazy initialize state variables only when function is first called
|
||||||
|
let timetableZoomLevel = 1
|
||||||
|
let baseContainerHeight: number | null = null
|
||||||
|
const originalEntryPositions = new Map<
|
||||||
|
Element,
|
||||||
|
{ topRatio: number; heightRatio: number }
|
||||||
|
>()
|
||||||
|
|
||||||
|
// Create zoom controls
|
||||||
|
const zoomControls = document.createElement("div")
|
||||||
|
zoomControls.className = "timetable-zoom-controls"
|
||||||
|
|
||||||
|
const zoomIn = document.createElement("button")
|
||||||
|
zoomIn.className = "uiButton timetable-zoom iconFamily"
|
||||||
|
zoomIn.innerHTML = "" // Unicode for zoom in icon (custom iconfamily)
|
||||||
|
|
||||||
|
const zoomOut = document.createElement("button")
|
||||||
|
zoomOut.className = "uiButton timetable-zoom iconFamily"
|
||||||
|
zoomOut.innerHTML = "" // Unicode for zoom out icon (custom iconfamily)
|
||||||
|
|
||||||
|
zoomControls.appendChild(zoomOut)
|
||||||
|
zoomControls.appendChild(zoomIn)
|
||||||
|
|
||||||
|
const toolbar = document.getElementById("toolbar")
|
||||||
|
toolbar?.appendChild(zoomControls)
|
||||||
|
|
||||||
|
// Store event listener references
|
||||||
|
const zoomInHandler = () => {
|
||||||
|
if (timetableZoomLevel < 2) {
|
||||||
|
timetableZoomLevel += 0.2
|
||||||
|
updateZoom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomOutHandler = () => {
|
||||||
|
if (timetableZoomLevel > 0.6) {
|
||||||
|
timetableZoomLevel -= 0.2
|
||||||
|
updateZoom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomIn.addEventListener("click", zoomInHandler)
|
||||||
|
zoomOut.addEventListener("click", zoomOutHandler)
|
||||||
|
|
||||||
|
// Store references for cleanup
|
||||||
|
zoomHandlers.set(zoomControls, { zoomIn: zoomInHandler, zoomOut: zoomOutHandler })
|
||||||
|
|
||||||
|
const initializePositions = () => {
|
||||||
|
// Get the base container height from the first TD
|
||||||
|
const firstDayColumn = document.querySelector(
|
||||||
|
".dailycal .content .days td",
|
||||||
|
) as HTMLElement
|
||||||
|
if (!firstDayColumn) return false
|
||||||
|
|
||||||
|
baseContainerHeight =
|
||||||
|
parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight
|
||||||
|
|
||||||
|
// Store original ratios
|
||||||
|
const entries = document.querySelectorAll(".entriesWrapper .entry")
|
||||||
|
entries.forEach((entry: Element) => {
|
||||||
|
const entryEl = entry as HTMLElement
|
||||||
|
|
||||||
|
// Calculate ratios relative to detected base height
|
||||||
|
if (baseContainerHeight === null) return
|
||||||
|
const topRatio = parseInt(entryEl.style.top) / baseContainerHeight
|
||||||
|
const heightRatio = parseInt(entryEl.style.height) / baseContainerHeight
|
||||||
|
|
||||||
|
originalEntryPositions.set(entry, { topRatio, heightRatio })
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateZoom = () => {
|
||||||
|
// Initialize positions if not already done
|
||||||
|
if (baseContainerHeight === null && !initializePositions()) {
|
||||||
|
console.error("Failed to initialize positions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`Updating zoom level to: ${timetableZoomLevel}`)
|
||||||
|
|
||||||
|
// Calculate new container height
|
||||||
|
if (baseContainerHeight === null) return
|
||||||
|
const newContainerHeight = baseContainerHeight * timetableZoomLevel
|
||||||
|
|
||||||
|
// Update all day columns (TDs)
|
||||||
|
const dayColumns = document.querySelectorAll(".dailycal .content .days td")
|
||||||
|
dayColumns.forEach((td: Element) => {
|
||||||
|
(td as HTMLElement).style.height = `${newContainerHeight}px`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update all entries using stored ratios
|
||||||
|
const entries = document.querySelectorAll(".entriesWrapper .entry")
|
||||||
|
entries.forEach((entry: Element) => {
|
||||||
|
const entryEl = entry as HTMLElement
|
||||||
|
const originalRatios = originalEntryPositions.get(entry)
|
||||||
|
|
||||||
|
if (originalRatios) {
|
||||||
|
// Calculate new positions from original ratios
|
||||||
|
const newTop = originalRatios.topRatio * newContainerHeight
|
||||||
|
const newHeight = originalRatios.heightRatio * newContainerHeight
|
||||||
|
|
||||||
|
// Apply new values
|
||||||
|
entryEl.style.top = `${Math.round(newTop)}px`
|
||||||
|
entryEl.style.height = `${Math.round(newHeight)}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update time column to match
|
||||||
|
const timeColumn = document.querySelector(".times")
|
||||||
|
if (timeColumn) {
|
||||||
|
const times = timeColumn.querySelectorAll(".time")
|
||||||
|
const timeHeight = newContainerHeight / times.length
|
||||||
|
times.forEach((time: Element) => {
|
||||||
|
(time as HTMLElement).style.height = `${timeHeight}px`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
entries[Math.round((entries.length - 1) / 2)].scrollIntoView({
|
||||||
|
behavior: "instant",
|
||||||
|
block: "center",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimetableAssessmentHide(): void {
|
||||||
|
const hideControls = document.createElement("div")
|
||||||
|
hideControls.className = "timetable-hide-controls"
|
||||||
|
|
||||||
|
const hideOn = document.createElement("button")
|
||||||
|
hideOn.className = "uiButton timetable-hide iconFamily"
|
||||||
|
hideOn.innerHTML = "👁"
|
||||||
|
|
||||||
|
hideControls.appendChild(hideOn)
|
||||||
|
|
||||||
|
const toolbar = document.getElementById("toolbar")
|
||||||
|
toolbar?.appendChild(hideControls)
|
||||||
|
|
||||||
|
function hideElements(): void {
|
||||||
|
const entries = document.querySelectorAll(".entry")
|
||||||
|
|
||||||
|
entries.forEach((entry: Element) => {
|
||||||
|
const entryEl = entry as HTMLElement
|
||||||
|
if (!entryEl.classList.contains("assessment")) {
|
||||||
|
entryEl.style.opacity = entryEl.style.opacity === "0.3" ? "1" : "0.3"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hideOn.addEventListener("click", hideElements)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default timetablePlugin;
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import type { EventsAPI, Plugin, PluginAPI, PluginSettings, SEQTAAPI, SettingsAPI, SettingValue, StorageAPI } from './types';
|
||||||
|
import { eventManager } from '@/seqta/utils/listeners/EventManager';
|
||||||
|
import ReactFiber from '@/seqta/utils/ReactFiber';
|
||||||
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
|
function createSEQTAAPI(): SEQTAAPI {
|
||||||
|
return {
|
||||||
|
onMount: (selector, callback) => {
|
||||||
|
return eventManager.register(
|
||||||
|
`${selector}Added`,
|
||||||
|
{
|
||||||
|
customCheck: (element) => element.matches(selector),
|
||||||
|
},
|
||||||
|
callback
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getFiber: (selector) => {
|
||||||
|
return ReactFiber.find(selector);
|
||||||
|
},
|
||||||
|
getCurrentPage: () => {
|
||||||
|
const path = window.location.hash.split('?page=/')[1] || '';
|
||||||
|
return path.split('/')[0];
|
||||||
|
},
|
||||||
|
onPageChange: (callback) => {
|
||||||
|
const handler = () => {
|
||||||
|
const page = window.location.hash.split('?page=/')[1] || '';
|
||||||
|
callback(page.split('/')[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', handler);
|
||||||
|
|
||||||
|
// Return an unregister function
|
||||||
|
return {
|
||||||
|
unregister: () => {
|
||||||
|
window.removeEventListener('hashchange', handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSettingsAPI<T extends PluginSettings>(plugin: Plugin<T>): SettingsAPI<T> & { loaded: Promise<void> } {
|
||||||
|
const storageKey = `plugin.${plugin.id}.settings`;
|
||||||
|
const listeners = new Map<keyof T, Set<(value: any) => void>>();
|
||||||
|
|
||||||
|
// Initialize with default values
|
||||||
|
const settingsWithMeta: any = {
|
||||||
|
onChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => {
|
||||||
|
if (!listeners.has(key)) {
|
||||||
|
listeners.set(key, new Set());
|
||||||
|
}
|
||||||
|
listeners.get(key)!.add(callback);
|
||||||
|
return {
|
||||||
|
unregister: () => {
|
||||||
|
listeners.get(key)!.delete(callback);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
offChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => {
|
||||||
|
listeners.get(key)?.delete(callback);
|
||||||
|
},
|
||||||
|
loaded: Promise.resolve() // will be replaced below
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fill with defaults first
|
||||||
|
for (const key in plugin.settings) {
|
||||||
|
settingsWithMeta[key] = plugin.settings[key].default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load stored settings and override defaults
|
||||||
|
const loaded = (async () => {
|
||||||
|
try {
|
||||||
|
const stored = await browser.storage.local.get(storageKey);
|
||||||
|
const storedSettings = stored[storageKey] as Partial<Record<keyof T, any>>;
|
||||||
|
if (storedSettings) {
|
||||||
|
for (const key in storedSettings) {
|
||||||
|
if (key in settingsWithMeta) {
|
||||||
|
settingsWithMeta[key] = storedSettings[key];
|
||||||
|
listeners.get(key as keyof T)?.forEach(cb => cb(storedSettings[key]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[BetterSEQTA+] Error loading settings for plugin ${plugin.id}:`, error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
settingsWithMeta.loaded = loaded;
|
||||||
|
|
||||||
|
// Listen for storage changes and update settingsWithMeta
|
||||||
|
const handleStorageChange = (changes: { [key: string]: browser.Storage.StorageChange }, area: string) => {
|
||||||
|
if (area !== 'local' || !(storageKey in changes)) return;
|
||||||
|
|
||||||
|
const newValue = changes[storageKey].newValue as Partial<Record<keyof T, any>> | undefined;
|
||||||
|
if (!newValue) return;
|
||||||
|
|
||||||
|
for (const key in newValue) {
|
||||||
|
const typedKey = key as keyof T;
|
||||||
|
settingsWithMeta[typedKey] = newValue[typedKey];
|
||||||
|
listeners.get(typedKey)?.forEach(cb => cb(newValue[typedKey]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
browser.storage.onChanged.addListener(handleStorageChange);
|
||||||
|
|
||||||
|
const proxy = new Proxy(settingsWithMeta, {
|
||||||
|
get(target, prop) {
|
||||||
|
return target[prop];
|
||||||
|
},
|
||||||
|
set(target, prop, value) {
|
||||||
|
if (['onChange', 'offChange', 'loaded'].includes(prop as string)) return false;
|
||||||
|
|
||||||
|
target[prop] = value;
|
||||||
|
|
||||||
|
// Reconstruct just the data keys for storage (excluding metadata methods)
|
||||||
|
const dataToStore: any = {};
|
||||||
|
for (const key in plugin.settings) {
|
||||||
|
dataToStore[key] = target[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.storage.local.set({ [storageKey]: dataToStore });
|
||||||
|
|
||||||
|
listeners.get(prop as keyof T)?.forEach(cb => cb(value));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}) as SettingsAPI<T> & { loaded: Promise<void> };
|
||||||
|
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStorageAPI<T = any>(pluginId: string): StorageAPI<T> & { [K in keyof T]: T[K] } {
|
||||||
|
const prefix = `plugin.${pluginId}.storage.`;
|
||||||
|
const cache: Record<string, any> = {};
|
||||||
|
const listeners = new Map<string, Set<(value: any) => void>>();
|
||||||
|
const storageListeners = new Set<(changes: { [key: string]: any }, area: string) => void>();
|
||||||
|
|
||||||
|
// Load all existing storage values for this plugin
|
||||||
|
const loadStoragePromise = (async () => {
|
||||||
|
try {
|
||||||
|
const allStorage = await browser.storage.local.get(null);
|
||||||
|
|
||||||
|
// Filter for this plugin's storage keys and populate cache
|
||||||
|
Object.entries(allStorage).forEach(([key, value]) => {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
const shortKey = key.slice(prefix.length);
|
||||||
|
cache[shortKey] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[BetterSEQTA+] Error loading storage for plugin ${pluginId}:`, error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Listen for storage changes
|
||||||
|
const handleStorageChange = (changes: { [key: string]: any }, area: string) => {
|
||||||
|
if (area === 'local') {
|
||||||
|
Object.entries(changes).forEach(([key, change]) => {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
const shortKey = key.slice(prefix.length);
|
||||||
|
cache[shortKey] = change.newValue;
|
||||||
|
|
||||||
|
// Notify listeners
|
||||||
|
listeners.get(shortKey)?.forEach(callback => callback(change.newValue));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
browser.storage.onChanged.addListener(handleStorageChange);
|
||||||
|
storageListeners.add(handleStorageChange);
|
||||||
|
|
||||||
|
// Create the proxy for direct property access
|
||||||
|
return new Proxy(cache, {
|
||||||
|
get(target, prop: string) {
|
||||||
|
if (prop === 'onChange') {
|
||||||
|
return (key: keyof T, callback: (value: T[keyof T]) => void) => {
|
||||||
|
if (!listeners.has(key as string)) {
|
||||||
|
listeners.set(key as string, new Set());
|
||||||
|
}
|
||||||
|
listeners.get(key as string)!.add(callback);
|
||||||
|
return {
|
||||||
|
unregister: () => {
|
||||||
|
listeners.get(key as string)?.delete(callback);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (prop === 'offChange') {
|
||||||
|
return (key: keyof T, callback: (value: T[keyof T]) => void) => {
|
||||||
|
listeners.get(key as string)?.delete(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (prop === 'loaded') {
|
||||||
|
return loadStoragePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct property access
|
||||||
|
return target[prop];
|
||||||
|
},
|
||||||
|
set(target, prop: string, value: any) {
|
||||||
|
if (['onChange', 'offChange', 'loaded'].includes(prop)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache and store in browser storage
|
||||||
|
target[prop] = value;
|
||||||
|
browser.storage.local.set({ [prefix + prop]: value });
|
||||||
|
|
||||||
|
// Notify listeners
|
||||||
|
listeners.get(prop)?.forEach(callback => callback(value));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}) as StorageAPI<T> & { [K in keyof T]: T[K] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEventsAPI(pluginId: string): EventsAPI {
|
||||||
|
const prefix = `plugin.${pluginId}.`;
|
||||||
|
const eventListeners = new Map<string, Set<{ callback: (...args: any[]) => void, listener: EventListener }>>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
on: (event, callback) => {
|
||||||
|
const fullEventName = prefix + event;
|
||||||
|
const listener = ((e: CustomEvent) => {
|
||||||
|
callback(...(e.detail || []));
|
||||||
|
}) as EventListener;
|
||||||
|
|
||||||
|
document.addEventListener(fullEventName, listener);
|
||||||
|
|
||||||
|
if (!eventListeners.has(event)) {
|
||||||
|
eventListeners.set(event, new Set());
|
||||||
|
}
|
||||||
|
eventListeners.get(event)!.add({ callback, listener });
|
||||||
|
|
||||||
|
return {
|
||||||
|
unregister: () => {
|
||||||
|
document.removeEventListener(fullEventName, listener);
|
||||||
|
eventListeners.get(event)?.delete({ callback, listener });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
emit: (event, ...args) => {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent(prefix + event, {
|
||||||
|
detail: args.length > 0 ? args : null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPluginAPI<T extends PluginSettings, S = any>(plugin: Plugin<T, S>): PluginAPI<T, S> {
|
||||||
|
return {
|
||||||
|
seqta: createSEQTAAPI(),
|
||||||
|
settings: createSettingsAPI(plugin),
|
||||||
|
storage: createStorageAPI<S>(plugin.id),
|
||||||
|
events: createEventsAPI(plugin.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import type { BooleanSetting, NumberSetting, Plugin, PluginSettings, SelectSetting, StringSetting } from './types';
|
||||||
|
import { createPluginAPI } from './createAPI';
|
||||||
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
|
interface PluginSettingsStorage {
|
||||||
|
enabled?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StorageChange<T = any> {
|
||||||
|
oldValue?: T;
|
||||||
|
newValue?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginManager {
|
||||||
|
private static instance: PluginManager;
|
||||||
|
private plugins: Map<string, Plugin<any, any>> = new Map();
|
||||||
|
private runningPlugins: Map<string, boolean> = new Map();
|
||||||
|
private eventBacklog: Map<string, any[]> = new Map();
|
||||||
|
private cleanupFunctions: Map<string, () => void> = new Map();
|
||||||
|
private listeners: Map<string, Set<(...args: any[]) => void>> = new Map();
|
||||||
|
private styleElements: Map<string, HTMLStyleElement> = new Map();
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.setupPluginStateListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): PluginManager {
|
||||||
|
if (!PluginManager.instance) {
|
||||||
|
PluginManager.instance = new PluginManager();
|
||||||
|
}
|
||||||
|
return PluginManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispatchPluginEvent(pluginId: string, event: string, args?: any) {
|
||||||
|
const fullEventName = `plugin.${pluginId}.${event}`;
|
||||||
|
|
||||||
|
// Dispatch plugin event if it's running otherwise queue it
|
||||||
|
if (this.runningPlugins.get(pluginId)) {
|
||||||
|
document.dispatchEvent(new CustomEvent(fullEventName, { detail: args }));
|
||||||
|
} else {
|
||||||
|
const key = `${pluginId}:${event}`;
|
||||||
|
if (!this.eventBacklog.has(key)) {
|
||||||
|
this.eventBacklog.set(key, []);
|
||||||
|
}
|
||||||
|
this.eventBacklog.get(key)!.push(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processBackloggedEvents(pluginId: string) {
|
||||||
|
for (const [key, argsList] of this.eventBacklog.entries()) {
|
||||||
|
const [eventPluginId, event] = key.split(':');
|
||||||
|
if (eventPluginId === pluginId) {
|
||||||
|
for (const args of argsList) {
|
||||||
|
this.dispatchPluginEvent(pluginId, event, args);
|
||||||
|
}
|
||||||
|
this.eventBacklog.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerPlugin<T extends PluginSettings, S>(plugin: Plugin<T, S>): void {
|
||||||
|
if (this.plugins.has(plugin.id)) {
|
||||||
|
throw new Error(`Plugin with id "${plugin.id}" is already registered`);
|
||||||
|
}
|
||||||
|
this.plugins.set(plugin.id, plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async startPlugin(pluginId: string): Promise<void> {
|
||||||
|
const plugin = this.plugins.get(pluginId);
|
||||||
|
if (!plugin) {
|
||||||
|
throw new Error(`Plugin "${pluginId}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.runningPlugins.get(pluginId)) {
|
||||||
|
console.warn(`Plugin "${pluginId}" is already running`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = createPluginAPI(plugin);
|
||||||
|
|
||||||
|
// Check if plugin is enabled before starting
|
||||||
|
if (plugin.disableToggle) {
|
||||||
|
const settings = await browser.storage.local.get(`plugin.${pluginId}.settings`);
|
||||||
|
const pluginSettings = settings[`plugin.${pluginId}.settings`] as PluginSettingsStorage | undefined;
|
||||||
|
const enabled = pluginSettings?.enabled ?? true;
|
||||||
|
if (!enabled) {
|
||||||
|
console.info(`Plugin "${pluginId}" is disabled, skipping initialization`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject plugin styles if provided
|
||||||
|
if (plugin.styles) {
|
||||||
|
const styleElement = document.createElement('style');
|
||||||
|
styleElement.textContent = plugin.styles;
|
||||||
|
document.head.appendChild(styleElement);
|
||||||
|
this.styleElements.set(pluginId, styleElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for both settings and storage to be loaded before starting the plugin
|
||||||
|
await Promise.all([
|
||||||
|
(api.settings as any).loaded,
|
||||||
|
api.storage.loaded
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await plugin.run(api);
|
||||||
|
if (typeof result === 'function') {
|
||||||
|
this.cleanupFunctions.set(plugin.id, result);
|
||||||
|
}
|
||||||
|
this.runningPlugins.set(pluginId, true);
|
||||||
|
console.info(`Plugin "${pluginId}" started successfully`);
|
||||||
|
|
||||||
|
// Process any backlogged events
|
||||||
|
await this.processBackloggedEvents(pluginId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[BetterSEQTA+] Failed to start plugin ${pluginId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async startAllPlugins(): Promise<void> {
|
||||||
|
const startPromises = Array.from(this.plugins.keys()).map(id =>
|
||||||
|
this.startPlugin(id).catch(error => {
|
||||||
|
console.error(`Failed to start plugin "${id}":`, error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.allSettled(startPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stopPlugin(pluginId: string): Promise<void> {
|
||||||
|
// Remove plugin styles
|
||||||
|
const styleElement = this.styleElements.get(pluginId);
|
||||||
|
if (styleElement) {
|
||||||
|
styleElement.remove();
|
||||||
|
this.styleElements.delete(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = this.cleanupFunctions.get(pluginId);
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
this.cleanupFunctions.delete(pluginId);
|
||||||
|
}
|
||||||
|
this.runningPlugins.set(pluginId, false);
|
||||||
|
console.info(`Plugin "${pluginId}" stopped`);
|
||||||
|
this.emit('plugin.stopped', pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public stopAllPlugins(): void {
|
||||||
|
Array.from(this.plugins.keys()).forEach(id => this.stopPlugin(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPlugin(pluginId: string): Plugin | undefined {
|
||||||
|
return this.plugins.get(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAllPlugins(): Plugin[] {
|
||||||
|
return Array.from(this.plugins.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAllPluginSettings(): Array<{
|
||||||
|
pluginId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
settings: {
|
||||||
|
[key: string]: (Omit<BooleanSetting, 'type'> & { type: 'boolean', id: string }) |
|
||||||
|
(Omit<StringSetting, 'type'> & { type: 'string', id: string }) |
|
||||||
|
(Omit<NumberSetting, 'type'> & { type: 'number', id: string }) |
|
||||||
|
(Omit<SelectSetting<string>, 'type'> & { type: 'select', id: string, options: Array<{ value: string, label: string }> });
|
||||||
|
}
|
||||||
|
}> {
|
||||||
|
return Array.from(this.plugins.entries()).map(([id, plugin]) => {
|
||||||
|
const settingsEntries = Object.entries(plugin.settings).map(([key, setting]) => {
|
||||||
|
const settingObj = setting as any;
|
||||||
|
// Create a copy of the setting object without any functions
|
||||||
|
const result: any = Object.fromEntries(
|
||||||
|
Object.entries(settingObj)
|
||||||
|
.filter(([_, value]) => typeof value !== 'function')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure required properties are present
|
||||||
|
result.id = key;
|
||||||
|
result.title = result.title || key;
|
||||||
|
result.description = result.description || '';
|
||||||
|
|
||||||
|
return [key, result];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (plugin.disableToggle) {
|
||||||
|
settingsEntries.push([
|
||||||
|
'enabled', {
|
||||||
|
id: 'enabled',
|
||||||
|
title: plugin.name,
|
||||||
|
description: plugin.description,
|
||||||
|
type: 'boolean',
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pluginId: id,
|
||||||
|
name: plugin.name,
|
||||||
|
description: plugin.description,
|
||||||
|
settings: Object.fromEntries(settingsEntries),
|
||||||
|
disableToggle: plugin.disableToggle
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPluginRunning(pluginId: string): boolean {
|
||||||
|
return this.runningPlugins.get(pluginId) || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: string, ...args: any[]): void {
|
||||||
|
const listeners = this.listeners.get(event);
|
||||||
|
if (listeners) {
|
||||||
|
listeners.forEach(listener => listener(...args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public on(event: string, callback: (...args: any[]) => void): void {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
this.listeners.set(event, new Set());
|
||||||
|
}
|
||||||
|
this.listeners.get(event)!.add(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public off(event: string, callback: (...args: any[]) => void): void {
|
||||||
|
const listeners = this.listeners.get(event);
|
||||||
|
if (listeners) {
|
||||||
|
listeners.delete(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add handler for plugin enable/disable state changes
|
||||||
|
private async handlePluginStateChange(pluginId: string, enabled: boolean): Promise<void> {
|
||||||
|
if (enabled) {
|
||||||
|
await this.startPlugin(pluginId);
|
||||||
|
} else {
|
||||||
|
await this.stopPlugin(pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add listener for plugin settings changes
|
||||||
|
private setupPluginStateListener(): void {
|
||||||
|
browser.storage.onChanged.addListener((changes: { [key: string]: StorageChange }, area: string) => {
|
||||||
|
if (area !== 'local') return;
|
||||||
|
|
||||||
|
for (const [key, change] of Object.entries(changes)) {
|
||||||
|
const match = key.match(/^plugin\.(.+)\.settings$/);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const pluginId = match[1];
|
||||||
|
const plugin = this.plugins.get(pluginId);
|
||||||
|
if (!plugin?.disableToggle) continue;
|
||||||
|
|
||||||
|
const enabled = (change.newValue as PluginSettingsStorage)?.enabled ?? true;
|
||||||
|
const wasEnabled = (change.oldValue as PluginSettingsStorage)?.enabled ?? true;
|
||||||
|
|
||||||
|
if (enabled !== wasEnabled) {
|
||||||
|
this.handlePluginStateChange(pluginId, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { PluginSettings } from './types';
|
||||||
|
|
||||||
|
export function Setting(settingDef: any): PropertyDecorator {
|
||||||
|
return (target, propertyKey) => {
|
||||||
|
const proto = target.constructor.prototype;
|
||||||
|
if (!proto.hasOwnProperty('settings')) {
|
||||||
|
Object.defineProperty(proto, 'settings', {
|
||||||
|
value: {},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
proto.settings[propertyKey] = settingDef;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base plugin class that handles settings
|
||||||
|
export abstract class BasePlugin<T extends PluginSettings = PluginSettings> {
|
||||||
|
// The settings property will be populated by decorators
|
||||||
|
// Keep the instance property and constructor logic as is,
|
||||||
|
// as changing it would require changing animated-background/index.ts
|
||||||
|
settings!: T; // Use definite assignment assertion
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Copy settings from the prototype to the instance
|
||||||
|
// This ensures that each instance has its own settings object
|
||||||
|
// IMPORTANT: Ensure the prototype actually HAS settings before copying
|
||||||
|
if (this.constructor.prototype.hasOwnProperty('settings')) {
|
||||||
|
// Deep clone might be safer if settings objects become complex,
|
||||||
|
// but a shallow clone is usually fine for this structure.
|
||||||
|
this.settings = { ...this.constructor.prototype.settings } as T;
|
||||||
|
} else {
|
||||||
|
// Fallback if decorators somehow didn't run or add the property
|
||||||
|
this.settings = {} as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { BooleanSetting, NumberSetting, SelectSetting, StringSetting } from './types';
|
||||||
|
|
||||||
|
export function numberSetting(options: Omit<NumberSetting, 'type'>): NumberSetting {
|
||||||
|
return {
|
||||||
|
type: 'number',
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function booleanSetting(options: Omit<BooleanSetting, 'type'>): BooleanSetting {
|
||||||
|
return {
|
||||||
|
type: 'boolean',
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringSetting(options: Omit<StringSetting, 'type'>): StringSetting {
|
||||||
|
return {
|
||||||
|
type: 'string',
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectSetting<T extends string>(options: Omit<SelectSetting<T>, 'type'>): SelectSetting<T> {
|
||||||
|
return {
|
||||||
|
type: 'select',
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defineSettings<T extends Record<string, any>>(settings: T): T {
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Setting(settingDef: any): PropertyDecorator {
|
||||||
|
return (target, propertyKey) => {
|
||||||
|
const proto = target.constructor.prototype;
|
||||||
|
if (!proto.hasOwnProperty('settings')) {
|
||||||
|
Object.defineProperty(proto, 'settings', {
|
||||||
|
value: {},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
proto.settings[propertyKey] = settingDef;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import ReactFiber from '@/seqta/utils/ReactFiber';
|
||||||
|
|
||||||
|
export interface BooleanSetting {
|
||||||
|
type: 'boolean';
|
||||||
|
default: boolean;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StringSetting {
|
||||||
|
type: 'string';
|
||||||
|
default: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
maxLength?: number;
|
||||||
|
pattern?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberSetting {
|
||||||
|
type: 'number';
|
||||||
|
default: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectSetting<T extends string> {
|
||||||
|
type: 'select';
|
||||||
|
options: readonly T[];
|
||||||
|
default: T;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginSetting = BooleanSetting | StringSetting | NumberSetting | SelectSetting<string>;
|
||||||
|
|
||||||
|
export type PluginSettings = {
|
||||||
|
[key: string]: PluginSetting;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper type to extract the actual value type from a setting
|
||||||
|
export type SettingValue<T extends PluginSetting> = T extends BooleanSetting ? boolean :
|
||||||
|
T extends StringSetting ? string :
|
||||||
|
T extends NumberSetting ? number :
|
||||||
|
T extends SelectSetting<infer O> ? O :
|
||||||
|
never;
|
||||||
|
|
||||||
|
export type SettingsAPI<T extends PluginSettings> = {
|
||||||
|
[K in keyof T]: SettingValue<T[K]>;
|
||||||
|
} & {
|
||||||
|
onChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => { unregister: () => void };
|
||||||
|
offChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => void;
|
||||||
|
loaded: Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SEQTAAPI {
|
||||||
|
onMount: (selector: string, callback: (element: Element) => void) => { unregister: () => void };
|
||||||
|
getFiber: (selector: string) => ReactFiber;
|
||||||
|
getCurrentPage: () => string;
|
||||||
|
onPageChange: (callback: (page: string) => void) => { unregister: () => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageAPI<T = any> {
|
||||||
|
/**
|
||||||
|
* Register a callback to be called when a storage value changes
|
||||||
|
*/
|
||||||
|
onChange: <K extends keyof T>(key: K, callback: (value: T[K]) => void) => { unregister: () => void };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promise that resolves when storage values are loaded
|
||||||
|
*/
|
||||||
|
loaded: Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TypedStorageAPI<T> = StorageAPI<T> & {
|
||||||
|
[K in keyof T]: T[K];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventsAPI {
|
||||||
|
on: (event: string, callback: (...args: any[]) => void) => { unregister: () => void };
|
||||||
|
emit: (event: string, ...args: any[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginAPI<T extends PluginSettings, S = any> {
|
||||||
|
seqta: SEQTAAPI;
|
||||||
|
settings: SettingsAPI<T>;
|
||||||
|
storage: TypedStorageAPI<S>;
|
||||||
|
events: EventsAPI;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Plugin<T extends PluginSettings = PluginSettings, S = any> {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
settings: T;
|
||||||
|
styles?: string; // Optional CSS styles for the plugin
|
||||||
|
disableToggle?: boolean; // Optional flag to show/hide the plugin's enable/disable toggle in settings
|
||||||
|
run: (api: PluginAPI<T, S>) => void | Promise<void> | (() => void) | Promise<(() => void)>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { PluginManager } from './core/manager';
|
||||||
|
|
||||||
|
// plugins
|
||||||
|
import timetablePlugin from './built-in/timetable';
|
||||||
|
import notificationCollectorPlugin from './built-in/notificationCollector';
|
||||||
|
import themesPlugin from './built-in/themes';
|
||||||
|
import animatedBackgroundPlugin from './built-in/animatedBackground';
|
||||||
|
import assessmentsAveragePlugin from './built-in/assessmentsAverage';
|
||||||
|
import globalSearchPlugin from './built-in/globalSearch/src/core';
|
||||||
|
import testPlugin from './built-in/test';
|
||||||
|
|
||||||
|
// Initialize plugin manager
|
||||||
|
const pluginManager = PluginManager.getInstance();
|
||||||
|
|
||||||
|
// Register built-in plugins
|
||||||
|
pluginManager.registerPlugin(themesPlugin);
|
||||||
|
pluginManager.registerPlugin(animatedBackgroundPlugin);
|
||||||
|
pluginManager.registerPlugin(assessmentsAveragePlugin);
|
||||||
|
pluginManager.registerPlugin(notificationCollectorPlugin);
|
||||||
|
pluginManager.registerPlugin(timetablePlugin);
|
||||||
|
pluginManager.registerPlugin(globalSearchPlugin);
|
||||||
|
pluginManager.registerPlugin(testPlugin);
|
||||||
|
|
||||||
|
export { init as Monofile } from './monofile';
|
||||||
|
|
||||||
|
export async function initializePlugins(): Promise<void> {
|
||||||
|
await pluginManager.startAllPlugins();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { pluginManager };
|
||||||
|
|
||||||
|
export function getAllPluginSettings() {
|
||||||
|
return pluginManager.getAllPluginSettings();
|
||||||
|
}
|
||||||
@@ -0,0 +1,676 @@
|
|||||||
|
// Third-party libraries
|
||||||
|
import browser from "webextension-polyfill"
|
||||||
|
import { animate, stagger } from "motion"
|
||||||
|
|
||||||
|
// Internal utilities and functions
|
||||||
|
import { ChangeMenuItemPositions, MenuOptionsOpen } from "@/seqta/utils/Openers/OpenMenuOptions"
|
||||||
|
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour"
|
||||||
|
import { waitForElm } from "@/seqta/utils/waitForElm"
|
||||||
|
import { delay } from "@/seqta/utils/delay"
|
||||||
|
import stringToHTML from "@/seqta/utils/stringToHTML"
|
||||||
|
import { MessageHandler } from "@/seqta/utils/listeners/MessageListener"
|
||||||
|
import {
|
||||||
|
settingsState,
|
||||||
|
} from "@/seqta/utils/listeners/SettingsState"
|
||||||
|
import { StorageChangeHandler } from "@/seqta/utils/listeners/StorageChanges"
|
||||||
|
import { eventManager } from "@/seqta/utils/listeners/EventManager"
|
||||||
|
|
||||||
|
// UI and theme management
|
||||||
|
import RegisterClickListeners from "@/seqta/utils/listeners/ClickListeners"
|
||||||
|
import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements"
|
||||||
|
import { updateAllColors } from "@/seqta/ui/colors/Manager"
|
||||||
|
import loading from "@/seqta/ui/Loading"
|
||||||
|
import { SendNewsPage } from "@/seqta/utils/SendNewsPage"
|
||||||
|
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"
|
||||||
|
import { OpenWhatsNewPopup } from "@/seqta/utils/Whatsnew"
|
||||||
|
|
||||||
|
// JSON content
|
||||||
|
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json"
|
||||||
|
|
||||||
|
// Icons and fonts
|
||||||
|
import IconFamily from "@/resources/fonts/IconFamily.woff"
|
||||||
|
|
||||||
|
// Stylesheets
|
||||||
|
import iframeCSS from "@/css/iframe.scss?raw"
|
||||||
|
|
||||||
|
function SetDisplayNone(ElementName: string) {
|
||||||
|
return `li[data-key=${ElementName}]{display:var(--menuHidden) !important; transition: 1s;}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function HideMenuItems(): Promise<void> {
|
||||||
|
try {
|
||||||
|
let stylesheetInnerText: string = ""
|
||||||
|
for (const [menuItem, { toggle }] of Object.entries(
|
||||||
|
settingsState.menuitems,
|
||||||
|
)) {
|
||||||
|
if (!toggle) {
|
||||||
|
stylesheetInnerText += SetDisplayNone(menuItem)
|
||||||
|
console.info(`[BetterSEQTA+] Hiding ${menuItem} menu item`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItemStyle: HTMLStyleElement = document.createElement("style")
|
||||||
|
menuItemStyle.innerText = stylesheetInnerText
|
||||||
|
document.head.appendChild(menuItemStyle)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[BetterSEQTA+] An error occurred:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideSideBar() {
|
||||||
|
const sidebar = document.getElementById("menu") // The sidebar element to be closed
|
||||||
|
const main = document.getElementById("main") // The main content element that must be resized to fill the page
|
||||||
|
|
||||||
|
const currentMenuWidth = window.getComputedStyle(sidebar!).width // Get the styles of the different elements
|
||||||
|
const currentContentPosition = window.getComputedStyle(main!).position
|
||||||
|
|
||||||
|
if (currentMenuWidth != "0") {
|
||||||
|
// Actually modify it to collapse the sidebar
|
||||||
|
sidebar!.style.width = "0"
|
||||||
|
} else {
|
||||||
|
sidebar!.style.width = "100%"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentContentPosition != "relative") {
|
||||||
|
main!.style.position = "relative"
|
||||||
|
} else {
|
||||||
|
main!.style.position = "absolute"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export async function finishLoad() {
|
||||||
|
try {
|
||||||
|
document.querySelector(".legacy-root")?.classList.remove("hidden")
|
||||||
|
|
||||||
|
const loadingbk = document.getElementById("loading")
|
||||||
|
loadingbk?.classList.add("closeLoading")
|
||||||
|
await delay(501)
|
||||||
|
loadingbk?.remove()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error during loading cleanup:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsState.justupdated && !document.getElementById("whatsnewbk")) {
|
||||||
|
OpenWhatsNewPopup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetCSSElement(file: string) {
|
||||||
|
const cssFile = browser.runtime.getURL(file)
|
||||||
|
const fileref = document.createElement("link")
|
||||||
|
fileref.setAttribute("rel", "stylesheet")
|
||||||
|
fileref.setAttribute("type", "text/css")
|
||||||
|
fileref.setAttribute("href", cssFile)
|
||||||
|
|
||||||
|
return fileref
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeThemeTagsFromNotices() {
|
||||||
|
// Grabs an array of the notice iFrames
|
||||||
|
const userHTMLArray = document.getElementsByClassName("userHTML")
|
||||||
|
// Iterates through the array, applying the iFrame css
|
||||||
|
for (const item of userHTMLArray) {
|
||||||
|
// Grabs the HTML of the body tag
|
||||||
|
const item1 = item as HTMLIFrameElement
|
||||||
|
const body = item1.contentWindow!.document.querySelectorAll("body")[0]
|
||||||
|
if (body) {
|
||||||
|
// Replaces the theme tag with nothing
|
||||||
|
const bodyText = body.innerHTML
|
||||||
|
body.innerHTML = bodyText
|
||||||
|
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||||
|
.replace(/ +/, " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateIframesWithDarkMode(): Promise<void> {
|
||||||
|
const cssLink = document.createElement("style")
|
||||||
|
cssLink.classList.add("iframecss")
|
||||||
|
const cssContent = document.createTextNode(iframeCSS)
|
||||||
|
cssLink.appendChild(cssContent)
|
||||||
|
|
||||||
|
eventManager.register(
|
||||||
|
"iframeAdded",
|
||||||
|
{
|
||||||
|
elementType: "iframe",
|
||||||
|
customCheck: (element: Element) =>
|
||||||
|
!element.classList.contains("iframecss"),
|
||||||
|
},
|
||||||
|
(element) => {
|
||||||
|
const iframe = element as HTMLIFrameElement
|
||||||
|
try {
|
||||||
|
applyDarkModeToIframe(iframe, cssLink)
|
||||||
|
|
||||||
|
if (element.classList.contains("cke_wysiwyg_frame")) {
|
||||||
|
(async () => {
|
||||||
|
await delay(100)
|
||||||
|
iframe.contentDocument?.body.setAttribute("spellcheck", "true")
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error applying dark mode:", error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDarkModeToIframe(
|
||||||
|
iframe: HTMLIFrameElement,
|
||||||
|
cssLink: HTMLStyleElement,
|
||||||
|
): void {
|
||||||
|
const iframeDocument = iframe.contentDocument
|
||||||
|
if (!iframeDocument) return
|
||||||
|
|
||||||
|
iframe.onload = () => {
|
||||||
|
applyDarkModeToIframe(iframe, cssLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsState.DarkMode) {
|
||||||
|
iframeDocument.documentElement.classList.add("dark")
|
||||||
|
}
|
||||||
|
|
||||||
|
const head = iframeDocument.head
|
||||||
|
if (head && !head.innerHTML.includes("iframecss")) {
|
||||||
|
head.innerHTML += cssLink.outerHTML
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortMessagePageItems(messagesParentElement: any) {
|
||||||
|
try {
|
||||||
|
let filterbutton = document.createElement("div")
|
||||||
|
filterbutton.classList.add("messages-filterbutton")
|
||||||
|
filterbutton.innerText = "Filter"
|
||||||
|
|
||||||
|
let header = document.querySelector(
|
||||||
|
"[class*='MessageList__MessageList___']",
|
||||||
|
) as HTMLElement
|
||||||
|
header.append(filterbutton)
|
||||||
|
messagesParentElement
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error sorting message page items:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function LoadPageElements(): Promise<void> {
|
||||||
|
await AddBetterSEQTAElements()
|
||||||
|
const sublink: string | undefined = window.location.href.split("/")[4]
|
||||||
|
|
||||||
|
eventManager.register(
|
||||||
|
"messagesAdded",
|
||||||
|
{
|
||||||
|
elementType: "div",
|
||||||
|
className: "messages",
|
||||||
|
},
|
||||||
|
handleMessages,
|
||||||
|
)
|
||||||
|
|
||||||
|
eventManager.register(
|
||||||
|
"noticesAdded",
|
||||||
|
{
|
||||||
|
elementType: "div",
|
||||||
|
className: "notices",
|
||||||
|
},
|
||||||
|
CheckNoticeTextColour,
|
||||||
|
)
|
||||||
|
|
||||||
|
eventManager.register(
|
||||||
|
"dashboardAdded",
|
||||||
|
{
|
||||||
|
elementType: "div",
|
||||||
|
className: "dashboard",
|
||||||
|
},
|
||||||
|
handleDashboard,
|
||||||
|
)
|
||||||
|
|
||||||
|
eventManager.register(
|
||||||
|
"documentsAdded",
|
||||||
|
{
|
||||||
|
elementType: "div",
|
||||||
|
className: "documents",
|
||||||
|
},
|
||||||
|
handleDocuments,
|
||||||
|
)
|
||||||
|
|
||||||
|
eventManager.register(
|
||||||
|
"reportsAdded",
|
||||||
|
{
|
||||||
|
elementType: "div",
|
||||||
|
className: "reports",
|
||||||
|
},
|
||||||
|
handleReports,
|
||||||
|
)
|
||||||
|
|
||||||
|
/* eventManager.register(
|
||||||
|
"timetableAdded",
|
||||||
|
{
|
||||||
|
elementType: "div",
|
||||||
|
className: "timetablepage",
|
||||||
|
},
|
||||||
|
handleTimetable,
|
||||||
|
) */
|
||||||
|
|
||||||
|
eventManager.register(
|
||||||
|
"noticesAdded",
|
||||||
|
{
|
||||||
|
elementType: "div",
|
||||||
|
className: "notice",
|
||||||
|
},
|
||||||
|
handleNotices,
|
||||||
|
)
|
||||||
|
|
||||||
|
RegisterClickListeners()
|
||||||
|
|
||||||
|
await handleSublink(sublink)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNotices(node: Element): Promise<void> {
|
||||||
|
if (!(node instanceof HTMLElement)) return
|
||||||
|
if (!settingsState.animations) return
|
||||||
|
|
||||||
|
node.style.opacity = "0"
|
||||||
|
|
||||||
|
// get index of node in relation to parent
|
||||||
|
const index = Array.from(node.parentElement!.children).indexOf(node)
|
||||||
|
|
||||||
|
animate(
|
||||||
|
node,
|
||||||
|
{ opacity: [0, 1], y: [50, 0], scale: [0.99, 1] },
|
||||||
|
{
|
||||||
|
delay: 0.1 * index,
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 250,
|
||||||
|
damping: 20,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSublink(sublink: string | undefined): Promise<void> {
|
||||||
|
switch (sublink) {
|
||||||
|
case "news":
|
||||||
|
await handleNewsPage()
|
||||||
|
break
|
||||||
|
case undefined:
|
||||||
|
window.location.replace(`${location.origin}/#?page=/${settingsState.defaultPage}`)
|
||||||
|
if (settingsState.defaultPage === "home") loadHomePage()
|
||||||
|
if (settingsState.defaultPage === "documents")
|
||||||
|
handleDocuments(document.querySelector(".documents")!)
|
||||||
|
if (settingsState.defaultPage === "reports")
|
||||||
|
handleReports(document.querySelector(".reports")!)
|
||||||
|
if (settingsState.defaultPage === "messages")
|
||||||
|
handleMessages(document.querySelector(".messages")!)
|
||||||
|
|
||||||
|
finishLoad()
|
||||||
|
break
|
||||||
|
case "home":
|
||||||
|
window.location.replace(`${location.origin}/#?page=/home`)
|
||||||
|
console.info("[BetterSEQTA+] Started Init")
|
||||||
|
if (settingsState.onoff) loadHomePage()
|
||||||
|
finishLoad()
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
await handleDefault()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNewsPage(): Promise<void> {
|
||||||
|
console.info("[BetterSEQTA+] Started Init")
|
||||||
|
if (settingsState.onoff) {
|
||||||
|
SendNewsPage()
|
||||||
|
finishLoad()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDefault(): Promise<void> {
|
||||||
|
finishLoad()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMessages(node: Element): Promise<void> {
|
||||||
|
if (!(node instanceof HTMLElement)) return
|
||||||
|
|
||||||
|
const element = document.getElementById("title")!.firstChild as HTMLElement
|
||||||
|
element.innerText = "Direct Messages"
|
||||||
|
document.title = "Direct Messages ― SEQTA Learn"
|
||||||
|
SortMessagePageItems(node)
|
||||||
|
|
||||||
|
if (!settingsState.animations) return
|
||||||
|
|
||||||
|
// Hides messages on page load
|
||||||
|
const style = document.createElement("style")
|
||||||
|
style.classList.add("messageHider")
|
||||||
|
style.innerHTML = "[data-message]{opacity: 0 !important;}"
|
||||||
|
document.head.append(style)
|
||||||
|
|
||||||
|
await waitForElm("[data-message]", true, 10)
|
||||||
|
const messages = Array.from(
|
||||||
|
document.querySelectorAll("[data-message]"),
|
||||||
|
).slice(0, 35)
|
||||||
|
animate(
|
||||||
|
messages,
|
||||||
|
{ opacity: [0, 1], y: [10, 0] },
|
||||||
|
{
|
||||||
|
delay: stagger(0.03),
|
||||||
|
duration: 0.5,
|
||||||
|
ease: [0.22, 0.03, 0.26, 1],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
document.head.querySelector("style.messageHider")?.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDashboard(node: Element): Promise<void> {
|
||||||
|
if (!(node instanceof HTMLElement)) return
|
||||||
|
if (!settingsState.animations) return
|
||||||
|
|
||||||
|
const style = document.createElement("style")
|
||||||
|
style.classList.add("dashboardHider")
|
||||||
|
style.innerHTML = ".dashboard{opacity: 0 !important;}"
|
||||||
|
document.head.append(style)
|
||||||
|
|
||||||
|
await waitForElm(".dashlet", true, 10)
|
||||||
|
animate(
|
||||||
|
".dashboard > *",
|
||||||
|
{ opacity: [0, 1], y: [10, 0] },
|
||||||
|
{
|
||||||
|
delay: stagger(0.1),
|
||||||
|
duration: 0.5,
|
||||||
|
ease: [0.22, 0.03, 0.26, 1],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
document.head.querySelector("style.dashboardHider")?.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDocuments(node: Element): Promise<void> {
|
||||||
|
if (!(node instanceof HTMLElement)) return
|
||||||
|
if (!settingsState.animations) return
|
||||||
|
|
||||||
|
await waitForElm(".document", true, 10)
|
||||||
|
animate(
|
||||||
|
".documents tbody tr.document",
|
||||||
|
{ opacity: [0, 1], y: [10, 0] },
|
||||||
|
{
|
||||||
|
delay: stagger(0.05),
|
||||||
|
duration: 0.5,
|
||||||
|
ease: [0.22, 0.03, 0.26, 1],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReports(node: Element): Promise<void> {
|
||||||
|
if (!(node instanceof HTMLElement)) return
|
||||||
|
if (!settingsState.animations) return
|
||||||
|
|
||||||
|
await waitForElm(".report", true, 10)
|
||||||
|
animate(
|
||||||
|
".reports .item",
|
||||||
|
{ opacity: [0, 1], y: [10, 0] },
|
||||||
|
{
|
||||||
|
delay: stagger(0.05, { startDelay: 0.2 }),
|
||||||
|
duration: 0.5,
|
||||||
|
ease: [0.22, 0.03, 0.26, 1],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckNoticeTextColour(notice: any) {
|
||||||
|
eventManager.register(
|
||||||
|
"noticeAdded",
|
||||||
|
{
|
||||||
|
elementType: "div",
|
||||||
|
className: "notice",
|
||||||
|
parentElement: notice,
|
||||||
|
},
|
||||||
|
(node) => {
|
||||||
|
var hex = (node as HTMLElement).style.cssText.split(" ")[1]
|
||||||
|
if (hex) {
|
||||||
|
const hex1 = hex.slice(0, -1)
|
||||||
|
var threshold = GetThresholdOfColor(hex1)
|
||||||
|
if (settingsState.DarkMode && threshold < 100) {
|
||||||
|
(node as HTMLElement).style.cssText = "--color: undefined;"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryLoad() {
|
||||||
|
waitForElm(".login").then(() => {
|
||||||
|
finishLoad()
|
||||||
|
})
|
||||||
|
|
||||||
|
waitForElm(".day-container").then(() => {
|
||||||
|
finishLoad()
|
||||||
|
})
|
||||||
|
|
||||||
|
waitForElm("[data-key=welcome]").then((elm: any) => {
|
||||||
|
elm.classList.remove("active")
|
||||||
|
})
|
||||||
|
|
||||||
|
waitForElm(".code", true, 50).then((elm: any) => {
|
||||||
|
if (!elm.innerText.includes("BetterSEQTA")) LoadPageElements()
|
||||||
|
})
|
||||||
|
|
||||||
|
updateIframesWithDarkMode()
|
||||||
|
// Waits for page to call on load, run scripts
|
||||||
|
document.addEventListener(
|
||||||
|
"load",
|
||||||
|
function () {
|
||||||
|
removeThemeTagsFromNotices()
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReplaceMenuSVG(element: HTMLElement, svg: string) {
|
||||||
|
let item = element.firstChild as HTMLElement
|
||||||
|
item!.firstChild!.remove()
|
||||||
|
|
||||||
|
item.innerHTML = `<span>${item.innerHTML}</span>`
|
||||||
|
|
||||||
|
let newsvg = stringToHTML(svg).firstChild
|
||||||
|
item.insertBefore(newsvg as Node, item.firstChild)
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedSymbol = Symbol('processed')
|
||||||
|
|
||||||
|
export async function ObserveMenuItemPosition() {
|
||||||
|
await waitForElm("#menu > ul > li")
|
||||||
|
|
||||||
|
eventManager.register(
|
||||||
|
"menuList",
|
||||||
|
{
|
||||||
|
parentElement: document.querySelector("#menu")!.firstChild as Element,
|
||||||
|
},
|
||||||
|
(element: Element) => {
|
||||||
|
const node = element as HTMLElement
|
||||||
|
|
||||||
|
// Only process top-level menu items and skip everything else
|
||||||
|
if (!node.classList.contains('item') ||
|
||||||
|
node.nodeName !== 'LI' ||
|
||||||
|
node.parentElement?.parentElement?.id !== 'menu') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Early exit if already processed
|
||||||
|
if ((element as any)[processedSymbol]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node?.dataset?.checked && !MenuOptionsOpen) {
|
||||||
|
const key =
|
||||||
|
MenuitemSVGKey[node?.dataset?.key! as keyof typeof MenuitemSVGKey]
|
||||||
|
if (key) {
|
||||||
|
ReplaceMenuSVG(
|
||||||
|
node,
|
||||||
|
MenuitemSVGKey[node.dataset.key as keyof typeof MenuitemSVGKey],
|
||||||
|
)
|
||||||
|
} else if (node?.firstChild?.nodeName === "LABEL") {
|
||||||
|
const label = node.firstChild as HTMLElement
|
||||||
|
let textNode = label.lastChild as HTMLElement
|
||||||
|
|
||||||
|
if (
|
||||||
|
textNode.nodeType === 3 &&
|
||||||
|
textNode.parentNode &&
|
||||||
|
textNode.parentNode.nodeName !== "SPAN"
|
||||||
|
) {
|
||||||
|
const span = document.createElement("span")
|
||||||
|
span.textContent = textNode.nodeValue
|
||||||
|
|
||||||
|
label.replaceChild(span, textNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChangeMenuItemPositions(settingsState.menuorder);
|
||||||
|
|
||||||
|
(element as any)[processedSymbol] = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showConflictPopup() {
|
||||||
|
if (document.getElementById("conflict-popup")) return
|
||||||
|
document.body.classList.remove("hidden")
|
||||||
|
|
||||||
|
const background = document.createElement("div")
|
||||||
|
background.id = "conflict-popup"
|
||||||
|
background.classList.add("whatsnewBackground")
|
||||||
|
background.style.zIndex = "10000000"
|
||||||
|
|
||||||
|
const container = document.createElement("div")
|
||||||
|
container.classList.add("whatsnewContainer")
|
||||||
|
container.style.height = "auto"
|
||||||
|
|
||||||
|
const headerHTML = /* html */ `
|
||||||
|
<div class="whatsnewHeader">
|
||||||
|
<h1>Extension Conflict Detected</h1>
|
||||||
|
<p>Legacy BetterSEQTA Installed</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
const header = stringToHTML(headerHTML).firstChild
|
||||||
|
|
||||||
|
const textHTML = /* html */ `
|
||||||
|
<div class="whatsnewTextContainer" style="overflow-y: auto; font-size: 1.3rem;">
|
||||||
|
<p>
|
||||||
|
It appears that you have the legacy BetterSEQTA extension installed alongside BetterSEQTA+.
|
||||||
|
This conflict may cause unexpected behavior. (and breaks the extension)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Please remove the older BetterSEQTA extension to ensure that BetterSEQTA+ works correctly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
const text = stringToHTML(textHTML).firstChild
|
||||||
|
|
||||||
|
const exitButton = document.createElement("div")
|
||||||
|
exitButton.id = "whatsnewclosebutton"
|
||||||
|
|
||||||
|
if (header) container.append(header)
|
||||||
|
if (text) container.append(text)
|
||||||
|
container.append(exitButton)
|
||||||
|
|
||||||
|
background.append(container)
|
||||||
|
|
||||||
|
document.getElementById("container")?.append(background)
|
||||||
|
|
||||||
|
if (settingsState.animations) {
|
||||||
|
animate([background as HTMLElement], { opacity: [0, 1] })
|
||||||
|
}
|
||||||
|
|
||||||
|
background.addEventListener("click", (event) => {
|
||||||
|
if (event.target === background) {
|
||||||
|
background.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
exitButton.addEventListener("click", () => {
|
||||||
|
background.remove()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
const handleDisabled = () => {
|
||||||
|
waitForElm(".code", true, 50).then(AppendElementsToDisabledPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsState.onoff) {
|
||||||
|
console.info("[BetterSEQTA+] Enabled")
|
||||||
|
if (settingsState.DarkMode) document.documentElement.classList.add("dark");
|
||||||
|
|
||||||
|
document.querySelector(".legacy-root")?.classList.add("hidden")
|
||||||
|
ObserveMenuItemPosition();
|
||||||
|
|
||||||
|
new StorageChangeHandler()
|
||||||
|
new MessageHandler()
|
||||||
|
|
||||||
|
updateAllColors()
|
||||||
|
loading()
|
||||||
|
InjectCustomIcons()
|
||||||
|
HideMenuItems()
|
||||||
|
tryLoad()
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const legacyElement = document.querySelector(
|
||||||
|
".outside-container .bottom-container",
|
||||||
|
)
|
||||||
|
if (legacyElement) {
|
||||||
|
console.log("Legacy extension detected")
|
||||||
|
showConflictPopup()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
handleDisabled()
|
||||||
|
window.addEventListener("load", handleDisabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function InjectCustomIcons() {
|
||||||
|
console.info("[BetterSEQTA+] Injecting Icons")
|
||||||
|
|
||||||
|
const style = document.createElement("style")
|
||||||
|
style.setAttribute("type", "text/css")
|
||||||
|
style.innerHTML = `
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IconFamily';
|
||||||
|
src: url('${browser.runtime.getURL(IconFamily)}') format('woff');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}`
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppendElementsToDisabledPage() {
|
||||||
|
console.info("[BetterSEQTA+] Appending elements to disabled page")
|
||||||
|
AddBetterSEQTAElements()
|
||||||
|
|
||||||
|
let settingsStyle = document.createElement("style")
|
||||||
|
settingsStyle.innerHTML = /* css */ `
|
||||||
|
.addedButton {
|
||||||
|
position: absolute !important;
|
||||||
|
right: 50px;
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
padding: 6px !important;
|
||||||
|
overflow: unset !important;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 7px !important;
|
||||||
|
cursor: pointer;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
.addedButton svg {
|
||||||
|
margin: 6px;
|
||||||
|
}
|
||||||
|
.outside-container {
|
||||||
|
top: 48px !important;
|
||||||
|
}
|
||||||
|
#ExtensionPopup {
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0px 0px 20px -2px rgba(0, 0, 0, 0.6);
|
||||||
|
transform-origin: 70% 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.append(settingsStyle)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export default {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Third-party libraries
|
||||||
|
import browser from "webextension-polyfill"
|
||||||
|
|
||||||
|
// Internal utilities and functions
|
||||||
|
import {
|
||||||
|
settingsState,
|
||||||
|
} from "@/seqta/utils/listeners/SettingsState"
|
||||||
|
|
||||||
|
// UI and theme management
|
||||||
|
import pageState from "@/pageState.js?url"
|
||||||
|
|
||||||
|
// Stylesheets
|
||||||
|
import injectedCSS from "@/css/injected.scss?inline"
|
||||||
|
|
||||||
|
export async function main() {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
if (settingsState.onoff) {
|
||||||
|
injectPageState()
|
||||||
|
|
||||||
|
// TEMP FIX for bug! -> this is a hack to get the injected.css file to have HMR in development mode as this import system is currently broken with crxjs
|
||||||
|
if (import.meta.env.MODE === "development") {
|
||||||
|
import("../css/injected.scss")
|
||||||
|
} else {
|
||||||
|
const injectedStyle = document.createElement("style")
|
||||||
|
injectedStyle.textContent = injectedCSS
|
||||||
|
document.head.appendChild(injectedStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(true)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error)
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectPageState() {
|
||||||
|
const mainScript = document.createElement("script")
|
||||||
|
mainScript.src = browser.runtime.getURL(pageState)
|
||||||
|
document.head.appendChild(mainScript)
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { addExtensionSettings, enableAnimatedBackground, GetThresholdOfColor, loadHomePage, SendNewsPage, setupSettingsButton } from "@/SEQTA";
|
import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings";
|
||||||
import { updateBgDurations } from "./Animation";
|
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||||
|
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
|
||||||
|
import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton";
|
||||||
|
|
||||||
|
|
||||||
|
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||||
import { appendBackgroundToUI } from "./ImageBackgrounds";
|
import { appendBackgroundToUI } from "./ImageBackgrounds";
|
||||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
@@ -35,7 +40,6 @@ async function getUserInfo() {
|
|||||||
|
|
||||||
export async function AddBetterSEQTAElements() {
|
export async function AddBetterSEQTAElements() {
|
||||||
if (settingsState.onoff) {
|
if (settingsState.onoff) {
|
||||||
initializeSettings();
|
|
||||||
if (settingsState.DarkMode) {
|
if (settingsState.DarkMode) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
}
|
}
|
||||||
@@ -69,11 +73,6 @@ export async function AddBetterSEQTAElements() {
|
|||||||
setupSettingsButton();
|
setupSettingsButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeSettings() {
|
|
||||||
enableAnimatedBackground();
|
|
||||||
updateBgDurations();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHomeButton(fragment: DocumentFragment, menuList: HTMLElement) {
|
function createHomeButton(fragment: DocumentFragment, menuList: HTMLElement) {
|
||||||
const container = document.getElementById('content')!;
|
const container = document.getElementById('content')!;
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the background animation durations based on the slider input.
|
|
||||||
* @param {Object} item - The object containing the bksliderinput property.
|
|
||||||
* @param {number} [minDuration=1] - The minimum animation duration in seconds.
|
|
||||||
* @param {number} [maxDuration=10] - The maximum animation duration in seconds.
|
|
||||||
*/
|
|
||||||
export function updateBgDurations() {
|
|
||||||
// Class names to look for
|
|
||||||
const bgClasses = ['bg', 'bg2', 'bg3'];
|
|
||||||
|
|
||||||
// Function to calculate animation duration
|
|
||||||
const calcDuration = (
|
|
||||||
baseValue: number,
|
|
||||||
offset = 0,
|
|
||||||
minBase = 50,
|
|
||||||
maxBase = 150,
|
|
||||||
) => {
|
|
||||||
const scaledValue = 2 + ((maxBase - baseValue) / (maxBase - minBase)) ** 4;
|
|
||||||
return scaledValue + offset;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Iterate through each class name to update its animation duration
|
|
||||||
bgClasses.forEach((className, index) => {
|
|
||||||
const elements = document.getElementsByClassName(className);
|
|
||||||
|
|
||||||
if (elements.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const offset = index * 0.05;
|
|
||||||
const duration = calcDuration(parseInt(settingsState.bksliderinput), offset);
|
|
||||||
(elements[0] as HTMLElement).style.animationDuration = `${duration}s`;
|
|
||||||
(elements[0] as HTMLElement).style.animationDelay = `${offset * 5}s`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import browser from 'webextension-polyfill'
|
import browser from 'webextension-polyfill'
|
||||||
import { GetThresholdOfColor } from '@/SEQTA';
|
import { GetThresholdOfColor } from '@/seqta/ui/colors/getThresholdColour';
|
||||||
import { lightenAndPaleColor } from './lightenAndPaleColor';
|
import { lightenAndPaleColor } from './lightenAndPaleColor';
|
||||||
import ColorLuminance from './ColorLuminance';
|
import ColorLuminance from './ColorLuminance';
|
||||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState';
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState';
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Color from "color"
|
||||||
|
export function GetThresholdOfColor(color: any) {
|
||||||
|
if (!color) return 0
|
||||||
|
// Case-insensitive regular expression for matching RGBA colors
|
||||||
|
const rgbaRegex = /rgba?\(([^)]+)\)/gi
|
||||||
|
|
||||||
|
// Check if the color string is a gradient (linear or radial)
|
||||||
|
if (color.includes("gradient")) {
|
||||||
|
let gradientThresholds = []
|
||||||
|
|
||||||
|
// Find and replace all instances of RGBA in the gradient
|
||||||
|
let match
|
||||||
|
while ((match = rgbaRegex.exec(color)) !== null) {
|
||||||
|
// Extract the individual components (r, g, b, a)
|
||||||
|
const rgbaString = match[1]
|
||||||
|
const [r, g, b] = rgbaString.split(",").map((str) => str.trim())
|
||||||
|
|
||||||
|
// Compute the threshold using your existing algorithm
|
||||||
|
const threshold = Math.sqrt(
|
||||||
|
parseInt(r) ** 2 + parseInt(g) ** 2 + parseInt(b) ** 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store the computed threshold
|
||||||
|
gradientThresholds.push(threshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the average threshold
|
||||||
|
const averageThreshold =
|
||||||
|
gradientThresholds.reduce((acc, val) => acc + val, 0) /
|
||||||
|
gradientThresholds.length
|
||||||
|
|
||||||
|
return averageThreshold
|
||||||
|
} else {
|
||||||
|
// Handle the color as a simple RGBA (or hex, or whatever the Color library supports)
|
||||||
|
const rgb = Color.rgb(color).object()
|
||||||
|
return Math.sqrt(rgb.r ** 2 + rgb.g ** 2 + rgb.b ** 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,26 +77,26 @@ const contentConfig: ContentConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
messageSubject: {
|
messageSubject: {
|
||||||
selector: '.MessageList__subject___1NV5O',
|
selector: '[class*="MessageList__subject___"]',
|
||||||
action: (element) => { element.textContent = getRandomElement(mockData.messages.subjects); }
|
action: (element) => { element.textContent = getRandomElement(mockData.messages.subjects); }
|
||||||
},
|
},
|
||||||
|
|
||||||
messageSender: {
|
messageSender: {
|
||||||
selector: '.MessageList__value___1sN24',
|
selector: '[class*="MessageList__value___"]',
|
||||||
action: (element) => { element.textContent = getRandomElement(mockData.messages.sender); }
|
action: (element) => { element.textContent = getRandomElement(mockData.messages.sender); }
|
||||||
},
|
},
|
||||||
|
|
||||||
messageRecipients: {
|
messageRecipients: {
|
||||||
selector: '.MessageList__recipients___3hqpE .MessageList__value___1sN24',
|
selector: '[class*="MessageList__recipients___"] [class*="MessageList__value___"]',
|
||||||
action: (element) => { element.textContent = 'Recipient(s) Redacted'; }
|
action: (element) => { element.textContent = 'Recipient(s) Redacted'; }
|
||||||
},
|
},
|
||||||
|
|
||||||
messageDate: {
|
messageDate: {
|
||||||
selector: '.MessageList__date___7muMb',
|
selector: '[class*="MessageList__date___"]',
|
||||||
action: (element) => { element.textContent = getRandomDate().toLocaleDateString('en-US', { weekday: 'long', day: 'numeric', month: 'long' }); }
|
action: (element) => { element.textContent = getRandomDate().toLocaleDateString('en-US', { weekday: 'long', day: 'numeric', month: 'long' }); }
|
||||||
},
|
},
|
||||||
avatarImage: {
|
avatarImage: {
|
||||||
selector: '.Avatar__Avatar___gE5kx',
|
selector: '[class*="Avatar__Avatar___"]',
|
||||||
action: (element) => {
|
action: (element) => {
|
||||||
if (element instanceof HTMLElement) {
|
if (element instanceof HTMLElement) {
|
||||||
element.style.removeProperty('background-image');
|
element.style.removeProperty('background-image');
|
||||||
@@ -105,7 +105,7 @@ const contentConfig: ContentConfig = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
notificationCount: {
|
notificationCount: {
|
||||||
selector: '.notifications__bubble___1EkSQ',
|
selector: '[class*="notifications__bubble___"]',
|
||||||
action: (element) => { element.textContent = Math.floor(Math.random() * 100).toString(); }
|
action: (element) => { element.textContent = Math.floor(Math.random() * 100).toString(); }
|
||||||
},
|
},
|
||||||
schoolName: {
|
schoolName: {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user