mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
Compare commits
253 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c613f4938 | |||
| 04843a90fe | |||
| 834d585ac7 | |||
| 343fa7ca9f | |||
| e049f34a5e | |||
| d692f60291 | |||
| a0367be686 | |||
| aa6b15aa1b | |||
| 08342c3873 | |||
| 6527a33e38 | |||
| 43f125e45d | |||
| 49cc1e26c0 | |||
| 809a82f31d | |||
| 3c8c68ce2f | |||
| 18603026a3 | |||
| 52d13cbdc2 | |||
| 26c04f1c24 | |||
| e11e402c80 | |||
| bcc7d58ddd | |||
| 685c6ad771 | |||
| 4b0372aa56 | |||
| 27aa28740e | |||
| 6291b7d0a7 | |||
| d9f0d89450 | |||
| 6ad221fcb5 | |||
| f1c55e127c | |||
| 2f6e551e22 | |||
| 6edffd0306 | |||
| 50de668d01 | |||
| 8a05d85344 | |||
| 915ce6f5f1 | |||
| 67f98b13ad | |||
| 2a147c1d3a | |||
| aae9aa6073 | |||
| 9a9885066f | |||
| 760d3349c2 | |||
| ec0dd70a4b | |||
| 4b2184955a | |||
| 8d214ff6a3 | |||
| 441df9cdf2 | |||
| e6e2789a82 | |||
| 70ceb50acd | |||
| 46d5c2e9fc | |||
| 725d2b2987 | |||
| 9581b793b5 | |||
| 3fc3f1191c | |||
| 098c79bc99 | |||
| 45b558373b | |||
| 3a2c438223 | |||
| 577287b8a8 | |||
| 1d13b054ee | |||
| dc3423df13 | |||
| 9791454d62 | |||
| 17f648f3ce | |||
| 7d89733f96 | |||
| a0e6bdfb20 | |||
| ac76ce3f03 | |||
| 4bef51a3be | |||
| 781171d60a | |||
| c01342a86c | |||
| d73a9b2acf | |||
| 1d3643a1fc | |||
| e50de00d08 | |||
| 8c87278850 | |||
| 520da46daf | |||
| 01f5e8f61d | |||
| 2faef2ae8d | |||
| 9d24d07c12 | |||
| d21ce90a5c | |||
| 889175f3de | |||
| 7a70b008c8 | |||
| 4b251e0ea4 | |||
| f242928682 | |||
| d64962147a | |||
| c2cd034556 | |||
| 1039ea8137 | |||
| ead8cf80f3 | |||
| 1f0b2d6627 | |||
| 40d7ece12b | |||
| 098ab27a01 | |||
| 170b1cf5c3 | |||
| 004c3cc61d | |||
| 1b938e2748 | |||
| 652e84783b | |||
| 17c2685cae | |||
| 5e89507276 | |||
| 0204b97de8 | |||
| d849951a66 | |||
| 1531afd046 | |||
| add8a90c77 | |||
| b9c3c2b5c5 | |||
| 1cc34c38a8 | |||
| e5859f419a | |||
| aadc295bdb | |||
| af2ef23078 | |||
| 7150f03d77 | |||
| a381de7c9b | |||
| ce6a5cfdc4 | |||
| de75468f2b | |||
| 011c1eddb4 | |||
| c9443bad27 | |||
| 445aa9d071 | |||
| 14a2e93b3a | |||
| 3746b05af2 | |||
| 1d215d8c75 | |||
| b4b1fed576 | |||
| e0009ad8dc | |||
| 401947031b | |||
| 9da8e104a8 | |||
| 3aef2312d0 | |||
| b402221477 | |||
| 8b6bda6dff | |||
| eed8bac45a | |||
| 3b7bbc9bc6 | |||
| 9820595a70 | |||
| 0a33ca7f6e | |||
| bba96d5159 | |||
| d9fe70f442 | |||
| 32c5c8392b | |||
| cc3f06b383 | |||
| 9ad90e9416 | |||
| 87fdda459a | |||
| 6c11bb8143 | |||
| 391fcfb9dd | |||
| 355c5f2d46 | |||
| afdb4336f8 | |||
| 7a04b22b22 | |||
| f594ed4902 | |||
| f1afa74ee6 | |||
| db3f0e0d81 | |||
| aceefa16c0 | |||
| 89589fe3dc | |||
| 2afe2364e4 | |||
| 2c0f48877f | |||
| 8791038bcf | |||
| aba2ba5bfa | |||
| 2b01834765 | |||
| 79c4fb511b | |||
| 14823dcc91 | |||
| 9d3494eb56 | |||
| 6af7c32c88 | |||
| c205a52f03 | |||
| a6d95f27ed | |||
| f05cd66e88 | |||
| a151e7a07e | |||
| 1f49fa4bae | |||
| 86d9cfe50c | |||
| ae59640162 | |||
| 5f935cd819 | |||
| 90e3a946bf | |||
| 51c940cdd9 | |||
| 07ff6d25ca | |||
| 89fd9bbd89 | |||
| c7033e61fb | |||
| 39f8cb1634 | |||
| 705c106da8 | |||
| 9b52bae404 | |||
| 9a002d18b0 | |||
| 3847ef4269 | |||
| f0d0068a2e | |||
| b89a6c634c | |||
| 29cfb4c792 | |||
| 5b590512ee | |||
| 3ff8ef144a | |||
| d9abed1c5d | |||
| 82a789bbec | |||
| ce6538f850 | |||
| 979ae7149f | |||
| 6e71437fe8 | |||
| 940ecf8714 | |||
| e0cc2e0fdf | |||
| 5a19ef92e8 | |||
| 0a3781e9c2 | |||
| a2e39c9d84 | |||
| 520abbb5c3 | |||
| d0a11da15f | |||
| fd5802f9a3 | |||
| 380d829d19 | |||
| 702528fb0c | |||
| 2c077bc755 | |||
| fd86e57442 | |||
| 60ce18280e | |||
| 668dbfd78b | |||
| 810aa17f15 | |||
| b64558e50a | |||
| 9b969bd708 | |||
| 1945f7c592 | |||
| 3e26d9af3c | |||
| 3c8d7e246b | |||
| 2e56518330 | |||
| e67f3110e0 | |||
| a67f4d2e25 | |||
| d6025140fd | |||
| 88e9ddf29c | |||
| 11adc4f933 | |||
| 15691e8d94 | |||
| 754b8d0589 | |||
| 1d634d0da1 | |||
| 7136de90be | |||
| 466628479e | |||
| 9c08d0bac2 | |||
| 6c5320007f | |||
| 4734a443b4 | |||
| 7c38e1dc29 | |||
| f3f90ef2a8 | |||
| 9bcc94aa8a | |||
| ff2431f269 | |||
| b442194bc5 | |||
| b59c0eae25 | |||
| e895ce9f6b | |||
| 7192f41535 | |||
| f1b707ab25 | |||
| 7f47cb8183 | |||
| 7f5d138bc9 | |||
| cef0f29640 | |||
| 157343dda9 | |||
| 7705c0a3cd | |||
| 7def7b190c | |||
| c294fb7369 | |||
| 0dbbef0eb1 | |||
| c3c747d996 | |||
| cdc8062275 | |||
| 1857b5ff01 | |||
| 700e3ebb48 | |||
| 16b9610301 | |||
| 7d11e203a6 | |||
| 530f07e640 | |||
| 08586781ce | |||
| 3ca5a49769 | |||
| 886c79b3ee | |||
| 30aa39142d | |||
| 4188ef0d67 | |||
| ad9a013b00 | |||
| cd1f954cc7 | |||
| 6ef6c986dc | |||
| f2e28175a0 | |||
| 3ddcb204ef | |||
| 766f0e6d3f | |||
| f1fcba58ef | |||
| dba2d13bb3 | |||
| 30bf345b86 | |||
| 0e98f52058 | |||
| f89508deb2 | |||
| c7b69ad97b | |||
| 2ef8bb215a | |||
| 16273cf012 | |||
| 13d3ccd8e4 | |||
| 7ebc4db9db | |||
| ed9d662ba4 | |||
| 8647e0b272 | |||
| d93abec615 | |||
| 339b409937 | |||
| 860916a5b8 |
+6
-8
@@ -4,12 +4,7 @@ package-lock.json
|
|||||||
bun.lockb
|
bun.lockb
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
.parcel-cache
|
|
||||||
.env
|
|
||||||
.env.submit
|
|
||||||
|
|
||||||
dependency-graph.svg
|
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
extension.zip
|
extension.zip
|
||||||
@@ -19,5 +14,8 @@ betterseqtaplus-safari/
|
|||||||
|
|
||||||
.million/
|
.million/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
**/.DS_Store
|
||||||
**/.DS_Store
|
.parcel-cache
|
||||||
|
.env
|
||||||
|
.env.submit
|
||||||
|
dependency-graph.svg
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a target="_blank" href="https://chrome.google.com/webstore/detail/betterseqta%20/afdgaoaclhkhemfkkkonemoapeinchel"><img src="https://user-images.githubusercontent.com/95666457/149519713-159d7ef7-2c21-4034-a616-f037ff46d9a4.png" alt="ChromeDownload" width="250"></a>
|
<a target="_blank" href="https://chrome.google.com/webstore/detail/betterseqta%20/afdgaoaclhkhemfkkkonemoapeinchel"><img src="https://user-images.githubusercontent.com/95666457/149519713-159d7ef7-2c21-4034-a616-f037ff46d9a4.png" alt="ChromeDownload" width="250"></a>
|
||||||
<a target="_blank" href="https://discord.gg/YzmbnCDkat"><img src="https://github.com/SethBurkart123/EvenBetterSEQTA/assets/108050083/23055730-b16e-44c0-9bef-221d8545af92" width="240" style="border-radius:10%;" /></a>
|
<a target="_blank" href="https://discord.gg/YzmbnCDkat"><img src="https://github.com/BetterSEQTA/BetterSEQTA-Plus/assets/108050083/23055730-b16e-44c0-9bef-221d8545af92" width="240" style="border-radius:10%;" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -64,7 +64,7 @@ Don't worry- if you get stuck feel free to ask around in the [discord](https://d
|
|||||||
- **🐛 Found a bug?** Open an [issue](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) or fix it yourself!
|
- **🐛 Found a bug?** Open an [issue](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) or fix it yourself!
|
||||||
- **💬 Need help?** Join our [Discord community](https://discord.gg/YzmbnCDkat)
|
- **💬 Need help?** Join our [Discord community](https://discord.gg/YzmbnCDkat)
|
||||||
|
|
||||||
We have lots of [`good first issue`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/good%20first%20issue) labels perfect for beginners!
|
We have lots of https://github.com/BetterSEQTA/BetterSEQTA-Plus/labels/good%20first%20issue labels perfect for beginners!
|
||||||
|
|
||||||
## Quick Development Setup
|
## Quick Development Setup
|
||||||
|
|
||||||
@@ -85,6 +85,8 @@ npm run dev
|
|||||||
2. Enable "Developer mode"
|
2. Enable "Developer mode"
|
||||||
3. Click "Load unpacked" → Select `dist` folder
|
3. Click "Load unpacked" → Select `dist` folder
|
||||||
4. Visit a SEQTA page to see it work! 🎉
|
4. Visit a SEQTA page to see it work! 🎉
|
||||||
|
> [!WARNING]
|
||||||
|
> Whenever you update the extension while not in dev mode, you will need to use the reload button on the extension page.
|
||||||
|
|
||||||
📚 **Need more details?** Check our [detailed setup guide](./docs/GETTING_STARTED_CONTRIBUTING.md#your-first-30-minutes)
|
📚 **Need more details?** Check our [detailed setup guide](./docs/GETTING_STARTED_CONTRIBUTING.md#your-first-30-minutes)
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,6 @@ Ready to contribute? Here's what to do next:
|
|||||||
Still confused about something? That's totally normal! Here are your options:
|
Still confused about something? That's totally normal! Here are your options:
|
||||||
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
|
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
|
||||||
- 🐛 Open an issue on GitHub
|
- 🐛 Open an issue on GitHub
|
||||||
- 📧 Email us at betterseqta@betterseqta.com
|
- 📧 Email us at betterseqta.plus@gmail.com
|
||||||
|
|
||||||
Remember: **Every expert was once a beginner!** We're here to help you learn and contribute. 🚀
|
Remember: **Every expert was once a beginner!** We're here to help you learn and contribute. 🚀
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
# Theme Creation Guide
|
||||||
|
|
||||||
|
This guide covers everything you need to know about creating custom themes for BetterSEQTA+.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Theme Structure](#theme-structure)
|
||||||
|
3. [CSS Variables](#css-variables)
|
||||||
|
4. [CSS Selectors & Classes](#css-selectors--classes)
|
||||||
|
5. [Custom Images](#custom-images)
|
||||||
|
6. [Theme Settings](#theme-settings)
|
||||||
|
7. [Best Practices](#best-practices)
|
||||||
|
8. [Examples](#examples)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Themes in BetterSEQTA+ allow you to completely customize the appearance of SEQTA Learn. A theme consists of:
|
||||||
|
|
||||||
|
- **Custom CSS**: CSS rules that override default styles
|
||||||
|
- **Custom Images**: Images that can be referenced via CSS variables
|
||||||
|
- **Theme Metadata**: Name, description, default color, etc.
|
||||||
|
- **Theme Settings**: Options like forcing dark/light mode
|
||||||
|
|
||||||
|
Themes are applied by injecting CSS into the SEQTA page and setting CSS custom properties (variables) on the document root.
|
||||||
|
|
||||||
|
## CSS Variables
|
||||||
|
|
||||||
|
BetterSEQTA+ provides a comprehensive set of CSS variables that you can use in your themes. These variables automatically adapt to light/dark mode and user preferences.
|
||||||
|
|
||||||
|
### Core Background Variables
|
||||||
|
|
||||||
|
| Variable | Light Mode | Dark Mode | Description |
|
||||||
|
|----------|------------|-----------|-------------|
|
||||||
|
| `--background-primary` | `#ffffff` | `#232323` | Main background color |
|
||||||
|
| `--background-secondary` | `#e5e7eb` | `#1a1a1a` | Secondary background color |
|
||||||
|
| `--theme-primary` | `#ffffff` | `#232323` | Primary theme color (same as background-primary) |
|
||||||
|
| `--theme-secondary` | `#e5e7eb` | `#1a1a1a` | Secondary theme color (same as background-secondary) |
|
||||||
|
| `--text-primary` | `black` | `white` | Primary text color |
|
||||||
|
| `--text-color` | `black` | `white` | Text color (alias for text-primary) |
|
||||||
|
|
||||||
|
### BetterSEQTA+ Specific Variables
|
||||||
|
|
||||||
|
| Variable | Description | Notes |
|
||||||
|
|----------|-------------|-------|
|
||||||
|
| `--better-main` | User's selected accent color | Dynamically set based on color picker |
|
||||||
|
| `--better-sub` | Dark navy color | Always `#161616` |
|
||||||
|
| `--better-pale` | Lightened version of accent color | Only available in light mode |
|
||||||
|
| `--better-light` | Lighter version of accent color | Calculated based on brightness |
|
||||||
|
| `--better-alert-highlight` | Alert/highlight color | `#c61851` |
|
||||||
|
| `--betterseqta-logo` | Logo URL | Changes based on dark/light mode |
|
||||||
|
| `--auto-background` | Auto background color | Falls back to `--better-pale` or `--background-secondary` |
|
||||||
|
| `--navy` | Navy color | `#1a1a1a` |
|
||||||
|
| `--theme-fg-parts` | Theme foreground parts | `white` |
|
||||||
|
|
||||||
|
### Subject/Item Color Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `--item-colour` | Subject/item color | Set dynamically per subject/item |
|
||||||
|
| `--colour` | Generic color variable | Used in various contexts |
|
||||||
|
| `--person-colour` | Person/avatar color | `var(--better-light)` for staff |
|
||||||
|
|
||||||
|
### Transparency Effects
|
||||||
|
|
||||||
|
When transparency effects are enabled, background variables become semi-transparent:
|
||||||
|
|
||||||
|
| Variable | Light Mode (Transparent) | Dark Mode (Transparent) |
|
||||||
|
|----------|--------------------------|-------------------------|
|
||||||
|
| `--background-primary` | `rgba(255, 255, 255, 0.6)` | `rgba(35, 35, 35, 0.6)` |
|
||||||
|
| `--background-secondary` | `rgba(229, 231, 235, 0.6)` | `rgba(26, 26, 26, 0.6)` |
|
||||||
|
|
||||||
|
### Using CSS Variables
|
||||||
|
|
||||||
|
You can use these variables in your custom CSS:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Example: Style a custom element */
|
||||||
|
.my-custom-element {
|
||||||
|
background: var(--background-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--better-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Example: Create a gradient */
|
||||||
|
.gradient-box {
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
var(--better-main),
|
||||||
|
var(--background-secondary)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Selectors & Classes
|
||||||
|
|
||||||
|
BetterSEQTA+ uses specific CSS selectors and classes that you can target in your themes. Here are the most important ones:
|
||||||
|
|
||||||
|
### Main Layout Elements
|
||||||
|
|
||||||
|
| Selector | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `#container` | Main container element |
|
||||||
|
| `#content` | Content area |
|
||||||
|
| `#main` | Main content wrapper |
|
||||||
|
| `#title` | Top title bar |
|
||||||
|
| `#menu` | Sidebar menu |
|
||||||
|
|
||||||
|
### Dark Mode
|
||||||
|
|
||||||
|
The `dark` class is added to `html` when dark mode is active:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Target dark mode specifically */
|
||||||
|
html.dark #main {
|
||||||
|
background: var(--background-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Target light mode */
|
||||||
|
html:not(.dark) #main {
|
||||||
|
background: var(--background-primary);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transparency Effects
|
||||||
|
|
||||||
|
When transparency effects are enabled, the `transparencyEffects` class is added to `html`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
html.transparencyEffects .notice {
|
||||||
|
backdrop-filter: blur(80px);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common SEQTA Classes
|
||||||
|
|
||||||
|
| Class/Selector | Description |
|
||||||
|
|----------------|-------------|
|
||||||
|
| `.notice` | Notice cards |
|
||||||
|
| `.day` | Day containers in timetable |
|
||||||
|
| `.dashboard` | Dashboard sections |
|
||||||
|
| `.dashlet` | Dashboard widgets |
|
||||||
|
| `.document` | Document elements |
|
||||||
|
| `.quickbar` | Quick action bar |
|
||||||
|
| `.calendar` | Calendar elements |
|
||||||
|
| `.message` | Message elements |
|
||||||
|
| `.thread` | Forum threads |
|
||||||
|
| `.shortcut` | Shortcut buttons |
|
||||||
|
| `.upcoming-assessment` | Upcoming assessments |
|
||||||
|
| `.entry.class` | Timetable entries |
|
||||||
|
|
||||||
|
### BetterSEQTA+ Specific Classes
|
||||||
|
|
||||||
|
| Class | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `.addedButton` | BetterSEQTA+ added buttons |
|
||||||
|
| `.tooltip` | Tooltip elements |
|
||||||
|
| `.notice-unified-content` | Unified notice content |
|
||||||
|
| `.home-container` | Home page container |
|
||||||
|
| `.timetable-container` | Timetable container |
|
||||||
|
| `.notices-container` | Notices container |
|
||||||
|
|
||||||
|
### Attribute Selectors
|
||||||
|
|
||||||
|
SEQTA uses data attributes that you can target:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Target specific data types */
|
||||||
|
[data-type="student"] .header {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Target specific labels */
|
||||||
|
[data-label="inbox"] {
|
||||||
|
/* Styles */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Modules
|
||||||
|
|
||||||
|
SEQTA uses CSS modules with hashed class names. You can target them using attribute selectors:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Target CSS module classes */
|
||||||
|
[class*="MessageList__MessageList___"] {
|
||||||
|
background: var(--background-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*="BasicPanel__BasicPanel___"] {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Images
|
||||||
|
|
||||||
|
Themes can include custom images that are made available as CSS variables.
|
||||||
|
|
||||||
|
### Adding Images
|
||||||
|
|
||||||
|
1. Upload an image in the theme creator
|
||||||
|
2. Set a CSS variable name (e.g., `custom-background`)
|
||||||
|
3. The image will be available as `var(--custom-background)`
|
||||||
|
|
||||||
|
### Using Image Variables
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Use as background */
|
||||||
|
.my-element {
|
||||||
|
background-image: var(--custom-background);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Use in content */
|
||||||
|
.my-icon::before {
|
||||||
|
content: '';
|
||||||
|
background-image: var(--custom-icon);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Image Variable Format
|
||||||
|
|
||||||
|
Images are stored as `url()` values:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* The variable contains: url(blob:...) */
|
||||||
|
--custom-background: url(blob:chrome-extension://...);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Theme Settings
|
||||||
|
|
||||||
|
### Force Dark/Light Mode
|
||||||
|
|
||||||
|
You can force a theme to always use dark or light mode:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
forceDark: true // Force dark mode
|
||||||
|
forceDark: false // Force light mode
|
||||||
|
forceDark: undefined // Use user's preference (default)
|
||||||
|
```
|
||||||
|
|
||||||
|
When `forceDark` is set, users cannot toggle dark/light mode while the theme is active.
|
||||||
|
|
||||||
|
### Default Color
|
||||||
|
|
||||||
|
Set a default accent color for your theme:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
defaultColour: "rgba(0, 123, 255, 1)" // Blue
|
||||||
|
defaultColour: "#ff6b6b" // Red (hex format)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Allow Color Changes
|
||||||
|
|
||||||
|
Control whether users can change the accent color:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
CanChangeColour: true // Users can change color
|
||||||
|
CanChangeColour: false // Color is locked
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use CSS Variables
|
||||||
|
|
||||||
|
Always use CSS variables instead of hardcoded colors:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good */
|
||||||
|
.my-element {
|
||||||
|
background: var(--background-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bad */
|
||||||
|
.my-element {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Support Both Light and Dark Modes
|
||||||
|
|
||||||
|
Unless your theme forces a specific mode, ensure it works in both:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Use variables that adapt automatically */
|
||||||
|
.my-element {
|
||||||
|
background: var(--background-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Or explicitly handle both modes */
|
||||||
|
html.dark .my-element {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .my-element {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use !important Sparingly
|
||||||
|
|
||||||
|
Only use `!important` when necessary to override SEQTA's default styles:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good - necessary override */
|
||||||
|
#title {
|
||||||
|
background: var(--background-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bad - unnecessary */
|
||||||
|
.my-element {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Test Responsive Design
|
||||||
|
|
||||||
|
SEQTA is responsive. Test your theme at different screen sizes:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Example: Mobile-specific styles */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
#menu {
|
||||||
|
transform: translate(-270px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Use Semantic Selectors
|
||||||
|
|
||||||
|
Prefer semantic selectors over fragile ones:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good - stable selector */
|
||||||
|
#main > .dashboard > section {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Caution - CSS module classes may change */
|
||||||
|
[class*="Dashboard__Dashboard___"] {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Optimize Images
|
||||||
|
|
||||||
|
Keep image file sizes reasonable:
|
||||||
|
|
||||||
|
- Use appropriate formats (PNG for transparency, JPG for photos)
|
||||||
|
- Compress images before uploading
|
||||||
|
- Consider using CSS for simple graphics instead of images
|
||||||
|
|
||||||
|
### 7. Document Your Theme
|
||||||
|
|
||||||
|
Include comments in your CSS explaining complex styles:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/*
|
||||||
|
* Custom gradient background for dashboard
|
||||||
|
* Uses the user's accent color for a cohesive look
|
||||||
|
*/
|
||||||
|
#main > .dashboard {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--better-main),
|
||||||
|
var(--background-secondary)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example 1: Simple Color Theme
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Change accent color throughout */
|
||||||
|
:root {
|
||||||
|
--better-main: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the menu */
|
||||||
|
#menu {
|
||||||
|
background: var(--background-primary);
|
||||||
|
border-right: 3px solid var(--better-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style buttons */
|
||||||
|
.uiButton {
|
||||||
|
background: var(--better-main);
|
||||||
|
color: var(--text-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Custom Background Image
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Use a custom background image */
|
||||||
|
body {
|
||||||
|
background-image: var(--custom-background);
|
||||||
|
background-size: cover;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add overlay for readability */
|
||||||
|
#main::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Rounded Corners Theme
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Make everything more rounded */
|
||||||
|
#main > .dashboard > section,
|
||||||
|
.dashlet,
|
||||||
|
.notice,
|
||||||
|
.document {
|
||||||
|
border-radius: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Round buttons */
|
||||||
|
.uiButton {
|
||||||
|
border-radius: 25px !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 4: Minimal Theme
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Remove shadows and borders */
|
||||||
|
#main > .dashboard > section,
|
||||||
|
.dashlet,
|
||||||
|
.notice {
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: 1px solid var(--background-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Simplify colors */
|
||||||
|
#menu {
|
||||||
|
background: var(--background-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove gradients */
|
||||||
|
.day {
|
||||||
|
background: var(--background-primary) !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 5: High Contrast Theme
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Increase contrast */
|
||||||
|
:root {
|
||||||
|
--background-primary: #000000;
|
||||||
|
--background-secondary: #1a1a1a;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) {
|
||||||
|
--background-primary: #ffffff;
|
||||||
|
--background-secondary: #f0f0f0;
|
||||||
|
--text-primary: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add borders for clarity */
|
||||||
|
.dashlet,
|
||||||
|
.notice,
|
||||||
|
.document {
|
||||||
|
border: 2px solid var(--better-main) !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Techniques
|
||||||
|
|
||||||
|
### CSS Custom Properties Override
|
||||||
|
|
||||||
|
You can override CSS variables in your theme:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Override a variable */
|
||||||
|
:root {
|
||||||
|
--better-main: #your-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override conditionally */
|
||||||
|
html.dark {
|
||||||
|
--background-primary: #your-dark-color;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animations
|
||||||
|
|
||||||
|
Add smooth transitions:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Smooth color transitions */
|
||||||
|
#menu li {
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effects */
|
||||||
|
.dashlet:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pseudo-elements
|
||||||
|
|
||||||
|
Use pseudo-elements for decorative elements:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Add decorative border */
|
||||||
|
.notice::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background: var(--better-main);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Theme Not Applying
|
||||||
|
|
||||||
|
1. Check browser console for CSS errors
|
||||||
|
2. Verify CSS syntax is correct
|
||||||
|
3. Ensure selectors are specific enough
|
||||||
|
4. Check if `!important` is needed
|
||||||
|
|
||||||
|
### Colors Not Changing
|
||||||
|
|
||||||
|
1. Verify you're using CSS variables
|
||||||
|
2. Check if `forceDark` is overriding your styles
|
||||||
|
3. Ensure variables are set on `:root` or `html`
|
||||||
|
|
||||||
|
### Images Not Showing
|
||||||
|
|
||||||
|
1. Verify image variable name matches CSS
|
||||||
|
2. Check image format is supported
|
||||||
|
3. Ensure image size is reasonable
|
||||||
|
4. Verify `url()` wrapper in CSS
|
||||||
|
|
||||||
|
### Dark Mode Issues
|
||||||
|
|
||||||
|
1. Test with `forceDark: true` and `forceDark: false`
|
||||||
|
2. Check if transparency effects are interfering
|
||||||
|
3. Verify `html.dark` selector is correct
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- **Theme Creator**: Access via BetterSEQTA+ settings
|
||||||
|
- **CSS Variables Reference**: See [CSS Variables](#css-variables) section above
|
||||||
|
- **SEQTA DOM Structure**: Inspect SEQTA pages in browser DevTools
|
||||||
|
- **BetterSEQTA+ Source**: Check `src/css/injected.scss` for default styles
|
||||||
|
|
||||||
|
## Contributing Themes
|
||||||
|
|
||||||
|
If you create a great theme, consider sharing it:
|
||||||
|
|
||||||
|
1. Export your theme (Share button in theme creator)
|
||||||
|
2. Submit to the BetterSEQTA+ theme store
|
||||||
|
3. Or share on GitHub/Discord
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note**: This documentation is based on BetterSEQTA+ v3.4.13. Some details may change in future versions.
|
||||||
|
|
||||||
+17
-11
@@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "betterseqtaplus",
|
"name": "betterseqtaplus",
|
||||||
"version": "3.4.9",
|
"version": "3.5.3",
|
||||||
"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",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"autoaudit": "npm audit && npm audit fix && npm run build",
|
||||||
"dev": "cross-env MODE=chrome vite dev",
|
"dev": "cross-env MODE=chrome vite dev",
|
||||||
"dev:firefox": "cross-env MODE=firefox vite build --watch",
|
"dev:firefox": "cross-env MODE=firefox vite build --watch",
|
||||||
|
"compile": "npm i && npm run build",
|
||||||
"build": "cross-env MODE=chrome vite build && cross-env MODE=firefox vite build",
|
"build": "cross-env MODE=chrome vite build && cross-env MODE=firefox vite build",
|
||||||
"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",
|
||||||
@@ -35,19 +37,19 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-transform-runtime": "^7.26.9",
|
"@babel/plugin-transform-runtime": "^7.26.9",
|
||||||
"@babel/runtime": "^7.26.9",
|
"@babel/runtime": "^7.26.9",
|
||||||
"@bedframe/cli": "^0.0.91",
|
"@bedframe/cli": "^0.0.95",
|
||||||
"@crxjs/vite-plugin": "2.0.0-beta.32",
|
"@crxjs/vite-plugin": "^2.2.0",
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^10.0.0",
|
||||||
"dependency-cruiser": "^16.10.0",
|
"dependency-cruiser": "^17.0.1",
|
||||||
"eslint": "9.22.0",
|
"eslint": "^9.33.0",
|
||||||
"glob": "^11.0.1",
|
"glob": "^11.0.1",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^3.0.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
"publish-browser-extension": "^3.0.0",
|
"publish-browser-extension": "^4.0.4",
|
||||||
"sass": "^1.85.1",
|
"sass": "^1.85.1",
|
||||||
"sass-loader": "^16.0.5",
|
"sass-loader": "^16.0.5",
|
||||||
"semver": "^7.7.1",
|
"semver": "^7.7.1",
|
||||||
@@ -55,6 +57,7 @@
|
|||||||
"url": "^0.11.4"
|
"url": "^0.11.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bedframe/core": "^0.0.46",
|
||||||
"@codemirror/autocomplete": "^6.18.6",
|
"@codemirror/autocomplete": "^6.18.6",
|
||||||
"@codemirror/commands": "^6.8.0",
|
"@codemirror/commands": "^6.8.0",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
@@ -65,10 +68,11 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tsconfig/svelte": "^5.0.4",
|
"@tsconfig/svelte": "^5.0.4",
|
||||||
"@types/chrome": "^0.0.308",
|
"@types/chrome": "^0.1.4",
|
||||||
"@types/color": "^4.2.0",
|
"@types/color": "^4.2.0",
|
||||||
"@types/lodash": "^4.17.16",
|
"@types/lodash": "^4.17.16",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^24.3.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/sortablejs": "^1.15.8",
|
"@types/sortablejs": "^1.15.8",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/webextension-polyfill": "^0.12.3",
|
"@types/webextension-polyfill": "^0.12.3",
|
||||||
@@ -92,7 +96,9 @@
|
|||||||
"mathjs": "^14.4.0",
|
"mathjs": "^14.4.0",
|
||||||
"million": "^3.1.11",
|
"million": "^3.1.11",
|
||||||
"motion": "^12.4.12",
|
"motion": "^12.4.12",
|
||||||
|
"pdfjs-dist": "^5.4.530",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "17",
|
"react": "17",
|
||||||
"react-best-gradient-color-picker": "3.0.11",
|
"react-best-gradient-color-picker": "3.0.11",
|
||||||
"react-dom": "17",
|
"react-dom": "17",
|
||||||
|
|||||||
+67
-22
@@ -11,6 +11,30 @@ import { main } from "@/seqta/main";
|
|||||||
import { delay } from "./seqta/utils/delay";
|
import { delay } from "./seqta/utils/delay";
|
||||||
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
||||||
|
|
||||||
|
function registerFetchSeqtaAppLinkListener() {
|
||||||
|
browser.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||||
|
if (request?.type !== "fetchSeqtaAppLink") return false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${location.origin}/seqta/student/load/profile`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const statusOk = data?.status === "200" || data?.status === 200;
|
||||||
|
const raw = data?.payload?.app_link;
|
||||||
|
const appLink = typeof raw === "string" && raw.length > 0 ? raw : null;
|
||||||
|
sendResponse({ appLink: statusOk ? appLink : null });
|
||||||
|
} catch {
|
||||||
|
sendResponse({ appLink: null });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export let MenuOptionsOpen = false;
|
export let MenuOptionsOpen = false;
|
||||||
|
|
||||||
var IsSEQTAPage = false;
|
var IsSEQTAPage = false;
|
||||||
@@ -25,35 +49,46 @@ if (document.childNodes[1]) {
|
|||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes BetterSEQTA+ on a SEQTA page.
|
|
||||||
*
|
|
||||||
* This function performs the following steps:
|
|
||||||
* 1. Verifies that the current page is a SEQTA page.
|
|
||||||
* 2. Injects CSS styles for document loading.
|
|
||||||
* 3. Changes the page's favicon.
|
|
||||||
* 4. Initializes the extension's settings state.
|
|
||||||
* 5. Sets default storage if settings are not already defined.
|
|
||||||
* 6. Calls the main function to apply core BetterSEQTA+ modifications.
|
|
||||||
* 7. Initializes legacy and new plugins if the extension is enabled.
|
|
||||||
* 8. Logs success or error messages during initialization.
|
|
||||||
*/
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const hasSEQTATitle = document.title.includes("SEQTA Learn");
|
if (
|
||||||
|
hasSEQTAText &&
|
||||||
if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) {
|
(document.title.includes("SEQTA Learn") ||
|
||||||
// Verify we are on a SEQTA page
|
document.title.includes("SEQTA Engage")) &&
|
||||||
|
!IsSEQTAPage
|
||||||
|
) {
|
||||||
IsSEQTAPage = true;
|
IsSEQTAPage = true;
|
||||||
console.info("[BetterSEQTA+] Verified SEQTA Page");
|
console.info("[BetterSEQTA+] Verified SEQTA Page");
|
||||||
|
|
||||||
|
registerFetchSeqtaAppLinkListener();
|
||||||
|
|
||||||
const documentLoadStyle = document.createElement("style");
|
const documentLoadStyle = document.createElement("style");
|
||||||
documentLoadStyle.textContent = documentLoadCSS;
|
documentLoadStyle.textContent = documentLoadCSS;
|
||||||
document.head.appendChild(documentLoadStyle);
|
document.head.appendChild(documentLoadStyle);
|
||||||
|
|
||||||
const icon = document.querySelector(
|
replaceIcons();
|
||||||
'link[rel*="icon"]',
|
|
||||||
)! as HTMLLinkElement;
|
const observer = new MutationObserver((mutations) => {
|
||||||
icon.href = icon48; // Change the icon
|
for (const mutation of mutations) {
|
||||||
|
|
||||||
|
if (
|
||||||
|
mutation.type === "attributes" &&
|
||||||
|
mutation.target instanceof HTMLLinkElement &&
|
||||||
|
mutation.target.rel.includes("icon") &&
|
||||||
|
mutation.attributeName === "href"
|
||||||
|
) {
|
||||||
|
replaceIcons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.head, {
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["href"],
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await initializeSettingsState();
|
await initializeSettingsState();
|
||||||
@@ -78,8 +113,18 @@ async function init() {
|
|||||||
console.info(
|
console.info(
|
||||||
"[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.",
|
"[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.",
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceIcons() {
|
||||||
|
document
|
||||||
|
.querySelectorAll<HTMLLinkElement>('link[rel*="icon"]')
|
||||||
|
.forEach((link) => {
|
||||||
|
if (link.href !== icon48) {
|
||||||
|
link.href = icon48;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+244
-40
@@ -6,7 +6,10 @@ function reloadSeqtaPages() {
|
|||||||
const result = browser.tabs.query({});
|
const result = browser.tabs.query({});
|
||||||
function open(tabs: any) {
|
function open(tabs: any) {
|
||||||
for (let tab of tabs) {
|
for (let tab of tabs) {
|
||||||
if (tab.title.includes("SEQTA Learn")) {
|
if (
|
||||||
|
tab.title?.includes("SEQTA Learn") ||
|
||||||
|
tab.title?.includes("SEQTA Engage")
|
||||||
|
) {
|
||||||
browser.tabs.reload(tab.id);
|
browser.tabs.reload(tab.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,52 +17,250 @@ function reloadSeqtaPages() {
|
|||||||
result.then(open, console.error);
|
result.then(open, console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
/** Callback for sending a response back to the message sender */
|
||||||
browser.runtime.onMessage.addListener(
|
type MessageSender = { (response?: unknown): void };
|
||||||
(request: any, _: any, sendResponse: (response?: any) => void) => {
|
|
||||||
switch (request.type) {
|
|
||||||
case "reloadTabs":
|
|
||||||
reloadSeqtaPages();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "extensionPages":
|
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
|
||||||
browser.tabs.query({}).then(function (tabs) {
|
const { token } = request;
|
||||||
for (let tab of tabs) {
|
const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
|
||||||
if (tab.url?.includes("chrome-extension://")) {
|
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
|
||||||
browser.tabs.sendMessage(tab.id!, request);
|
const headers: Record<string, string> = {};
|
||||||
}
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
}
|
fetch(apiUrl, { cache: "no-store", headers })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(sendResponse)
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
|
||||||
|
fetch(githubUrl, { cache: "no-store" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => sendResponse({ success: true, data: { themes: data.themes ?? [] } }))
|
||||||
|
.catch((fallbackErr) => {
|
||||||
|
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
|
||||||
|
sendResponse({ success: false, error: fallbackErr?.message });
|
||||||
});
|
});
|
||||||
break;
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
case "currentTab":
|
function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boolean {
|
||||||
browser.tabs
|
const { themeId, token } = request;
|
||||||
.query({ active: true, currentWindow: true })
|
if (!themeId || typeof themeId !== "string") {
|
||||||
.then(function (tabs) {
|
sendResponse({ success: false, error: "Missing themeId" });
|
||||||
browser.tabs
|
return false;
|
||||||
.sendMessage(tabs[0].id!, request)
|
}
|
||||||
.then(function (response) {
|
const headers: Record<string, string> = {};
|
||||||
sendResponse(response);
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
});
|
fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers })
|
||||||
});
|
.then((r) => r.json())
|
||||||
return true;
|
.then(sendResponse)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[Background] fetchThemeDetails error:", err);
|
||||||
|
sendResponse({ success: false, error: err?.message });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
case "githubTab":
|
function handleFetchFromUrl(request: any, sendResponse: MessageSender): boolean {
|
||||||
browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
|
const { url } = request;
|
||||||
break;
|
if (!url || typeof url !== "string") {
|
||||||
|
sendResponse({ error: "Missing url" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fetch(url, { cache: "no-store" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => sendResponse({ data }))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[Background] fetchFromUrl error:", err);
|
||||||
|
sendResponse({ error: err?.message });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
case "setDefaultStorage":
|
async function parseJsonResponse(r: Response): Promise<any> {
|
||||||
SetStorageValue(getDefaultValues());
|
const text = await r.text();
|
||||||
break;
|
try {
|
||||||
|
return text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "sendNews":
|
function handleCloudReserveClient(request: any, sendResponse: MessageSender): boolean {
|
||||||
fetchNews(request.source ?? "australia", sendResponse);
|
const redirect_uri = request.redirect_uri ?? "https://accounts.betterseqta.org/auth/bsplus/callback";
|
||||||
return true;
|
fetch("https://accounts.betterseqta.org/api/bsplus/client/reserve", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ redirect_uri }),
|
||||||
|
})
|
||||||
|
.then(async (r) => {
|
||||||
|
const data = await parseJsonResponse(r);
|
||||||
|
if (!r.ok) sendResponse({ error: data?.error ?? `Reserve failed (${r.status})` });
|
||||||
|
else sendResponse(data);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[Background] cloudReserveClient error:", err);
|
||||||
|
sendResponse({ error: err?.message ?? "Network error" });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
|
||||||
console.log("Unknown request type");
|
const { client_id, redirect_uri, login, password } = request;
|
||||||
|
if (!client_id || !redirect_uri || !login || !password) {
|
||||||
|
sendResponse({ error: "Missing client_id, redirect_uri, login, or password" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fetch("https://accounts.betterseqta.org/api/bsplus/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ client_id, redirect_uri, login, password }),
|
||||||
|
})
|
||||||
|
.then(async (r) => {
|
||||||
|
const data = await parseJsonResponse(r);
|
||||||
|
if (!r.ok) sendResponse({ error: data?.error ?? "Login failed" });
|
||||||
|
else sendResponse(data);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[Background] cloudLogin error:", err);
|
||||||
|
sendResponse({ error: err?.message ?? "Network error" });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
|
||||||
|
const { refresh_token, client_id } = request;
|
||||||
|
if (!refresh_token || !client_id) {
|
||||||
|
sendResponse({ error: "Missing refresh_token or client_id" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fetch("https://accounts.betterseqta.org/api/bsplus/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ refresh_token, client_id }),
|
||||||
|
})
|
||||||
|
.then(async (r) => {
|
||||||
|
const data = await parseJsonResponse(r);
|
||||||
|
if (!r.ok) sendResponse({ error: data?.error ?? "Refresh failed" });
|
||||||
|
else sendResponse(data);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[Background] cloudRefresh error:", err);
|
||||||
|
sendResponse({ error: err?.message ?? "Network error" });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
|
||||||
|
const { themeId, token, action } = request;
|
||||||
|
if (!themeId || !token) {
|
||||||
|
sendResponse({ success: false, error: "Theme ID and token required" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const isFavorite = action === "favorite";
|
||||||
|
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, {
|
||||||
|
method: isFavorite ? "POST" : "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(sendResponse)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[Background] cloudFavorite error:", err);
|
||||||
|
sendResponse({ success: false, error: err?.message });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handler for a message type; receives request, sendResponse, and optional sender (for tab routing) */
|
||||||
|
type MessageHandler = {
|
||||||
|
(request: any, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender): boolean | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isSeqtaOrigin(origin: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(origin);
|
||||||
|
return u.hostname.includes("seqta") || u.hostname.endsWith(".edu.au");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
||||||
|
reloadTabs: () => reloadSeqtaPages(),
|
||||||
|
extensionPages: (req) => {
|
||||||
|
browser.tabs.query({}).then((tabs) => {
|
||||||
|
for (const tab of tabs) {
|
||||||
|
if (tab.url?.includes("chrome-extension://")) browser.tabs.sendMessage(tab.id!, req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
currentTab: (req, sendResponse) => {
|
||||||
|
browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => {
|
||||||
|
browser.tabs.sendMessage(tabs[0].id!, req).then(sendResponse);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
githubTab: () => {
|
||||||
|
void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
|
||||||
|
},
|
||||||
|
setDefaultStorage: () => SetStorageValue(getDefaultValues()),
|
||||||
|
sendNews: (req, sendResponse) => {
|
||||||
|
fetchNews(req.source ?? "australia", sendResponse);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
fetchThemes: handleFetchThemes,
|
||||||
|
fetchThemeDetails: handleFetchThemeDetails,
|
||||||
|
fetchFromUrl: handleFetchFromUrl,
|
||||||
|
cloudReserveClient: handleCloudReserveClient,
|
||||||
|
cloudLogin: handleCloudLogin,
|
||||||
|
cloudRefresh: handleCloudRefresh,
|
||||||
|
cloudFavorite: handleCloudFavorite,
|
||||||
|
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
let tabId = sender?.tab?.id;
|
||||||
|
let originForCheck: string | undefined = req.baseUrl;
|
||||||
|
|
||||||
|
if (tabId == null) {
|
||||||
|
const tabs = await browser.tabs.query({ active: true, lastFocusedWindow: true });
|
||||||
|
const tab = tabs[0];
|
||||||
|
if (!tab?.id || !tab.url) {
|
||||||
|
sendResponse({ appLink: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabId = tab.id;
|
||||||
|
if (!originForCheck) originForCheck = new URL(tab.url).origin;
|
||||||
|
} else if (!originForCheck && sender?.tab?.url) {
|
||||||
|
originForCheck = new URL(sender.tab.url).origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!originForCheck || !isSeqtaOrigin(originForCheck)) {
|
||||||
|
sendResponse({ appLink: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = (await browser.tabs.sendMessage(tabId, { type: "fetchSeqtaAppLink" })) as
|
||||||
|
| { appLink?: string | null }
|
||||||
|
| undefined;
|
||||||
|
const appLink = typeof reply?.appLink === "string" && reply.appLink.length > 0 ? reply.appLink : null;
|
||||||
|
sendResponse({ appLink });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Background] getSeqtaSession error:", err);
|
||||||
|
sendResponse({ appLink: null });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener(
|
||||||
|
// @ts-ignore - OnMessageListener expects literal true for async, we return boolean
|
||||||
|
(request: any, sender: browser.Runtime.MessageSender, sendResponse: MessageSender) => {
|
||||||
|
const handler = MESSAGE_HANDLERS[request.type];
|
||||||
|
if (handler) {
|
||||||
|
const result = handler(request, sendResponse, sender);
|
||||||
|
return result === true;
|
||||||
}
|
}
|
||||||
|
console.log("Unknown request type");
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -108,7 +309,7 @@ function getDefaultValues(): SettingsState {
|
|||||||
originalSelectedColor: "",
|
originalSelectedColor: "",
|
||||||
DarkMode: true,
|
DarkMode: true,
|
||||||
animations: !isLowEndDevice,
|
animations: !isLowEndDevice,
|
||||||
assessmentsAverage: true,
|
assessmentsAverage: false,
|
||||||
defaultPage: "home",
|
defaultPage: "home",
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{
|
{
|
||||||
@@ -127,6 +328,9 @@ function getDefaultValues(): SettingsState {
|
|||||||
customshortcuts: [],
|
customshortcuts: [],
|
||||||
lettergrade: false,
|
lettergrade: false,
|
||||||
newsSource: "australia",
|
newsSource: "australia",
|
||||||
|
iconOnlySidebar: false,
|
||||||
|
adaptiveThemeColour: false,
|
||||||
|
adaptiveThemeGradient: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-12
@@ -92,8 +92,12 @@ const rssFeedsByCountry: Record<string, string[]> = {
|
|||||||
* used to send the fetched news data back to the caller.
|
* used to send the fetched news data back to the caller.
|
||||||
* It's called with an object like `{ news: { articles: [...] } }`.
|
* It's called with an object like `{ news: { articles: [...] } }`.
|
||||||
*/
|
*/
|
||||||
export async function fetchNews(source: string, sendResponse: any) {
|
export async function fetchNews(source: string | undefined, sendResponse: any) {
|
||||||
if (source === "australia") {
|
const normalizedSource = typeof source === "string" && source.trim()
|
||||||
|
? source.trim()
|
||||||
|
: "australia";
|
||||||
|
|
||||||
|
if (normalizedSource === "australia") {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
|
|
||||||
const from =
|
const from =
|
||||||
@@ -111,18 +115,15 @@ export async function fetchNews(source: string, sendResponse: any) {
|
|||||||
|
|
||||||
const parser = new Parser();
|
const parser = new Parser();
|
||||||
let feeds: string[];
|
let feeds: string[];
|
||||||
console.log("fetchNews", source);
|
console.log("fetchNews", normalizedSource);
|
||||||
|
|
||||||
if (rssFeedsByCountry[source.toLowerCase()]) {
|
if (rssFeedsByCountry[normalizedSource.toLowerCase()]) {
|
||||||
// If the source is a country, fetch from predefined feeds
|
feeds = rssFeedsByCountry[normalizedSource.toLowerCase()];
|
||||||
feeds = rssFeedsByCountry[source.toLowerCase()];
|
} else if (normalizedSource.startsWith("http")) {
|
||||||
} else if (source.startsWith("http")) {
|
feeds = [normalizedSource];
|
||||||
// If the source is a URL, use it directly
|
|
||||||
feeds = [source];
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
console.warn("[BetterSEQTA+] Invalid news source, falling back to Australia", normalizedSource);
|
||||||
"Invalid source. Provide a country code or a valid RSS feed URL.",
|
return fetchNews("australia", sendResponse);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const articlesPromises = feeds.map(async (feedUrl) => {
|
const articlesPromises = feeds.map(async (feedUrl) => {
|
||||||
|
|||||||
@@ -17,10 +17,42 @@
|
|||||||
|
|
||||||
@use "injected/popup.scss";
|
@use "injected/popup.scss";
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Roboto";
|
||||||
|
src: url("https://fonts.gstatic.com/s/roboto/v50/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2")
|
||||||
|
format("woff2");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IconFamily";
|
||||||
|
src: url("@/resources/fonts/IconFamily.woff") format("woff");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base, override;
|
||||||
|
|
||||||
|
@layer override {
|
||||||
|
* {
|
||||||
|
font-family: Rubik, sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconFamily,
|
||||||
|
.iconFamily *,
|
||||||
|
[class~="iconFamily"],
|
||||||
|
[class~="iconFamily"] * {
|
||||||
|
font-family: "IconFamily" !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
background: #161616 !important;
|
background: #161616 !important;
|
||||||
background-color: #161616;
|
background-color: #161616;
|
||||||
font-family: Rubik, Roboto !important;
|
font-family: Roboto, system-ui, -apple-system, sans-serif !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip svg {
|
.tooltip svg {
|
||||||
|
|||||||
+1
-2
@@ -116,8 +116,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cke_panel_listItem > a {
|
.cke_panel_listItem > a {
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #3d3d3e !important;
|
background: #3d3d3e !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+455
-353
File diff suppressed because it is too large
Load Diff
@@ -35,9 +35,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#menu .sub {
|
#menu .sub {
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease, left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu > ul:has(li.hasChildren.active) > li.active {
|
#menu > ul:has(li.hasChildren.active) > li.active {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Icon-only collapsed: submenu slides over narrow icons */
|
||||||
|
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li::before,
|
||||||
|
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li::before,
|
||||||
|
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li > label,
|
||||||
|
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li > svg,
|
||||||
|
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > label,
|
||||||
|
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > svg {
|
||||||
|
transform: translateX(-70px);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"last_updated": "2024-06-15T12:00:00Z",
|
||||||
|
"whatsnew_html": "<div class=\"whatsnewTextContainer\" style=\"overflow-y: auto; font-size: 1.3rem; line-height: 1.6;\"><p>It has come to our attention that several schools have expressed concerns about BetterSEQTA+. This is very disheartening, so we have decided to release a statement on the situation.</p><p>To view our privacy policy, please click the <strong>shield icon</strong> in the settings menu, or <a href=\"https://betterseqta.org/privacy\" target=\"_blank\" rel=\"noopener noreferrer\" id=\"privacy-link\" style=\"color: inherit; text-decoration: underline; cursor: pointer; white-space: nowrap;\">click here</a>.</p><p style=\"font-weight: bold; margin-top: 15px;\">We never collect any information from you, and aim to provide the best features possible.</p></div>"
|
||||||
|
}
|
||||||
@@ -2,6 +2,16 @@ div:has(> #rbgcp-wrapper) {
|
|||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#rbgcp-inputs-wrap {
|
||||||
|
padding-top: 4px !important;
|
||||||
|
margin-bottom: -8px;
|
||||||
|
|
||||||
|
#rbgcp-hex-input,
|
||||||
|
#rbgcp-input {
|
||||||
|
height: 28px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
#rbgcp-wrapper {
|
#rbgcp-wrapper {
|
||||||
div[style="padding-top: 11px; position: relative;"] div {
|
div[style="padding-top: 11px; position: relative;"] div {
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ export default function Picker({
|
|||||||
<ColorPicker
|
<ColorPicker
|
||||||
disableDarkMode={true}
|
disableDarkMode={true}
|
||||||
presets={presets}
|
presets={presets}
|
||||||
hideInputs={customOnChange ? false : true}
|
|
||||||
value={customThemeColor ?? ""}
|
value={customThemeColor ?? ""}
|
||||||
onChange={(color: string) => {
|
onChange={(color: string) => {
|
||||||
if (customOnChange) {
|
if (customOnChange) {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
|
import browser from "webextension-polyfill";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
import { portal } from "../utils/portal";
|
||||||
|
|
||||||
|
let showQrModal = $state(false);
|
||||||
|
let qrDataUrl = $state<string | null>(null);
|
||||||
|
let appLink = $state<string | null>(null);
|
||||||
|
let errorMessage = $state<string | null>(null);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let isStandalone = $state(false);
|
||||||
|
|
||||||
|
function isExtensionPage(): boolean {
|
||||||
|
return (
|
||||||
|
window.location.protocol === "chrome-extension:" ||
|
||||||
|
window.location.protocol === "moz-extension:"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSeqtaUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.hostname.includes("seqta") || u.hostname.endsWith(".edu.au");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.origin;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAppLink(): Promise<string | null> {
|
||||||
|
let baseUrl: string | undefined;
|
||||||
|
|
||||||
|
if (isExtensionPage()) {
|
||||||
|
baseUrl = undefined;
|
||||||
|
} else {
|
||||||
|
baseUrl = normalizeBaseUrl(window.location.href);
|
||||||
|
if (!isSeqtaUrl(baseUrl)) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { appLink: link } = (await browser.runtime.sendMessage({
|
||||||
|
type: "getSeqtaSession",
|
||||||
|
baseUrl,
|
||||||
|
})) as { appLink: string | null };
|
||||||
|
return link ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateQrCode() {
|
||||||
|
errorMessage = null;
|
||||||
|
qrDataUrl = null;
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isStandalone = isExtensionPage();
|
||||||
|
const link = await getAppLink();
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
if (isStandalone) {
|
||||||
|
errorMessage =
|
||||||
|
"Open SEQTA Learn in a tab and log in, then open settings from that tab to generate a QR code.";
|
||||||
|
} else {
|
||||||
|
errorMessage = "Please log in to SEQTA Learn first.";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = await QRCode.toDataURL(link, { width: 256, margin: 2 });
|
||||||
|
appLink = link;
|
||||||
|
qrDataUrl = dataUrl;
|
||||||
|
showQrModal = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ConnectMobileApp] Failed to generate QR:", err);
|
||||||
|
errorMessage = "Failed to generate QR code. Please try again.";
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showQrModal = false;
|
||||||
|
qrDataUrl = null;
|
||||||
|
appLink = null;
|
||||||
|
errorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAppLink() {
|
||||||
|
if (appLink) window.location.href = appLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadQrImage() {
|
||||||
|
if (!qrDataUrl) return;
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = qrDataUrl;
|
||||||
|
link.download = "desqta-login-qr.png";
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 items-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={generateQrCode}
|
||||||
|
disabled={isLoading}
|
||||||
|
class="px-5 py-1.5 text-[0.75rem] text-nowrap shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-opacity">
|
||||||
|
{isLoading ? "Generating..." : "Generate QR"}
|
||||||
|
</button>
|
||||||
|
{#if errorMessage}
|
||||||
|
<p class="text-xs text-right text-amber-600 dark:text-amber-400">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showQrModal && qrDataUrl}
|
||||||
|
<div
|
||||||
|
use:portal
|
||||||
|
class="fixed cursor-auto inset-0 z-[10000] flex justify-center items-center bg-black/50 {isStandalone ? 'backdrop-blur-sm' : ''}"
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) closeModal();
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Escape") closeModal();
|
||||||
|
}}
|
||||||
|
transition:fade={{ duration: 150 }}>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="p-6 mx-4 w-full max-w-sm bg-white rounded-2xl shadow-2xl dark:bg-zinc-800"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Scan with DesQTA</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closeModal}
|
||||||
|
class="p-2 rounded-lg transition-colors text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 dark:hover:text-zinc-400 dark:hover:bg-zinc-700"
|
||||||
|
aria-label="Close">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center p-4 bg-white rounded-xl dark:bg-zinc-900">
|
||||||
|
<img src={qrDataUrl} alt="SEQTA Learn app link QR code" class="w-64 h-64" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={openAppLink}
|
||||||
|
class="px-4 py-2.5 w-full text-sm font-medium text-white bg-indigo-600 rounded-lg transition-colors dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
|
||||||
|
Sign into DesQTA Desktop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={downloadQrImage}
|
||||||
|
class="px-4 py-2 w-full text-xs font-medium rounded-lg border transition-colors text-zinc-500 dark:text-zinc-400 border-zinc-200 dark:border-zinc-600 hover:bg-zinc-50 dark:hover:bg-zinc-800/50">
|
||||||
|
Download QR as image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-center text-zinc-600 dark:text-zinc-400">
|
||||||
|
Or scan this QR code with DesQTA on your phone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { animate } from 'motion';
|
||||||
|
|
||||||
|
let { onConfirm, onCancel, title, message } = $props<{
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let modalElement: HTMLElement;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (modalElement) {
|
||||||
|
animate(
|
||||||
|
modalElement,
|
||||||
|
{ scale: [0.9, 1], opacity: [0, 1] },
|
||||||
|
{
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 25
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex fixed inset-0 z-[10000] justify-center items-center bg-black/50"
|
||||||
|
style="position: fixed; top: 0; left: 0; right: 0; bottom: 0;"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onCancel();
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
bind:this={modalElement}
|
||||||
|
class="p-4 mx-4 w-full max-w-md bg-white rounded-2xl shadow-2xl dark:bg-zinc-800"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 class="mb-3 text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="mb-6 text-lg text-gray-700 whitespace-pre-line dark:text-gray-300">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
onclick={onCancel}
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg transition-colors hover:bg-gray-200 dark:bg-zinc-700 dark:text-gray-200 dark:hover:bg-zinc-600"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={onConfirm}
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg shadow-inner transition-colors hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600"
|
||||||
|
>
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -8,12 +8,12 @@
|
|||||||
let select: HTMLSelectElement;
|
let select: HTMLSelectElement;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 shadow-2xl rounded-lg w-full overflow-clip">
|
<div class="select-wrapper relative w-full overflow-hidden rounded-2xl border shadow-2xl">
|
||||||
<select
|
<select
|
||||||
bind:this={select}
|
bind:this={select}
|
||||||
value={state}
|
value={state}
|
||||||
onchange={() => onChange(select.value)}
|
onchange={() => onChange(select.value)}
|
||||||
class="px-4 py-1 text-[0.75rem] dark:text-white w-full border-none bg-transparent focus:ring-0 focus:bg-white/20 dark:focus:bg-black/10"
|
class="select-input w-full appearance-none border-none bg-transparent px-4 py-2.5 pr-10 text-[0.875rem] font-medium transition-colors"
|
||||||
>
|
>
|
||||||
{#each options as option}
|
{#each options as option}
|
||||||
<option value={option.value}>
|
<option value={option.value}>
|
||||||
@@ -21,4 +21,62 @@
|
|||||||
</option>
|
</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
<span class="select-icon pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
||||||
|
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.select-wrapper {
|
||||||
|
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 72%, transparent);
|
||||||
|
border-radius: 18px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
background-color 180ms ease,
|
||||||
|
border-color 180ms ease,
|
||||||
|
box-shadow 180ms ease,
|
||||||
|
transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-wrapper:hover {
|
||||||
|
background: color-mix(in srgb, var(--background-primary) 94%, var(--background-secondary) 6%);
|
||||||
|
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 88%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-wrapper:focus-within {
|
||||||
|
background: color-mix(in srgb, var(--background-primary) 96%, var(--background-secondary) 4%);
|
||||||
|
border-color: color-mix(in srgb, var(--text-primary) 22%, var(--theme-offset-bg, var(--background-secondary)) 78%);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-input {
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-input:hover,
|
||||||
|
.select-input:focus {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-input option {
|
||||||
|
background: var(--background-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-icon {
|
||||||
|
color: color-mix(in srgb, var(--text-primary) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-input {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .select-input {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
|
import { animate } from "motion";
|
||||||
|
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
|
||||||
|
|
||||||
|
let { onClose } = $props<{ onClose: () => void }>();
|
||||||
|
let modalElement: HTMLElement;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (modalElement) {
|
||||||
|
animate(modalElement, { scale: [0.9, 1], opacity: [0, 1] }, { type: "spring", stiffness: 300, damping: 25 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSignIn() {
|
||||||
|
onClose();
|
||||||
|
if (document.getElementById("ExtensionPopup")) {
|
||||||
|
closeExtensionPopup();
|
||||||
|
} else {
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex fixed inset-0 z-[10000] justify-center items-center bg-black/50"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
bind:this={modalElement}
|
||||||
|
class="p-4 mx-4 w-full max-w-md bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 class="mb-3 text-xl font-bold text-zinc-900 dark:text-white">
|
||||||
|
Sign in to favorite themes
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="mb-6 text-zinc-600 dark:text-zinc-400">
|
||||||
|
Sign in in the Theme Store to save favorites across devices, or create an account to get started.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClose}
|
||||||
|
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="https://accounts.betterseqta.org/register"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleSignIn}
|
||||||
|
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
let percentage = $derived(((state - min) / (max - min)) * 100);
|
let percentage = $derived(((state - min) / (max - min)) * 100);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative mx-auto w-full max-w-lg">
|
<div class="relative w-full min-w-0">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={min}
|
min={min}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
import './TabbedContainer.css';
|
import './TabbedContainer.css';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>();
|
let { tabs, activeTab = $bindable(0) } = $props<{ tabs: { title: string, Content: any, props?: any }[]; activeTab?: number }>();
|
||||||
let activeTab = $state(0);
|
|
||||||
let containerRef: HTMLElement | null = null;
|
let containerRef: HTMLElement | null = null;
|
||||||
let tabWidth = $state(0);
|
let tabWidth = $state(0);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
|
||||||
|
let username = $state("");
|
||||||
|
let password = $state("");
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let cloudState = $state(cloudAuth.state);
|
||||||
|
let open = $state(false);
|
||||||
|
let dropdownEl: HTMLElement;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const unsubscribe = cloudAuth.subscribe((state) => {
|
||||||
|
cloudState = state;
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (dropdownEl && !dropdownEl.contains(e.target as Node)) {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
document.addEventListener("click", handleClickOutside);
|
||||||
|
}, 0);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener("click", handleClickOutside);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (loading) return;
|
||||||
|
error = null;
|
||||||
|
if (!username.trim() || !password) {
|
||||||
|
error = "Please enter username and password";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const result = await cloudAuth.login(username.trim(), password);
|
||||||
|
if (result.success) {
|
||||||
|
password = "";
|
||||||
|
open = false;
|
||||||
|
} else {
|
||||||
|
error = result.error ?? "Login failed";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await cloudAuth.logout();
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(): string {
|
||||||
|
const u = cloudState.user;
|
||||||
|
if (!u) return "?";
|
||||||
|
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
|
||||||
|
if (u.username) return u.username.slice(0, 2).toUpperCase();
|
||||||
|
if (u.email) return u.email.slice(0, 2).toUpperCase();
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative flex items-center" bind:this={dropdownEl}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-100/80 dark:bg-zinc-700/80 hover:bg-zinc-200/80 dark:hover:bg-zinc-600/80 transition-colors duration-200 text-base font-medium text-zinc-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{#if cloudState.isLoggedIn}
|
||||||
|
{#if cloudState.user?.pfpUrl}
|
||||||
|
<img
|
||||||
|
src={cloudState.user.pfpUrl}
|
||||||
|
alt=""
|
||||||
|
class="w-8 h-8 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-sm">
|
||||||
|
{getInitials()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<span class="hidden max-w-24 truncate sm:inline text-base">
|
||||||
|
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xl font-IconFamily" aria-hidden="true">{'\ued53'}</span>
|
||||||
|
<span class="text-base font-medium">Sign in</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full mt-2 w-80 rounded-xl border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-xl z-[100] overflow-hidden"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="p-4 border-b border-zinc-200 dark:border-zinc-600">
|
||||||
|
<h3 class="text-xl font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
|
||||||
|
<p class="text-base text-zinc-500 dark:text-zinc-400">Sync favorites across devices</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
{#if cloudState.isLoggedIn}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if cloudState.user?.pfpUrl}
|
||||||
|
<img
|
||||||
|
src={cloudState.user.pfpUrl}
|
||||||
|
alt=""
|
||||||
|
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
|
||||||
|
{getInitials()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-base font-medium text-zinc-900 dark:text-white truncate">
|
||||||
|
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
|
||||||
|
</p>
|
||||||
|
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
|
||||||
|
<p class="text-base text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleLogout}
|
||||||
|
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-4 text-base text-zinc-600 dark:text-zinc-400">
|
||||||
|
Sign in to favorite themes. Your favorites sync across devices when logged in.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
autocomplete="off"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleLogin();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="betterseqta-cloud-username"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Email or username"
|
||||||
|
bind:value={username}
|
||||||
|
disabled={loading}
|
||||||
|
readonly
|
||||||
|
onfocus={(e) => e.currentTarget.removeAttribute('readonly')}
|
||||||
|
class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="betterseqta-cloud-password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="Password"
|
||||||
|
bind:value={password}
|
||||||
|
disabled={loading}
|
||||||
|
readonly
|
||||||
|
onfocus={(e) => e.currentTarget.removeAttribute('readonly')}
|
||||||
|
class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
|
||||||
|
/>
|
||||||
|
{#if error}
|
||||||
|
<p class="text-base text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="https://accounts.betterseqta.org/register"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
{#if coverThemes.length > 0}
|
{#if coverThemes.length > 0}
|
||||||
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
|
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
|
||||||
<div
|
<div
|
||||||
class="w-full aspect-8/3"
|
class="w-full aspect-[5/1] max-h-[500px]"
|
||||||
use:emblaCarouselSvelte={{ options, plugins }}
|
use:emblaCarouselSvelte={{ options, plugins }}
|
||||||
onemblaInit={onInit}
|
onemblaInit={onInit}
|
||||||
>
|
>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
|
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
|
||||||
onclick={() => setDisplayTheme(theme)}
|
onclick={() => setDisplayTheme(theme)}
|
||||||
>
|
>
|
||||||
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-full" />
|
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-full" />
|
||||||
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
|
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
|
||||||
<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>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import logoDark from '@/resources/icons/betterseqta-light-full.png';
|
import logoDark from '@/resources/icons/betterseqta-light-full.png';
|
||||||
import { closeStore } from '@/seqta/ui/renderStore'
|
import { closeStore } from '@/seqta/ui/renderStore'
|
||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
|
import CloudHeader from './CloudHeader.svelte';
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
|
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
|
||||||
@@ -39,6 +40,8 @@
|
|||||||
>
|
>
|
||||||
Backgrounds
|
Backgrounds
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<CloudHeader />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex relative gap-2">
|
<div class="flex relative gap-2">
|
||||||
|
|||||||
@@ -1,19 +1,110 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Theme } from '@/interface/types/Theme'
|
import type { Theme } from '@/interface/types/Theme'
|
||||||
|
|
||||||
let { theme, onClick } = $props<{ theme: Theme; onClick: () => void }>();
|
|
||||||
|
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte';
|
||||||
|
|
||||||
|
let { theme, onClick, toggleFavorite, isLoggedIn } = $props<{
|
||||||
|
theme: Theme;
|
||||||
|
onClick: () => void;
|
||||||
|
toggleFavorite: (theme: Theme) => void;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
}>();
|
||||||
|
let menuOpen = $state(false);
|
||||||
|
let showSignInModal = $state(false);
|
||||||
|
let menuRef: HTMLDivElement;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const closeMenu = (e: MouseEvent) => {
|
||||||
|
if (menuOpen && menuRef && !menuRef.contains(e.target as Node)) {
|
||||||
|
menuOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', closeMenu);
|
||||||
|
return () => document.removeEventListener('click', closeMenu);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleCardClick(e: MouseEvent) {
|
||||||
|
if ((e.target as HTMLElement).closest('[data-theme-menu]')) return;
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFavoriteClick(e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isLoggedIn) {
|
||||||
|
toggleFavorite(theme);
|
||||||
|
} else {
|
||||||
|
showSignInModal = true;
|
||||||
|
}
|
||||||
|
menuOpen = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={onClick}>
|
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={handleCardClick}>
|
||||||
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
|
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
|
||||||
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white">
|
<!-- Menu dropdown -->
|
||||||
{theme.name}
|
<div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex justify-center items-center w-8 h-8 rounded-lg bg-black/40 hover:bg-black/60 text-white transition-all"
|
||||||
|
onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}
|
||||||
|
aria-label="Theme options"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
|
||||||
|
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if menuOpen}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full mt-1 py-1 min-w-[140px] rounded-lg bg-white dark:bg-zinc-800 shadow-lg border border-zinc-200 dark:border-zinc-700"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex gap-2 items-center w-full px-3 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700"
|
||||||
|
role="menuitem"
|
||||||
|
onclick={handleFavoriteClick}
|
||||||
|
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill={theme.is_favorited ? 'currentColor' : 'none'}
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="w-5 h-5 {theme.is_favorited ? 'text-red-500' : ''}"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
|
</svg>
|
||||||
|
{theme.is_favorited ? 'Favorited' : 'Favorite'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-1 left-3 right-3 z-10 mb-1 flex flex-col gap-0.5">
|
||||||
|
<span class="text-xl font-bold text-white drop-shadow-md">{theme.name}</span>
|
||||||
|
<div class="flex gap-3 text-xs font-medium text-white/90 drop-shadow-sm">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
{(theme.download_count ?? 0).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
|
</svg>
|
||||||
|
{(theme.favorite_count ?? 0).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
|
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
|
||||||
<div class='w-full'>
|
<div class='w-full'>
|
||||||
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
|
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showSignInModal}
|
||||||
|
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
import type { Theme } from '@/interface/types/Theme'
|
import type { Theme } from '@/interface/types/Theme'
|
||||||
import ThemeCard from './ThemeCard.svelte';
|
import ThemeCard from './ThemeCard.svelte';
|
||||||
|
|
||||||
let { themes, searchTerm, setDisplayTheme } = $props<{ themes: Theme[]; searchTerm: string, setDisplayTheme: (theme: Theme) => void }>();
|
let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn } = $props<{
|
||||||
|
themes: Theme[];
|
||||||
|
searchTerm: string;
|
||||||
|
setDisplayTheme: (theme: Theme) => void;
|
||||||
|
toggleFavorite: (theme: Theme) => void;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
let filteredThemes = $derived(themes.filter((theme: Theme) =>
|
let filteredThemes = $derived(themes.filter((theme: Theme) =>
|
||||||
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
|
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
@@ -12,7 +18,12 @@
|
|||||||
<div class="relative" >
|
<div class="relative" >
|
||||||
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each filteredThemes as theme (theme.id)}
|
{#each filteredThemes as theme (theme.id)}
|
||||||
<ThemeCard theme={theme} onClick={() => setDisplayTheme(theme)} />
|
<ThemeCard
|
||||||
|
{theme}
|
||||||
|
onClick={() => setDisplayTheme(theme)}
|
||||||
|
{toggleFavorite}
|
||||||
|
{isLoggedIn}
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if filteredThemes.length !== 0}
|
{#if filteredThemes.length !== 0}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import type { Theme } from '@/interface/types/Theme'
|
import type { Theme } from '@/interface/types/Theme'
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { animate } from 'motion';
|
import { animate } from 'motion';
|
||||||
|
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte';
|
||||||
|
|
||||||
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme } = $props<{
|
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme, toggleFavorite, isLoggedIn } = $props<{
|
||||||
theme: Theme | null;
|
theme: Theme | null;
|
||||||
currentThemes: string[];
|
currentThemes: string[];
|
||||||
setDisplayTheme: (theme: Theme | null) => void;
|
setDisplayTheme: (theme: Theme | null) => void;
|
||||||
@@ -11,15 +12,30 @@
|
|||||||
onRemove: (themeId: string) => void;
|
onRemove: (themeId: string) => void;
|
||||||
allThemes: Theme[];
|
allThemes: Theme[];
|
||||||
displayTheme: Theme | null;
|
displayTheme: Theme | null;
|
||||||
|
toggleFavorite?: (theme: Theme) => void;
|
||||||
|
isLoggedIn?: boolean;
|
||||||
}>();
|
}>();
|
||||||
let installing = $state(false);
|
let installing = $state(false);
|
||||||
|
let showSignInModal = $state(false);
|
||||||
let modalElement: HTMLElement;
|
let modalElement: HTMLElement;
|
||||||
|
|
||||||
|
function handleFavoriteClick() {
|
||||||
|
if (isLoggedIn && toggleFavorite && theme) {
|
||||||
|
toggleFavorite(theme);
|
||||||
|
} else {
|
||||||
|
showSignInModal = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Function to get related themes
|
// Function to get related themes
|
||||||
function getRelatedThemes() {
|
function getRelatedThemes() {
|
||||||
|
if (!theme) return [];
|
||||||
return allThemes
|
return allThemes
|
||||||
.filter((t: Theme) => t.id !== theme.id)
|
.filter((t: Theme) => !!t && t.id !== theme.id)
|
||||||
.sort((a: Theme, b: Theme) => a.name.localeCompare(theme.name) - b.name.localeCompare(theme.name))
|
.sort(
|
||||||
|
(a: Theme, b: Theme) =>
|
||||||
|
a.name.localeCompare(theme.name) - b.name.localeCompare(theme.name),
|
||||||
|
)
|
||||||
.slice(0, 4);
|
.slice(0, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,36 +88,69 @@
|
|||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onkeydown={(e) => e.stopPropagation()}
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
{#if theme}
|
||||||
<div class="relative h-auto">
|
<div class="relative h-auto">
|
||||||
<button class="absolute top-0 right-0 p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
|
<div class="absolute top-0 right-0 flex gap-1 items-center">
|
||||||
{'\ued8a'}
|
<button class="p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
|
||||||
</button>
|
{'\ued8a'}
|
||||||
<h2 class="mb-4 text-2xl font-bold">
|
</button>
|
||||||
|
</div>
|
||||||
|
<h2 class="mb-2 text-2xl font-bold">
|
||||||
{theme.name}
|
{theme.name}
|
||||||
</h2>
|
</h2>
|
||||||
<img src={theme.marqueeImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" />
|
<div class="flex gap-4 mb-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
{(theme.download_count ?? 0).toLocaleString()} downloads
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
|
</svg>
|
||||||
|
{(theme.favorite_count ?? 0).toLocaleString()} favorites
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" />
|
||||||
<p class="mb-4 text-gray-700 dark:text-gray-300">
|
<p class="mb-4 text-gray-700 dark:text-gray-300">
|
||||||
{theme.description}
|
{theme.description}
|
||||||
</p>
|
</p>
|
||||||
{#if currentThemes.includes(theme.id)}
|
<div class="flex flex-wrap gap-2 mt-4 justify-end items-center">
|
||||||
<button onclick={async () => {installing = true; await onRemove(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 mt-4 ml-auto w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200">
|
{#if toggleFavorite && theme}
|
||||||
{#if installing}
|
<button
|
||||||
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
type="button"
|
||||||
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
|
class="flex items-center gap-2 px-4 py-2 rounded-full transition-all duration-200 hover:scale-105 active:scale-95 {theme.is_favorited ? 'text-red-500 bg-red-500/10 dark:bg-red-500/20' : 'bg-zinc-200 dark:bg-zinc-700 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600'}"
|
||||||
|
onclick={handleFavoriteClick}
|
||||||
|
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
|
||||||
|
aria-label={theme.is_favorited ? 'Unfavorite' : 'Favorite'}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{theme.is_favorited ? 'Favorited' : 'Favorite'}
|
||||||
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Remove</span>
|
</button>
|
||||||
</button>
|
{/if}
|
||||||
{:else}
|
{#if currentThemes.includes(theme.id)}
|
||||||
<button onclick={async () => {installing = true; await onInstall(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 mt-4 ml-auto w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200">
|
<button onclick={async () => {installing = true; await onRemove(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95">
|
||||||
{#if installing}
|
{#if installing}
|
||||||
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
|
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Install</span>
|
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Remove</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{:else}
|
||||||
|
<button onclick={async () => {installing = true; await onInstall(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95">
|
||||||
|
{#if installing}
|
||||||
|
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Install</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
|
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
|
||||||
|
|
||||||
@@ -116,11 +165,22 @@
|
|||||||
{relatedTheme.name}
|
{relatedTheme.name}
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 z-0 w-full h-3/4 to-transparent from-black/80 bg-linear-to-t"></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 || relatedTheme.coverImage} alt="Theme Preview" class="object-cover w-full h-48" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex justify-center items-center h-full text-zinc-600 dark:text-zinc-300">
|
||||||
|
<button class="px-4 py-2 rounded-lg bg-zinc-200 dark:bg-zinc-700 transition-all duration-200 hover:scale-105 active:scale-95" onclick={() => hideModal()}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showSignInModal}
|
||||||
|
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
|
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
|
||||||
import { onDestroy, onMount } from 'svelte'
|
import { onDestroy, onMount } from 'svelte'
|
||||||
|
import browser from 'webextension-polyfill'
|
||||||
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
||||||
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/utils/Closers/closeExtensionPopup'
|
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
|
||||||
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
||||||
|
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
||||||
|
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte'
|
||||||
|
|
||||||
const themeManager = ThemeManager.getInstance();
|
const themeManager = ThemeManager.getInstance();
|
||||||
|
|
||||||
@@ -13,6 +16,17 @@
|
|||||||
let { isEditMode } = $props<{ isEditMode: boolean }>();
|
let { isEditMode } = $props<{ isEditMode: boolean }>();
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
let tempTheme = $state(null);
|
let tempTheme = $state(null);
|
||||||
|
let favoriteStatus = $state<Record<string, boolean>>({});
|
||||||
|
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
|
||||||
|
let prevLoggedIn = $state(false);
|
||||||
|
let showSignInModal = $state(false);
|
||||||
|
|
||||||
|
cloudAuth.subscribe((s) => {
|
||||||
|
const now = s.isLoggedIn;
|
||||||
|
if (now && !prevLoggedIn && themes) void fetchThemes();
|
||||||
|
prevLoggedIn = now;
|
||||||
|
cloudLoggedIn = now;
|
||||||
|
});
|
||||||
|
|
||||||
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
|
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
|
||||||
if (isEditMode) return;
|
if (isEditMode) return;
|
||||||
@@ -87,11 +101,55 @@
|
|||||||
themes: await themeManager.getAvailableThemes(),
|
themes: await themeManager.getAvailableThemes(),
|
||||||
selectedTheme: themeManager.getSelectedThemeId() || '',
|
selectedTheme: themeManager.getSelectedThemeId() || '',
|
||||||
}
|
}
|
||||||
|
if (themes && cloudLoggedIn) {
|
||||||
|
const token = await cloudAuth.getStoredToken();
|
||||||
|
if (token) {
|
||||||
|
const status: Record<string, boolean> = {};
|
||||||
|
await Promise.all(
|
||||||
|
themes.themes.map(async (t) => {
|
||||||
|
try {
|
||||||
|
const res = (await browser.runtime.sendMessage({
|
||||||
|
type: 'fetchThemeDetails',
|
||||||
|
themeId: t.id,
|
||||||
|
token,
|
||||||
|
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
|
||||||
|
if (res?.success && res?.data?.theme) {
|
||||||
|
status[t.id] = !!res.data.theme.is_favorited;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Theme may not exist on store (e.g. locally created)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
favoriteStatus = status;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
favoriteStatus = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!cloudLoggedIn) {
|
||||||
|
showSignInModal = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = await cloudAuth.getStoredToken();
|
||||||
|
if (!token) return;
|
||||||
|
const isFavorite = !favoriteStatus[theme.id];
|
||||||
|
const result = (await browser.runtime.sendMessage({
|
||||||
|
type: 'cloudFavorite',
|
||||||
|
themeId: theme.id,
|
||||||
|
token,
|
||||||
|
action: isFavorite ? 'favorite' : 'unfavorite',
|
||||||
|
})) as { success?: boolean };
|
||||||
|
if (result?.success) {
|
||||||
|
favoriteStatus = { ...favoriteStatus, [theme.id]: isFavorite };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await fetchThemes();
|
await fetchThemes();
|
||||||
|
|
||||||
themeUpdates.addListener(fetchThemes);
|
themeUpdates.addListener(fetchThemes);
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -144,6 +202,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !isEditMode}
|
{#if !isEditMode}
|
||||||
|
<div
|
||||||
|
class="flex absolute right-24 top-1/4 z-20 place-items-center p-2 w-8 h-8 text-center rounded-full opacity-0 transition-all -translate-y-1/2 group-hover:opacity-100 group-hover:top-1/2 {(favoriteStatus[theme.id] ?? false) ? 'text-red-400' : 'text-white/80'} bg-black/50"
|
||||||
|
onclick={(event) => handleToggleFavorite(theme, event)}
|
||||||
|
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') handleToggleFavorite(theme, event as any) }}
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
title={cloudLoggedIn ? ((favoriteStatus[theme.id] ?? false) ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={(favoriteStatus[theme.id] ?? false) ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
|
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
|
||||||
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
|
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
|
||||||
@@ -211,3 +281,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showSignInModal}
|
||||||
|
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -11,13 +11,17 @@
|
|||||||
|
|
||||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
|
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
|
||||||
import { OpenAboutPage } from "@/seqta/utils/Openers/OpenAboutPage";
|
import { OpenAboutPage } from "@/seqta/utils/Openers/OpenAboutPage";
|
||||||
import { OpenWhatsNewPopup } from "@/seqta/utils/Whatsnew";
|
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
|
||||||
import { OpenMinecraftServerPopup } from "@/seqta/utils/AboutMinecraftServer";
|
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
|
||||||
|
|
||||||
import ColourPicker from "../components/ColourPicker.svelte";
|
import ColourPicker from "../components/ColourPicker.svelte";
|
||||||
|
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
||||||
import { settingsPopup } from "../hooks/SettingsPopup";
|
import { settingsPopup } from "../hooks/SettingsPopup";
|
||||||
|
|
||||||
let devModeSequence = "";
|
let devModeSequence = "";
|
||||||
|
let settingsActiveTab = $state(0);
|
||||||
|
let showDisclaimerModal = $state(false);
|
||||||
|
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
|
||||||
|
|
||||||
const handleDevModeToggle = () => {
|
const handleDevModeToggle = () => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -50,21 +54,32 @@
|
|||||||
closeExtensionPopup();
|
closeExtensionPopup();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openMinecraftServer = () => {
|
/* const openMinecraftServer = () => {
|
||||||
OpenMinecraftServerPopup();
|
OpenMinecraftServerPopup();
|
||||||
closeExtensionPopup();
|
closeExtensionPopup();
|
||||||
|
}; */
|
||||||
|
|
||||||
|
const openPrivacyStatement = () => {
|
||||||
|
window.open("https://betterseqta.org/privacy", "_blank");
|
||||||
|
closeExtensionPopup();
|
||||||
};
|
};
|
||||||
|
|
||||||
let { standalone } = $props<{ standalone?: boolean }>();
|
let { standalone } = $props<{ standalone?: boolean }>();
|
||||||
let showColourPicker = $state<boolean>(false);
|
let showColourPicker = $state<boolean>(false);
|
||||||
|
|
||||||
onMount(async () => {
|
const showDisclaimer = (onConfirm: () => void, onCancel: () => void) => {
|
||||||
|
disclaimerCallbacks = { onConfirm, onCancel };
|
||||||
|
showDisclaimerModal = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
settingsPopup.addListener(() => {
|
settingsPopup.addListener(() => {
|
||||||
showColourPicker = false;
|
showColourPicker = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!standalone) return;
|
if (standalone) {
|
||||||
StandaloneStore.setStandalone(true);
|
StandaloneStore.setStandalone(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -101,25 +116,34 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{#if !standalone}
|
{#if !standalone}
|
||||||
<button
|
<div class="flex absolute top-1 right-1 gap-1 items-center">
|
||||||
onclick={openAbout}
|
<button
|
||||||
class="absolute top-1 right-[62px] w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
|
onclick={openAbout}
|
||||||
>
|
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
|
||||||
{"\ueb73"}
|
>
|
||||||
</button>
|
{"\ueb73"}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={openChangelog}
|
onclick={openChangelog}
|
||||||
class="absolute top-1 right-10 w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
|
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
|
||||||
>
|
>
|
||||||
{"\ue929"}
|
{"\ue929"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={openMinecraftServer}
|
onclick={openPrivacyStatement}
|
||||||
class="absolute top-1 right-1 w-8 h-8 bg-zinc-100 dark:bg-zinc-700 rounded-xl p-1"
|
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
|
||||||
aria-label="Open Minecraft Server"
|
aria-label="Privacy Statement"
|
||||||
>
|
>
|
||||||
|
{"\uecba"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- <button
|
||||||
|
onclick={openMinecraftServer}
|
||||||
|
class="flex justify-center items-center p-1 w-8 h-8 rounded-xl bg-zinc-100 dark:bg-zinc-700"
|
||||||
|
aria-label="Open Minecraft Server"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 64 70"
|
viewBox="0 0 64 70"
|
||||||
@@ -247,16 +271,18 @@
|
|||||||
transform="translate(18,10)"
|
transform="translate(18,10)"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button> -->
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabbedContainer
|
<TabbedContainer
|
||||||
|
bind:activeTab={settingsActiveTab}
|
||||||
tabs={[
|
tabs={[
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
Content: Settings,
|
Content: Settings,
|
||||||
props: { showColourPicker: openColourPicker },
|
props: { showColourPicker: openColourPicker, showDisclaimer },
|
||||||
},
|
},
|
||||||
{ title: "Shortcuts", Content: Shortcuts },
|
{ title: "Shortcuts", Content: Shortcuts },
|
||||||
{ title: "Themes", Content: Theme },
|
{ title: "Themes", Content: Theme },
|
||||||
@@ -272,3 +298,27 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showDisclaimerModal && disclaimerCallbacks}
|
||||||
|
<DisclaimerModal
|
||||||
|
title="Assessment Averages Disclaimer"
|
||||||
|
message="This feature calculates a simple average of your assessment grades. It does not take into account:
|
||||||
|
• Assessment weightings
|
||||||
|
• Different grading scales
|
||||||
|
• Other factors used in official reports
|
||||||
|
|
||||||
|
The displayed average may be inaccurate compared to your actual marks found in reports.
|
||||||
|
|
||||||
|
Do you want to enable this feature?"
|
||||||
|
onConfirm={() => {
|
||||||
|
disclaimerCallbacks?.onConfirm();
|
||||||
|
showDisclaimerModal = false;
|
||||||
|
disclaimerCallbacks = null;
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
disclaimerCallbacks?.onCancel();
|
||||||
|
showDisclaimerModal = false;
|
||||||
|
disclaimerCallbacks = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
import type { SettingsList } from "@/interface/types/SettingsProps"
|
import type { SettingsList } from "@/interface/types/SettingsProps"
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
|
||||||
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
|
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
|
||||||
|
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
|
||||||
|
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
|
||||||
|
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
||||||
|
|
||||||
import { getAllPluginSettings } from "@/plugins"
|
import { getAllPluginSettings } from "@/plugins"
|
||||||
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
|
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
|
||||||
@@ -90,7 +93,10 @@
|
|||||||
loadPluginSettings();
|
loadPluginSettings();
|
||||||
})
|
})
|
||||||
|
|
||||||
const { showColourPicker } = $props<{ showColourPicker: () => void }>();
|
const { showColourPicker, showDisclaimer } = $props<{
|
||||||
|
showColourPicker: () => void;
|
||||||
|
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
|
||||||
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet Setting({ title, description, Component, props }: SettingsList) }
|
{#snippet Setting({ title, description, Component, props }: SettingsList) }
|
||||||
@@ -108,27 +114,15 @@
|
|||||||
<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: "Connect Mobile App",
|
||||||
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)",
|
description: "Link your SEQTA session to DesQTA — the modern desktop and mobile app for SEQTA Learn",
|
||||||
id: 1,
|
id: 0,
|
||||||
Component: Switch,
|
Component: ConnectMobileApp,
|
||||||
props: {
|
props: {}
|
||||||
state: $settingsState.transparencyEffects,
|
|
||||||
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Custom Theme Colour",
|
|
||||||
description: "Customise the overall theme colour of SEQTA Learn.",
|
|
||||||
id: 4,
|
|
||||||
Component: PickerSwatch,
|
|
||||||
props: {
|
|
||||||
onClick: showColourPicker
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Edit Sidebar Layout",
|
title: "Edit Sidebar Layout",
|
||||||
description: "Customise the sidebar layout.",
|
description: "Reorder pages on the sidebar",
|
||||||
id: 5,
|
id: 5,
|
||||||
Component: Button,
|
Component: Button,
|
||||||
props: {
|
props: {
|
||||||
@@ -136,9 +130,28 @@
|
|||||||
text: "Edit"
|
text: "Edit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Custom Theme Colour",
|
||||||
|
description: "Customise the overall theme colour of SEQTA Learn",
|
||||||
|
id: 4,
|
||||||
|
Component: PickerSwatch,
|
||||||
|
props: {
|
||||||
|
onClick: showColourPicker
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Icon Only Sidebar",
|
||||||
|
description: "Show only icons in the sidebar for a compact layout",
|
||||||
|
id: 14,
|
||||||
|
Component: Switch,
|
||||||
|
props: {
|
||||||
|
state: $settingsState.iconOnlySidebar ?? false,
|
||||||
|
onChange: (isOn: boolean) => settingsState.iconOnlySidebar = isOn
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Animations",
|
title: "Animations",
|
||||||
description: "Enables animations on certain pages.",
|
description: "Enable animations on certain pages",
|
||||||
id: 6,
|
id: 6,
|
||||||
Component: Switch,
|
Component: Switch,
|
||||||
props: {
|
props: {
|
||||||
@@ -156,9 +169,19 @@
|
|||||||
onChange: (isOn: boolean) => settingsState.timeFormat = isOn ? "12" : "24"
|
onChange: (isOn: boolean) => settingsState.timeFormat = isOn ? "12" : "24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Transparency Effects",
|
||||||
|
description: "Enable transparency effects on certain elements, such as blur (May impact battery life)",
|
||||||
|
id: 1,
|
||||||
|
Component: Switch,
|
||||||
|
props: {
|
||||||
|
state: $settingsState.transparencyEffects,
|
||||||
|
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Default Page",
|
title: "Default Page",
|
||||||
description: "The page to load when SEQTA Learn is opened.",
|
description: "The page to load when SEQTA Learn is opened",
|
||||||
id: 10,
|
id: 10,
|
||||||
Component: Select,
|
Component: Select,
|
||||||
props: {
|
props: {
|
||||||
@@ -177,30 +200,62 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "News Feed Source",
|
title: "News Feed Source",
|
||||||
description: "Choose sources of your news feed.",
|
description: "Choose the sources for your news feed",
|
||||||
id: 11,
|
id: 11,
|
||||||
Component: Select,
|
Component: Select,
|
||||||
props: {
|
props: {
|
||||||
state: $settingsState.newsSource,
|
state: $settingsState.newsSource,
|
||||||
onChange: (value: string) => settingsState.newsSource = value,
|
onChange: (value: string) => settingsState.newsSource = value,
|
||||||
options: [
|
options: [
|
||||||
{ value: "australia", label: "Australia" },
|
{ value: "australia", label: "Australia" },
|
||||||
{ value: "usa", label: "USA" },
|
{ value: "usa", label: "USA" },
|
||||||
{ value: "taiwan", label: "Taiwan" },
|
{ value: "uk", label: "UK" },
|
||||||
{ value: "hong_kong", label: "Hong Kong" },
|
{ value: "taiwan", label: "Taiwan" },
|
||||||
{ value: "panama", label: "Panama" },
|
{ value: "hong_kong", label: "Hong Kong" },
|
||||||
{ value: "canada", label: "Canada" },
|
{ value: "panama", label: "Panama" },
|
||||||
{ value: "singapore", label: "Singapore" },
|
{ value: "canada", label: "Canada" },
|
||||||
{ value: "uk", label: "UK" },
|
{ value: "singapore", label: "Singapore" },
|
||||||
{ value: "japan", label: "Japan" },
|
{ value: "japan", label: "Japan" },
|
||||||
{ value: "netherlands", label: "Netherlands" }
|
{ value: "netherlands", label: "Netherlands" }
|
||||||
]
|
]
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
] as option}
|
] as option}
|
||||||
{@render Setting(option)}
|
{@render Setting(option)}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<div class="border-none">
|
||||||
|
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
|
||||||
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Adaptive Theme Colour</h2>
|
||||||
|
<p class="text-xs">Change the theme colour based on the current class (e.g. when viewing a course or assessments page)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Switch
|
||||||
|
state={$settingsState.adaptiveThemeColour ?? false}
|
||||||
|
onChange={(isOn: boolean) => settingsState.adaptiveThemeColour = isOn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if $settingsState.adaptiveThemeColour}
|
||||||
|
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Soft Gradient</h2>
|
||||||
|
<p class="text-xs">Use a soft gradient instead of a solid colour when viewing a class</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Switch
|
||||||
|
state={$settingsState.adaptiveThemeGradient ?? false}
|
||||||
|
onChange={(isOn: boolean) => settingsState.adaptiveThemeGradient = isOn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#each pluginSettings as plugin}
|
{#each pluginSettings as plugin}
|
||||||
<div class="border-none">
|
<div class="border-none">
|
||||||
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40 {!(plugin as any).disableToggle && Object.keys(plugin.settings).length === 0 ? 'hidden' : ''}">
|
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40 {!(plugin as any).disableToggle && Object.keys(plugin.settings).length === 0 ? 'hidden' : ''}">
|
||||||
@@ -221,7 +276,19 @@
|
|||||||
<div>
|
<div>
|
||||||
<Switch
|
<Switch
|
||||||
state={pluginSettingsValues[plugin.pluginId]?.enabled ?? true}
|
state={pluginSettingsValues[plugin.pluginId]?.enabled ?? true}
|
||||||
onChange={(value) => updatePluginSetting(plugin.pluginId, 'enabled', value)}
|
onChange={async (value) => {
|
||||||
|
if (plugin.pluginId === 'assessments-average' && value === true) {
|
||||||
|
showDisclaimer(
|
||||||
|
async () => {
|
||||||
|
await updatePluginSetting(plugin.pluginId, 'enabled', true);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await updatePluginSetting(plugin.pluginId, 'enabled', value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,13 +310,15 @@
|
|||||||
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
||||||
/>
|
/>
|
||||||
{:else if setting.type === 'number'}
|
{:else if setting.type === 'number'}
|
||||||
<Slider
|
<div class="w-28 shrink-0">
|
||||||
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
|
<Slider
|
||||||
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
|
||||||
min={setting.min}
|
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
||||||
max={setting.max}
|
min={setting.min}
|
||||||
step={setting.step}
|
max={setting.max}
|
||||||
/>
|
step={setting.step}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{:else if setting.type === 'string'}
|
{:else if setting.type === 'string'}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -339,6 +408,25 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Show Privacy Notification</h2>
|
||||||
|
<p class="text-xs">Show the privacy notification popup on next page load</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
settingsState.privacyStatementShown = false;
|
||||||
|
settingsState.privacyStatementLastUpdated = undefined;
|
||||||
|
closeExtensionPopup();
|
||||||
|
// Small delay to ensure popup is closed before showing notification
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
await showPrivacyNotification();
|
||||||
|
}}
|
||||||
|
text="Show Now"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,13 +23,19 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const switchChange = (shortcut: any) => {
|
const switchChange = (shortcut: any) => {
|
||||||
const value = $settingsState.shortcuts.find(s => s.name === shortcut);
|
const idx = $settingsState.shortcuts.findIndex(s => s.name === shortcut);
|
||||||
if (value) {
|
if (idx !== -1) {
|
||||||
value.enabled = !value.enabled;
|
// Create a new array with the toggled value to ensure reactivity
|
||||||
settingsState.shortcuts = settingsState.shortcuts;
|
const updated = settingsState.shortcuts.map(s =>
|
||||||
|
s.name === shortcut ? { ...s, enabled: !s.enabled } : s
|
||||||
|
);
|
||||||
|
settingsState.shortcuts = updated;
|
||||||
} else {
|
} else {
|
||||||
settingsState.shortcuts = [...settingsState.shortcuts, { name: shortcut, enabled: true }];
|
settingsState.shortcuts = [
|
||||||
|
...settingsState.shortcuts,
|
||||||
|
{ name: shortcut, enabled: true }
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,16 +202,6 @@
|
|||||||
</MotionDiv>
|
</MotionDiv>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each Object.entries(Shortcuts) as shortcut}
|
|
||||||
<div class="flex justify-between items-center px-4 py-3">
|
|
||||||
<div class="pr-4">
|
|
||||||
<!-- Use DisplayName if it exists, otherwise use the key (shortcut[0]) as a fallback -->
|
|
||||||
<h2 class="text-sm">{shortcut[1].DisplayName || shortcut[0]}</h2>
|
|
||||||
</div>
|
|
||||||
<Switch state={$settingsState.shortcuts.find(s => s.name === shortcut[0])?.enabled ?? false} onChange={() => switchChange(shortcut[0])} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- Custom Shortcuts Section -->
|
<!-- Custom Shortcuts Section -->
|
||||||
{#each $settingsState.customshortcuts as shortcut, index}
|
{#each $settingsState.customshortcuts as shortcut, index}
|
||||||
<div class="flex justify-between items-center px-4 py-3">
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
@@ -217,6 +213,16 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#each Object.entries(Shortcuts) as shortcut}
|
||||||
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<!-- Use DisplayName if it exists, otherwise use the key (shortcut[0]) as a fallback -->
|
||||||
|
<h2 class="text-sm">{shortcut[1].DisplayName || shortcut[0]}</h2>
|
||||||
|
</div>
|
||||||
|
<Switch state={$settingsState.shortcuts.find(s => s.name === shortcut[0])?.enabled ?? false} onChange={() => switchChange(shortcut[0])} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="p-4 text-center">
|
<div class="p-4 text-center">
|
||||||
Loading shortcuts...
|
Loading shortcuts...
|
||||||
|
|||||||
@@ -21,13 +21,16 @@
|
|||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<button
|
<button
|
||||||
onclick={() => editMode = !editMode}
|
onclick={() => editMode = !editMode}
|
||||||
class="absolute top-0 right-0 z-10 w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700">{editMode ? '\ue9e4' : '\uec38'}</button>
|
class="absolute top-0 right-0 z-10 px-2 h-8 text-lg rounded-xl bg-zinc-100 dark:bg-zinc-700">
|
||||||
|
<span class="mr-2">{editMode ? 'Done' : 'Edit'}</span>
|
||||||
|
<span class="font-IconFamily">{editMode ? '\ue9e4' : '\uec38'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<BackgroundSelector isEditMode={editMode} bind:selectedBackground={selectedBackground} bind:selectNoBackground={selectNoBackground} />
|
<BackgroundSelector isEditMode={editMode} bind:selectedBackground={selectedBackground} bind:selectNoBackground={selectNoBackground} />
|
||||||
<ThemeSelector isEditMode={editMode} />
|
<ThemeSelector isEditMode={editMode} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center justify-center w-full h-full">
|
<div class="flex justify-center items-center w-full h-full">
|
||||||
<div class="text-lg">
|
<div class="text-lg">
|
||||||
Open SEQTA and use the embedded settings to access theme settings. 🫠
|
Open SEQTA and use the embedded settings to access theme settings. 🫠
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,12 @@
|
|||||||
|
|
||||||
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
|
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
|
||||||
import Backgrounds from '../components/store/Backgrounds.svelte'
|
import Backgrounds from '../components/store/Backgrounds.svelte'
|
||||||
|
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
||||||
|
|
||||||
const themeManager = ThemeManager.getInstance();
|
const themeManager = ThemeManager.getInstance();
|
||||||
|
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
|
||||||
|
|
||||||
|
cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
|
||||||
|
|
||||||
// State variables
|
// State variables
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
@@ -48,20 +52,57 @@
|
|||||||
activeTab = tab;
|
activeTab = tab;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch themes and initialize app
|
const toggleFavorite = async (theme: Theme) => {
|
||||||
|
const token = await cloudAuth.getStoredToken();
|
||||||
|
if (!token) return;
|
||||||
|
const isFavorite = !theme.is_favorited;
|
||||||
|
const result = (await browser.runtime.sendMessage({
|
||||||
|
type: 'cloudFavorite',
|
||||||
|
themeId: theme.id,
|
||||||
|
token,
|
||||||
|
action: isFavorite ? 'favorite' : 'unfavorite',
|
||||||
|
})) as { success?: boolean };
|
||||||
|
if (result?.success) {
|
||||||
|
const delta = isFavorite ? 1 : -1;
|
||||||
|
themes = themes.map((t) =>
|
||||||
|
t.id === theme.id
|
||||||
|
? { ...t, is_favorited: isFavorite, favorite_count: Math.max(0, (t.favorite_count ?? 0) + delta) }
|
||||||
|
: t
|
||||||
|
);
|
||||||
|
if (displayTheme?.id === theme.id) {
|
||||||
|
displayTheme = {
|
||||||
|
...displayTheme,
|
||||||
|
is_favorited: isFavorite,
|
||||||
|
favorite_count: Math.max(0, (displayTheme.favorite_count ?? 0) + delta),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch themes via background script (avoids CORS when store runs inside SEQTA page)
|
||||||
const fetchThemes = async () => {
|
const fetchThemes = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${(new Date()).getTime()}`, { cache: 'no-store' });
|
const token = await cloudAuth.getStoredToken();
|
||||||
const data = await response.json();
|
const data = (await browser.runtime.sendMessage({
|
||||||
themes = data.themes;
|
type: 'fetchThemes',
|
||||||
|
token: token ?? undefined,
|
||||||
|
})) as {
|
||||||
|
success?: boolean;
|
||||||
|
data?: { themes: Theme[] };
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (!data?.success || !data?.data?.themes) {
|
||||||
|
throw new Error(data?.error || 'Failed to fetch themes');
|
||||||
|
}
|
||||||
|
themes = data.data.themes;
|
||||||
|
|
||||||
// Shuffle for cover themes
|
// Shuffle for cover themes
|
||||||
const shuffled = [...themes].sort(() => 0.5 - Math.random());
|
const shuffled = [...themes].sort(() => 0.5 - Math.random());
|
||||||
coverThemes = shuffled.slice(0, 3);
|
coverThemes = shuffled.slice(0, 3);
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch themes', error);
|
console.error('Failed to fetch themes', err);
|
||||||
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs
|
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -91,6 +132,17 @@
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refetch themes when user logs in (from another tab) to get is_favorited
|
||||||
|
let lastLoggedIn = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
if (cloudLoggedIn && !lastLoggedIn) {
|
||||||
|
lastLoggedIn = true;
|
||||||
|
fetchThemes();
|
||||||
|
} else if (!cloudLoggedIn) {
|
||||||
|
lastLoggedIn = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}">
|
<div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}">
|
||||||
@@ -111,7 +163,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ThemeGrid to display filtered themes -->
|
<!-- ThemeGrid to display filtered themes -->
|
||||||
<ThemeGrid themes={filteredThemes} {searchTerm} {setDisplayTheme} />
|
<ThemeGrid
|
||||||
|
themes={filteredThemes}
|
||||||
|
{searchTerm}
|
||||||
|
{setDisplayTheme}
|
||||||
|
{toggleFavorite}
|
||||||
|
isLoggedIn={cloudLoggedIn}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if displayTheme}
|
{#if displayTheme}
|
||||||
<ThemeModal
|
<ThemeModal
|
||||||
@@ -120,6 +178,8 @@
|
|||||||
theme={displayTheme}
|
theme={displayTheme}
|
||||||
{displayTheme}
|
{displayTheme}
|
||||||
{setDisplayTheme}
|
{setDisplayTheme}
|
||||||
|
{toggleFavorite}
|
||||||
|
isLoggedIn={cloudLoggedIn}
|
||||||
onInstall={async () => {
|
onInstall={async () => {
|
||||||
if (displayTheme) {
|
if (displayTheme) {
|
||||||
await themeManager.downloadTheme(displayTheme);
|
await themeManager.downloadTheme(displayTheme);
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
export type Theme = {
|
export type Theme = {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
coverImage: string;
|
coverImage: string;
|
||||||
marqueeImage: string;
|
marqueeImage?: string;
|
||||||
id: string;
|
theme_json_url?: string;
|
||||||
|
is_favorited?: boolean;
|
||||||
|
favorite_count?: number;
|
||||||
|
download_count?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Action } from "svelte/action";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Svelte action that moves the element to a different DOM target.
|
||||||
|
* Defaults to the nearest ShadowRoot so styles remain intact when the app
|
||||||
|
* is rendered inside a shadow DOM. Falls back to document.body otherwise.
|
||||||
|
* Keeps all Svelte reactivity/events intact while escaping ancestor stacking contexts.
|
||||||
|
*/
|
||||||
|
export const portal: Action<HTMLElement, HTMLElement | ShadowRoot | undefined> = (node, target) => {
|
||||||
|
const root = node.getRootNode();
|
||||||
|
const dest = target ?? (root instanceof ShadowRoot ? root : document.body);
|
||||||
|
dest.appendChild(node);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(newTarget) {
|
||||||
|
(newTarget ?? dest).appendChild(node);
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
node.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as pdfjs from "pdfjs-dist";
|
||||||
|
import browser from "webextension-polyfill";
|
||||||
|
import pdfWorkerHref from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||||
|
import pdfLegacyHref from "pdfjs-dist/legacy/build/pdf.min.mjs?url";
|
||||||
|
|
||||||
|
function extensionAssetUrl(viteAssetHref: string): string {
|
||||||
|
const path = viteAssetHref.replace(/^\/+/, "");
|
||||||
|
return browser.runtime.getURL(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let workerConfigured = false;
|
||||||
|
|
||||||
|
/** Required before pdfjs spawns a worker (content-script / extension isolate). */
|
||||||
|
export function ensurePdfjsWorker(): void {
|
||||||
|
if (workerConfigured) return;
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = extensionAssetUrl(pdfWorkerHref);
|
||||||
|
workerConfigured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page-context script on Firefox must load these chrome-extension:// URLs (see web_accessible_resources). */
|
||||||
|
export function getPdfjsPageContextUrls(): { lib: string; worker: string } {
|
||||||
|
return {
|
||||||
|
lib: extensionAssetUrl(pdfLegacyHref),
|
||||||
|
worker: extensionAssetUrl(pdfWorkerHref),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ const updatedFirefoxManifest = {
|
|||||||
},
|
},
|
||||||
browser_specific_settings: {
|
browser_specific_settings: {
|
||||||
gecko: {
|
gecko: {
|
||||||
id: pkg.author.email,
|
id: "betterseqta@betterseqta.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,12 +16,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permissions": ["tabs", "notifications", "storage"],
|
"permissions": ["tabs", "notifications", "storage"],
|
||||||
"host_permissions": ["https://newsapi.org/", "*://*/*"],
|
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
|
||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background.ts"
|
"service_worker": "background.ts"
|
||||||
},
|
},
|
||||||
"content_security_policy": {
|
"content_security_policy": {
|
||||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org"
|
||||||
},
|
},
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
@@ -32,7 +32,12 @@
|
|||||||
],
|
],
|
||||||
"web_accessible_resources": [
|
"web_accessible_resources": [
|
||||||
{
|
{
|
||||||
"resources": ["resources/icons/*", "resources/update-image.webp"],
|
"resources": [
|
||||||
|
"resources/icons/*",
|
||||||
|
"resources/update-image.webp",
|
||||||
|
"resources/pdfjs/pdf.worker.min.mjs",
|
||||||
|
"resources/pdfjs/pdf.legacy.min.mjs"
|
||||||
|
],
|
||||||
"matches": ["*://*/*"]
|
"matches": ["*://*/*"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Setting,
|
Setting,
|
||||||
} from "@/plugins/core/settingsHelpers";
|
} from "@/plugins/core/settingsHelpers";
|
||||||
import styles from "./styles.css?inline";
|
import styles from "./styles.css?inline";
|
||||||
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
|
||||||
const settings = defineSettings({
|
const settings = defineSettings({
|
||||||
speed: numberSetting({
|
speed: numberSetting({
|
||||||
@@ -35,13 +36,10 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
|
|||||||
settings: instance.settings,
|
settings: instance.settings,
|
||||||
|
|
||||||
run: async (api) => {
|
run: async (api) => {
|
||||||
// Create the background elements
|
const [container, menu] = await Promise.all([
|
||||||
const container = document.getElementById("container");
|
waitForElm("#container", true),
|
||||||
const menu = document.getElementById("menu");
|
waitForElm("#menu", true),
|
||||||
|
]);
|
||||||
if (!container || !menu) {
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const backgrounds = [
|
const backgrounds = [
|
||||||
{ classes: ["bg"] },
|
{ classes: ["bg"] },
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ import {
|
|||||||
import { type Plugin } from "@/plugins/core/types";
|
import { type Plugin } from "@/plugins/core/types";
|
||||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
|
||||||
|
import {
|
||||||
|
clearStuck,
|
||||||
|
getClassByPattern,
|
||||||
|
initStorage,
|
||||||
|
letterToNumber,
|
||||||
|
parseAssessments,
|
||||||
|
processAssessments,
|
||||||
|
} from "./utils.ts";
|
||||||
|
|
||||||
|
interface weightingsStorage {
|
||||||
|
weightings: Record<string, string>;
|
||||||
|
assessments: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
const settings = defineSettings({
|
const settings = defineSettings({
|
||||||
lettergrade: booleanSetting({
|
lettergrade: booleanSetting({
|
||||||
@@ -23,7 +37,7 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
|
|||||||
|
|
||||||
const instance = new AssessmentsAveragePluginClass();
|
const instance = new AssessmentsAveragePluginClass();
|
||||||
|
|
||||||
const assessmentsAveragePlugin: Plugin<typeof settings> = {
|
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||||
id: "assessments-average",
|
id: "assessments-average",
|
||||||
name: "Assessment Averages",
|
name: "Assessment Averages",
|
||||||
description: "Adds an average grade to the Assessments page",
|
description: "Adds an average grade to the Assessments page",
|
||||||
@@ -32,8 +46,10 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
|
|||||||
settings: instance.settings,
|
settings: instance.settings,
|
||||||
|
|
||||||
run: async (api) => {
|
run: async (api) => {
|
||||||
|
await initStorage(api);
|
||||||
|
clearStuck(api);
|
||||||
|
|
||||||
api.seqta.onMount(".assessmentsWrapper", async () => {
|
api.seqta.onMount(".assessmentsWrapper", async () => {
|
||||||
// Wait for any assessment item to load first
|
|
||||||
await waitForElm(
|
await waitForElm(
|
||||||
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
|
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
|
||||||
true,
|
true,
|
||||||
@@ -41,26 +57,13 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
|
|||||||
1000,
|
1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper function to find actual class names by their base pattern
|
await parseAssessments(api);
|
||||||
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(
|
const sampleAssessmentItem = document.querySelector(
|
||||||
"[class*='AssessmentItem__AssessmentItem___']",
|
"[class*='AssessmentItem__AssessmentItem___']",
|
||||||
);
|
);
|
||||||
if (!sampleAssessmentItem) return;
|
if (!sampleAssessmentItem) return;
|
||||||
|
|
||||||
// Extract all necessary class patterns from a sample assessment item
|
|
||||||
const assessmentItemClass =
|
const assessmentItemClass =
|
||||||
Array.from(sampleAssessmentItem.classList).find((c) =>
|
Array.from(sampleAssessmentItem.classList).find((c) =>
|
||||||
c.startsWith("AssessmentItem__AssessmentItem___"),
|
c.startsWith("AssessmentItem__AssessmentItem___"),
|
||||||
@@ -83,7 +86,6 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
|
|||||||
"AssessmentItem__title___",
|
"AssessmentItem__title___",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get Thermoscore classes
|
|
||||||
const thermoscoreElement = document.querySelector(
|
const thermoscoreElement = document.querySelector(
|
||||||
"[class*='Thermoscore__Thermoscore___']",
|
"[class*='Thermoscore__Thermoscore___']",
|
||||||
);
|
);
|
||||||
@@ -102,62 +104,34 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
|
|||||||
"Thermoscore__text___",
|
"Thermoscore__text___",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find assessment list
|
|
||||||
const assessmentsList = document.querySelector(
|
const assessmentsList = document.querySelector(
|
||||||
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
|
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
|
||||||
);
|
);
|
||||||
if (!assessmentsList) return;
|
if (!assessmentsList) return;
|
||||||
|
|
||||||
const gradeElements = document.querySelectorAll(
|
const state = await ReactFiber.find(
|
||||||
"[class*='Thermoscore__text___']",
|
"[class*='AssessmentList__items___']",
|
||||||
|
).getState();
|
||||||
|
const marks = state["marks"];
|
||||||
|
if (!marks || !marks.length) return;
|
||||||
|
|
||||||
|
const assessmentItems = Array.from(
|
||||||
|
assessmentsList.querySelectorAll(
|
||||||
|
`[class*='AssessmentItem__AssessmentItem___']`,
|
||||||
|
),
|
||||||
|
).filter(
|
||||||
|
(item) =>
|
||||||
|
!item
|
||||||
|
.querySelector(`[class*='AssessmentItem__title___']`)
|
||||||
|
?.textContent?.includes("Subject Average"),
|
||||||
);
|
);
|
||||||
if (!gradeElements.length) return;
|
|
||||||
|
|
||||||
// Parse and average grades
|
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
|
||||||
const letterToNumber: Record<string, number> = {
|
await processAssessments(api, assessmentItems);
|
||||||
"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 {
|
if (!count || totalWeight === 0) return;
|
||||||
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;
|
const avg = weightedTotal / totalWeight;
|
||||||
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 rounded = Math.ceil(avg / 5) * 5;
|
||||||
const numberToLetter = Object.entries(letterToNumber).reduce(
|
const numberToLetter = Object.entries(letterToNumber).reduce(
|
||||||
(acc, [k, v]) => {
|
(acc, [k, v]) => {
|
||||||
@@ -172,33 +146,86 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
|
|||||||
? letterAvg
|
? letterAvg
|
||||||
: `${avg.toFixed(2)}%`;
|
: `${avg.toFixed(2)}%`;
|
||||||
|
|
||||||
// Prevent duplicate
|
|
||||||
const existing = assessmentsList.querySelector(
|
const existing = assessmentsList.querySelector(
|
||||||
`[class*='AssessmentItem__title___']`,
|
`[class*='AssessmentItem__title___']`,
|
||||||
);
|
);
|
||||||
if (existing?.textContent === "Subject Average") return;
|
if (existing?.textContent === "Subject Average") return;
|
||||||
|
|
||||||
// Use the dynamic class names in the HTML template
|
let warningHTML = "";
|
||||||
const averageElement = stringToHTML(/* html */ `
|
if (hasInaccurateWeighting) {
|
||||||
|
warningHTML = /* html */ `
|
||||||
|
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
|
||||||
|
⚠ Some weightings unavailable
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
assessmentsList.insertBefore(
|
||||||
|
stringToHTML(/* html */ `
|
||||||
<div class="${assessmentItemClass}">
|
<div class="${assessmentItemClass}">
|
||||||
<div class="${metaContainerClass}">
|
<div class="${metaContainerClass}">
|
||||||
<div class="${metaClass}">
|
<div class="${metaClass}">
|
||||||
<div class="${simpleResultClass}">
|
<div class="${simpleResultClass}">
|
||||||
<div class="${titleClass}">Subject Average</div>
|
<div class="${titleClass}">Subject Average</div>
|
||||||
|
${warningHTML}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="${thermoscoreClass}">
|
<div class="${thermoscoreClass}">
|
||||||
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
|
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
|
||||||
<div class="${textClass}" title="${display}">${display}</div>
|
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).firstChild;
|
`).firstChild!,
|
||||||
|
assessmentsList.firstChild,
|
||||||
|
);
|
||||||
|
|
||||||
assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild);
|
applySubjectColourToOverallResult();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
applySubjectColourToOverallResult();
|
||||||
|
});
|
||||||
|
const wrapper = document.querySelector(".assessmentsWrapper");
|
||||||
|
if (wrapper) {
|
||||||
|
observer.observe(wrapper, { childList: true, subtree: true });
|
||||||
|
setTimeout(() => observer.disconnect(), 10000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function applySubjectColourToOverallResult() {
|
||||||
|
const selectedAssessmentItem = document.querySelector(
|
||||||
|
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
|
||||||
|
) || document.querySelector(
|
||||||
|
"[class*='Collapsible__content___'] [class*='AssessmentItem__AssessmentItem___']",
|
||||||
|
);
|
||||||
|
const assessmentThermoscore = selectedAssessmentItem?.querySelector(
|
||||||
|
"[class*='Thermoscore__Thermoscore___']",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
const overallResult = document.querySelector(
|
||||||
|
"[class*='OverallResult__OverallResult___']",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
const assessableCriterionHeaders = document.querySelectorAll(
|
||||||
|
"[class*='AssessableCriterion__header___']",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (assessmentThermoscore && (overallResult || assessableCriterionHeaders.length > 0)) {
|
||||||
|
const accentColour =
|
||||||
|
getComputedStyle(assessmentThermoscore).getPropertyValue("--assessment-accent-colour").trim() ||
|
||||||
|
getComputedStyle(assessmentThermoscore).getPropertyValue("--fill-colour").trim() ||
|
||||||
|
getComputedStyle(assessmentThermoscore.closest("[class*='Collapsible__Collapsible___']") || assessmentThermoscore).getPropertyValue("--assessment-accent-colour").trim() ||
|
||||||
|
getComputedStyle(assessmentThermoscore.closest("[class*='Collapsible__Collapsible___']") || assessmentThermoscore).getPropertyValue("--item-colour").trim();
|
||||||
|
if (accentColour) {
|
||||||
|
overallResult?.style.setProperty("--assessment-accent-colour", accentColour);
|
||||||
|
overallResult?.style.setProperty("--fill-colour", accentColour);
|
||||||
|
assessableCriterionHeaders.forEach((el) => {
|
||||||
|
(el as HTMLElement).style.setProperty("--assessment-accent-colour", accentColour);
|
||||||
|
(el as HTMLElement).style.setProperty("--fill-colour", accentColour);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default assessmentsAveragePlugin;
|
export default assessmentsAveragePlugin;
|
||||||
|
|||||||
@@ -0,0 +1,584 @@
|
|||||||
|
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
|
||||||
|
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
|
||||||
|
import {
|
||||||
|
ensurePdfjsWorker,
|
||||||
|
getPdfjsPageContextUrls,
|
||||||
|
} from "@/lib/pdfjsExtension.ts";
|
||||||
|
import * as pdfjs from "pdfjs-dist";
|
||||||
|
|
||||||
|
ensurePdfjsWorker();
|
||||||
|
|
||||||
|
export async function initStorage(api: any) {
|
||||||
|
await api.storage.loaded;
|
||||||
|
|
||||||
|
if (!api.storage.weightings) {
|
||||||
|
api.storage.weightings = {};
|
||||||
|
}
|
||||||
|
if (!api.storage.assessments) {
|
||||||
|
api.storage.assessments = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStuck(api: any) {
|
||||||
|
let hasStuckProcessing = false;
|
||||||
|
for (const key in api.storage.weightings) {
|
||||||
|
if (api.storage.weightings[key] === "processing") {
|
||||||
|
delete api.storage.weightings[key];
|
||||||
|
hasStuckProcessing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasStuckProcessing) {
|
||||||
|
api.storage.weightings = { ...api.storage.weightings };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to find actual class names by their base pattern
|
||||||
|
export const getClassByPattern = (
|
||||||
|
element: Element | Document,
|
||||||
|
basePattern: string,
|
||||||
|
): string => {
|
||||||
|
const classes = Array.from(element.querySelectorAll("*"))
|
||||||
|
.flatMap((el) => Array.from(el.classList))
|
||||||
|
.filter((className) => className.startsWith(basePattern));
|
||||||
|
|
||||||
|
return classes.length ? classes[0] : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWeightLabel(
|
||||||
|
assessmentItem: Element,
|
||||||
|
weighting: string | undefined,
|
||||||
|
) {
|
||||||
|
const statsContainer = assessmentItem.querySelector(
|
||||||
|
`[class*='AssessmentItem__stats___']`,
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!statsContainer ||
|
||||||
|
statsContainer.querySelector(".betterseqta-weight-label")
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const label = statsContainer.querySelector(
|
||||||
|
`[class*='Label__Label___']`,
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
|
if (!label) return;
|
||||||
|
|
||||||
|
const weightLabel = label.cloneNode(true) as HTMLElement;
|
||||||
|
weightLabel.classList.add("betterseqta-weight-label");
|
||||||
|
|
||||||
|
const innerTextDiv = weightLabel.querySelector(
|
||||||
|
`[class*='Label__innerText___']`,
|
||||||
|
);
|
||||||
|
if (innerTextDiv) innerTextDiv.textContent = "Weight";
|
||||||
|
|
||||||
|
const textNodes = Array.from(weightLabel.childNodes).filter(
|
||||||
|
(node) => node.nodeType === Node.TEXT_NODE,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (textNodes.length) {
|
||||||
|
textNodes[0].textContent =
|
||||||
|
weighting && weighting !== "processing"
|
||||||
|
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
|
||||||
|
: "N/A";
|
||||||
|
}
|
||||||
|
|
||||||
|
statsContainer.style.position = "relative";
|
||||||
|
weightLabel.style.position = "absolute";
|
||||||
|
weightLabel.style.right = "0";
|
||||||
|
weightLabel.style.top = "50%";
|
||||||
|
weightLabel.style.transform = "translateY(-50%)";
|
||||||
|
|
||||||
|
statsContainer.appendChild(weightLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isFirefox =
|
||||||
|
navigator.userAgent.toLowerCase().indexOf("firefox") > -1 &&
|
||||||
|
!navigator.userAgent.toLowerCase().includes("seamonkey") &&
|
||||||
|
!navigator.userAgent.toLowerCase().includes("waterfox");
|
||||||
|
|
||||||
|
async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
||||||
|
const isBlobUrl = url.startsWith("blob:");
|
||||||
|
|
||||||
|
if (isBlobUrl || isFirefox) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`;
|
||||||
|
const escapedUrl = url.replace(/'/g, "\\'");
|
||||||
|
|
||||||
|
script.textContent = `
|
||||||
|
(function() {
|
||||||
|
fetch('${escapedUrl}')
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
|
||||||
|
}
|
||||||
|
return response.arrayBuffer();
|
||||||
|
})
|
||||||
|
.then(arrayBuffer => {
|
||||||
|
window.postMessage({
|
||||||
|
type: '${requestId}',
|
||||||
|
success: true,
|
||||||
|
data: Array.from(new Uint8Array(arrayBuffer))
|
||||||
|
}, '*');
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
window.postMessage({
|
||||||
|
type: '${requestId}',
|
||||||
|
success: false,
|
||||||
|
error: error.message || String(error)
|
||||||
|
}, '*');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
const messageHandler = (event: MessageEvent) => {
|
||||||
|
if (event.data?.type === requestId) {
|
||||||
|
window.removeEventListener("message", messageHandler);
|
||||||
|
if (script.parentNode) {
|
||||||
|
script.parentNode.removeChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.success) {
|
||||||
|
resolve(new Uint8Array(event.data.data).buffer);
|
||||||
|
} else {
|
||||||
|
reject(new Error(event.data.error || "Failed to fetch PDF"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", messageHandler);
|
||||||
|
(document.head || document.documentElement).appendChild(script);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.removeEventListener("message", messageHandler);
|
||||||
|
if (script.parentNode) {
|
||||||
|
script.parentNode.removeChild(script);
|
||||||
|
}
|
||||||
|
reject(new Error("Timeout fetching PDF"));
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
redirect: "follow",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.url && response.url.startsWith("blob:")) {
|
||||||
|
return await fetchPDFAsArrayBuffer(response.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch PDF: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.arrayBuffer();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (
|
||||||
|
error?.message?.includes("blob") ||
|
||||||
|
error?.message?.includes("Security") ||
|
||||||
|
error?.message?.includes("CSP")
|
||||||
|
) {
|
||||||
|
return await fetchPDFAsArrayBuffer(url);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractPDFText(url: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
if (isFirefox) {
|
||||||
|
const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls();
|
||||||
|
const escJsSingleQuoted = (s: string) =>
|
||||||
|
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||||
|
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
|
||||||
|
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
|
||||||
|
|
||||||
|
const escapedUrl = url
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/'/g, "\\'")
|
||||||
|
.replace(/"/g, '\\"');
|
||||||
|
|
||||||
|
script.textContent = `
|
||||||
|
(function() {
|
||||||
|
const requestId = '${requestId}';
|
||||||
|
const url = '${escapedUrl}';
|
||||||
|
const pdfLibSrc = '${pdfLibInj}';
|
||||||
|
const pdfWorkerSrc = '${pdfWorkerInj}';
|
||||||
|
|
||||||
|
if (window.pdfjsLib) {
|
||||||
|
extractPDF();
|
||||||
|
} else {
|
||||||
|
const pdfjsScript = document.createElement('script');
|
||||||
|
pdfjsScript.src = pdfLibSrc;
|
||||||
|
pdfjsScript.type = 'module';
|
||||||
|
|
||||||
|
pdfjsScript.onload = function() {
|
||||||
|
extractPDF();
|
||||||
|
};
|
||||||
|
pdfjsScript.onerror = function() {
|
||||||
|
window.postMessage({
|
||||||
|
type: requestId,
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to load pdfjs library'
|
||||||
|
}, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.head.appendChild(pdfjsScript);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPDF() {
|
||||||
|
try {
|
||||||
|
window.pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('GET', url, true);
|
||||||
|
xhr.responseType = 'arraybuffer';
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
|
||||||
|
xhr.onload = function() {
|
||||||
|
if (xhr.status !== 200) {
|
||||||
|
window.postMessage({
|
||||||
|
type: requestId,
|
||||||
|
success: false,
|
||||||
|
error: 'HTTP ' + xhr.status + ': ' + xhr.statusText
|
||||||
|
}, '*');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const arrayBuffer = xhr.response;
|
||||||
|
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
|
||||||
|
throw new Error('PDF response is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.pdfjsLib.getDocument({
|
||||||
|
data: arrayBuffer,
|
||||||
|
useSystemFonts: true,
|
||||||
|
verbosity: 0,
|
||||||
|
useWorkerFetch: false,
|
||||||
|
isEvalSupported: false
|
||||||
|
}).promise
|
||||||
|
.then(pdf => {
|
||||||
|
const pagePromises = [];
|
||||||
|
for (let i = 1; i <= pdf.numPages; i++) {
|
||||||
|
pagePromises.push(
|
||||||
|
pdf.getPage(i).then(page => {
|
||||||
|
return page.getTextContent().then(content => {
|
||||||
|
return content.items.map(item => item.str).join(' ');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.all(pagePromises);
|
||||||
|
})
|
||||||
|
.then(pages => {
|
||||||
|
const text = pages.join('\\n');
|
||||||
|
window.postMessage({
|
||||||
|
type: requestId,
|
||||||
|
success: true,
|
||||||
|
text: text
|
||||||
|
}, '*');
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
window.postMessage({
|
||||||
|
type: requestId,
|
||||||
|
success: false,
|
||||||
|
error: 'PDF parsing error: ' + (error.message || String(error))
|
||||||
|
}, '*');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
window.postMessage({
|
||||||
|
type: requestId,
|
||||||
|
success: false,
|
||||||
|
error: 'ArrayBuffer error: ' + (error.message || String(error))
|
||||||
|
}, '*');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = function() {
|
||||||
|
window.postMessage({
|
||||||
|
type: requestId,
|
||||||
|
success: false,
|
||||||
|
error: 'Network error fetching PDF'
|
||||||
|
}, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.ontimeout = function() {
|
||||||
|
window.postMessage({
|
||||||
|
type: requestId,
|
||||||
|
success: false,
|
||||||
|
error: 'Timeout fetching PDF'
|
||||||
|
}, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.timeout = 30000;
|
||||||
|
xhr.send();
|
||||||
|
} catch (error) {
|
||||||
|
window.postMessage({
|
||||||
|
type: requestId,
|
||||||
|
success: false,
|
||||||
|
error: 'Setup error: ' + (error.message || String(error))
|
||||||
|
}, '*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
const messageHandler = (event: MessageEvent) => {
|
||||||
|
if (event.data?.type === requestId) {
|
||||||
|
window.removeEventListener("message", messageHandler);
|
||||||
|
if (script.parentNode) {
|
||||||
|
script.parentNode.removeChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.success) {
|
||||||
|
resolve(event.data.text);
|
||||||
|
} else {
|
||||||
|
reject(
|
||||||
|
new Error(event.data.error || "Failed to extract PDF text"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", messageHandler);
|
||||||
|
(document.head || document.documentElement).appendChild(script);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.removeEventListener("message", messageHandler);
|
||||||
|
if (script.parentNode) {
|
||||||
|
script.parentNode.removeChild(script);
|
||||||
|
}
|
||||||
|
reject(new Error("Timeout extracting PDF text"));
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await fetchPDFAsArrayBuffer(url);
|
||||||
|
|
||||||
|
if (arrayBuffer.byteLength === 0) {
|
||||||
|
throw new Error("PDF response is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdf = await pdfjs.getDocument({
|
||||||
|
data: arrayBuffer,
|
||||||
|
useSystemFonts: true,
|
||||||
|
}).promise;
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
for (let i = 1; i <= pdf.numPages; i++) {
|
||||||
|
const page = await pdf.getPage(i);
|
||||||
|
const content = await page.getTextContent();
|
||||||
|
text += content.items.map((item: any) => item.str).join(" ") + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[BetterSEQTA+] Failed to extract PDF text:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWeightings(mark: any, api: any) {
|
||||||
|
const assessmentID = mark.id;
|
||||||
|
const metaclassID = mark.metaclassID;
|
||||||
|
const userInfo = await getUserInfo();
|
||||||
|
const userID = userInfo.id;
|
||||||
|
const title = mark.title;
|
||||||
|
|
||||||
|
if (
|
||||||
|
api.storage.weightings[assessmentID] != undefined &&
|
||||||
|
api.storage.weightings[assessmentID] !== "processing"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.storage.weightings = {
|
||||||
|
...api.storage.weightings,
|
||||||
|
[assessmentID]: "processing",
|
||||||
|
};
|
||||||
|
|
||||||
|
api.storage.assessments = {
|
||||||
|
...api.storage.assessments,
|
||||||
|
[title.trim()]: assessmentID,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filename =
|
||||||
|
"BetterSEQTA-" +
|
||||||
|
String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
|
||||||
|
|
||||||
|
const printResponse = await fetch(
|
||||||
|
`${location.origin}/seqta/student/print/assessment`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileName: filename,
|
||||||
|
id: assessmentID,
|
||||||
|
metaclass: metaclassID,
|
||||||
|
student: userID,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!printResponse.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
|
||||||
|
|
||||||
|
if (pdfUrl.startsWith("blob:")) {
|
||||||
|
throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await extractPDFText(pdfUrl);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (
|
||||||
|
isFirefox &&
|
||||||
|
(error?.message?.includes("blob") ||
|
||||||
|
error?.message?.includes("Security") ||
|
||||||
|
error?.message?.includes("CSP"))
|
||||||
|
) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
text = await extractPDFText(pdfUrl);
|
||||||
|
} else {
|
||||||
|
throw new Error(`PDF extraction failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = text.match(/weight:\s*(\d+\.?\d*)/i);
|
||||||
|
|
||||||
|
api.storage.weightings = {
|
||||||
|
...api.storage.weightings,
|
||||||
|
[assessmentID]: match ? match[1] : "N/A",
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
api.storage.weightings = {
|
||||||
|
...api.storage.weightings,
|
||||||
|
[assessmentID]: "N/A",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseAssessments(api: any) {
|
||||||
|
const state = await ReactFiber.find(
|
||||||
|
"[class*='AssessmentList__items___']",
|
||||||
|
).getState();
|
||||||
|
|
||||||
|
const marks = state["marks"];
|
||||||
|
if (!marks) return;
|
||||||
|
|
||||||
|
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||||
|
let weightedTotal = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
let hasInaccurateWeighting = false;
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const assessmentItem of assessmentItems) {
|
||||||
|
const gradeElement = assessmentItem.querySelector(
|
||||||
|
`[class*='Thermoscore__text___']`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!gradeElement) continue;
|
||||||
|
|
||||||
|
const grade = parseGrade(gradeElement.textContent || "");
|
||||||
|
if (grade <= 0) continue;
|
||||||
|
|
||||||
|
const titleEl = assessmentItem.querySelector(
|
||||||
|
`[class*='AssessmentItem__title___']`,
|
||||||
|
);
|
||||||
|
if (!titleEl) continue;
|
||||||
|
|
||||||
|
const title = titleEl.textContent?.trim();
|
||||||
|
if (!title) continue;
|
||||||
|
|
||||||
|
const assessmentID = api.storage.assessments?.[title];
|
||||||
|
const weighting = assessmentID
|
||||||
|
? api.storage.weightings?.[assessmentID]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
createWeightLabel(assessmentItem, weighting);
|
||||||
|
|
||||||
|
if (
|
||||||
|
weighting === null ||
|
||||||
|
weighting === undefined ||
|
||||||
|
weighting === "N/A" ||
|
||||||
|
weighting === "processing"
|
||||||
|
) {
|
||||||
|
hasInaccurateWeighting = true;
|
||||||
|
weightedTotal += grade;
|
||||||
|
totalWeight += 1;
|
||||||
|
} else {
|
||||||
|
const weight = parseFloat(weighting);
|
||||||
|
|
||||||
|
if (!isNaN(weight) && weight >= 0) {
|
||||||
|
weightedTotal += grade * weight;
|
||||||
|
totalWeight += weight;
|
||||||
|
} else {
|
||||||
|
weightedTotal += grade;
|
||||||
|
totalWeight += 1;
|
||||||
|
hasInaccurateWeighting = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
weightedTotal,
|
||||||
|
totalWeight,
|
||||||
|
hasInaccurateWeighting,
|
||||||
|
count,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@
|
|||||||
|
|
||||||
interface FilterOptions {
|
interface FilterOptions {
|
||||||
subject: string;
|
subject: string;
|
||||||
sortBy: "due" | "grade" | "subject" | "title";
|
sortBy: "due" | "grade" | "subject" | "title" | "year";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HIDDEN_ASSESSMENTS_KEY = "betterseqta-hidden-assessments";
|
||||||
|
|
||||||
function percentageToLetter(percentage: number): string {
|
function percentageToLetter(percentage: number): string {
|
||||||
const letterMap: Record<number, string> = {
|
const letterMap: Record<number, string> = {
|
||||||
100: "A+",
|
100: "A+",
|
||||||
@@ -41,48 +43,108 @@
|
|||||||
|
|
||||||
let filteredAssessments: any[] = [];
|
let filteredAssessments: any[] = [];
|
||||||
let statusGroups: Record<string, any[]> = {};
|
let statusGroups: Record<string, any[]> = {};
|
||||||
|
let columns: { key: string; title: string; className: string; icon: string }[] = [];
|
||||||
|
|
||||||
|
function getAssessmentYear(a: any): number {
|
||||||
|
const dateStr = a.due || a.date || a.dueDate || a.created;
|
||||||
|
return dateStr ? new Date(dateStr).getFullYear() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssessmentType(a: any): string {
|
||||||
|
return (a.type || a.assessmentType || a.taskType || "Other").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssessmentGrade(a: any): string {
|
||||||
|
const val = getGradeValue(a);
|
||||||
|
if (val === null) return "No grade";
|
||||||
|
return percentageToLetter(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupKey(assessment: any): string {
|
||||||
|
switch (currentFilters.sortBy) {
|
||||||
|
case "due":
|
||||||
|
return determineStatus(assessment);
|
||||||
|
case "year":
|
||||||
|
return String(getAssessmentYear(assessment) || "Unknown");
|
||||||
|
case "subject":
|
||||||
|
return assessment.code || "Unknown";
|
||||||
|
case "grade":
|
||||||
|
return getAssessmentGrade(assessment);
|
||||||
|
case "title":
|
||||||
|
const first = (assessment.title || "?")[0].toUpperCase();
|
||||||
|
return /[A-Z0-9]/.test(first) ? first : "#";
|
||||||
|
default:
|
||||||
|
return determineStatus(assessment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortCompare(a: any, b: any): number {
|
||||||
|
return new Date(a.due || a.date || 0).getTime() - new Date(b.due || b.date || 0).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLUMNS = [
|
||||||
|
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "📅" },
|
||||||
|
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "⏰" },
|
||||||
|
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "🚨" },
|
||||||
|
{ key: "SUBMITTED", title: "Submitted", className: "column-submitted", icon: "📝" },
|
||||||
|
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "✅" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildGroupsAndColumns() {
|
||||||
|
if (!data?.assessments) return { filteredAssessments: [], statusGroups: {}, columns: [] };
|
||||||
|
const subjectFilters = settingsState.subjectfilters || {};
|
||||||
|
const hiddenAssessmentIds = new Set(
|
||||||
|
(JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]")).map(String)
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = data.assessments.filter((a: any) => {
|
||||||
|
if (hiddenAssessmentIds.has(String(a.id))) return false;
|
||||||
|
if (subjectFilters[a.code] === false) return false;
|
||||||
|
return currentFilters.subject === "all" || a.code === currentFilters.subject;
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups: Record<string, any[]> = {};
|
||||||
|
filtered.forEach((assessment) => {
|
||||||
|
const key = getGroupKey(assessment);
|
||||||
|
if (!groups[key]) groups[key] = [];
|
||||||
|
groups[key].push(assessment);
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(groups).forEach((key) => {
|
||||||
|
groups[key].sort(sortCompare);
|
||||||
|
});
|
||||||
|
|
||||||
|
let cols: { key: string; title: string; className: string; icon: string }[];
|
||||||
|
if (currentFilters.sortBy === "due") {
|
||||||
|
cols = STATUS_COLUMNS;
|
||||||
|
} else {
|
||||||
|
const keys = Object.keys(groups).filter((k) => groups[k]?.length > 0);
|
||||||
|
if (currentFilters.sortBy === "year") {
|
||||||
|
cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: "📆" }));
|
||||||
|
} else if (currentFilters.sortBy === "subject") {
|
||||||
|
const subjectTitles = new Map(data?.subjects?.map((s: any) => [s.code, `${s.code} - ${s.title}`]) || []);
|
||||||
|
cols = keys.sort().map((k) => ({ key: k, title: subjectTitles.get(k) || k, className: "column-custom", icon: "📚" }));
|
||||||
|
} else {
|
||||||
|
cols = keys.sort().map((k) => ({ key: k, title: k, className: "column-custom", icon: "📋" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { filteredAssessments: filtered, statusGroups: groups, columns: cols };
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (data) {
|
||||||
|
const _ = currentFilters.sortBy && currentFilters.subject;
|
||||||
|
const result = buildGroupsAndColumns();
|
||||||
|
filteredAssessments = result.filteredAssessments;
|
||||||
|
statusGroups = result.statusGroups;
|
||||||
|
columns = result.columns;
|
||||||
|
}
|
||||||
|
|
||||||
function updateAssessments() {
|
function updateAssessments() {
|
||||||
filteredAssessments = data.assessments.filter((a: any) => {
|
const result = buildGroupsAndColumns();
|
||||||
const subjectMatch =
|
filteredAssessments = result.filteredAssessments;
|
||||||
currentFilters.subject === "all" || a.code === currentFilters.subject;
|
statusGroups = result.statusGroups;
|
||||||
return subjectMatch;
|
columns = result.columns;
|
||||||
});
|
|
||||||
|
|
||||||
filteredAssessments.sort((a: any, b: any) => {
|
|
||||||
switch (currentFilters.sortBy) {
|
|
||||||
case "due":
|
|
||||||
return new Date(a.due).getTime() - new Date(b.due).getTime();
|
|
||||||
case "grade":
|
|
||||||
const gradeA = getGradeValue(a);
|
|
||||||
const gradeB = getGradeValue(b);
|
|
||||||
if (gradeA === null && gradeB === null) return 0;
|
|
||||||
if (gradeA === null) return 1;
|
|
||||||
if (gradeB === null) return -1;
|
|
||||||
return gradeB - gradeA;
|
|
||||||
case "subject":
|
|
||||||
return a.code.localeCompare(b.code);
|
|
||||||
case "title":
|
|
||||||
return a.title.localeCompare(b.title);
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
statusGroups = {
|
|
||||||
UPCOMING: [],
|
|
||||||
DUE_SOON: [],
|
|
||||||
OVERDUE: [],
|
|
||||||
SUBMITTED: [],
|
|
||||||
MARKS_RELEASED: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
filteredAssessments.forEach((assessment) => {
|
|
||||||
const status = determineStatus(assessment);
|
|
||||||
if (statusGroups[status]) {
|
|
||||||
statusGroups[status].push(assessment);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDueDateClass(assessment: any): string {
|
function getDueDateClass(assessment: any): string {
|
||||||
@@ -123,6 +185,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideAssessment(assessment: any) {
|
||||||
|
const hidden = JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]");
|
||||||
|
const id = String(assessment.id);
|
||||||
|
if (!hidden.includes(id)) {
|
||||||
|
hidden.push(id);
|
||||||
|
localStorage.setItem(HIDDEN_ASSESSMENTS_KEY, JSON.stringify(hidden));
|
||||||
|
visibilityRefresh++;
|
||||||
|
closeAllMenus();
|
||||||
|
updateAssessments();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSubject(subjectCode: string) {
|
||||||
|
const filters = { ...(settingsState.subjectfilters || {}) };
|
||||||
|
filters[subjectCode] = false;
|
||||||
|
settingsState.subjectfilters = filters;
|
||||||
|
closeAllMenus();
|
||||||
|
updateAssessments();
|
||||||
|
}
|
||||||
|
|
||||||
|
function unhideSubject(subjectCode: string) {
|
||||||
|
const filters = { ...(settingsState.subjectfilters || {}) };
|
||||||
|
filters[subjectCode] = true;
|
||||||
|
settingsState.subjectfilters = filters;
|
||||||
|
updateAssessments();
|
||||||
|
}
|
||||||
|
|
||||||
|
function unhideAssessment(assessmentId: string) {
|
||||||
|
const hidden = JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]");
|
||||||
|
const idStr = String(assessmentId);
|
||||||
|
const filtered = hidden.filter((id: string) => id !== idStr);
|
||||||
|
localStorage.setItem(HIDDEN_ASSESSMENTS_KEY, JSON.stringify(filtered));
|
||||||
|
visibilityRefresh++;
|
||||||
|
updateAssessments();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSubjectFilters() {
|
||||||
|
const filters = settingsState.subjectfilters || {};
|
||||||
|
let updated = false;
|
||||||
|
data.subjects.forEach((s: any) => {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(filters, s.code)) {
|
||||||
|
filters[s.code] = true;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (updated) {
|
||||||
|
settingsState.subjectfilters = filters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function checkForCelebration() {
|
function checkForCelebration() {
|
||||||
const overdueCount = statusGroups.OVERDUE?.length || 0;
|
const overdueCount = statusGroups.OVERDUE?.length || 0;
|
||||||
const dueSoonCount = statusGroups.DUE_SOON?.length || 0;
|
const dueSoonCount = statusGroups.DUE_SOON?.length || 0;
|
||||||
@@ -201,6 +313,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let openMenuId: string | null = null;
|
let openMenuId: string | null = null;
|
||||||
|
let showVisibilityPanel = false;
|
||||||
|
let visibilityRefresh = 0;
|
||||||
|
|
||||||
|
$: hiddenSubjects = data?.subjects?.filter(
|
||||||
|
(s: any) => (settingsState.subjectfilters || {})[s.code] === false
|
||||||
|
) || [];
|
||||||
|
$: hiddenAssessmentIds = (() => {
|
||||||
|
visibilityRefresh; // Dependency for reactivity
|
||||||
|
return new Set((JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]")).map(String));
|
||||||
|
})();
|
||||||
|
$: hiddenAssessmentsWithInfo = data?.assessments?.filter(
|
||||||
|
(a: any) => hiddenAssessmentIds.has(String(a.id))
|
||||||
|
) || [];
|
||||||
|
$: hasHiddenItems = hiddenSubjects.length > 0 || hiddenAssessmentsWithInfo.length > 0;
|
||||||
|
|
||||||
function toggleMenu(assessmentId: string, event: Event) {
|
function toggleMenu(assessmentId: string, event: Event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -211,44 +337,13 @@
|
|||||||
openMenuId = null;
|
openMenuId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$: {
|
$: if (data) {
|
||||||
if (data) {
|
initSubjectFilters();
|
||||||
updateAssessments();
|
updateAssessments();
|
||||||
}
|
void currentFilters.sortBy;
|
||||||
|
void currentFilters.subject;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
key: "UPCOMING",
|
|
||||||
title: "Upcoming",
|
|
||||||
className: "column-upcoming",
|
|
||||||
icon: "📅",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "DUE_SOON",
|
|
||||||
title: "Due Soon",
|
|
||||||
className: "column-due-soon",
|
|
||||||
icon: "⏰",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "OVERDUE",
|
|
||||||
title: "Overdue",
|
|
||||||
className: "column-overdue",
|
|
||||||
icon: "🚨",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "SUBMITTED",
|
|
||||||
title: "Submitted",
|
|
||||||
className: "column-submitted",
|
|
||||||
icon: "📝",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "MARKS_RELEASED",
|
|
||||||
title: "Marked",
|
|
||||||
className: "column-marked",
|
|
||||||
icon: "✅",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={closeAllMenus} />
|
<svelte:window on:click={closeAllMenus} />
|
||||||
@@ -263,15 +358,58 @@
|
|||||||
<option value={subject.code}>{subject.code} - {subject.title}</option>
|
<option value={subject.code}>{subject.code} - {subject.title}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<select class="filter-select" bind:value={currentFilters.sortBy}>
|
<select class="filter-select" bind:value={currentFilters.sortBy} title="Group by - columns change based on this">
|
||||||
<option value="due">Sort by Due Date</option>
|
<option value="due">Group: Status</option>
|
||||||
<option value="grade">Sort by Grade</option>
|
<option value="year">Group: Year</option>
|
||||||
<option value="subject">Sort by Subject</option>
|
<option value="subject">Group: Subject</option>
|
||||||
<option value="title">Sort by Title</option>
|
<option value="grade">Group: Grade</option>
|
||||||
|
<option value="title">Group: Title (A-Z)</option>
|
||||||
</select>
|
</select>
|
||||||
|
{#if hasHiddenItems}
|
||||||
|
<button
|
||||||
|
class="visibility-toggle"
|
||||||
|
class:active={showVisibilityPanel}
|
||||||
|
on:click={() => (showVisibilityPanel = !showVisibilityPanel)}
|
||||||
|
title="Manage hidden subjects and assessments"
|
||||||
|
>
|
||||||
|
👁 Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showVisibilityPanel && hasHiddenItems}
|
||||||
|
<div class="visibility-panel">
|
||||||
|
<h4 class="visibility-panel-title">Hidden items</h4>
|
||||||
|
{#if hiddenSubjects.length > 0}
|
||||||
|
<div class="visibility-section">
|
||||||
|
<span class="visibility-label">Subjects:</span>
|
||||||
|
<div class="visibility-chips">
|
||||||
|
{#each hiddenSubjects as subject}
|
||||||
|
<span class="visibility-chip">
|
||||||
|
{subject.code}
|
||||||
|
<button class="visibility-unhide" on:click={() => unhideSubject(subject.code)}>Show</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if hiddenAssessmentsWithInfo.length > 0}
|
||||||
|
<div class="visibility-section">
|
||||||
|
<span class="visibility-label">Assessments:</span>
|
||||||
|
<div class="visibility-chips">
|
||||||
|
{#each hiddenAssessmentsWithInfo as assessment}
|
||||||
|
<span class="visibility-chip">
|
||||||
|
{assessment.title}
|
||||||
|
<button class="visibility-unhide" on:click={() => unhideAssessment(assessment.id)}>Show</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div id="main-grid-content">
|
<div id="main-grid-content">
|
||||||
{#if filteredAssessments.length === 0}
|
{#if filteredAssessments.length === 0}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
@@ -340,6 +478,12 @@
|
|||||||
Mark as Not Complete
|
Mark as Not Complete
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button class="menu-item menu-item-hide" on:click={() => hideAssessment(assessment)}>
|
||||||
|
Hide assessment
|
||||||
|
</button>
|
||||||
|
<button class="menu-item menu-item-hide" on:click={() => hideSubject(assessment.code)}>
|
||||||
|
Hide subject ({assessment.code})
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -349,7 +493,7 @@
|
|||||||
{#if !assessment.results && !isCompleted}
|
{#if !assessment.results && !isCompleted}
|
||||||
<div class="assessment-meta">
|
<div class="assessment-meta">
|
||||||
<div class="due-date {dueDateClass}">
|
<div class="due-date {dueDateClass}">
|
||||||
📅 {formatDate(assessment.due, assessment.submitted)}
|
📅 {formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -381,4 +525,4 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ async function loadUpcoming(student: number) {
|
|||||||
return res.payload;
|
return res.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAssessmentDates(t: any, subject: Subject): any {
|
||||||
|
const normalized = { ...t };
|
||||||
|
// Past API may use different date fields - ensure we have 'due' for year filter & display
|
||||||
|
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
|
||||||
|
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
|
||||||
|
}
|
||||||
|
if (!normalized.programmeID) normalized.programmeID = subject.programme;
|
||||||
|
if (!normalized.metaclassID) normalized.metaclassID = subject.metaclass;
|
||||||
|
if (!normalized.code && t.subject) normalized.code = t.subject;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPast(student: number, subjects: Subject[]) {
|
async function loadPast(student: number, subjects: Subject[]) {
|
||||||
const map: Record<number, any> = {};
|
const map: Record<number, any> = {};
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -65,10 +77,22 @@ async function loadPast(student: number, subjects: Subject[]) {
|
|||||||
metaclass: s.metaclass,
|
metaclass: s.metaclass,
|
||||||
student,
|
student,
|
||||||
});
|
});
|
||||||
if (res.payload.tasks) {
|
const processAssessment = (t: any) => {
|
||||||
res.payload.tasks.forEach((t: any) => {
|
if (t && t.id) {
|
||||||
map[t.id] = t;
|
const merged = {
|
||||||
});
|
...t,
|
||||||
|
programmeID: t.programmeID || t.programme || s.programme,
|
||||||
|
metaclassID: t.metaclassID || t.metaclass || s.metaclass,
|
||||||
|
code: t.code || t.subject || s.code,
|
||||||
|
};
|
||||||
|
map[t.id] = normalizeAssessmentDates(merged, s);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (res.payload?.pending && Array.isArray(res.payload.pending)) {
|
||||||
|
res.payload.pending.forEach(processAssessment);
|
||||||
|
}
|
||||||
|
if (res.payload?.tasks && Array.isArray(res.payload.tasks)) {
|
||||||
|
res.payload.tasks.forEach(processAssessment);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Plugin } from "../../core/types";
|
import type { Plugin } from "../../core/types";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
import { getAssessmentsData } from "./api";
|
import { getAssessmentsData } from "./api";
|
||||||
import { renderSkeletonLoader, renderErrorState } from "./ui";
|
import { renderErrorState, renderSkeletonLoader } from "./ui";
|
||||||
import styles from "./styles.css?inline";
|
import styles from "./styles.css?inline";
|
||||||
import { delay } from "@/seqta/utils/delay";
|
import { delay } from "@/seqta/utils/delay";
|
||||||
|
|
||||||
|
|||||||
@@ -34,19 +34,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-select {
|
.filter-select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
background: #ffffff !important;
|
background: #ffffff !important;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2364748b'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
|
||||||
|
background-position: right 0.9rem center !important;
|
||||||
|
background-repeat: no-repeat !important;
|
||||||
|
background-size: 1rem !important;
|
||||||
border: 2px solid #e2e8f0;
|
border: 2px solid #e2e8f0;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
padding: 0.75rem 1rem;
|
color-scheme: light;
|
||||||
|
padding: 0.75rem 2.5rem 0.75rem 1rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
font-family: Rubik, sans-serif;
|
||||||
|
line-height: 1.2;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-select::-ms-expand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select option {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-select:focus {
|
.filter-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #d41e3a;
|
border-color: #d41e3a;
|
||||||
@@ -61,8 +80,10 @@
|
|||||||
/* Dark mode dropdowns */
|
/* Dark mode dropdowns */
|
||||||
.dark .filter-select {
|
.dark .filter-select {
|
||||||
background: var(--background-primary) !important;
|
background: var(--background-primary) !important;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='rgba(255,255,255,0.72)'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
|
||||||
border-color: var(--background-secondary);
|
border-color: var(--background-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
color-scheme: dark;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +94,8 @@
|
|||||||
|
|
||||||
.dark .filter-select:hover {
|
.dark .filter-select:hover {
|
||||||
border-color: var(--background-secondary);
|
border-color: var(--background-secondary);
|
||||||
background: var(--background-secondary);
|
background: var(--background-secondary) !important;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='rgba(255,255,255,0.72)'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .filter-select option {
|
.dark .filter-select option {
|
||||||
@@ -106,7 +128,6 @@
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 0 0 2px #e2e8f0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -336,11 +357,146 @@
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-item.menu-item-hide {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .menu-item.menu-item-hide {
|
||||||
|
color: var(--text-primary);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-toggle {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-toggle:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-toggle.active {
|
||||||
|
border-color: #d41e3a;
|
||||||
|
background: rgba(212, 30, 58, 0.08);
|
||||||
|
color: #d41e3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .visibility-toggle {
|
||||||
|
background: var(--background-primary);
|
||||||
|
border-color: var(--background-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .visibility-toggle:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .visibility-toggle.active {
|
||||||
|
border-color: #d41e3a;
|
||||||
|
background: rgba(212, 30, 58, 0.15);
|
||||||
|
color: #d41e3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-panel {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin: 0 1rem 1rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .visibility-panel {
|
||||||
|
background: var(--background-secondary);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-panel-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .visibility-panel-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-section {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .visibility-label {
|
||||||
|
color: var(--text-primary);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #1a1a1a;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .visibility-chip {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-unhide {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
background: #d41e3a;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-unhide:hover {
|
||||||
|
background: #b91c33;
|
||||||
|
}
|
||||||
|
|
||||||
.assessment-title {
|
.assessment-title {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
padding-right: 2rem; /* Make room for menu button */
|
padding-right: 2rem; /* Make room for menu button */
|
||||||
}
|
}
|
||||||
@@ -456,6 +612,10 @@
|
|||||||
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
|
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.column-custom .column-header {
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #f1f5f9 100%);
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode column headers */
|
/* Dark mode column headers */
|
||||||
.dark .column-upcoming .column-header {
|
.dark .column-upcoming .column-header {
|
||||||
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%);
|
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%);
|
||||||
@@ -477,6 +637,10 @@
|
|||||||
background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%);
|
background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .column-custom .column-header {
|
||||||
|
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a5f 100%);
|
||||||
|
}
|
||||||
|
|
||||||
/* Subject filter view */
|
/* Subject filter view */
|
||||||
.subject-section {
|
.subject-section {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import localforage from 'localforage'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
|
let fileInput = $state<HTMLInputElement | undefined>(undefined)
|
||||||
|
let dragging = $state(false)
|
||||||
|
let filename = $state<string | undefined>(undefined)
|
||||||
|
let durationText = $state<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const store = localforage.createInstance({
|
||||||
|
name: 'background-music-store',
|
||||||
|
storeName: 'music',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadExisting() {
|
||||||
|
const name = await store.getItem<string>('audio-name')
|
||||||
|
filename = name ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => { loadExisting() })
|
||||||
|
|
||||||
|
function triggerSelect() { fileInput?.click() }
|
||||||
|
|
||||||
|
async function handleFiles(files: FileList | null) {
|
||||||
|
const file = files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
// Accept WAV and MP3 files
|
||||||
|
const isSupported = file.type === 'audio/wav' || file.type === 'audio/mpeg' ||
|
||||||
|
file.name.toLowerCase().endsWith('.wav') || file.name.toLowerCase().endsWith('.mp3')
|
||||||
|
if (!isSupported) {
|
||||||
|
alert('Please select a .wav or .mp3 audio file')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await store.setItem('audio-blob', file)
|
||||||
|
await store.setItem('audio-name', file.name)
|
||||||
|
filename = file.name
|
||||||
|
|
||||||
|
// Probe duration
|
||||||
|
try {
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
const audio = new Audio(url)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
audio.onloadedmetadata = () => resolve()
|
||||||
|
audio.onerror = () => reject()
|
||||||
|
})
|
||||||
|
if (!isNaN(audio.duration) && audio.duration !== Infinity) {
|
||||||
|
const minutes = Math.floor(audio.duration / 60)
|
||||||
|
const seconds = Math.round(audio.duration % 60)
|
||||||
|
durationText = `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
} else {
|
||||||
|
durationText = undefined
|
||||||
|
}
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch {
|
||||||
|
durationText = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event('betterseqta-background-music-updated'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange() { handleFiles(fileInput?.files || null) }
|
||||||
|
|
||||||
|
function onDrop(event: DragEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
dragging = false
|
||||||
|
handleFiles(event.dataTransfer?.files || null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAudio() {
|
||||||
|
await store.removeItem('audio-blob')
|
||||||
|
await store.removeItem('audio-name')
|
||||||
|
filename = undefined
|
||||||
|
durationText = undefined
|
||||||
|
window.dispatchEvent(new Event('betterseqta-background-music-stop'))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative cursor-pointer select-none"
|
||||||
|
onclick={() => triggerSelect()}
|
||||||
|
ondragover={(e) => { e.stopPropagation(); dragging = true }}
|
||||||
|
ondragleave={() => dragging = false}
|
||||||
|
ondrop={onDrop}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
triggerSelect()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class="flex gap-3 items-center">
|
||||||
|
{#if filename}
|
||||||
|
<div class="flex items-center px-3 py-1 rounded-lg bg-zinc-200 dark:bg-zinc-800">
|
||||||
|
<div class="text-xs text-zinc-600 dark:text-zinc-300">
|
||||||
|
{filename}
|
||||||
|
<p>{durationText}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center m-1 text-lg dark:text-white size-7"
|
||||||
|
onclick={(e) => { e.stopPropagation(); removeAudio() }}
|
||||||
|
aria-label="Remove audio"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex gap-2 items-center px-3 py-1 text-xs rounded-lg border border-dashed transition border-zinc-300 dark:border-zinc-600 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 text-nowrap">
|
||||||
|
<span class="text-lg font-IconFamily">{'\ued47'}</span>
|
||||||
|
<span>Upload audio</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<input type="file" accept="audio/wav,audio/mpeg" class="hidden" bind:this={fileInput} onchange={onFileChange} />
|
||||||
|
{#if dragging}
|
||||||
|
<div class="absolute inset-0 rounded-lg bg-zinc-200/40 dark:bg-zinc-700/40"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import type { Plugin } from "@/plugins/core/types";
|
||||||
|
import { booleanSetting, componentSetting, defineSettings, numberSetting } from "@/plugins/core/settingsHelpers";
|
||||||
|
import styles from "./styles.css?inline";
|
||||||
|
import BackgroundMusicSetting from "./BackgroundMusicSetting.svelte";
|
||||||
|
import localforage from "localforage";
|
||||||
|
|
||||||
|
const settings = defineSettings({
|
||||||
|
uploader: componentSetting({
|
||||||
|
title: "Background Music",
|
||||||
|
description: "Upload a .wav or .mp3 audio file to play in the background",
|
||||||
|
component: BackgroundMusicSetting,
|
||||||
|
}),
|
||||||
|
volume: numberSetting({
|
||||||
|
title: "Volume",
|
||||||
|
description: "Set background music volume",
|
||||||
|
default: 0.5,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.05,
|
||||||
|
}),
|
||||||
|
pauseOnHidden: booleanSetting({
|
||||||
|
title: "Pause when tab hidden",
|
||||||
|
description: "Pause music when switching to another tab or minimizing the browser",
|
||||||
|
default: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = localforage.createInstance({
|
||||||
|
name: "background-music-store",
|
||||||
|
storeName: "music",
|
||||||
|
});
|
||||||
|
|
||||||
|
let currentAudio: HTMLAudioElement | null = null;
|
||||||
|
let currentObjectUrl: string | null = null;
|
||||||
|
let cleanupRegistered = false;
|
||||||
|
let pendingGestureCancel: (() => void) | null = null;
|
||||||
|
let visibilityResumeTimeout: number | null = null;
|
||||||
|
|
||||||
|
async function loadAudioBlob(): Promise<Blob | null> {
|
||||||
|
const blob = await store.getItem<Blob>("audio-blob");
|
||||||
|
return blob && blob instanceof Blob ? blob : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAndCleanupAudio(): void {
|
||||||
|
if (currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
currentAudio.src = "";
|
||||||
|
currentAudio.remove();
|
||||||
|
currentAudio = null;
|
||||||
|
}
|
||||||
|
if (currentObjectUrl) {
|
||||||
|
URL.revokeObjectURL(currentObjectUrl);
|
||||||
|
currentObjectUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGestureStart(handler: () => void): () => void {
|
||||||
|
const eventTypes = ["pointerdown", "keydown", "touchstart"]; // broad user gesture coverage
|
||||||
|
const listener = () => {
|
||||||
|
handler();
|
||||||
|
for (const type of eventTypes) {
|
||||||
|
window.removeEventListener(type, listener);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const type of eventTypes) {
|
||||||
|
window.addEventListener(type, listener, { once: true, passive: true });
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
for (const type of eventTypes) {
|
||||||
|
window.removeEventListener(type, listener);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPlayback(volume: number): Promise<void> {
|
||||||
|
const blob = await loadAudioBlob();
|
||||||
|
if (!blob) return;
|
||||||
|
|
||||||
|
stopAndCleanupAudio();
|
||||||
|
|
||||||
|
currentObjectUrl = URL.createObjectURL(blob);
|
||||||
|
const audio = new Audio(currentObjectUrl);
|
||||||
|
audio.loop = true;
|
||||||
|
audio.volume = Math.max(0, Math.min(1, volume));
|
||||||
|
audio.preload = "auto";
|
||||||
|
audio.crossOrigin = "anonymous";
|
||||||
|
audio.style.display = "none";
|
||||||
|
document.body.appendChild(audio);
|
||||||
|
currentAudio = audio;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Attempt immediate play; may be blocked until gesture
|
||||||
|
await audio.play();
|
||||||
|
} catch {
|
||||||
|
// Ignore; will be started after gesture if enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const backgroundMusicPlugin: Plugin<typeof settings> = {
|
||||||
|
id: "background-music",
|
||||||
|
name: "Background Music",
|
||||||
|
description: "Play your own music in the background while SEQTA is open",
|
||||||
|
version: "1.0.0",
|
||||||
|
settings,
|
||||||
|
styles,
|
||||||
|
disableToggle: true,
|
||||||
|
defaultEnabled: false,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
await api.storage.loaded;
|
||||||
|
|
||||||
|
// react to specific setting changes
|
||||||
|
api.settings.onChange("volume" as any, (value: any) => {
|
||||||
|
const vol = (typeof value === "number" ? value : 0.5) as number;
|
||||||
|
if (currentAudio) currentAudio.volume = Math.max(0, Math.min(1, vol));
|
||||||
|
});
|
||||||
|
|
||||||
|
api.settings.onChange("pauseOnHidden" as any, (value: any) => {
|
||||||
|
const pauseOnHidden = (typeof value === "boolean" ? value : true) as boolean;
|
||||||
|
// If the setting is disabled and audio is currently paused due to tab being hidden, resume it
|
||||||
|
if (!pauseOnHidden && currentAudio && currentAudio.paused && document.visibilityState === "hidden") {
|
||||||
|
currentAudio.play().catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: Stop button/event removed by user; no stop handling needed
|
||||||
|
|
||||||
|
// Start if we have audio and autoplay is enabled
|
||||||
|
const tryStart = async () => {
|
||||||
|
const vol = (api.settings as any).volume ?? 0.5;
|
||||||
|
await startPlayback(vol);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always arm gesture start and attempt immediate start
|
||||||
|
const cancel = ensureGestureStart(() => { tryStart(); });
|
||||||
|
cleanupRegistered = true;
|
||||||
|
(window as any).__betterseqta_bg_music_cancel__ = cancel;
|
||||||
|
tryStart();
|
||||||
|
|
||||||
|
// Pause on tab hide, resume on show with a small delay (if enabled)
|
||||||
|
const visHandler = () => {
|
||||||
|
if (!currentAudio) return;
|
||||||
|
const pauseOnHidden = (api.settings as any).pauseOnHidden ?? true;
|
||||||
|
if (!pauseOnHidden) return;
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
if (visibilityResumeTimeout !== null) {
|
||||||
|
clearTimeout(visibilityResumeTimeout);
|
||||||
|
visibilityResumeTimeout = null;
|
||||||
|
}
|
||||||
|
currentAudio.pause();
|
||||||
|
} else if (document.visibilityState === "visible") {
|
||||||
|
if (visibilityResumeTimeout !== null) {
|
||||||
|
clearTimeout(visibilityResumeTimeout);
|
||||||
|
}
|
||||||
|
visibilityResumeTimeout = window.setTimeout(() => {
|
||||||
|
visibilityResumeTimeout = null;
|
||||||
|
currentAudio?.play().catch(() => {});
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", visHandler);
|
||||||
|
|
||||||
|
// Allow uploads to trigger refresh
|
||||||
|
const uploadedHandler = () => {
|
||||||
|
const vol = (api.settings as any).volume ?? 0.5;
|
||||||
|
startPlayback(vol);
|
||||||
|
};
|
||||||
|
window.addEventListener("betterseqta-background-music-updated", uploadedHandler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", visHandler);
|
||||||
|
window.removeEventListener("betterseqta-background-music-updated", uploadedHandler);
|
||||||
|
if (cleanupRegistered && (window as any).__betterseqta_bg_music_cancel__) {
|
||||||
|
(window as any).__betterseqta_bg_music_cancel__();
|
||||||
|
(window as any).__betterseqta_bg_music_cancel__ = undefined;
|
||||||
|
}
|
||||||
|
if (pendingGestureCancel) { pendingGestureCancel(); pendingGestureCancel = null; }
|
||||||
|
if (visibilityResumeTimeout !== null) { clearTimeout(visibilityResumeTimeout); visibilityResumeTimeout = null; }
|
||||||
|
stopAndCleanupAudio();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default backgroundMusicPlugin;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.background-music-hidden{display:none}
|
||||||
|
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
defineSettings,
|
defineSettings,
|
||||||
hotkeySetting,
|
hotkeySetting,
|
||||||
} from "../../core/settingsHelpers";
|
} from "../../core/settingsHelpers";
|
||||||
|
import styles from "./src/core/styles.css?inline";
|
||||||
|
|
||||||
// Platform-aware default hotkey
|
// Platform-aware default hotkey
|
||||||
const getDefaultHotkey = () => {
|
const getDefaultHotkey = () => {
|
||||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||||
@@ -40,32 +42,69 @@ const settings = defineSettings({
|
|||||||
|
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
try {
|
try {
|
||||||
// Dynamically import the worker manager to avoid loading heavy dependencies
|
// Dynamically import modules to avoid loading heavy dependencies
|
||||||
const { VectorWorkerManager } = await import("./src/indexing/worker/vectorWorkerManager");
|
const { VectorWorkerManager } = await import("./src/indexing/worker/vectorWorkerManager");
|
||||||
const workerManager = VectorWorkerManager.getInstance();
|
const { resetDatabase } = await import("./src/indexing/db");
|
||||||
await workerManager.resetWorker();
|
|
||||||
console.log("Vector worker reset successfully");
|
// Reset vector worker first
|
||||||
} catch (e) {
|
try {
|
||||||
console.warn("Failed to reset vector worker:", e);
|
const workerManager = VectorWorkerManager.getInstance();
|
||||||
}
|
await workerManager.resetWorker();
|
||||||
|
console.log("Vector worker reset successfully");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to reset vector worker:", e);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete both 'embeddiaDB' and 'betterseqta-index' using native IndexedDB APIs
|
// Close all database connections properly before deletion
|
||||||
const deleteDb = (dbName: string) => {
|
try {
|
||||||
return new Promise<void>((resolve, reject) => {
|
await resetDatabase();
|
||||||
const req = indexedDB.deleteDatabase(dbName);
|
console.log("betterseqta-index database closed and reset");
|
||||||
req.onsuccess = () => resolve();
|
} catch (e) {
|
||||||
req.onerror = () => reject(req.error);
|
console.warn("Failed to reset betterseqta-index database:", e);
|
||||||
req.onblocked = () => {
|
}
|
||||||
reject(new Error(`One database is open, failed to remove: ${dbName}`));
|
|
||||||
};
|
// Wait a bit for connections to fully close
|
||||||
});
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
};
|
|
||||||
try {
|
// Delete embeddiaDB (vector search database)
|
||||||
await deleteDb("embeddiaDB");
|
const deleteDb = (dbName: string) => {
|
||||||
await deleteDb("betterseqta-index");
|
return new Promise<void>((resolve, reject) => {
|
||||||
alert("Search index and storage have been reset.");
|
const req = indexedDB.deleteDatabase(dbName);
|
||||||
|
req.onsuccess = () => {
|
||||||
|
console.log(`Successfully deleted database: ${dbName}`);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
req.onerror = () => {
|
||||||
|
console.error(`Error deleting database ${dbName}:`, req.error);
|
||||||
|
reject(req.error);
|
||||||
|
};
|
||||||
|
req.onblocked = () => {
|
||||||
|
console.warn(`Database ${dbName} deletion blocked - connections still open`);
|
||||||
|
// Wait and retry once
|
||||||
|
setTimeout(() => {
|
||||||
|
const retryReq = indexedDB.deleteDatabase(dbName);
|
||||||
|
retryReq.onsuccess = () => {
|
||||||
|
console.log(`Successfully deleted database on retry: ${dbName}`);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
retryReq.onerror = () => reject(retryReq.error);
|
||||||
|
retryReq.onblocked = () => {
|
||||||
|
reject(new Error(`One database is open, failed to remove: ${dbName}. Please close other tabs and try again.`));
|
||||||
|
};
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteDb("embeddiaDB");
|
||||||
|
await deleteDb("betterseqta-index");
|
||||||
|
alert("Search index and storage have been reset successfully.");
|
||||||
|
} catch (e) {
|
||||||
|
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert("Failed to reset one or more databases: " + String(e));
|
alert("Failed to reset index: " + String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -82,6 +121,7 @@ export default defineLazyPlugin({
|
|||||||
disableToggle: true,
|
disableToggle: true,
|
||||||
defaultEnabled: false,
|
defaultEnabled: false,
|
||||||
beta: true,
|
beta: true,
|
||||||
|
styles: styles,
|
||||||
|
|
||||||
// Lazy loader - only imports the heavy plugin when actually needed
|
// Lazy loader - only imports the heavy plugin when actually needed
|
||||||
loader: () => import("./src/core/index")
|
loader: () => import("./src/core/index")
|
||||||
|
|||||||
@@ -35,6 +35,8 @@
|
|||||||
let isIndexing = $state(false);
|
let isIndexing = $state(false);
|
||||||
let completedJobs = $state(0);
|
let completedJobs = $state(0);
|
||||||
let totalJobs = $state(0);
|
let totalJobs = $state(0);
|
||||||
|
let indexingStatus = $state<string | null>(null);
|
||||||
|
let indexingDetail = $state<string | null>(null);
|
||||||
|
|
||||||
let commandPalleteOpen = $state(false);
|
let commandPalleteOpen = $state(false);
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
@@ -110,10 +112,12 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const progressHandler = (event: CustomEvent) => {
|
const progressHandler = (event: CustomEvent) => {
|
||||||
const { completed, total, indexing } = event.detail;
|
const { completed, total, indexing, status, detail } = event.detail;
|
||||||
completedJobs = completed;
|
completedJobs = completed;
|
||||||
totalJobs = total;
|
totalJobs = total;
|
||||||
isIndexing = indexing;
|
isIndexing = indexing;
|
||||||
|
indexingStatus = status || null;
|
||||||
|
indexingDetail = detail || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
||||||
@@ -167,7 +171,10 @@
|
|||||||
combinedResults = await doSearch(
|
combinedResults = await doSearch(
|
||||||
term,
|
term,
|
||||||
commandsFuse,
|
commandsFuse,
|
||||||
commandIdToItemMap,
|
commandIdToItemMap,
|
||||||
|
dynamicContentFuse,
|
||||||
|
dynamicIdToItemMap,
|
||||||
|
true, // sortByRecent
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
combinedResults = [];
|
combinedResults = [];
|
||||||
@@ -176,13 +183,19 @@
|
|||||||
isLoading = false;
|
isLoading = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const debouncedPerformSearch = debounce(performSearch, 20);
|
// Optimized debounce: shorter delay for better responsiveness
|
||||||
|
const debouncedPerformSearch = debounce(performSearch, 50);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (commandPalleteOpen) {
|
if (commandPalleteOpen) {
|
||||||
if (searchTerm === '') {
|
if (searchTerm === '') {
|
||||||
|
// Immediate search for empty query (shows recent items)
|
||||||
|
performSearch();
|
||||||
|
} else if (searchTerm.length <= 2) {
|
||||||
|
// Immediate search for very short queries
|
||||||
performSearch();
|
performSearch();
|
||||||
} else {
|
} else {
|
||||||
|
// Debounced search for longer queries
|
||||||
debouncedPerformSearch();
|
debouncedPerformSearch();
|
||||||
}
|
}
|
||||||
tick().then(() => searchbar?.focus());
|
tick().then(() => searchbar?.focus());
|
||||||
@@ -389,19 +402,6 @@
|
|||||||
{@render Shortcut({ text: 'Select', keybind: ['↵']})}
|
{@render Shortcut({ text: 'Select', keybind: ['↵']})}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
booleanSetting,
|
booleanSetting,
|
||||||
buttonSetting,
|
buttonSetting,
|
||||||
defineSettings,
|
defineSettings,
|
||||||
Setting,
|
|
||||||
hotkeySetting,
|
hotkeySetting,
|
||||||
|
Setting,
|
||||||
} from "@/plugins/core/settingsHelpers";
|
} from "@/plugins/core/settingsHelpers";
|
||||||
import styles from "./styles.css?inline";
|
import styles from "./styles.css?inline";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
@@ -14,6 +14,7 @@ import { initVectorSearch } from "../search/vector/vectorSearch";
|
|||||||
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
|
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
|
||||||
import { IndexedDbManager } from "embeddia";
|
import { IndexedDbManager } from "embeddia";
|
||||||
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
|
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
|
||||||
|
import { checkAndHandleUpdate } from "../utils/versionCheck";
|
||||||
|
|
||||||
// Platform-aware default hotkey
|
// Platform-aware default hotkey
|
||||||
const getDefaultHotkey = () => {
|
const getDefaultHotkey = () => {
|
||||||
@@ -50,31 +51,67 @@ const settings = defineSettings({
|
|||||||
|
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
try {
|
try {
|
||||||
|
// Import resetDatabase function to properly close connections
|
||||||
|
const { resetDatabase } = await import("../indexing/db");
|
||||||
|
|
||||||
// Reset the vector worker first
|
// Reset the vector worker first
|
||||||
const workerManager = VectorWorkerManager.getInstance();
|
try {
|
||||||
await workerManager.resetWorker();
|
const workerManager = VectorWorkerManager.getInstance();
|
||||||
console.log("Vector worker reset successfully");
|
await workerManager.resetWorker();
|
||||||
} catch (e) {
|
console.log("Vector worker reset successfully");
|
||||||
console.warn("Failed to reset vector worker:", e);
|
} catch (e) {
|
||||||
}
|
console.warn("Failed to reset vector worker:", e);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete both 'embeddiaDB' and 'betterseqta-index' using native IndexedDB APIs
|
// Close all database connections properly before deletion
|
||||||
const deleteDb = (dbName: string) => {
|
try {
|
||||||
return new Promise<void>((resolve, reject) => {
|
await resetDatabase();
|
||||||
const req = indexedDB.deleteDatabase(dbName);
|
} catch (e) {
|
||||||
req.onsuccess = () => resolve();
|
console.warn("Failed to reset betterseqta-index database:", e);
|
||||||
req.onerror = () => reject(req.error);
|
}
|
||||||
req.onblocked = () => {
|
|
||||||
reject(new Error(`One database is open, failed to remove: ${dbName}`));
|
// Wait a bit for connections to fully close
|
||||||
};
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
});
|
|
||||||
};
|
// Delete embeddiaDB (vector search database)
|
||||||
try {
|
const deleteDb = (dbName: string) => {
|
||||||
await deleteDb("embeddiaDB");
|
return new Promise<void>((resolve, reject) => {
|
||||||
await deleteDb("betterseqta-index");
|
const req = indexedDB.deleteDatabase(dbName);
|
||||||
alert("Search index and storage have been reset.");
|
req.onsuccess = () => {
|
||||||
|
console.log(`Successfully deleted database: ${dbName}`);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
req.onerror = () => {
|
||||||
|
console.error(`Error deleting database ${dbName}:`, req.error);
|
||||||
|
reject(req.error);
|
||||||
|
};
|
||||||
|
req.onblocked = () => {
|
||||||
|
console.warn(`Database ${dbName} deletion blocked - connections still open`);
|
||||||
|
// Wait and retry once
|
||||||
|
setTimeout(() => {
|
||||||
|
const retryReq = indexedDB.deleteDatabase(dbName);
|
||||||
|
retryReq.onsuccess = () => {
|
||||||
|
console.log(`Successfully deleted database on retry: ${dbName}`);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
retryReq.onerror = () => reject(retryReq.error);
|
||||||
|
retryReq.onblocked = () => {
|
||||||
|
reject(new Error(`One database is open, failed to remove: ${dbName}. Please close other tabs and try again.`));
|
||||||
|
};
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteDb("embeddiaDB");
|
||||||
|
await deleteDb("betterseqta-index");
|
||||||
|
alert("Search index and storage have been reset successfully.");
|
||||||
|
} catch (e) {
|
||||||
|
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert("Failed to reset one or more databases: " + String(e));
|
alert("Failed to reset index: " + String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -114,6 +151,27 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
|||||||
run: async (api) => {
|
run: async (api) => {
|
||||||
const appRef = { current: null };
|
const appRef = { current: null };
|
||||||
|
|
||||||
|
// Check for extension updates and clear caches if needed
|
||||||
|
// Use a timeout to avoid blocking initialization
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const wasUpdated = await checkAndHandleUpdate();
|
||||||
|
if (wasUpdated) {
|
||||||
|
console.log("[Global Search] Extension updated - caches cleared");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// Handle CSS preload errors and other failures gracefully
|
||||||
|
// These can happen in Firefox or when assets aren't available
|
||||||
|
if (error?.message?.includes("preload CSS") ||
|
||||||
|
error?.message?.includes("MIME type") ||
|
||||||
|
error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")) {
|
||||||
|
console.debug("[Global Search] Version check skipped due to asset loading restrictions:", error.message);
|
||||||
|
} else {
|
||||||
|
console.warn("[Global Search] Failed to check for updates:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
|
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
|
||||||
primaryKey: "id",
|
primaryKey: "id",
|
||||||
@@ -126,10 +184,16 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
|||||||
|
|
||||||
initVectorSearch();
|
initVectorSearch();
|
||||||
|
|
||||||
// Warm up vector worker in background to improve initial response time
|
// Warm up vector worker in background to improve initial response time (skip in Firefox)
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
VectorWorkerManager.getInstance();
|
// Only initialize worker if vector search is supported
|
||||||
|
const { isVectorSearchSupported } = await import("../utils/browserDetection");
|
||||||
|
if (isVectorSearchSupported()) {
|
||||||
|
VectorWorkerManager.getInstance();
|
||||||
|
} else {
|
||||||
|
console.debug("[Global Search] Skipping vector worker warm-up (Firefox detected - using text search only)");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Global Search] Vector worker warm-up failed:", error);
|
console.warn("[Global Search] Vector worker warm-up failed:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import browser from "webextension-polyfill";
|
|||||||
export function mountSearchBar(
|
export function mountSearchBar(
|
||||||
titleElement: Element,
|
titleElement: Element,
|
||||||
api: any,
|
api: any,
|
||||||
appRef: { current: any; storageChangeHandler?: any },
|
appRef: { current: any; storageChangeHandler?: any; progressHandler?: any },
|
||||||
) {
|
) {
|
||||||
if (titleElement.querySelector(".search-trigger")) {
|
if (titleElement.querySelector(".search-trigger")) {
|
||||||
return;
|
return;
|
||||||
@@ -21,6 +21,72 @@ export function mountSearchBar(
|
|||||||
const searchButton = document.createElement("div");
|
const searchButton = document.createElement("div");
|
||||||
searchButton.className = "search-trigger";
|
searchButton.className = "search-trigger";
|
||||||
|
|
||||||
|
// Create progress indicator container
|
||||||
|
const progressContainer = document.createElement("div");
|
||||||
|
progressContainer.className = "search-progress-container";
|
||||||
|
progressContainer.style.cssText = "display: flex; align-items: center; gap: 8px; margin-left: 8px; min-width: 120px;";
|
||||||
|
|
||||||
|
// Create progress bar
|
||||||
|
const progressBarWrapper = document.createElement("div");
|
||||||
|
progressBarWrapper.className = "search-progress-bar-wrapper";
|
||||||
|
progressBarWrapper.style.cssText = "flex: 1; height: 4px; background: rgba(0, 0, 0, 0.1); border-radius: 2px; overflow: hidden; display: none;";
|
||||||
|
|
||||||
|
const progressBar = document.createElement("div");
|
||||||
|
progressBar.className = "search-progress-bar";
|
||||||
|
progressBar.style.cssText = "height: 100%; background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6); transition: width 0.3s ease-out; width: 0%; position: relative;";
|
||||||
|
|
||||||
|
// Add shimmer effect
|
||||||
|
const shimmer = document.createElement("div");
|
||||||
|
shimmer.style.cssText = "position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); animation: shimmer 2s infinite;";
|
||||||
|
progressBar.appendChild(shimmer);
|
||||||
|
progressBarWrapper.appendChild(progressBar);
|
||||||
|
|
||||||
|
// Create progress text
|
||||||
|
const progressText = document.createElement("span");
|
||||||
|
progressText.className = "search-progress-text";
|
||||||
|
progressText.style.cssText = "font-size: 11px; color: #666; white-space: nowrap; display: none;";
|
||||||
|
|
||||||
|
progressContainer.appendChild(progressBarWrapper);
|
||||||
|
progressContainer.appendChild(progressText);
|
||||||
|
|
||||||
|
// Indexing state
|
||||||
|
let isIndexing = false;
|
||||||
|
let completedJobs = 0;
|
||||||
|
let totalJobs = 0;
|
||||||
|
let indexingStatus: string | null = null;
|
||||||
|
|
||||||
|
const updateProgressDisplay = () => {
|
||||||
|
if (isIndexing && totalJobs > 0) {
|
||||||
|
const percentage = Math.round((completedJobs / totalJobs) * 100);
|
||||||
|
progressBar.style.width = `${Math.max(2, percentage)}%`;
|
||||||
|
progressBarWrapper.style.display = "block";
|
||||||
|
|
||||||
|
if (indexingStatus) {
|
||||||
|
progressText.textContent = indexingStatus.length > 20 ? indexingStatus.substring(0, 20) + "..." : indexingStatus;
|
||||||
|
progressText.style.display = "block";
|
||||||
|
} else {
|
||||||
|
progressText.textContent = `${completedJobs}/${totalJobs} (${percentage}%)`;
|
||||||
|
progressText.style.display = "block";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
progressBarWrapper.style.display = "none";
|
||||||
|
progressText.style.display = "none";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for indexing progress events
|
||||||
|
const progressHandler = (event: CustomEvent) => {
|
||||||
|
const { completed, total, indexing, status } = event.detail;
|
||||||
|
completedJobs = completed || 0;
|
||||||
|
totalJobs = total || 0;
|
||||||
|
isIndexing = indexing || false;
|
||||||
|
indexingStatus = status || null;
|
||||||
|
updateProgressDisplay();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
||||||
|
appRef.progressHandler = progressHandler;
|
||||||
|
|
||||||
const updateSearchButtonDisplay = () => {
|
const updateSearchButtonDisplay = () => {
|
||||||
searchButton.innerHTML = /* html */ `
|
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">
|
<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">
|
||||||
@@ -34,6 +100,7 @@ export function mountSearchBar(
|
|||||||
|
|
||||||
updateSearchButtonDisplay();
|
updateSearchButtonDisplay();
|
||||||
titleElement.appendChild(searchButton);
|
titleElement.appendChild(searchButton);
|
||||||
|
titleElement.appendChild(progressContainer);
|
||||||
|
|
||||||
// Listen for hotkey setting changes
|
// Listen for hotkey setting changes
|
||||||
const handleStorageChange = (changes: any, area: string) => {
|
const handleStorageChange = (changes: any, area: string) => {
|
||||||
@@ -72,7 +139,7 @@ export function mountSearchBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any }) {
|
export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any; progressHandler?: any }) {
|
||||||
if (appRef.current) {
|
if (appRef.current) {
|
||||||
try {
|
try {
|
||||||
unmount(appRef.current);
|
unmount(appRef.current);
|
||||||
@@ -82,11 +149,23 @@ export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove progress event listener
|
||||||
|
if (appRef.progressHandler) {
|
||||||
|
window.removeEventListener('indexing-progress', appRef.progressHandler as EventListener);
|
||||||
|
appRef.progressHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove search trigger button
|
// Remove search trigger button
|
||||||
const searchTrigger = document.querySelector(".search-trigger");
|
const searchTrigger = document.querySelector(".search-trigger");
|
||||||
if (searchTrigger) {
|
if (searchTrigger) {
|
||||||
searchTrigger.remove();
|
searchTrigger.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove progress container
|
||||||
|
const progressContainer = document.querySelector(".search-progress-container");
|
||||||
|
if (progressContainer) {
|
||||||
|
progressContainer.remove();
|
||||||
|
}
|
||||||
|
|
||||||
// Remove search root
|
// Remove search root
|
||||||
const searchRoot = document.querySelector("div[data-search-root]");
|
const searchRoot = document.querySelector("div[data-search-root]");
|
||||||
|
|||||||
@@ -68,4 +68,72 @@
|
|||||||
|
|
||||||
.dark .highlight {
|
.dark .highlight {
|
||||||
background-color: rgba(255, 230, 100, 0.4);
|
background-color: rgba(255, 230, 100, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shimmer {
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress indicator next to search trigger */
|
||||||
|
.search-progress-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 8px;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 200px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-progress-bar-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: none;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-progress-bar-wrapper {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6);
|
||||||
|
transition: width 0.3s ease-out;
|
||||||
|
width: 0%;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-progress-bar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-progress-text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-progress-text {
|
||||||
|
color: #999;
|
||||||
}
|
}
|
||||||
@@ -59,17 +59,132 @@ export const actionMap: Record<string, ActionHandler<any>> = {
|
|||||||
}) as ActionHandler<any>,
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
assessment: (async (item: IndexItem & { metadata: AssessmentMetadata }) => {
|
assessment: (async (item: IndexItem & { metadata: AssessmentMetadata }) => {
|
||||||
if (item.metadata.isMessageBased) {
|
// Deep clone the entire item to avoid Firefox XrayWrapper issues
|
||||||
|
// Firefox XrayWrapper prevents direct access to nested properties
|
||||||
|
let itemClone: IndexItem & { metadata: AssessmentMetadata };
|
||||||
|
let metadata: AssessmentMetadata;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First try to clone the entire item
|
||||||
|
itemClone = JSON.parse(JSON.stringify(item));
|
||||||
|
metadata = itemClone.metadata || {};
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Assessment Action] Failed to clone item, trying to clone metadata separately:", e);
|
||||||
|
try {
|
||||||
|
// If full clone fails, try cloning just metadata
|
||||||
|
metadata = JSON.parse(JSON.stringify(item.metadata || {}));
|
||||||
|
itemClone = { ...item, metadata };
|
||||||
|
} catch (e2) {
|
||||||
|
console.warn("[Assessment Action] Failed to clone metadata, using direct access:", e2);
|
||||||
|
itemClone = item;
|
||||||
|
metadata = item.metadata || {} as AssessmentMetadata;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract metadata values using multiple methods to handle XrayWrapper
|
||||||
|
const getMetadataValue = (key: string, altKey?: string): any => {
|
||||||
|
try {
|
||||||
|
// Try direct access first
|
||||||
|
const value = metadata[key];
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (altKey) {
|
||||||
|
const altValue = metadata[altKey];
|
||||||
|
if (altValue !== undefined && altValue !== null) {
|
||||||
|
return altValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try accessing via Object.keys iteration (works around XrayWrapper)
|
||||||
|
try {
|
||||||
|
const keys = Object.keys(metadata);
|
||||||
|
for (const k of keys) {
|
||||||
|
if (k === key || k === altKey) {
|
||||||
|
const val = metadata[k];
|
||||||
|
if (val !== undefined && val !== null) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Object.keys might fail on XrayWrapper, that's okay
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Assessment Action] Failed to access metadata.${key}:`, e);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (getMetadataValue('isMessageBased')) {
|
||||||
window.location.hash = `#?page=/messages`;
|
window.location.hash = `#?page=/messages`;
|
||||||
|
|
||||||
await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20);
|
await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20);
|
||||||
|
|
||||||
// Select the specific direct message
|
// Select the specific direct message
|
||||||
ReactFiber.find('[class*="Viewer__Viewer___"] > div').setState({
|
ReactFiber.find('[class*="Viewer__Viewer___"] > div').setState({
|
||||||
selected: new Set([item.metadata.messageId]),
|
selected: new Set([getMetadataValue('messageId')]),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
window.location.hash = `#?page=/assessments&id=${item.metadata.assessmentId}`;
|
// Extract values - check both camelCase and PascalCase, and try multiple access methods
|
||||||
|
let programmeId = getMetadataValue('programmeId', 'programmeID');
|
||||||
|
let metaclassId = getMetadataValue('metaclassId', 'metaclassID');
|
||||||
|
let assessmentId = getMetadataValue('assessmentId', 'assessmentID');
|
||||||
|
|
||||||
|
// Fallback: try to extract assessmentId from item ID if metadata is missing
|
||||||
|
if ((assessmentId === undefined || assessmentId === null) && itemClone.id && itemClone.id.startsWith('assignment-')) {
|
||||||
|
const extractedId = itemClone.id.replace('assignment-', '');
|
||||||
|
assessmentId = Number(extractedId) || extractedId;
|
||||||
|
console.log("[Assessment Action] Extracted assessmentId from item ID:", assessmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to numbers, but preserve 0 as valid
|
||||||
|
if (programmeId !== undefined && programmeId !== null && programmeId !== '') {
|
||||||
|
const num = Number(programmeId);
|
||||||
|
programmeId = isNaN(num) ? programmeId : num;
|
||||||
|
}
|
||||||
|
if (metaclassId !== undefined && metaclassId !== null && metaclassId !== '') {
|
||||||
|
const num = Number(metaclassId);
|
||||||
|
metaclassId = isNaN(num) ? metaclassId : num;
|
||||||
|
}
|
||||||
|
if (assessmentId !== undefined && assessmentId !== null && assessmentId !== '') {
|
||||||
|
const num = Number(assessmentId);
|
||||||
|
assessmentId = isNaN(num) ? assessmentId : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if values exist (including 0, which is a valid ID)
|
||||||
|
// Use typeof check to properly handle 0
|
||||||
|
const hasProgrammeId = programmeId !== undefined && programmeId !== null && programmeId !== '' && typeof programmeId === 'number';
|
||||||
|
const hasMetaclassId = metaclassId !== undefined && metaclassId !== null && metaclassId !== '' && typeof metaclassId === 'number';
|
||||||
|
const hasAssessmentId = assessmentId !== undefined && assessmentId !== null && assessmentId !== '' && typeof assessmentId === 'number';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProgrammeId && hasMetaclassId && hasAssessmentId) {
|
||||||
|
const url = `#?page=/assessments/${programmeId}:${metaclassId}&item=${assessmentId}`;
|
||||||
|
console.log("[Assessment Action] ✅ Navigating to:", url);
|
||||||
|
window.location.hash = url;
|
||||||
|
} else {
|
||||||
|
// Fallback: try to navigate to assessments page if metadata is incomplete
|
||||||
|
console.error("[Assessment Action] ❌ Missing required metadata:", {
|
||||||
|
programmeId,
|
||||||
|
metaclassId,
|
||||||
|
assessmentId,
|
||||||
|
hasProgrammeId,
|
||||||
|
hasMetaclassId,
|
||||||
|
hasAssessmentId,
|
||||||
|
metadataKeys: Object.keys(metadata),
|
||||||
|
metadataString: JSON.stringify(metadata),
|
||||||
|
itemId: itemClone.id,
|
||||||
|
});
|
||||||
|
// If we at least have an assessmentId, try to navigate to the general assessments page
|
||||||
|
if (hasAssessmentId) {
|
||||||
|
window.location.hash = `#?page=/assessments/upcoming&item=${assessmentId}`;
|
||||||
|
} else {
|
||||||
|
console.warn("[Assessment Action] No valid assessment ID, redirecting to upcoming");
|
||||||
|
window.location.hash = `#?page=/assessments/upcoming`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}) as ActionHandler<any>,
|
}) as ActionHandler<any>,
|
||||||
|
|
||||||
|
|||||||
@@ -213,25 +213,54 @@ export async function clear(store: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function resetDatabase(): Promise<void> {
|
export async function resetDatabase(): Promise<void> {
|
||||||
|
// Close cached database connection
|
||||||
if (cachedDb) {
|
if (cachedDb) {
|
||||||
cachedDb.close();
|
try {
|
||||||
|
cachedDb.close();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[DB] Error closing cached database:", e);
|
||||||
|
}
|
||||||
cachedDb = null;
|
cachedDb = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close pending database promise
|
||||||
if (dbPromise) {
|
if (dbPromise) {
|
||||||
try {
|
try {
|
||||||
const db = await dbPromise;
|
const db = await dbPromise;
|
||||||
db.close();
|
db.close();
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
// Database might not be open yet, that's okay
|
||||||
|
}
|
||||||
dbPromise = null;
|
dbPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait a bit for connections to fully close
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = indexedDB.deleteDatabase(DB_NAME);
|
const req = indexedDB.deleteDatabase(DB_NAME);
|
||||||
req.onsuccess = () => {
|
req.onsuccess = () => {
|
||||||
localStorage.removeItem(VERSION_KEY);
|
localStorage.removeItem(VERSION_KEY);
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
req.onerror = () => reject(req.error);
|
req.onerror = () => {
|
||||||
|
console.error("[DB] Error deleting database:", req.error);
|
||||||
|
reject(req.error);
|
||||||
|
};
|
||||||
|
req.onblocked = () => {
|
||||||
|
console.warn("[DB] Database deletion blocked - waiting for connections to close");
|
||||||
|
// Wait a bit longer and try again
|
||||||
|
setTimeout(() => {
|
||||||
|
const retryReq = indexedDB.deleteDatabase(DB_NAME);
|
||||||
|
retryReq.onsuccess = () => {
|
||||||
|
localStorage.removeItem(VERSION_KEY);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
retryReq.onerror = () => reject(retryReq.error);
|
||||||
|
retryReq.onblocked = () => {
|
||||||
|
reject(new Error(`Database is still open. Please close other tabs/windows and try again.`));
|
||||||
|
};
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { clear, getAll, get, put, remove } from "./db";
|
import { clear, get, getAll, put, remove } from "./db";
|
||||||
import { jobs } from "./jobs";
|
import { jobs } from "./jobs";
|
||||||
import { renderComponentMap } from "./renderComponents";
|
import { renderComponentMap } from "./renderComponents";
|
||||||
import type { IndexItem, Job, JobContext } from "./types";
|
import type { IndexItem, Job, JobContext } from "./types";
|
||||||
@@ -396,18 +396,34 @@ export async function runIndexing(): Promise<void> {
|
|||||||
stopHeartbeat();
|
stopHeartbeat();
|
||||||
|
|
||||||
allItemsInPrimaryStores = await loadAllStoredItems();
|
allItemsInPrimaryStores = await loadAllStoredItems();
|
||||||
allItemsInPrimaryStores.forEach(item => {
|
// Create new objects to avoid XrayWrapper issues in Firefox
|
||||||
const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId];
|
const itemsWithComponents = allItemsInPrimaryStores.map(item => {
|
||||||
if (jobDef) {
|
try {
|
||||||
const renderComponent = renderComponentMap[jobDef.renderComponentId];
|
const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId];
|
||||||
if (renderComponent) {
|
let renderComponent = item.renderComponent;
|
||||||
item.renderComponent = renderComponent;
|
if (jobDef) {
|
||||||
}
|
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
|
||||||
} else if (renderComponentMap[item.renderComponentId]) {
|
} else if (renderComponentMap[item.renderComponentId]) {
|
||||||
item.renderComponent = renderComponentMap[item.renderComponentId];
|
renderComponent = renderComponentMap[item.renderComponentId];
|
||||||
|
}
|
||||||
|
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
|
||||||
|
// Use JSON serialization to ensure all nested properties are accessible
|
||||||
|
try {
|
||||||
|
const cloned = JSON.parse(JSON.stringify(item));
|
||||||
|
cloned.renderComponent = renderComponent;
|
||||||
|
return cloned;
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to shallow copy if deep clone fails
|
||||||
|
console.warn("[Indexer] Failed to deep clone item, using shallow copy:", e);
|
||||||
|
return { ...item, renderComponent };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
|
||||||
|
console.warn("[Indexer] Failed to add render component to item (Firefox XrayWrapper):", error);
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
loadDynamicItems(allItemsInPrimaryStores);
|
loadDynamicItems(itemsWithComponents);
|
||||||
window.dispatchEvent(new Event("dynamic-items-updated"));
|
window.dispatchEvent(new Event("dynamic-items-updated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { messagesJob } from "./jobs/messages";
|
|||||||
import { notificationsJob } from "./jobs/notifications";
|
import { notificationsJob } from "./jobs/notifications";
|
||||||
import { forumsJob } from "./jobs/forums";
|
import { forumsJob } from "./jobs/forums";
|
||||||
import { subjectsJob } from "./jobs/subjects";
|
import { subjectsJob } from "./jobs/subjects";
|
||||||
|
import { assignmentsJob } from "./jobs/assignments";
|
||||||
|
|
||||||
export const jobs: Record<string, Job> = {
|
export const jobs: Record<string, Job> = {
|
||||||
messages: messagesJob,
|
messages: messagesJob,
|
||||||
notifications: notificationsJob,
|
notifications: notificationsJob,
|
||||||
forums: forumsJob,
|
forums: forumsJob,
|
||||||
subjects: subjectsJob,
|
subjects: subjectsJob,
|
||||||
|
assignments: assignmentsJob,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import type { IndexItem, Job } from "../types";
|
||||||
|
|
||||||
|
const fetchJSON = async (url: string, body: any) => {
|
||||||
|
const res = await fetch(`${location.origin}${url}`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchUpcomingAssessments = async (student: number = 69) => {
|
||||||
|
try {
|
||||||
|
const res = await fetchJSON("/seqta/student/assessment/list/upcoming?", {
|
||||||
|
student,
|
||||||
|
});
|
||||||
|
// Match analytics.rs: payload is an array, return empty array if not found
|
||||||
|
return Array.isArray(res.payload) ? res.payload : [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Assignments job] Failed to fetch upcoming assessments:", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSubjects = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchJSON("/seqta/student/load/subjects?", {});
|
||||||
|
return res.payload
|
||||||
|
?.filter((s: any) => s.active === 1)
|
||||||
|
?.flatMap((s: any) => s.subjects) || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Assignments job] Failed to fetch subjects:", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPastAssessments = async (student: number = 69, subjects: any[]) => {
|
||||||
|
const map: Record<number, any> = {};
|
||||||
|
|
||||||
|
// Fetch past assessments for all subjects in parallel (like assessmentsOverview does)
|
||||||
|
// This is much faster than sequential fetching
|
||||||
|
await Promise.all(
|
||||||
|
subjects.map(async (subject) => {
|
||||||
|
try {
|
||||||
|
// Match analytics.rs exactly: parameter order is programme, metaclass, student
|
||||||
|
const res = await fetchJSON("/seqta/student/assessment/list/past?", {
|
||||||
|
programme: subject.programme,
|
||||||
|
metaclass: subject.metaclass,
|
||||||
|
student,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Past assessments API can return data in payload.tasks OR payload.pending (or both)
|
||||||
|
// Based on analytics.rs fetch_past_assessments, we need to check both arrays
|
||||||
|
const processAssessment = (assessment: any) => {
|
||||||
|
if (assessment && assessment.id) {
|
||||||
|
// Ensure programme and metaclass are included from the subject
|
||||||
|
// Use the assessment's IDs if available, otherwise fall back to subject's
|
||||||
|
map[assessment.id] = {
|
||||||
|
...assessment,
|
||||||
|
programme: assessment.programme || assessment.programmeID || subject.programme,
|
||||||
|
programmeID: assessment.programmeID || assessment.programme || subject.programme,
|
||||||
|
metaclass: assessment.metaclass || assessment.metaclassID || subject.metaclass,
|
||||||
|
metaclassID: assessment.metaclassID || assessment.metaclass || subject.metaclass,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Match analytics.rs: Check both pending and tasks arrays
|
||||||
|
// Check for pending array first (matching Rust code order)
|
||||||
|
if (res.payload?.pending && Array.isArray(res.payload.pending)) {
|
||||||
|
res.payload.pending.forEach(processAssessment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for tasks array
|
||||||
|
if (res.payload?.tasks && Array.isArray(res.payload.tasks)) {
|
||||||
|
res.payload.tasks.forEach(processAssessment);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Assignments job] Failed to fetch past assessments for subject ${subject.code || subject.subject || 'unknown'}:`, e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.values(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assignmentsJob: Job = {
|
||||||
|
id: "assignments",
|
||||||
|
label: "Assignments",
|
||||||
|
renderComponentId: "assessment",
|
||||||
|
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 }, // Daily
|
||||||
|
|
||||||
|
boostCriteria: (item, searchTerm) => {
|
||||||
|
if (searchTerm === "") {
|
||||||
|
return -100;
|
||||||
|
}
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Boost upcoming assignments
|
||||||
|
if (item.metadata.dueDate) {
|
||||||
|
const dueDate = new Date(item.metadata.dueDate).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
const daysUntilDue = (dueDate - now) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
if (daysUntilDue >= 0 && daysUntilDue <= 7) {
|
||||||
|
score += 0.05; // Boost assignments due within a week
|
||||||
|
}
|
||||||
|
if (daysUntilDue < 0) {
|
||||||
|
score -= 0.1; // Penalty for overdue assignments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost if submitted
|
||||||
|
if (item.metadata.submitted) {
|
||||||
|
score += 0.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
},
|
||||||
|
|
||||||
|
run: async (ctx) => {
|
||||||
|
// Don't filter by existing IDs - we want to process ALL assessments (both new and old)
|
||||||
|
// to ensure metadata is up-to-date and all past assignments are indexed
|
||||||
|
const existingItems = await ctx.getStoredItems("assignments");
|
||||||
|
const existingIds = new Set(existingItems.map((i) => i.id));
|
||||||
|
|
||||||
|
const student = 69; // TODO: Get from context if available
|
||||||
|
|
||||||
|
console.debug("[Assignments job] Starting indexing - fetching all assessments (upcoming and past)...");
|
||||||
|
|
||||||
|
// Fetch data in parallel
|
||||||
|
const [upcoming, subjects] = await Promise.all([
|
||||||
|
fetchUpcomingAssessments(student),
|
||||||
|
fetchSubjects(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.debug(`[Assignments job] Fetched ${upcoming.length} upcoming assessments and ${subjects.length} subjects`);
|
||||||
|
|
||||||
|
// Fetch past assessments for ALL subjects to ensure we get all historical assignments
|
||||||
|
const past = await fetchPastAssessments(student, subjects);
|
||||||
|
|
||||||
|
console.debug(`[Assignments job] Fetched ${past.length} past assessments`);
|
||||||
|
|
||||||
|
// Create a lookup map from subject code to programme/metaclass
|
||||||
|
const subjectLookup = new Map<string, { programme: number; metaclass: number }>();
|
||||||
|
subjects.forEach((s: any) => {
|
||||||
|
if (s.code && s.programme && s.metaclass) {
|
||||||
|
subjectLookup.set(s.code, { programme: s.programme, metaclass: s.metaclass });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine and deduplicate
|
||||||
|
const allAssessments = new Map<number, any>();
|
||||||
|
|
||||||
|
upcoming.forEach((a: any) => {
|
||||||
|
if (a && a.id) {
|
||||||
|
// Prioritize capital ID fields (programmeID, metaclassID) as that's what the API returns
|
||||||
|
let programme = a.programmeID || a.programme;
|
||||||
|
let metaclass = a.metaclassID || a.metaclass;
|
||||||
|
|
||||||
|
// If missing, try to get from subject lookup
|
||||||
|
if ((!programme || !metaclass) && a.code) {
|
||||||
|
const subjectInfo = subjectLookup.get(a.code);
|
||||||
|
if (subjectInfo) {
|
||||||
|
programme = programme || subjectInfo.programme;
|
||||||
|
metaclass = metaclass || subjectInfo.metaclass;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allAssessments.set(a.id, {
|
||||||
|
...a,
|
||||||
|
programme,
|
||||||
|
metaclass,
|
||||||
|
programmeID: programme, // Ensure both formats are available
|
||||||
|
metaclassID: metaclass,
|
||||||
|
isUpcoming: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
past.forEach((a: any) => {
|
||||||
|
if (a && a.id) {
|
||||||
|
// Prioritize capital ID fields (programmeID, metaclassID) as that's what the API returns
|
||||||
|
let programme = a.programmeID || a.programme;
|
||||||
|
let metaclass = a.metaclassID || a.metaclass;
|
||||||
|
|
||||||
|
const existing = allAssessments.get(a.id);
|
||||||
|
if (existing) {
|
||||||
|
// Merge past assessment data, ensuring programme/metaclass are preserved
|
||||||
|
// Use existing values if new ones are missing
|
||||||
|
programme = programme || existing.programme || existing.programmeID;
|
||||||
|
metaclass = metaclass || existing.metaclass || existing.metaclassID;
|
||||||
|
|
||||||
|
Object.assign(existing, {
|
||||||
|
...a,
|
||||||
|
programme,
|
||||||
|
metaclass,
|
||||||
|
programmeID: programme,
|
||||||
|
metaclassID: metaclass,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
allAssessments.set(a.id, {
|
||||||
|
...a,
|
||||||
|
programme,
|
||||||
|
metaclass,
|
||||||
|
programmeID: programme,
|
||||||
|
metaclassID: metaclass,
|
||||||
|
isUpcoming: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const items: IndexItem[] = [];
|
||||||
|
const processedIds = new Set<string>();
|
||||||
|
|
||||||
|
// Process assessments in batches to avoid overwhelming the API
|
||||||
|
const assessmentArray = Array.from(allAssessments.values());
|
||||||
|
const pastCount = assessmentArray.filter(a => !a.isUpcoming).length;
|
||||||
|
const upcomingCount = assessmentArray.filter(a => a.isUpcoming).length;
|
||||||
|
console.debug(`[Assignments job] Processing ${assessmentArray.length} total assessments (${upcomingCount} upcoming, ${pastCount} past)`);
|
||||||
|
const batchSize = 15; // Increased batch size for better performance
|
||||||
|
|
||||||
|
// Skip fetching assessment details - the API endpoint doesn't exist or returns 404
|
||||||
|
// Details are optional and not critical for search functionality
|
||||||
|
|
||||||
|
// Process ALL assessments (both upcoming and past) to ensure everything is indexed
|
||||||
|
for (let i = 0; i < assessmentArray.length; i += batchSize) {
|
||||||
|
const batch = assessmentArray.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
const batchItems = await Promise.all(
|
||||||
|
batch.map(async (assessment) => {
|
||||||
|
const id = `assignment-${assessment.id}`;
|
||||||
|
|
||||||
|
// Skip if already processed in this batch
|
||||||
|
if (processedIds.has(id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedIds.add(id);
|
||||||
|
|
||||||
|
// Process ALL assessments (both new and existing, upcoming and past)
|
||||||
|
// This ensures all historical assignments are indexed and metadata is up-to-date
|
||||||
|
|
||||||
|
// Skip fetching details - API endpoint doesn't exist
|
||||||
|
const description = "";
|
||||||
|
|
||||||
|
const subjectName = assessment.subject || assessment.code || "Unknown Subject";
|
||||||
|
const dueDate = assessment.due ? new Date(assessment.due).getTime() : null;
|
||||||
|
|
||||||
|
// Prioritize capital ID fields (programmeID, metaclassID) as that's what the API returns
|
||||||
|
const programmeId = assessment.programmeID || assessment.programme;
|
||||||
|
const metaclassId = assessment.metaclassID || assessment.metaclass;
|
||||||
|
|
||||||
|
// Validate that we have the required IDs for navigation
|
||||||
|
if (!programmeId || !metaclassId || !assessment.id) {
|
||||||
|
console.warn(`[Assignments job] Skipping assignment ${assessment.id} - missing required IDs:`, {
|
||||||
|
programmeId,
|
||||||
|
metaclassId,
|
||||||
|
assessmentId: assessment.id,
|
||||||
|
programmeID: assessment.programmeID,
|
||||||
|
metaclassID: assessment.metaclassID,
|
||||||
|
programme: assessment.programme,
|
||||||
|
metaclass: assessment.metaclass,
|
||||||
|
assessment,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to numbers, preserving 0 as valid
|
||||||
|
let finalProgrammeId: number | undefined;
|
||||||
|
let finalMetaclassId: number | undefined;
|
||||||
|
|
||||||
|
if (programmeId !== undefined && programmeId !== null && programmeId !== '') {
|
||||||
|
const num = Number(programmeId);
|
||||||
|
finalProgrammeId = isNaN(num) ? undefined : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metaclassId !== undefined && metaclassId !== null && metaclassId !== '') {
|
||||||
|
const num = Number(metaclassId);
|
||||||
|
finalMetaclassId = isNaN(num) ? undefined : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final validation - check for actual numbers (including 0)
|
||||||
|
if (finalProgrammeId === undefined || finalMetaclassId === undefined || !assessment.id) {
|
||||||
|
console.error(`[Assignments job] ❌ Skipping assignment ${assessment.id} - invalid IDs after conversion:`, {
|
||||||
|
programmeId: finalProgrammeId,
|
||||||
|
metaclassId: finalMetaclassId,
|
||||||
|
assessmentId: assessment.id,
|
||||||
|
rawProgrammeId: programmeId,
|
||||||
|
rawMetaclassId: metaclassId,
|
||||||
|
assessment,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item: IndexItem = {
|
||||||
|
id,
|
||||||
|
text: assessment.title || assessment.name || "Untitled Assignment",
|
||||||
|
category: "assignments",
|
||||||
|
content: `${description}\nSubject: ${subjectName}\nDue: ${assessment.due || "No due date"}`.trim(),
|
||||||
|
dateAdded: dueDate || Date.now(),
|
||||||
|
metadata: {
|
||||||
|
assessmentId: assessment.id,
|
||||||
|
assessmentID: assessment.id, // Store both variants for compatibility
|
||||||
|
subject: subjectName,
|
||||||
|
subjectCode: assessment.code,
|
||||||
|
dueDate: assessment.due,
|
||||||
|
programmeId: finalProgrammeId,
|
||||||
|
programmeID: finalProgrammeId, // Store both variants for compatibility
|
||||||
|
metaclassId: finalMetaclassId,
|
||||||
|
metaclassID: finalMetaclassId, // Store both variants for compatibility
|
||||||
|
submitted: assessment.submitted || false,
|
||||||
|
isUpcoming: assessment.isUpcoming || false,
|
||||||
|
term: assessment.term,
|
||||||
|
timestamp: assessment.due || new Date().toISOString(), // Required by AssessmentMetadata interface
|
||||||
|
},
|
||||||
|
actionId: "assessment",
|
||||||
|
renderComponentId: "assessment",
|
||||||
|
};
|
||||||
|
|
||||||
|
console.debug(`[Assignments job] ✅ Created item for assignment ${assessment.id}:`, {
|
||||||
|
id: item.id,
|
||||||
|
programmeId: item.metadata.programmeId,
|
||||||
|
programmeID: item.metadata.programmeID,
|
||||||
|
metaclassId: item.metadata.metaclassId,
|
||||||
|
metaclassID: item.metadata.metaclassID,
|
||||||
|
assessmentId: item.metadata.assessmentId,
|
||||||
|
assessmentID: item.metadata.assessmentID,
|
||||||
|
});
|
||||||
|
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter out nulls and add to items
|
||||||
|
batchItems.forEach(item => {
|
||||||
|
if (item) {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Small delay between batches to avoid rate limiting
|
||||||
|
if (i + batchSize < assessmentArray.length) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50)); // Reduced delay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItemsCount = items.filter(item => !existingIds.has(item.id)).length;
|
||||||
|
const updatedItemsCount = items.length - newItemsCount;
|
||||||
|
console.debug(`[Assignments job] Indexed ${items.length} assignment items (${newItemsCount} new, ${updatedItemsCount} updated)`);
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => {
|
||||||
|
// Keep ALL assignments - don't purge old ones as users may want to search for them
|
||||||
|
// Only remove items that are truly invalid (missing required metadata)
|
||||||
|
return items.filter((i) => {
|
||||||
|
// Keep all items that have valid metadata
|
||||||
|
return i.metadata &&
|
||||||
|
i.metadata.assessmentId &&
|
||||||
|
i.metadata.programmeId !== undefined &&
|
||||||
|
i.metadata.metaclassId !== undefined;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Job, IndexItem } from "../types";
|
import type { IndexItem, Job } from "../types";
|
||||||
|
|
||||||
const fetchForums = async () => {
|
const fetchForums = async () => {
|
||||||
const res = await fetch(`${location.origin}/seqta/student/load/forums`, {
|
const res = await fetch(`${location.origin}/seqta/student/load/forums`, {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Job, IndexItem } from "../types";
|
import type { IndexItem, Job } from "../types";
|
||||||
import { htmlToPlainText } from "../utils";
|
import { htmlToPlainText } from "../utils";
|
||||||
import { delay } from "@/seqta/utils/delay";
|
import { delay } from "@/seqta/utils/delay";
|
||||||
import { VectorWorkerManager } from "../worker/vectorWorkerManager";
|
import { VectorWorkerManager } from "../worker/vectorWorkerManager";
|
||||||
@@ -604,22 +604,34 @@ export const messagesJob: Job = {
|
|||||||
if (processedItems.length > 0) {
|
if (processedItems.length > 0) {
|
||||||
try {
|
try {
|
||||||
const currentItems = await loadAllStoredItems();
|
const currentItems = await loadAllStoredItems();
|
||||||
currentItems.forEach((item) => {
|
// Create new objects to avoid XrayWrapper issues in Firefox
|
||||||
const jobDef =
|
const itemsWithComponents = currentItems.map((item) => {
|
||||||
jobs[item.category] ||
|
try {
|
||||||
Object.values(jobs).find((j) => j.id === item.category) ||
|
const jobDef =
|
||||||
jobs[item.renderComponentId];
|
jobs[item.category] ||
|
||||||
if (jobDef) {
|
Object.values(jobs).find((j) => j.id === item.category) ||
|
||||||
const renderComponent =
|
jobs[item.renderComponentId];
|
||||||
renderComponentMap[jobDef.renderComponentId];
|
let renderComponent = item.renderComponent;
|
||||||
if (renderComponent) {
|
if (jobDef) {
|
||||||
item.renderComponent = renderComponent;
|
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
|
||||||
|
} else if (renderComponentMap[item.renderComponentId]) {
|
||||||
|
renderComponent = renderComponentMap[item.renderComponentId];
|
||||||
}
|
}
|
||||||
} else if (renderComponentMap[item.renderComponentId]) {
|
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
|
||||||
item.renderComponent = renderComponentMap[item.renderComponentId];
|
try {
|
||||||
|
const cloned = JSON.parse(JSON.stringify(item));
|
||||||
|
cloned.renderComponent = renderComponent;
|
||||||
|
return cloned;
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to shallow copy if deep clone fails
|
||||||
|
return { ...item, renderComponent };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
loadDynamicItems(currentItems);
|
loadDynamicItems(itemsWithComponents);
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("dynamic-items-updated", {
|
new CustomEvent("dynamic-items-updated", {
|
||||||
detail: {
|
detail: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Job, IndexItem } from "../types";
|
import type { IndexItem, Job } from "../types";
|
||||||
import { htmlToPlainText } from "../utils";
|
import { htmlToPlainText } from "../utils";
|
||||||
import { fetchMessageContent } from "./messages";
|
import { fetchMessageContent } from "./messages";
|
||||||
import { delay } from "@/seqta/utils/delay";
|
import { delay } from "@/seqta/utils/delay";
|
||||||
@@ -372,23 +372,34 @@ export const notificationsJob: Job = {
|
|||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
try {
|
try {
|
||||||
const currentItems = await loadAllStoredItems();
|
const currentItems = await loadAllStoredItems();
|
||||||
currentItems.forEach((item) => {
|
// Create new objects to avoid XrayWrapper issues in Firefox
|
||||||
const jobDef =
|
const itemsWithComponents = currentItems.map((item) => {
|
||||||
jobs[item.category] ||
|
try {
|
||||||
Object.values(jobs).find((j) => j.id === item.category) ||
|
const jobDef =
|
||||||
jobs[item.renderComponentId];
|
jobs[item.category] ||
|
||||||
if (jobDef) {
|
Object.values(jobs).find((j) => j.id === item.category) ||
|
||||||
const renderComponent =
|
jobs[item.renderComponentId];
|
||||||
renderComponentMap[jobDef.renderComponentId];
|
let renderComponent = item.renderComponent;
|
||||||
if (renderComponent) {
|
if (jobDef) {
|
||||||
item.renderComponent = renderComponent;
|
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
|
||||||
|
} else if (renderComponentMap[item.renderComponentId]) {
|
||||||
|
renderComponent = renderComponentMap[item.renderComponentId];
|
||||||
}
|
}
|
||||||
} else if (renderComponentMap[item.renderComponentId]) {
|
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
|
||||||
item.renderComponent =
|
try {
|
||||||
renderComponentMap[item.renderComponentId];
|
const cloned = JSON.parse(JSON.stringify(item));
|
||||||
|
cloned.renderComponent = renderComponent;
|
||||||
|
return cloned;
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to shallow copy if deep clone fails
|
||||||
|
return { ...item, renderComponent };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
loadDynamicItems(currentItems);
|
loadDynamicItems(itemsWithComponents);
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("dynamic-items-updated", {
|
new CustomEvent("dynamic-items-updated", {
|
||||||
detail: {
|
detail: {
|
||||||
|
|||||||
@@ -1,140 +1,140 @@
|
|||||||
import type { IndexItem, Job } from "../types";
|
import type { IndexItem, Job } from "../types";
|
||||||
|
|
||||||
const fetchSubjects = async () => {
|
const fetchSubjects = async () => {
|
||||||
const res = await fetch(`${location.origin}/seqta/student/load/subjects`, {
|
const res = await fetch(`${location.origin}/seqta/student/load/subjects`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
body: JSON.stringify({ mode: "list" }),
|
body: JSON.stringify({ mode: "list" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const subjectsJob: Job = {
|
export const subjectsJob: Job = {
|
||||||
id: "subjects",
|
id: "subjects",
|
||||||
label: "Subjects",
|
label: "Subjects",
|
||||||
renderComponentId: "subject",
|
renderComponentId: "subject",
|
||||||
frequency: {
|
frequency: {
|
||||||
type: "expiry",
|
type: "expiry",
|
||||||
afterMs: 1000 * 60 * 60 * 24 * 30,
|
afterMs: 1000 * 60 * 60 * 24 * 30,
|
||||||
},
|
},
|
||||||
boostCriteria: (item, searchTerm) => {
|
boostCriteria: (item, searchTerm) => {
|
||||||
if (searchTerm == "") {
|
if (searchTerm == "") {
|
||||||
return -100;
|
return -100;
|
||||||
}
|
}
|
||||||
|
|
||||||
let score = 0;
|
let score = 0;
|
||||||
if (item.metadata.isActive) {
|
if (item.metadata.isActive) {
|
||||||
score += 0.01; // Boost for active subjects
|
score += 0.01; // Boost for active subjects
|
||||||
} else {
|
} else {
|
||||||
score -= 50; // Penalty for inactive subjects
|
score -= 50; // Penalty for inactive subjects
|
||||||
}
|
}
|
||||||
|
|
||||||
return score;
|
return score;
|
||||||
},
|
},
|
||||||
|
|
||||||
run: async (ctx) => {
|
run: async (ctx) => {
|
||||||
const existingIds = new Set(
|
const existingIds = new Set(
|
||||||
(await ctx.getStoredItems("subjects")).map((i) => i.id),
|
(await ctx.getStoredItems("subjects")).map((i) => i.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
let list;
|
let list;
|
||||||
try {
|
try {
|
||||||
list = await fetchSubjects();
|
list = await fetchSubjects();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Subjects job] list fetch failed:", e);
|
console.error("[Subjects job] list fetch failed:", e);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (list.status !== "200") {
|
if (list.status !== "200") {
|
||||||
console.error("[Subjects job] API returned non-200 status:", list.status);
|
console.error("[Subjects job] API returned non-200 status:", list.status);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have the expected data structure
|
// Check if we have the expected data structure
|
||||||
if (!list.payload || !Array.isArray(list.payload)) {
|
if (!list.payload || !Array.isArray(list.payload)) {
|
||||||
console.error("[Subjects job] Unexpected API response structure:", list);
|
console.error("[Subjects job] Unexpected API response structure:", list);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: IndexItem[] = [];
|
const items: IndexItem[] = [];
|
||||||
|
|
||||||
// Process each semester
|
// Process each semester
|
||||||
for (const semester of list.payload) {
|
for (const semester of list.payload) {
|
||||||
if (!semester.subjects || !Array.isArray(semester.subjects)) {
|
if (!semester.subjects || !Array.isArray(semester.subjects)) {
|
||||||
console.warn("[Subjects job] Skipping invalid semester:", semester);
|
console.warn("[Subjects job] Skipping invalid semester:", semester);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each subject in the semester
|
// Process each subject in the semester
|
||||||
for (const subject of semester.subjects) {
|
for (const subject of semester.subjects) {
|
||||||
// Skip if subject doesn't have required fields
|
// Skip if subject doesn't have required fields
|
||||||
if (!subject || !subject.code || !subject.title) {
|
if (!subject || !subject.code || !subject.title) {
|
||||||
console.warn("[Subjects job] Skipping invalid subject:", subject);
|
console.warn("[Subjects job] Skipping invalid subject:", subject);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = `${semester.code}-${subject.code}-${subject.metaclass}`;
|
const id = `${semester.code}-${subject.code}-${subject.metaclass}`;
|
||||||
if (existingIds.has(id)) continue;
|
if (existingIds.has(id)) continue;
|
||||||
|
|
||||||
const isActive = semester.active === 1;
|
const isActive = semester.active === 1;
|
||||||
|
|
||||||
// Create two items for each subject - one for assessments and one for course
|
// Create two items for each subject - one for assessments and one for course
|
||||||
const assessmentsItem = {
|
const assessmentsItem = {
|
||||||
id: `${id}-assessments`,
|
id: `${id}-assessments`,
|
||||||
text: `${subject.title} Assessments`,
|
text: `${subject.title} Assessments`,
|
||||||
category: "subjects",
|
category: "subjects",
|
||||||
content: `View assessments for ${subject.title} (${semester.description})`,
|
content: `View assessments for ${subject.title} (${semester.description})`,
|
||||||
dateAdded: Date.now(),
|
dateAdded: Date.now(),
|
||||||
metadata: {
|
metadata: {
|
||||||
subjectId: subject.metaclass,
|
subjectId: subject.metaclass,
|
||||||
subjectName: subject.title,
|
subjectName: subject.title,
|
||||||
subjectCode: subject.code,
|
subjectCode: subject.code,
|
||||||
programme: subject.programme,
|
programme: subject.programme,
|
||||||
semesterCode: semester.code,
|
semesterCode: semester.code,
|
||||||
semesterDescription: semester.description,
|
semesterDescription: semester.description,
|
||||||
type: "assessments",
|
type: "assessments",
|
||||||
isActive
|
isActive
|
||||||
},
|
},
|
||||||
actionId: "subjectassessment",
|
actionId: "subjectassessment",
|
||||||
renderComponentId: "subject",
|
renderComponentId: "subject",
|
||||||
};
|
};
|
||||||
|
|
||||||
const courseItem = {
|
const courseItem = {
|
||||||
id: `${id}-course`,
|
id: `${id}-course`,
|
||||||
text: `${subject.title}`,
|
text: `${subject.title}`,
|
||||||
category: "subjects",
|
category: "subjects",
|
||||||
content: `View course content for ${subject.title} (${semester.description})`,
|
content: `View course content for ${subject.title} (${semester.description})`,
|
||||||
dateAdded: Date.now(),
|
dateAdded: Date.now(),
|
||||||
metadata: {
|
metadata: {
|
||||||
subjectId: subject.metaclass,
|
subjectId: subject.metaclass,
|
||||||
subjectName: subject.title,
|
subjectName: subject.title,
|
||||||
subjectCode: subject.code,
|
subjectCode: subject.code,
|
||||||
programme: subject.programme,
|
programme: subject.programme,
|
||||||
semesterCode: semester.code,
|
semesterCode: semester.code,
|
||||||
semesterDescription: semester.description,
|
semesterDescription: semester.description,
|
||||||
type: "course",
|
type: "course",
|
||||||
isActive
|
isActive
|
||||||
},
|
},
|
||||||
actionId: "subjectcourse",
|
actionId: "subjectcourse",
|
||||||
renderComponentId: "subject",
|
renderComponentId: "subject",
|
||||||
};
|
};
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
assessmentsItem,
|
assessmentsItem,
|
||||||
courseItem
|
courseItem
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug(`[Subjects job] Indexed ${items.length} subject items`);
|
console.debug(`[Subjects job] Indexed ${items.length} subject items`);
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
|
|
||||||
purge: (items) => {
|
purge: (items) => {
|
||||||
// Keep all subjects as they are relatively static
|
// Keep all subjects as they are relatively static
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -3,9 +3,24 @@ import type { IndexItem } from "../types";
|
|||||||
|
|
||||||
let vectorIndex: EmbeddingIndex | null = null;
|
let vectorIndex: EmbeddingIndex | null = null;
|
||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
|
let initializationFailed = false;
|
||||||
let currentAbortController: AbortController | null = null;
|
let currentAbortController: AbortController | null = null;
|
||||||
let loadedItemIds = new Set<string>();
|
let loadedItemIds = new Set<string>();
|
||||||
|
|
||||||
|
// Detect Firefox in worker context
|
||||||
|
function isFirefoxWorker(): boolean {
|
||||||
|
try {
|
||||||
|
// Check for Firefox-specific APIs or user agent
|
||||||
|
if (typeof navigator !== "undefined") {
|
||||||
|
return navigator.userAgent.toLowerCase().includes("firefox");
|
||||||
|
}
|
||||||
|
// In worker context, check for Firefox-specific behavior
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let streamingSession: {
|
let streamingSession: {
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
totalExpected: number;
|
totalExpected: number;
|
||||||
@@ -21,6 +36,16 @@ async function initWorker() {
|
|||||||
console.debug("Vector worker already initialized.");
|
console.debug("Vector worker already initialized.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip initialization in Firefox
|
||||||
|
if (isFirefoxWorker()) {
|
||||||
|
console.debug("[Vector Worker] Vector search not supported in Firefox - skipping initialization");
|
||||||
|
isInitialized = true;
|
||||||
|
initializationFailed = true;
|
||||||
|
vectorIndex = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.debug("Initializing vector worker...");
|
console.debug("Initializing vector worker...");
|
||||||
try {
|
try {
|
||||||
await initializeModel();
|
await initializeModel();
|
||||||
@@ -48,8 +73,9 @@ async function initWorker() {
|
|||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
console.debug("Vector worker initialized successfully.");
|
console.debug("Vector worker initialized successfully.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to initialize vector worker:", e);
|
console.warn("[Vector Worker] Failed to initialize vector worker (will use text search only):", e);
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
|
initializationFailed = true;
|
||||||
vectorIndex = null;
|
vectorIndex = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,18 +106,29 @@ async function startStreamingSession(
|
|||||||
totalExpected: number,
|
totalExpected: number,
|
||||||
batchSize: number = 5,
|
batchSize: number = 5,
|
||||||
) {
|
) {
|
||||||
|
if (initializationFailed || isFirefoxWorker()) {
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "complete",
|
||||||
|
message: "Vector search not available in Firefox - using text search only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!vectorIndex) {
|
if (!vectorIndex) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"Streaming requested but vector index not ready. Attempting init.",
|
"Streaming requested but vector index not ready. Attempting init.",
|
||||||
);
|
);
|
||||||
await initWorker();
|
await initWorker();
|
||||||
if (!vectorIndex) {
|
if (!vectorIndex || initializationFailed) {
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: "progress",
|
type: "progress",
|
||||||
data: {
|
data: {
|
||||||
status: "error",
|
status: "complete",
|
||||||
message:
|
message:
|
||||||
"Vector index not available for streaming after init attempt.",
|
"Vector index not available - using text search only",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -306,18 +343,29 @@ async function endStreamingSession() {
|
|||||||
async function processItems(items: IndexItem[], signal: AbortSignal) {
|
async function processItems(items: IndexItem[], signal: AbortSignal) {
|
||||||
console.debug("Worker received process request.");
|
console.debug("Worker received process request.");
|
||||||
|
|
||||||
|
if (initializationFailed || isFirefoxWorker()) {
|
||||||
|
self.postMessage({
|
||||||
|
type: "progress",
|
||||||
|
data: {
|
||||||
|
status: "complete",
|
||||||
|
message: "Vector search not available - using text search only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!vectorIndex) {
|
if (!vectorIndex) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"Processing requested but vector index not ready. Attempting init.",
|
"Processing requested but vector index not ready. Attempting init.",
|
||||||
);
|
);
|
||||||
await initWorker();
|
await initWorker();
|
||||||
if (!vectorIndex) {
|
if (!vectorIndex || initializationFailed) {
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: "progress",
|
type: "progress",
|
||||||
data: {
|
data: {
|
||||||
status: "error",
|
status: "complete",
|
||||||
message:
|
message:
|
||||||
"Vector index not available for processing after init attempt.",
|
"Vector index not available - using text search only",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { refreshVectorCache } from "../../search/vector/vectorSearch";
|
import { refreshVectorCache } from "../../search/vector/vectorSearch";
|
||||||
import type { IndexItem } from "../types";
|
import type { IndexItem } from "../types";
|
||||||
|
import { isVectorSearchSupported } from "../../utils/browserDetection";
|
||||||
import vectorWorker from "./vectorWorker.ts?inlineWorker";
|
import vectorWorker from "./vectorWorker.ts?inlineWorker";
|
||||||
|
|
||||||
export type ProgressCallback = (data: {
|
export type ProgressCallback = (data: {
|
||||||
@@ -42,6 +43,13 @@ export class VectorWorkerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async initWorker(): Promise<void> {
|
private async initWorker(): Promise<void> {
|
||||||
|
// Skip initialization if vector search is not supported (e.g., Firefox)
|
||||||
|
if (!isVectorSearchSupported()) {
|
||||||
|
console.debug("[VectorWorkerManager] Vector search not supported - skipping worker initialization");
|
||||||
|
this.isInitialized = false;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isInitialized) return Promise.resolve();
|
if (this.isInitialized) return Promise.resolve();
|
||||||
if (this.readyPromise) return this.readyPromise;
|
if (this.readyPromise) return this.readyPromise;
|
||||||
|
|
||||||
@@ -234,6 +242,17 @@ export class VectorWorkerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async processItems(items: IndexItem[], onProgress?: ProgressCallback) {
|
async processItems(items: IndexItem[], onProgress?: ProgressCallback) {
|
||||||
|
// Skip if vector search is not supported
|
||||||
|
if (!isVectorSearchSupported()) {
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({
|
||||||
|
status: "complete",
|
||||||
|
message: "Vector search not available - using text search only"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Only initialize worker if we actually have items to process
|
// Only initialize worker if we actually have items to process
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
@@ -298,6 +317,18 @@ export class VectorWorkerManager {
|
|||||||
batchSize: number = 10,
|
batchSize: number = 10,
|
||||||
jobId?: string,
|
jobId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Skip if vector search is not supported
|
||||||
|
if (!isVectorSearchSupported()) {
|
||||||
|
console.debug("[VectorWorker] Vector search not supported - skipping streaming session");
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({
|
||||||
|
status: "complete",
|
||||||
|
message: "Vector search not available - using text search only",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Only initialize if we expect items to process
|
// Only initialize if we expect items to process
|
||||||
if (totalExpectedItems === 0) {
|
if (totalExpectedItems === 0) {
|
||||||
console.debug("[VectorWorker] No items expected, not starting streaming session");
|
console.debug("[VectorWorker] No items expected, not starting streaming session");
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import type { IndexItem } from "../indexing/types";
|
||||||
|
import type { CombinedResult } from "../core/types";
|
||||||
|
import { searchVectors, type VectorSearchResult } from "./vector/vectorSearch";
|
||||||
|
import { jobs } from "../indexing/jobs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hybrid Search Implementation
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. BM25 (Fuse.js) gets top N results fast
|
||||||
|
* 2. Vector search reranks by semantic similarity
|
||||||
|
* 3. Apply optional boosting (recency, popularity, tags)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface HybridSearchOptions {
|
||||||
|
/** Maximum number of BM25 results to retrieve before reranking */
|
||||||
|
bm25TopK?: number;
|
||||||
|
/** Maximum number of final results to return */
|
||||||
|
finalLimit?: number;
|
||||||
|
/** Whether to apply recency boost */
|
||||||
|
recencyBoost?: boolean;
|
||||||
|
/** Weight for BM25 scores (0-1) */
|
||||||
|
bm25Weight?: number;
|
||||||
|
/** Weight for vector similarity scores (0-1) */
|
||||||
|
vectorWeight?: number;
|
||||||
|
/** Weight for recency boost */
|
||||||
|
recencyWeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: Required<HybridSearchOptions> = {
|
||||||
|
bm25TopK: 50, // Get top 50 from BM25, then rerank
|
||||||
|
finalLimit: 10,
|
||||||
|
recencyBoost: true,
|
||||||
|
bm25Weight: 0.4, // 40% BM25, 60% vector
|
||||||
|
vectorWeight: 0.6,
|
||||||
|
recencyWeight: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a score to 0-1 range
|
||||||
|
*/
|
||||||
|
function normalizeScore(score: number, min: number, max: number): number {
|
||||||
|
if (max === min) return 0.5;
|
||||||
|
return Math.max(0, Math.min(1, (score - min) / (max - min)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates recency boost based on item age
|
||||||
|
*/
|
||||||
|
function calculateRecencyBoost(item: IndexItem, now: number): number {
|
||||||
|
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
|
||||||
|
// Exponential decay: newer items get higher boost
|
||||||
|
// Items from today get boost of 1, items from 30 days ago get ~0.03
|
||||||
|
return 1 / (1 + ageInDays / 7); // Half-life of 7 days
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates popularity boost (can be extended with click tracking, etc.)
|
||||||
|
*/
|
||||||
|
function calculatePopularityBoost(item: IndexItem): number {
|
||||||
|
// For now, boost based on category and metadata
|
||||||
|
let boost = 0;
|
||||||
|
|
||||||
|
// Boost assignments/assessments
|
||||||
|
if (item.category === "assignments") {
|
||||||
|
boost += 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost upcoming items
|
||||||
|
if (item.metadata?.isUpcoming) {
|
||||||
|
boost += 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost items with subject codes (more structured)
|
||||||
|
if (item.metadata?.subjectCode) {
|
||||||
|
boost += 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(boost, 0.3); // Cap at 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reranks BM25 results using vector search
|
||||||
|
*/
|
||||||
|
export async function hybridSearch(
|
||||||
|
bm25Results: CombinedResult[],
|
||||||
|
query: string,
|
||||||
|
options: HybridSearchOptions = {},
|
||||||
|
): Promise<CombinedResult[]> {
|
||||||
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
const trimmedQuery = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
// If no BM25 results, return empty
|
||||||
|
if (bm25Results.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit BM25 results to top K
|
||||||
|
const topBm25Results = bm25Results.slice(0, opts.bm25TopK);
|
||||||
|
|
||||||
|
// Get vector search results for reranking
|
||||||
|
// We'll search the full index and then filter to our BM25 results
|
||||||
|
let vectorResults: VectorSearchResult[] = [];
|
||||||
|
|
||||||
|
if (trimmedQuery.length > 2) {
|
||||||
|
try {
|
||||||
|
// Get more vector results than BM25 results to ensure coverage
|
||||||
|
// This allows us to find semantic matches that BM25 might have missed
|
||||||
|
const vectorSearchResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
|
||||||
|
|
||||||
|
// Create a map of item ID to vector similarity
|
||||||
|
const vectorMap = new Map<string, number>();
|
||||||
|
vectorSearchResults.forEach(v => {
|
||||||
|
// Use the highest similarity if item appears multiple times
|
||||||
|
const existing = vectorMap.get(v.object.id);
|
||||||
|
if (!existing || v.similarity > existing) {
|
||||||
|
vectorMap.set(v.object.id, v.similarity);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now rerank BM25 results with vector scores
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const rerankedResults = topBm25Results.map(result => {
|
||||||
|
const item = result.item;
|
||||||
|
|
||||||
|
// Normalize BM25 score to 0-1
|
||||||
|
// Fuse.js scores: lower is better (0 = perfect match)
|
||||||
|
// We need to invert: higher score = better match
|
||||||
|
// Result.score is typically 0-100, where higher = better
|
||||||
|
// So we normalize it to 0-1
|
||||||
|
const normalizedBm25Score = Math.max(0, Math.min(1, result.score / 100));
|
||||||
|
|
||||||
|
// Get vector similarity (0-1, already normalized)
|
||||||
|
// If item wasn't in vector results, use a default low score
|
||||||
|
const vectorSimilarity = vectorMap.get(item.id) || 0.3; // Default to 0.3 if not found
|
||||||
|
|
||||||
|
// Calculate recency boost (0-1 range)
|
||||||
|
const recencyBoost = opts.recencyBoost
|
||||||
|
? calculateRecencyBoost(item, now) * opts.recencyWeight
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Calculate popularity boost (0-1 range)
|
||||||
|
const popularityBoost = calculatePopularityBoost(item);
|
||||||
|
|
||||||
|
// Apply job-specific boost if available
|
||||||
|
const job = jobs[item.category];
|
||||||
|
let jobBoost = 0;
|
||||||
|
if (job && typeof job.boostCriteria === 'function') {
|
||||||
|
const boost = job.boostCriteria(item, trimmedQuery);
|
||||||
|
if (boost) {
|
||||||
|
jobBoost = boost / 100; // Normalize boost to 0-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine scores using weighted average
|
||||||
|
// BM25 and vector are weighted, boosts are additive
|
||||||
|
const hybridScore =
|
||||||
|
(normalizedBm25Score * opts.bm25Weight) +
|
||||||
|
(vectorSimilarity * opts.vectorWeight) +
|
||||||
|
recencyBoost +
|
||||||
|
popularityBoost +
|
||||||
|
jobBoost;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
score: hybridScore * 100, // Scale back to 0-100 for consistency
|
||||||
|
// Store component scores for debugging (optional, can be removed in production)
|
||||||
|
_hybridScores: {
|
||||||
|
bm25: normalizedBm25Score,
|
||||||
|
vector: vectorSimilarity,
|
||||||
|
recency: recencyBoost,
|
||||||
|
popularity: popularityBoost,
|
||||||
|
jobBoost: jobBoost,
|
||||||
|
final: hybridScore,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by hybrid score descending
|
||||||
|
rerankedResults.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
// Return top results
|
||||||
|
return rerankedResults.slice(0, opts.finalLimit);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Hybrid Search] Vector reranking failed, using BM25 only:", e);
|
||||||
|
// Fallback to BM25 only
|
||||||
|
return topBm25Results.slice(0, opts.finalLimit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If query is too short for vector search, just return BM25 results
|
||||||
|
return topBm25Results.slice(0, opts.finalLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced hybrid search that also includes vector-only results not found by BM25
|
||||||
|
*/
|
||||||
|
export async function hybridSearchWithExpansion(
|
||||||
|
bm25Results: CombinedResult[],
|
||||||
|
query: string,
|
||||||
|
allItems: IndexItem[],
|
||||||
|
options: HybridSearchOptions = {},
|
||||||
|
): Promise<CombinedResult[]> {
|
||||||
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
const trimmedQuery = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
// First, rerank BM25 results
|
||||||
|
const rerankedBm25 = await hybridSearch(bm25Results, query, options);
|
||||||
|
|
||||||
|
// If query is too short, skip vector expansion
|
||||||
|
if (trimmedQuery.length <= 2) {
|
||||||
|
return rerankedBm25;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get vector search results
|
||||||
|
let vectorResults: VectorSearchResult[] = [];
|
||||||
|
try {
|
||||||
|
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Hybrid Search] Vector search failed:", e);
|
||||||
|
return rerankedBm25;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find vector results that weren't in BM25 results
|
||||||
|
const bm25Ids = new Set(bm25Results.map(r => r.item.id));
|
||||||
|
const vectorOnlyResults: CombinedResult[] = [];
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
vectorResults.forEach(v => {
|
||||||
|
if (!bm25Ids.has(v.object.id)) {
|
||||||
|
// This is a semantic match that BM25 missed
|
||||||
|
const item = v.object;
|
||||||
|
|
||||||
|
// Calculate boosts
|
||||||
|
const recencyBoost = opts.recencyBoost
|
||||||
|
? calculateRecencyBoost(item, now) * opts.recencyWeight
|
||||||
|
: 0;
|
||||||
|
const popularityBoost = calculatePopularityBoost(item);
|
||||||
|
|
||||||
|
// Vector-only results get lower base score but high vector similarity
|
||||||
|
const vectorScore = v.similarity * opts.vectorWeight + recencyBoost + popularityBoost;
|
||||||
|
|
||||||
|
// Apply job-specific boost if available
|
||||||
|
const job = jobs[item.category];
|
||||||
|
let jobBoost = 0;
|
||||||
|
if (job && typeof job.boostCriteria === 'function') {
|
||||||
|
const boost = job.boostCriteria(item, trimmedQuery);
|
||||||
|
if (boost) {
|
||||||
|
jobBoost = boost / 100; // Normalize boost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vectorOnlyResults.push({
|
||||||
|
id: item.id,
|
||||||
|
type: "dynamic" as const,
|
||||||
|
score: (vectorScore + jobBoost) * 100,
|
||||||
|
item,
|
||||||
|
_hybridScores: {
|
||||||
|
bm25: 0,
|
||||||
|
vector: v.similarity,
|
||||||
|
recency: recencyBoost,
|
||||||
|
popularity: popularityBoost,
|
||||||
|
final: vectorScore + jobBoost,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine reranked BM25 results with vector-only results
|
||||||
|
const allResults = [...rerankedBm25, ...vectorOnlyResults];
|
||||||
|
|
||||||
|
// Sort by score and return top results
|
||||||
|
allResults.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
return allResults.slice(0, opts.finalLimit);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,32 +6,79 @@ import type { IndexItem } from "../indexing/types";
|
|||||||
import { searchVectors } from "./vector/vectorSearch";
|
import { searchVectors } from "./vector/vectorSearch";
|
||||||
import type { VectorSearchResult } from "./vector/vectorTypes";
|
import type { VectorSearchResult } from "./vector/vectorTypes";
|
||||||
import { jobs } from "../indexing/jobs";
|
import { jobs } from "../indexing/jobs";
|
||||||
|
import { hybridSearchWithExpansion } from "./hybridSearch";
|
||||||
|
|
||||||
|
// Search result cache for better performance
|
||||||
|
const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>();
|
||||||
|
const CACHE_TTL = 1000 * 60 * 5; // 5 minutes
|
||||||
|
const MAX_CACHE_SIZE = 100;
|
||||||
|
|
||||||
|
function getCachedResults(query: string): CombinedResult[] | null {
|
||||||
|
const cached = searchCache.get(query);
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
return cached.results;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedResults(query: string, results: CombinedResult[]) {
|
||||||
|
// Limit cache size
|
||||||
|
if (searchCache.size >= MAX_CACHE_SIZE) {
|
||||||
|
const firstKey = searchCache.keys().next().value;
|
||||||
|
searchCache.delete(firstKey);
|
||||||
|
}
|
||||||
|
searchCache.set(query, { results, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the search result cache
|
||||||
|
*/
|
||||||
|
export function clearSearchCache(): void {
|
||||||
|
searchCache.clear();
|
||||||
|
console.debug("[Search] Search result cache cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for cache clear events (e.g., on extension update)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('betterseqta-clear-search-cache', () => {
|
||||||
|
clearSearchCache();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createSearchIndexes() {
|
export function createSearchIndexes() {
|
||||||
const commands = getStaticCommands();
|
const commands = getStaticCommands();
|
||||||
const dynamicItems = getDynamicItems();
|
const dynamicItems = getDynamicItems();
|
||||||
|
|
||||||
|
// Optimized command search options
|
||||||
const commandOptions = {
|
const commandOptions = {
|
||||||
keys: ["text", "category", "keywords"],
|
keys: ["text", "category", "keywords"],
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
includeMatches: true,
|
includeMatches: true,
|
||||||
threshold: 0.4,
|
threshold: 0.35, // Slightly more permissive for better recall
|
||||||
minMatchCharLength: 2,
|
minMatchCharLength: 2,
|
||||||
useExtendedSearch: false,
|
useExtendedSearch: false,
|
||||||
|
ignoreLocation: false,
|
||||||
|
findAllMatches: false, // Performance optimization
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Optimized dynamic content search options
|
||||||
const dynamicOptions = {
|
const dynamicOptions = {
|
||||||
keys: [
|
keys: [
|
||||||
{ name: "text", weight: 2 },
|
{ name: "text", weight: 3 }, // Increased weight for title matches
|
||||||
{ name: "content", weight: 1 },
|
{ name: "content", weight: 1 },
|
||||||
{ name: "category", weight: 1 },
|
{ name: "category", weight: 0.5 }, // Lower weight for category
|
||||||
|
{ name: "metadata.subjectName", weight: 1.5 }, // Boost subject name matches
|
||||||
|
{ name: "metadata.subjectCode", weight: 1.5 }, // Boost subject code matches
|
||||||
],
|
],
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
includeMatches: true,
|
includeMatches: true,
|
||||||
threshold: 0.4,
|
threshold: 0.5, // More permissive for better partial word matching (increased from 0.4)
|
||||||
minMatchCharLength: 2,
|
minMatchCharLength: 2, // Minimum 2 characters for Fuse.js matches (substring fallback handles shorter queries)
|
||||||
distance: 100,
|
distance: 100, // Increased to allow matches across longer strings
|
||||||
useExtendedSearch: true,
|
useExtendedSearch: true,
|
||||||
|
ignoreLocation: true, // Allow matches anywhere in the string for better partial word matching
|
||||||
|
findAllMatches: true, // Enable to find all matches for better partial word support
|
||||||
|
shouldSort: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -105,17 +152,63 @@ export function searchDynamicItems(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const searchResults = dynamicContentFuse.search(query, { limit });
|
const queryLower = query.toLowerCase();
|
||||||
|
const queryTrimmed = query.trim();
|
||||||
|
|
||||||
|
// For short queries (3 chars or less), use a more permissive approach
|
||||||
|
const isShortQuery = queryTrimmed.length <= 3;
|
||||||
|
const searchLimit = Math.min(limit * 3, 50);
|
||||||
|
|
||||||
|
// First, try Fuse.js search
|
||||||
|
const searchResults = dynamicContentFuse.search(query, { limit: searchLimit });
|
||||||
|
|
||||||
|
// For short queries, always do a simple substring match to supplement Fuse.js results
|
||||||
|
// This ensures we catch partial word matches like "SAT" in "SAT 1: Differential Calculus"
|
||||||
|
let additionalMatches: IndexItem[] = [];
|
||||||
|
if (isShortQuery) {
|
||||||
|
// Always do substring search for short queries to catch partial word matches
|
||||||
|
for (const item of dynamicIdToItemMap.values()) {
|
||||||
|
const textLower = item.text.toLowerCase();
|
||||||
|
const contentLower = (item.content || '').toLowerCase();
|
||||||
|
const subjectNameLower = (item.metadata?.subjectName || '').toLowerCase();
|
||||||
|
const subjectCodeLower = (item.metadata?.subjectCode || '').toLowerCase();
|
||||||
|
|
||||||
|
// Check if query appears anywhere in the text, content, or metadata
|
||||||
|
if (textLower.includes(queryLower) ||
|
||||||
|
contentLower.includes(queryLower) ||
|
||||||
|
subjectNameLower.includes(queryLower) ||
|
||||||
|
subjectCodeLower.includes(queryLower)) {
|
||||||
|
// Only add if not already in Fuse.js results
|
||||||
|
if (!searchResults.find(r => r.item.id === item.id)) {
|
||||||
|
additionalMatches.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return searchResults.map((result: FuseResult<IndexItem>) => {
|
const results = searchResults.map((result: FuseResult<IndexItem>) => {
|
||||||
const item = result.item;
|
const item = result.item;
|
||||||
const fuseScore = 10 * (1 - (result.score || 0.5));
|
const fuseScore = 10 * (1 - (result.score || 0.5));
|
||||||
|
|
||||||
let score = fuseScore;
|
let score = fuseScore;
|
||||||
|
|
||||||
|
// Recency boost
|
||||||
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
|
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
|
||||||
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
|
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
|
||||||
score += recencyBoost;
|
score += recencyBoost;
|
||||||
|
|
||||||
|
// Boost for exact text matches (especially at the start)
|
||||||
|
const textLower = item.text.toLowerCase();
|
||||||
|
if (textLower.startsWith(queryLower)) {
|
||||||
|
score += 5; // Strong boost for prefix matches
|
||||||
|
} else if (textLower.includes(queryLower)) {
|
||||||
|
score += 2; // Boost for substring matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost for category matches
|
||||||
|
if (item.category.toLowerCase().includes(queryLower)) {
|
||||||
|
score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -125,60 +218,124 @@ export function searchDynamicItems(
|
|||||||
matches: result.matches,
|
matches: result.matches,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add additional matches from simple substring search
|
||||||
|
additionalMatches.forEach((item) => {
|
||||||
|
// Check if already in results
|
||||||
|
if (!results.find(r => r.id === item.id)) {
|
||||||
|
const textLower = item.text.toLowerCase();
|
||||||
|
let score = 5; // Base score for substring matches
|
||||||
|
|
||||||
|
// Boost for prefix matches
|
||||||
|
if (textLower.startsWith(queryLower)) {
|
||||||
|
score += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recency boost
|
||||||
|
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
|
||||||
|
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
|
||||||
|
score += recencyBoost;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
id: item.id,
|
||||||
|
type: "dynamic" as const,
|
||||||
|
score,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by score and return top results
|
||||||
|
return results.sort((a, b) => b.score - a.score).slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function performSearch(
|
export async function performSearch(
|
||||||
query: string,
|
query: string,
|
||||||
commandsFuse: Fuse<StaticCommandItem>,
|
commandsFuse: Fuse<StaticCommandItem>,
|
||||||
commandIdToItemMap: Map<string, StaticCommandItem>,
|
commandIdToItemMap: Map<string, StaticCommandItem>,
|
||||||
|
dynamicContentFuse?: Fuse<IndexItem>,
|
||||||
|
dynamicIdToItemMap?: Map<string, IndexItem>,
|
||||||
|
sortByRecent: boolean = true,
|
||||||
): Promise<CombinedResult[]> {
|
): Promise<CombinedResult[]> {
|
||||||
// Get all results first
|
const trimmedQuery = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (trimmedQuery.length > 2) {
|
||||||
|
const cached = getCachedResults(trimmedQuery);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Get command results (these don't need hybrid search)
|
||||||
const commandResults = searchCommands(
|
const commandResults = searchCommands(
|
||||||
commandsFuse,
|
commandsFuse,
|
||||||
query,
|
trimmedQuery,
|
||||||
commandIdToItemMap,
|
commandIdToItemMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get vector results in parallel
|
// Step 2: Get BM25 results for dynamic items
|
||||||
let vectorResults: VectorSearchResult[] = [];
|
let dynamicResults: CombinedResult[] = [];
|
||||||
try {
|
if (dynamicContentFuse && dynamicIdToItemMap) {
|
||||||
vectorResults = await searchVectors(query);
|
// Get BM25 results first (fast text-based search)
|
||||||
} catch (e) {}
|
const bm25Results = searchDynamicItems(
|
||||||
|
dynamicContentFuse,
|
||||||
|
trimmedQuery,
|
||||||
|
dynamicIdToItemMap,
|
||||||
|
50, // Get top 50 for reranking
|
||||||
|
sortByRecent,
|
||||||
|
);
|
||||||
|
|
||||||
// Create a map to store our final results, using ID as key to avoid duplicates
|
// Step 3: Apply hybrid search (BM25 + Vector reranking + boosting)
|
||||||
const resultMap = new Map<string, CombinedResult>();
|
if (trimmedQuery.length > 2 && bm25Results.length > 0) {
|
||||||
|
try {
|
||||||
// Add command results first (they keep their original scores)
|
// Get all items for expansion
|
||||||
commandResults.forEach((r) => resultMap.set(r.id, r));
|
const allItems = Array.from(dynamicIdToItemMap.values());
|
||||||
|
|
||||||
// Process dynamic results and vector results together
|
// Apply hybrid search with expansion
|
||||||
const seenIds = new Set<string>();
|
dynamicResults = await hybridSearchWithExpansion(
|
||||||
|
bm25Results,
|
||||||
vectorResults.forEach((v) => {
|
trimmedQuery,
|
||||||
const id = v.object.id;
|
allItems,
|
||||||
|
{
|
||||||
if (!seenIds.has(id)) {
|
bm25TopK: 50,
|
||||||
// This is a semantic match that Fuse missed - add it with the vector similarity as score
|
finalLimit: 20, // Return top 20 after reranking
|
||||||
let score = v.similarity * 0.5; // High base score for semantic matches
|
recencyBoost: sortByRecent,
|
||||||
const job = jobs[v.object.category];
|
bm25Weight: 0.4, // 40% BM25, 60% vector
|
||||||
if (job && typeof job.boostCriteria === 'function') {
|
vectorWeight: 0.6,
|
||||||
const boost = job.boostCriteria(v.object, query);
|
recencyWeight: 0.1,
|
||||||
if (boost) {
|
},
|
||||||
score += boost;
|
);
|
||||||
}
|
} catch (e) {
|
||||||
|
console.warn("[Search] Hybrid search failed, using BM25 only:", e);
|
||||||
|
// Fallback to BM25 only
|
||||||
|
dynamicResults = bm25Results.slice(0, 20);
|
||||||
}
|
}
|
||||||
resultMap.set(id, {
|
} else {
|
||||||
id,
|
// For very short queries or no BM25 results, use BM25 only
|
||||||
type: "dynamic" as const,
|
dynamicResults = bm25Results.slice(0, 20);
|
||||||
score,
|
|
||||||
item: v.object,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Combine command and dynamic results
|
||||||
|
const allResults = [...commandResults, ...dynamicResults];
|
||||||
|
|
||||||
|
// Sort by score (commands typically have higher priority)
|
||||||
|
allResults.sort((a, b) => {
|
||||||
|
// Commands always come first if scores are similar
|
||||||
|
if (a.type === "command" && b.type === "dynamic") {
|
||||||
|
return b.score - a.score - 10; // Commands get +10 boost
|
||||||
|
}
|
||||||
|
if (a.type === "dynamic" && b.type === "command") {
|
||||||
|
return b.score - a.score + 10; // Commands get +10 boost
|
||||||
|
}
|
||||||
|
return b.score - a.score;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert to array and sort by score
|
// Cache results for queries longer than 2 chars
|
||||||
const results = Array.from(resultMap.values());
|
if (trimmedQuery.length > 2) {
|
||||||
results.sort((a, b) => b.score - a.score);
|
setCachedResults(trimmedQuery, allResults);
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
return allResults;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
|
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
|
||||||
import type { IndexItem } from "../../indexing/types";
|
import type { IndexItem } from "../../indexing/types";
|
||||||
import type { SearchResult } from "embeddia";
|
import type { SearchResult } from "embeddia";
|
||||||
|
import { isVectorSearchSupported } from "../../utils/browserDetection";
|
||||||
|
|
||||||
let vectorIndex: EmbeddingIndex | null = null;
|
let vectorIndex: EmbeddingIndex | null = null;
|
||||||
|
let initializationAttempted = false;
|
||||||
|
let initializationFailed = false;
|
||||||
|
|
||||||
export async function initVectorSearch() {
|
export async function initVectorSearch() {
|
||||||
|
// Skip initialization if already attempted and failed, or if not supported
|
||||||
|
if (initializationFailed || !isVectorSearchSupported()) {
|
||||||
|
if (!isVectorSearchSupported()) {
|
||||||
|
console.debug("[Vector Search] Vector search not supported in Firefox - using text search only");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initializationAttempted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializationAttempted = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await initializeModel();
|
await initializeModel();
|
||||||
vectorIndex = new EmbeddingIndex([]);
|
vectorIndex = new EmbeddingIndex([]);
|
||||||
vectorIndex.preloadIndexedDB();
|
vectorIndex.preloadIndexedDB();
|
||||||
|
console.debug("[Vector Search] Initialized successfully");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error initializing vector search", e);
|
console.warn("[Vector Search] Failed to initialize vector search (will use text search only):", e);
|
||||||
|
initializationFailed = true;
|
||||||
|
vectorIndex = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,28 +38,111 @@ export interface VectorSearchResult extends SearchResult {
|
|||||||
object: IndexItem & { embedding: number[] };
|
object: IndexItem & { embedding: number[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache for query embeddings to avoid recomputing
|
||||||
|
const embeddingCache = new Map<string, number[]>();
|
||||||
|
const EMBEDDING_CACHE_TTL = 1000 * 60 * 30; // 30 minutes
|
||||||
|
const MAX_EMBEDDING_CACHE_SIZE = 50;
|
||||||
|
|
||||||
|
function getCachedEmbedding(query: string): number[] | null {
|
||||||
|
const cached = embeddingCache.get(query);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedEmbedding(query: string, embedding: number[]) {
|
||||||
|
// Limit cache size
|
||||||
|
if (embeddingCache.size >= MAX_EMBEDDING_CACHE_SIZE) {
|
||||||
|
const firstKey = embeddingCache.keys().next().value;
|
||||||
|
embeddingCache.delete(firstKey);
|
||||||
|
}
|
||||||
|
embeddingCache.set(query, embedding);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the embedding cache
|
||||||
|
*/
|
||||||
|
export function clearEmbeddingCache(): void {
|
||||||
|
embeddingCache.clear();
|
||||||
|
console.debug("[Vector Search] Embedding cache cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for cache clear events (e.g., on extension update)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('betterseqta-clear-embedding-cache', () => {
|
||||||
|
clearEmbeddingCache();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchVectors(
|
export async function searchVectors(
|
||||||
query: string,
|
query: string,
|
||||||
topK: number = 20,
|
topK: number = 20,
|
||||||
): Promise<VectorSearchResult[]> {
|
): Promise<VectorSearchResult[]> {
|
||||||
if (!vectorIndex) await initVectorSearch();
|
// Return empty array if vector search is not supported or failed to initialize
|
||||||
|
if (!isVectorSearchSupported() || initializationFailed) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const queryEmbedding = await getEmbedding(query.slice(0, 100));
|
if (!vectorIndex) {
|
||||||
|
await initVectorSearch();
|
||||||
|
if (!vectorIndex) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const results = await vectorIndex!.search(queryEmbedding, {
|
// Normalize query for caching
|
||||||
topK,
|
const normalizedQuery = query.trim().toLowerCase().slice(0, 100);
|
||||||
useStorage: "indexedDB",
|
|
||||||
dedupeEntries: true,
|
// Check cache first
|
||||||
});
|
let queryEmbedding = getCachedEmbedding(normalizedQuery);
|
||||||
|
|
||||||
|
if (!queryEmbedding) {
|
||||||
|
try {
|
||||||
|
queryEmbedding = await getEmbedding(normalizedQuery);
|
||||||
|
setCachedEmbedding(normalizedQuery, queryEmbedding);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Vector Search] Failed to get embedding:", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// filter results with a similarity below 0.81
|
try {
|
||||||
const filteredResults = results.filter((r) => r.similarity > 0.81);
|
const results = await vectorIndex!.search(queryEmbedding, {
|
||||||
|
topK: Math.min(topK * 2, 30), // Get more results, filter later
|
||||||
|
useStorage: "indexedDB",
|
||||||
|
dedupeEntries: true,
|
||||||
|
});
|
||||||
|
|
||||||
return filteredResults as VectorSearchResult[];
|
// Filter results with a similarity below 0.80 (slightly more permissive)
|
||||||
|
// and sort by similarity descending
|
||||||
|
const filteredResults = results
|
||||||
|
.filter((r) => r.similarity > 0.80)
|
||||||
|
.sort((a, b) => b.similarity - a.similarity)
|
||||||
|
.slice(0, topK);
|
||||||
|
|
||||||
|
return filteredResults as VectorSearchResult[];
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Vector Search] Search failed:", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshVectorCache() {
|
export async function refreshVectorCache() {
|
||||||
if (!vectorIndex) await initVectorSearch();
|
if (!isVectorSearchSupported() || initializationFailed) {
|
||||||
vectorIndex!.clearIndexedDBCache();
|
return;
|
||||||
vectorIndex!.preloadIndexedDB();
|
}
|
||||||
|
|
||||||
|
if (!vectorIndex) {
|
||||||
|
await initVectorSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vectorIndex) {
|
||||||
|
try {
|
||||||
|
vectorIndex.clearIndexedDBCache();
|
||||||
|
vectorIndex.preloadIndexedDB();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Vector Search] Failed to refresh cache:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects if the current browser is Firefox
|
||||||
|
*/
|
||||||
|
export function isFirefox(): boolean {
|
||||||
|
try {
|
||||||
|
// Firefox-specific API
|
||||||
|
if (typeof (browser.runtime as any).getBrowserInfo === "function") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fallback: check user agent
|
||||||
|
if (typeof navigator !== "undefined") {
|
||||||
|
return navigator.userAgent.toLowerCase().includes("firefox");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
// If we can't detect, assume not Firefox (safer for Chrome/Edge)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if vector search is supported in the current browser
|
||||||
|
* Currently disabled for Firefox due to security restrictions
|
||||||
|
*/
|
||||||
|
export function isVectorSearchSupported(): boolean {
|
||||||
|
return !isFirefox();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
|
const VERSION_STORAGE_KEY = "betterseqta-global-search-version";
|
||||||
|
const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current extension version from the manifest
|
||||||
|
*/
|
||||||
|
export function getCurrentVersion(): string {
|
||||||
|
try {
|
||||||
|
return browser.runtime.getManifest().version;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Version Check] Failed to get manifest version:", e);
|
||||||
|
return "0.0.0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the last stored version from localStorage
|
||||||
|
*/
|
||||||
|
export function getStoredVersion(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(VERSION_STORAGE_KEY);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Version Check] Failed to get stored version:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the current version in localStorage
|
||||||
|
*/
|
||||||
|
export function storeVersion(version: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VERSION_STORAGE_KEY, version);
|
||||||
|
localStorage.setItem(VERSION_CACHE_KEY, version);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Version Check] Failed to store version:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the extension has been updated and clears caches if needed
|
||||||
|
* Returns true if an update was detected
|
||||||
|
*/
|
||||||
|
export async function checkAndHandleUpdate(): Promise<boolean> {
|
||||||
|
const currentVersion = getCurrentVersion();
|
||||||
|
const storedVersion = getStoredVersion();
|
||||||
|
|
||||||
|
// If no stored version, this is first run - store current version
|
||||||
|
if (!storedVersion) {
|
||||||
|
console.debug(`[Version Check] First run detected, storing version ${currentVersion}`);
|
||||||
|
storeVersion(currentVersion);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If versions match, no update
|
||||||
|
if (storedVersion === currentVersion) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version mismatch detected - extension was updated
|
||||||
|
console.log(`[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, clearing caches...`);
|
||||||
|
|
||||||
|
// Clear all caches
|
||||||
|
await clearAllCaches();
|
||||||
|
|
||||||
|
// Store new version
|
||||||
|
storeVersion(currentVersion);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all search-related caches
|
||||||
|
*/
|
||||||
|
export async function clearAllCaches(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Clear search result cache (in-memory Map)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Dispatch event to clear caches in other modules
|
||||||
|
window.dispatchEvent(new CustomEvent('betterseqta-clear-search-cache'));
|
||||||
|
window.dispatchEvent(new CustomEvent('betterseqta-clear-embedding-cache'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try to directly clear caches if modules are already loaded
|
||||||
|
// Use setTimeout to avoid blocking and handle CSS preload errors
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { clearSearchCache } = await import("../search/searchUtils");
|
||||||
|
clearSearchCache();
|
||||||
|
} catch (e: any) {
|
||||||
|
// Module might not be loaded yet, or CSS preload error - that's okay
|
||||||
|
if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) {
|
||||||
|
console.debug("[Version Check] Could not clear search cache:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { clearEmbeddingCache } = await import("../search/vector/vectorSearch");
|
||||||
|
clearEmbeddingCache();
|
||||||
|
} catch (e: any) {
|
||||||
|
// Module might not be loaded yet, or CSS preload error - that's okay
|
||||||
|
if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) {
|
||||||
|
console.debug("[Version Check] Could not clear embedding cache:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
console.debug("[Version Check] All caches cleared");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Version Check] Error clearing caches:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
|
|||||||
"[class*='notifications__bubble___']",
|
"[class*='notifications__bubble___']",
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
|
|
||||||
if (api.storage.lastNotificationCount !== 0) {
|
if (alertDiv && api.storage.lastNotificationCount !== 0) {
|
||||||
alertDiv.textContent = api.storage.lastNotificationCount.toString();
|
alertDiv.textContent = api.storage.lastNotificationCount.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
|
|||||||
const notificationCount = data.payload.notifications.length;
|
const notificationCount = data.payload.notifications.length;
|
||||||
api.storage.lastNotificationCount = notificationCount;
|
api.storage.lastNotificationCount = notificationCount;
|
||||||
api.storage.lastCheckedTime = new Date().toISOString();
|
api.storage.lastCheckedTime = new Date().toISOString();
|
||||||
|
|
||||||
// Reset error count on success
|
// Reset error count on success
|
||||||
api.storage.consecutiveErrors = 0;
|
api.storage.consecutiveErrors = 0;
|
||||||
|
|
||||||
@@ -74,31 +74,36 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[BetterSEQTA+] Error fetching notifications:", error);
|
console.error("[BetterSEQTA+] Error fetching notifications:", error);
|
||||||
api.storage.consecutiveErrors = (api.storage.consecutiveErrors || 0) + 1;
|
api.storage.consecutiveErrors =
|
||||||
|
(api.storage.consecutiveErrors || 0) + 1;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNextInterval = () => {
|
const getNextInterval = () => {
|
||||||
// Exponential backoff on errors, max 5 minutes
|
// Exponential backoff on errors, max 5 minutes
|
||||||
const errorMultiplier = Math.min(Math.pow(2, api.storage.consecutiveErrors || 0), 10);
|
const errorMultiplier = Math.min(
|
||||||
|
Math.pow(2, api.storage.consecutiveErrors || 0),
|
||||||
|
10,
|
||||||
|
);
|
||||||
return Math.min(baseInterval * errorMultiplier, maxInterval);
|
return Math.min(baseInterval * errorMultiplier, maxInterval);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startPolling = () => {
|
const startPolling = () => {
|
||||||
if (pollInterval) return; // Already polling
|
if (pollInterval) return; // Already polling
|
||||||
checkNotifications();
|
checkNotifications();
|
||||||
|
|
||||||
const scheduleNext = () => {
|
const scheduleNext = () => {
|
||||||
const interval = getNextInterval();
|
const interval = getNextInterval();
|
||||||
pollInterval = window.setTimeout(() => {
|
pollInterval = window.setTimeout(() => {
|
||||||
checkNotifications().then(() => {
|
checkNotifications().then(() => {
|
||||||
if (pollInterval) { // Only continue if not stopped
|
if (pollInterval) {
|
||||||
|
// Only continue if not stopped
|
||||||
scheduleNext();
|
scheduleNext();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, interval);
|
}, interval);
|
||||||
};
|
};
|
||||||
|
|
||||||
scheduleNext();
|
scheduleNext();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,14 +129,16 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
|
|||||||
isVisible = !document.hidden;
|
isVisible = !document.hidden;
|
||||||
if (isVisible && !pollInterval) {
|
if (isVisible && !pollInterval) {
|
||||||
// Resume polling when tab becomes visible
|
// Resume polling when tab becomes visible
|
||||||
const alertDiv = document.querySelector("[class*='notifications__bubble___']");
|
const alertDiv = document.querySelector(
|
||||||
|
"[class*='notifications__bubble___']",
|
||||||
|
);
|
||||||
if (alertDiv) {
|
if (alertDiv) {
|
||||||
startPolling();
|
startPolling();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
api.seqta.onMount("[class*='notifications__bubble___']", (_) => {
|
api.seqta.onMount("[class*='notifications__bubble___']", (_) => {
|
||||||
startPolling();
|
startPolling();
|
||||||
@@ -139,7 +146,7 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Plugin } from "@/plugins/core/types";
|
import type { Plugin } from "@/plugins/core/types";
|
||||||
import { defineSettings, componentSetting } from "@/plugins/core/settingsHelpers";
|
import { componentSetting, defineSettings } from "@/plugins/core/settingsHelpers";
|
||||||
import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
|
import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
import styles from "./styles.css?inline";
|
import styles from "./styles.css?inline";
|
||||||
@@ -13,7 +13,6 @@ const settings = defineSettings({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const profilePicturePlugin: Plugin<typeof settings> = {
|
const profilePicturePlugin: Plugin<typeof settings> = {
|
||||||
id: "profile-picture",
|
id: "profile-picture",
|
||||||
name: "Custom Profile Picture",
|
name: "Custom Profile Picture",
|
||||||
@@ -74,7 +73,7 @@ const profilePicturePlugin: Plugin<typeof settings> = {
|
|||||||
window.addEventListener('profile-picture-updated', handler);
|
window.addEventListener('profile-picture-updated', handler);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('profile-picture-updated', handler);
|
window.removeEventListener("profile-picture-updated", handler);
|
||||||
if (img) img.remove();
|
if (img) img.remove();
|
||||||
if (svg) svg.style.display = "";
|
if (svg) svg.style.display = "";
|
||||||
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
|
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
|
||||||
@@ -83,4 +82,3 @@ const profilePicturePlugin: Plugin<typeof settings> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default profilePicturePlugin;
|
export default profilePicturePlugin;
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,16 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
box-shadow: 0 0 0 3px #000000;
|
box-shadow: 0 0 0 3px #000000;
|
||||||
|
transition: box-shadow 0.05s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .userInfoImg {
|
.dark .userInfoImg {
|
||||||
box-shadow: 0 0 0 3px #ffffff;
|
box-shadow: 0 0 0 3px #ffffff;
|
||||||
|
transition: box-shadow 0.05s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.userInfosvgdiv {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
filter: invert(0) !important;
|
.userInfoImg {
|
||||||
}
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import localforage from "localforage";
|
import localforage from "localforage";
|
||||||
|
import browser from "webextension-polyfill";
|
||||||
import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes";
|
import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import debounce from "@/seqta/utils/debounce";
|
import debounce from "@/seqta/utils/debounce";
|
||||||
@@ -147,14 +148,21 @@ export class ThemeManager {
|
|||||||
public async initialize(): Promise<void> {
|
public async initialize(): Promise<void> {
|
||||||
console.debug("[ThemeManager] Starting initialization");
|
console.debug("[ThemeManager] Starting initialization");
|
||||||
try {
|
try {
|
||||||
// Check if theme creator was open during reload
|
const neumorphicThemeId = "9a9786d1-b5fc-4a91-8c7a-f8bf7f7679ad";
|
||||||
|
const migrationCSS = "#title {\nbackground: transparent !important;\n}";
|
||||||
|
|
||||||
|
const theme = (await localforage.getItem(neumorphicThemeId)) as CustomTheme | null;
|
||||||
|
if (theme && theme.CustomCSS && !theme.CustomCSS.includes("#title {\nbackground: transparent !important;\n}")) {
|
||||||
|
theme.CustomCSS = theme.CustomCSS + "\n" + migrationCSS;
|
||||||
|
await localforage.setItem(neumorphicThemeId, theme);
|
||||||
|
}
|
||||||
|
|
||||||
const themeCreatorOpen = localStorage.getItem("themeCreatorOpen");
|
const themeCreatorOpen = localStorage.getItem("themeCreatorOpen");
|
||||||
if (themeCreatorOpen === "true") {
|
if (themeCreatorOpen === "true") {
|
||||||
console.debug(
|
console.debug(
|
||||||
"[ThemeManager] Theme creator was open, clearing preview state",
|
"[ThemeManager] Theme creator was open, clearing preview state",
|
||||||
);
|
);
|
||||||
this.clearPreview();
|
this.clearPreview();
|
||||||
// Clean up the flag
|
|
||||||
localStorage.removeItem("themeCreatorOpen");
|
localStorage.removeItem("themeCreatorOpen");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,23 +471,53 @@ export class ThemeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly THEME_API_BASE = 'https://betterseqta.org/api';
|
||||||
|
private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download and install a theme from the store
|
* Fetch JSON from a URL via background script (avoids CORS when running inside SEQTA page)
|
||||||
|
*/
|
||||||
|
private async fetchFromUrl(url: string): Promise<any> {
|
||||||
|
const result = (await browser.runtime.sendMessage({
|
||||||
|
type: 'fetchFromUrl',
|
||||||
|
url,
|
||||||
|
})) as { data?: unknown; error?: string };
|
||||||
|
if (result?.error) throw new Error(result.error);
|
||||||
|
return result?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download and install a theme from the store.
|
||||||
|
* Uses API first (increments download_count), falls back to GitHub if unreachable.
|
||||||
*/
|
*/
|
||||||
public async downloadTheme(themeContent: {
|
public async downloadTheme(themeContent: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description?: string;
|
||||||
coverImage: string;
|
coverImage?: string;
|
||||||
|
theme_json_url?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
|
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
|
||||||
try {
|
try {
|
||||||
if (!themeContent.id) return;
|
if (!themeContent.id) return;
|
||||||
|
|
||||||
const response = await fetch(
|
let themeData: ThemeContent;
|
||||||
`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes/${themeContent.id}/theme.json`,
|
|
||||||
);
|
try {
|
||||||
const themeData = (await response.json()) as ThemeContent;
|
// Try API first (increments download_count)
|
||||||
|
const downloadData = (await this.fetchFromUrl(
|
||||||
|
`${this.THEME_API_BASE}/themes/${themeContent.id}/download`
|
||||||
|
)) as { success?: boolean; data?: { theme_json_url: string } };
|
||||||
|
if (!downloadData?.success || !downloadData?.data?.theme_json_url) {
|
||||||
|
throw new Error("Failed to get theme download URL");
|
||||||
|
}
|
||||||
|
themeData = (await this.fetchFromUrl(downloadData.data.theme_json_url)) as ThemeContent;
|
||||||
|
} catch (apiError) {
|
||||||
|
// Fallback to GitHub if API is unreachable
|
||||||
|
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
|
||||||
|
const githubUrl = `${this.GITHUB_THEMES_BASE}/${themeContent.id}/theme.json`;
|
||||||
|
themeData = (await this.fetchFromUrl(githubUrl)) as ThemeContent;
|
||||||
|
}
|
||||||
|
|
||||||
await this.installTheme(themeData);
|
await this.installTheme(themeData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function handleTimetableAssessmentHide(): void {
|
|||||||
|
|
||||||
const hideOn = document.createElement("button");
|
const hideOn = document.createElement("button");
|
||||||
hideOn.className = "uiButton timetable-hide iconFamily";
|
hideOn.className = "uiButton timetable-hide iconFamily";
|
||||||
hideOn.innerHTML = "👁";
|
hideOn.innerHTML = "";
|
||||||
|
|
||||||
hideControls.appendChild(hideOn);
|
hideControls.appendChild(hideOn);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import type { Plugin } from "../../core/types";
|
||||||
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
import styles from "./styles.css?inline";
|
||||||
|
|
||||||
|
interface TimetableEntryData {
|
||||||
|
ci: number;
|
||||||
|
description: string;
|
||||||
|
room: string;
|
||||||
|
staff: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimetableOverrides {
|
||||||
|
[ci: string]: { room?: string; staff?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimetableOverridesBySubject {
|
||||||
|
[description: string]: { room?: string; staff?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimetableStorage {
|
||||||
|
timetableOverrides?: TimetableOverrides;
|
||||||
|
timetableOverridesBySubject?: TimetableOverridesBySubject;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SEQTA timetable entries use .teacher and .room as direct children, and data-instance for ci */
|
||||||
|
function getRoomAndTeacherElements(entry: HTMLElement): {
|
||||||
|
roomEl: HTMLElement | null;
|
||||||
|
teacherEl: HTMLElement | null;
|
||||||
|
} {
|
||||||
|
const roomEl = entry.querySelector(".room") as HTMLElement | null;
|
||||||
|
const teacherEl = entry.querySelector(".teacher") as HTMLElement | null;
|
||||||
|
return { roomEl, teacherEl };
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDIT_ICON_SVG =
|
||||||
|
'<svg width="24" height="24" viewBox="0 0 24 24"><g style="fill: currentcolor;"><path d="M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z"/></g></svg>';
|
||||||
|
|
||||||
|
function showEditModal(
|
||||||
|
item: TimetableEntryData,
|
||||||
|
overrides: TimetableOverrides | undefined,
|
||||||
|
overridesBySubject: TimetableOverridesBySubject | undefined,
|
||||||
|
onSave: (
|
||||||
|
ci: number,
|
||||||
|
room: string,
|
||||||
|
staff: string,
|
||||||
|
applyToFuture: boolean,
|
||||||
|
) => void,
|
||||||
|
onClear: (ci: number) => void,
|
||||||
|
): void {
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "timetable-edit-modal-overlay";
|
||||||
|
|
||||||
|
const modal = document.createElement("div");
|
||||||
|
modal.className = "timetable-edit-modal";
|
||||||
|
|
||||||
|
const override = overrides?.[String(item.ci)] ?? overridesBySubject?.[item.description];
|
||||||
|
|
||||||
|
const roomValue = override?.room ?? item.room ?? "";
|
||||||
|
const staffValue = override?.staff ?? item.staff ?? "";
|
||||||
|
|
||||||
|
const escapeHtml = (s: string) =>
|
||||||
|
s.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
||||||
|
const title = escapeHtml(item.description);
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<h3>Edit ${title}</h3>
|
||||||
|
<label for="timetable-edit-room">Room</label>
|
||||||
|
<input type="text" id="timetable-edit-room" value="${roomValue.replace(/"/g, """)}" placeholder="Room" />
|
||||||
|
<label for="timetable-edit-staff">Teacher</label>
|
||||||
|
<input type="text" id="timetable-edit-staff" value="${staffValue.replace(/"/g, """)}" placeholder="Teacher" />
|
||||||
|
<div class="timetable-edit-modal-checkbox">
|
||||||
|
<input type="checkbox" id="timetable-edit-apply-future" />
|
||||||
|
<label for="timetable-edit-apply-future">Apply to future weeks</label>
|
||||||
|
</div>
|
||||||
|
<div class="timetable-edit-modal-actions">
|
||||||
|
${override ? '<button type="button" class="timetable-edit-btn-clear">Clear</button>' : ""}
|
||||||
|
<button type="button" class="timetable-edit-btn-cancel">Cancel</button>
|
||||||
|
<button type="button" class="timetable-edit-btn-save">Save</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
|
||||||
|
const removeModal = () => {
|
||||||
|
overlay.remove();
|
||||||
|
document.removeEventListener("keydown", handleKeydown);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") removeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
overlay.addEventListener("click", (e) => {
|
||||||
|
if (e.target === overlay) removeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.addEventListener("click", (e) => e.stopPropagation());
|
||||||
|
modal.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||||
|
modal.addEventListener("mouseup", (e) => e.stopPropagation());
|
||||||
|
|
||||||
|
const roomInput = modal.querySelector("#timetable-edit-room") as HTMLInputElement;
|
||||||
|
const staffInput = modal.querySelector("#timetable-edit-staff") as HTMLInputElement;
|
||||||
|
const applyFutureCheckbox = modal.querySelector("#timetable-edit-apply-future") as HTMLInputElement;
|
||||||
|
|
||||||
|
modal.querySelector(".timetable-edit-btn-save")?.addEventListener("click", () => {
|
||||||
|
onSave(
|
||||||
|
item.ci,
|
||||||
|
roomInput.value.trim(),
|
||||||
|
staffInput.value.trim(),
|
||||||
|
applyFutureCheckbox?.checked ?? false,
|
||||||
|
);
|
||||||
|
removeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.querySelector(".timetable-edit-btn-cancel")?.addEventListener("click", removeModal);
|
||||||
|
|
||||||
|
const clearBtn = modal.querySelector(".timetable-edit-btn-clear");
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener("click", () => {
|
||||||
|
onClear(item.ci);
|
||||||
|
removeModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.addEventListener("keydown", handleKeydown);
|
||||||
|
roomInput?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||||
|
id: "timetableEdit",
|
||||||
|
name: "Edit Rooms & Teachers",
|
||||||
|
description: "Edit room and teacher names in timetable classes",
|
||||||
|
version: "1.0.0",
|
||||||
|
settings: {},
|
||||||
|
disableToggle: true,
|
||||||
|
defaultEnabled: true,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
const styleEl = document.createElement("style");
|
||||||
|
styleEl.textContent = styles;
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
|
||||||
|
await api.storage.loaded;
|
||||||
|
|
||||||
|
let observer: MutationObserver | null = null;
|
||||||
|
let quickbarObserver: MutationObserver | null = null;
|
||||||
|
let lastClickedCi: number | null = null;
|
||||||
|
let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null;
|
||||||
|
|
||||||
|
const getOverrides = (): TimetableOverrides =>
|
||||||
|
api.storage.timetableOverrides ?? {};
|
||||||
|
const getOverridesBySubject = (): TimetableOverridesBySubject =>
|
||||||
|
api.storage.timetableOverridesBySubject ?? {};
|
||||||
|
|
||||||
|
const getEffectiveOverride = (
|
||||||
|
ci: number,
|
||||||
|
description: string,
|
||||||
|
): { room?: string; staff?: string } | undefined =>
|
||||||
|
getOverrides()[String(ci)] ?? getOverridesBySubject()[description];
|
||||||
|
|
||||||
|
const processEntry = (entry: HTMLElement): void => {
|
||||||
|
if (entry.classList.contains("assessment") || entry.hasAttribute("data-timetable-edit-processed")) return;
|
||||||
|
|
||||||
|
const ciStr = entry.getAttribute("data-instance");
|
||||||
|
if (!ciStr) return;
|
||||||
|
|
||||||
|
const ci = parseInt(ciStr, 10);
|
||||||
|
if (isNaN(ci)) return;
|
||||||
|
|
||||||
|
const { roomEl, teacherEl } = getRoomAndTeacherElements(entry);
|
||||||
|
if (!roomEl && !teacherEl) return;
|
||||||
|
|
||||||
|
const titleEl = entry.querySelector(".title");
|
||||||
|
const description = titleEl?.textContent?.trim() ?? "";
|
||||||
|
const room = roomEl?.textContent?.trim() ?? "";
|
||||||
|
const staff = teacherEl?.textContent?.trim() ?? "";
|
||||||
|
|
||||||
|
const item: TimetableEntryData = { ci, description, room, staff };
|
||||||
|
|
||||||
|
entry.setAttribute("data-timetable-edit-processed", "true");
|
||||||
|
|
||||||
|
const override = getEffectiveOverride(ci, description);
|
||||||
|
if (override) {
|
||||||
|
if (override.room !== undefined && roomEl) roomEl.textContent = override.room;
|
||||||
|
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const captureClick = (e: MouseEvent) => {
|
||||||
|
lastClickedCi = ci;
|
||||||
|
lastClickedEntry = { roomEl, teacherEl, item };
|
||||||
|
};
|
||||||
|
entry.addEventListener("click", captureClick, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const processAllEntries = () => {
|
||||||
|
document.querySelectorAll(".timetablepage .entry.class").forEach((entry) => {
|
||||||
|
processEntry(entry as HTMLElement);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEditButtonToQuickbar = (quickbar: HTMLElement) => {
|
||||||
|
if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return;
|
||||||
|
|
||||||
|
const actions = quickbar.querySelector(".actions");
|
||||||
|
if (!actions) return;
|
||||||
|
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "uiButton timetable-edit-quickbar-btn";
|
||||||
|
btn.title = "Edit room and teacher";
|
||||||
|
btn.innerHTML = EDIT_ICON_SVG;
|
||||||
|
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const ci = lastClickedCi;
|
||||||
|
const entryData = lastClickedEntry;
|
||||||
|
if (!ci || !entryData) return;
|
||||||
|
|
||||||
|
const qb = (e.currentTarget as HTMLElement).closest(".quickbar");
|
||||||
|
if (!qb) return;
|
||||||
|
const quickbarRoom = qb.querySelector(".meta .room")?.textContent?.trim() ?? "";
|
||||||
|
const quickbarTeacher = qb.querySelector(".meta .teacher")?.textContent?.trim() ?? "";
|
||||||
|
const quickbarTitle = qb.querySelector(".title")?.textContent?.trim() ?? "";
|
||||||
|
const item: TimetableEntryData = {
|
||||||
|
ci,
|
||||||
|
description: quickbarTitle || entryData.item.description,
|
||||||
|
room: quickbarRoom || entryData.item.room,
|
||||||
|
staff: quickbarTeacher || entryData.item.staff,
|
||||||
|
};
|
||||||
|
|
||||||
|
showEditModal(
|
||||||
|
item,
|
||||||
|
getOverrides(),
|
||||||
|
getOverridesBySubject(),
|
||||||
|
(ci, room, staff, applyToFuture) => {
|
||||||
|
if (applyToFuture) {
|
||||||
|
const bySubject = { ...getOverridesBySubject() };
|
||||||
|
bySubject[item.description] = {
|
||||||
|
room: room || undefined,
|
||||||
|
staff: staff || undefined,
|
||||||
|
};
|
||||||
|
api.storage.timetableOverridesBySubject = bySubject;
|
||||||
|
} else {
|
||||||
|
const current = getOverrides();
|
||||||
|
api.storage.timetableOverrides = {
|
||||||
|
...current,
|
||||||
|
[String(ci)]: { room: room || undefined, staff: staff || undefined },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (entryData.roomEl) entryData.roomEl.textContent = room;
|
||||||
|
if (entryData.teacherEl) entryData.teacherEl.textContent = staff;
|
||||||
|
processAllEntries();
|
||||||
|
},
|
||||||
|
(ci) => {
|
||||||
|
const current = getOverrides();
|
||||||
|
delete current[String(ci)];
|
||||||
|
api.storage.timetableOverrides = current;
|
||||||
|
const bySubject = getOverridesBySubject();
|
||||||
|
delete bySubject[item.description];
|
||||||
|
api.storage.timetableOverridesBySubject = bySubject;
|
||||||
|
if (entryData.roomEl) entryData.roomEl.textContent = item.room;
|
||||||
|
if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff;
|
||||||
|
processAllEntries();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.insertBefore(btn, actions.firstChild);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncQuickbarFromDOM = () => {
|
||||||
|
const quickbar = document.querySelector(".timetablepage .quickbar.visible");
|
||||||
|
if (quickbar && quickbar.getAttribute("data-type") === "class") {
|
||||||
|
const titleEl = quickbar.querySelector(".title");
|
||||||
|
const roomEl = quickbar.querySelector(".meta .room");
|
||||||
|
const teacherEl = quickbar.querySelector(".meta .teacher");
|
||||||
|
if (titleEl && roomEl && teacherEl && lastClickedCi !== null && lastClickedEntry) {
|
||||||
|
addEditButtonToQuickbar(quickbar as HTMLElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupQuickbarObserver = () => {
|
||||||
|
const timetablePage = document.querySelector(".timetablepage");
|
||||||
|
if (!timetablePage || quickbarObserver) return;
|
||||||
|
|
||||||
|
quickbarObserver = new MutationObserver(() => {
|
||||||
|
const quickbar = document.querySelector(".timetablepage .quickbar.visible");
|
||||||
|
if (quickbar?.getAttribute("data-type") === "class") {
|
||||||
|
addEditButtonToQuickbar(quickbar as HTMLElement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
quickbarObserver.observe(timetablePage, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimetable = async () => {
|
||||||
|
await waitForElm(".timetablepage .entry", true, 10, 100);
|
||||||
|
processAllEntries();
|
||||||
|
setupQuickbarObserver();
|
||||||
|
syncQuickbarFromDOM();
|
||||||
|
|
||||||
|
const timetablePage = document.querySelector(".timetablepage");
|
||||||
|
if (timetablePage && !observer) {
|
||||||
|
observer = new MutationObserver(() => {
|
||||||
|
document.querySelectorAll(".timetablepage .entry.class").forEach((entry) => {
|
||||||
|
if (!entry.hasAttribute("data-timetable-edit-processed")) {
|
||||||
|
processEntry(entry as HTMLElement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
observer.observe(timetablePage, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { unregister } = api.seqta.onMount(".timetablepage", handleTimetable);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unregister();
|
||||||
|
observer?.disconnect();
|
||||||
|
quickbarObserver?.disconnect();
|
||||||
|
styleEl.remove();
|
||||||
|
document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => {
|
||||||
|
el.removeAttribute("data-timetable-edit-processed");
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".timetable-edit-quickbar-btn").forEach((el) => el.remove());
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default timetableEditPlugin;
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/* Timetable Edit Plugin - BetterSEQTA Plus style */
|
||||||
|
|
||||||
|
/* Edit button in quickbar */
|
||||||
|
.timetable-edit-quickbar-btn {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-quickbar-btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-quickbar-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-quickbar-btn svg {
|
||||||
|
fill: currentColor;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edit modal animations */
|
||||||
|
@keyframes timetable-edit-overlay-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes timetable-edit-modal-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edit modal overlay - fix click-through with proper stacking */
|
||||||
|
.timetable-edit-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2147483647;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
pointer-events: auto;
|
||||||
|
animation: timetable-edit-overlay-in 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin: 0 1rem;
|
||||||
|
min-width: 18rem;
|
||||||
|
max-width: 24rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--background-primary);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
pointer-events: auto;
|
||||||
|
border: 1px solid var(--background-secondary);
|
||||||
|
animation: timetable-edit-modal-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.5rem 1rem 0.5rem 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: 1px solid var(--background-secondary);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--background-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal input[type="text"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--better-main, #007bff);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal-checkbox input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal-checkbox label {
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal-actions button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal-actions .timetable-edit-btn-clear {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--background-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal-actions .timetable-edit-btn-clear:hover {
|
||||||
|
background: var(--background-secondary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal-actions .timetable-edit-btn-cancel {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--background-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal-actions .timetable-edit-btn-cancel:hover {
|
||||||
|
background: var(--background-secondary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal-actions .timetable-edit-btn-save {
|
||||||
|
background: var(--better-main, #007bff);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal-actions .timetable-edit-btn-save:hover {
|
||||||
|
transform: scale(1.03) translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable-edit-modal-actions .timetable-edit-btn-save:active {
|
||||||
|
transform: scale(0.98) translateY(0);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
@@ -47,7 +47,17 @@ export function createLazyPlugin<T extends PluginSettings = PluginSettings, S =
|
|||||||
|
|
||||||
// Execute the actual plugin's run function
|
// Execute the actual plugin's run function
|
||||||
return await actualPlugin.run(api);
|
return await actualPlugin.run(api);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
// Handle Firefox MIME type errors gracefully
|
||||||
|
if (error?.message?.includes("MIME type") || error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")) {
|
||||||
|
console.error(
|
||||||
|
`[BetterSEQTA+] Failed to load plugin "${lazyPlugin.id}" due to Firefox module loading restrictions. ` +
|
||||||
|
`This may be a build configuration issue. Error:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
// Don't throw - allow the extension to continue functioning without this plugin
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error(`[BetterSEQTA+] Failed to dynamically load plugin "${lazyPlugin.id}":`, error);
|
console.error(`[BetterSEQTA+] Failed to dynamically load plugin "${lazyPlugin.id}":`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type {
|
import type {
|
||||||
BooleanSetting,
|
BooleanSetting,
|
||||||
|
ButtonSetting,
|
||||||
|
ComponentSetting,
|
||||||
|
HotkeySetting,
|
||||||
NumberSetting,
|
NumberSetting,
|
||||||
Plugin,
|
Plugin,
|
||||||
PluginSettings,
|
PluginSettings,
|
||||||
SelectSetting,
|
SelectSetting,
|
||||||
StringSetting,
|
StringSetting,
|
||||||
ButtonSetting,
|
|
||||||
HotkeySetting,
|
|
||||||
ComponentSetting,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { createPluginAPI } from "./createAPI";
|
import { createPluginAPI } from "./createAPI";
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type {
|
import type {
|
||||||
BooleanSetting,
|
BooleanSetting,
|
||||||
ButtonSetting,
|
ButtonSetting,
|
||||||
|
ComponentSetting,
|
||||||
|
HotkeySetting,
|
||||||
NumberSetting,
|
NumberSetting,
|
||||||
|
PluginSettings,
|
||||||
SelectSetting,
|
SelectSetting,
|
||||||
StringSetting,
|
StringSetting,
|
||||||
HotkeySetting,
|
|
||||||
PluginSettings,
|
|
||||||
ComponentSetting,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { PluginManager } from "./core/manager";
|
|||||||
|
|
||||||
// Lightweight plugins (load immediately)
|
// Lightweight plugins (load immediately)
|
||||||
import timetablePlugin from "./built-in/timetable";
|
import timetablePlugin from "./built-in/timetable";
|
||||||
|
import timetableEditPlugin from "./built-in/timetableEdit";
|
||||||
import notificationCollectorPlugin from "./built-in/notificationCollector";
|
import notificationCollectorPlugin from "./built-in/notificationCollector";
|
||||||
import themesPlugin from "./built-in/themes";
|
import themesPlugin from "./built-in/themes";
|
||||||
import animatedBackgroundPlugin from "./built-in/animatedBackground";
|
import animatedBackgroundPlugin from "./built-in/animatedBackground";
|
||||||
import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
|
import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
|
||||||
import profilePicturePlugin from "./built-in/profilePicture";
|
import profilePicturePlugin from "./built-in/profilePicture";
|
||||||
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
|
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
|
||||||
|
import backgroundMusicPlugin from "./built-in/backgroundMusic";
|
||||||
//import testPlugin from './built-in/test';
|
//import testPlugin from './built-in/test';
|
||||||
|
|
||||||
// Heavy plugins (lazy-loaded only when enabled)
|
// Heavy plugins (lazy-loaded only when enabled)
|
||||||
@@ -22,8 +24,10 @@ pluginManager.registerPlugin(animatedBackgroundPlugin);
|
|||||||
pluginManager.registerPlugin(assessmentsAveragePlugin);
|
pluginManager.registerPlugin(assessmentsAveragePlugin);
|
||||||
pluginManager.registerPlugin(notificationCollectorPlugin);
|
pluginManager.registerPlugin(notificationCollectorPlugin);
|
||||||
pluginManager.registerPlugin(timetablePlugin);
|
pluginManager.registerPlugin(timetablePlugin);
|
||||||
|
pluginManager.registerPlugin(timetableEditPlugin);
|
||||||
pluginManager.registerPlugin(profilePicturePlugin);
|
pluginManager.registerPlugin(profilePicturePlugin);
|
||||||
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
||||||
|
pluginManager.registerPlugin(backgroundMusicPlugin);
|
||||||
//pluginManager.registerPlugin(testPlugin);
|
//pluginManager.registerPlugin(testPlugin);
|
||||||
|
|
||||||
// Register heavy plugins with lazy loading
|
// Register heavy plugins with lazy loading
|
||||||
|
|||||||
+180
-47
@@ -17,18 +17,17 @@ import { StorageChangeHandler } from "@/seqta/utils/listeners/StorageChanges";
|
|||||||
import { eventManager } from "@/seqta/utils/listeners/EventManager";
|
import { eventManager } from "@/seqta/utils/listeners/EventManager";
|
||||||
|
|
||||||
// UI and theme management
|
// UI and theme management
|
||||||
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
import RegisterClickListeners from "@/seqta/utils/listeners/ClickListeners";
|
import RegisterClickListeners from "@/seqta/utils/listeners/ClickListeners";
|
||||||
import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements";
|
import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements";
|
||||||
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
||||||
import loading from "@/seqta/ui/Loading";
|
import loading from "@/seqta/ui/Loading";
|
||||||
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
|
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
|
||||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||||
import { OpenWhatsNewPopup } from "@/seqta/utils/Whatsnew";
|
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
|
||||||
//import { OpenMinecraftServerPopup } from "@/seqta/utils/AboutMinecraftServer";
|
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification";
|
||||||
|
|
||||||
import {
|
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
||||||
updateTimetableTimes,
|
|
||||||
} from "@/seqta/utils/updateTimetableTimes";
|
|
||||||
|
|
||||||
// JSON content
|
// JSON content
|
||||||
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
|
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
|
||||||
@@ -84,7 +83,12 @@ export function hideSideBar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let betterSeqtaFinishLoadDone = false;
|
||||||
|
|
||||||
export async function finishLoad() {
|
export async function finishLoad() {
|
||||||
|
if (betterSeqtaFinishLoadDone) return;
|
||||||
|
betterSeqtaFinishLoadDone = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
document.querySelector(".legacy-root")?.classList.remove("hidden");
|
document.querySelector(".legacy-root")?.classList.remove("hidden");
|
||||||
|
|
||||||
@@ -96,7 +100,12 @@ export async function finishLoad() {
|
|||||||
console.error("Error during loading cleanup:", err);
|
console.error("Error during loading cleanup:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settingsState.justupdated && !document.getElementById("whatsnewbk")) {
|
// Check and show privacy statement notification (before what's new)
|
||||||
|
if (!document.getElementById("privacy-notification")) {
|
||||||
|
await showPrivacyNotification();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsState.justupdated && !document.getElementById("whatsnewbk") && !document.getElementById("privacy-notification")) {
|
||||||
OpenWhatsNewPopup();
|
OpenWhatsNewPopup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,19 +121,19 @@ export function GetCSSElement(file: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeThemeTagsFromNotices() {
|
function removeThemeTagsFromNotices() {
|
||||||
// Grabs an array of the notice iFrames
|
|
||||||
const userHTMLArray = document.getElementsByClassName("userHTML");
|
const userHTMLArray = document.getElementsByClassName("userHTML");
|
||||||
// Iterates through the array, applying the iFrame css
|
|
||||||
for (const item of userHTMLArray) {
|
for (const item of userHTMLArray) {
|
||||||
// Grabs the HTML of the body tag
|
const iframe = item as HTMLIFrameElement;
|
||||||
const item1 = item as HTMLIFrameElement;
|
try {
|
||||||
const body = item1.contentWindow!.document.querySelectorAll("body")[0];
|
const doc = iframe.contentDocument;
|
||||||
if (body) {
|
if (!doc?.body) continue;
|
||||||
// Replaces the theme tag with nothing
|
const body = doc.body;
|
||||||
const bodyText = body.innerHTML;
|
const bodyText = body.innerHTML;
|
||||||
body.innerHTML = bodyText
|
body.innerHTML = bodyText
|
||||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||||
.replace(/ +/, " ");
|
.replace(/ +/, " ");
|
||||||
|
} catch {
|
||||||
|
// Cross-origin or otherwise inaccessible iframe (common during Engage load / filter frames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,6 +302,11 @@ async function handleNotices(node: Element): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSublink(sublink: string | undefined): Promise<void> {
|
async function handleSublink(sublink: string | undefined): Promise<void> {
|
||||||
|
if (isSeqtaEngageExperience()) {
|
||||||
|
finishLoad();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (sublink) {
|
switch (sublink) {
|
||||||
case "news":
|
case "news":
|
||||||
await handleNewsPage();
|
await handleNewsPage();
|
||||||
@@ -379,15 +393,22 @@ async function handleDashboard(node: Element): Promise<void> {
|
|||||||
document.head.append(style);
|
document.head.append(style);
|
||||||
|
|
||||||
await waitForElm(".dashlet", true, 10);
|
await waitForElm(".dashlet", true, 10);
|
||||||
animate(
|
try {
|
||||||
".dashboard > *",
|
const children = document.querySelectorAll(".dashboard > *");
|
||||||
{ opacity: [0, 1], y: [10, 0] },
|
if (children.length) {
|
||||||
{
|
animate(
|
||||||
delay: stagger(0.1),
|
children,
|
||||||
duration: 0.5,
|
{ opacity: [0, 1], y: [10, 0] },
|
||||||
ease: [0.22, 0.03, 0.26, 1],
|
{
|
||||||
},
|
delay: stagger(0.1),
|
||||||
);
|
duration: 0.5,
|
||||||
|
ease: [0.22, 0.03, 0.26, 1],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Avoid uncaught errors if motion hits an unexpected DOM state during load.
|
||||||
|
}
|
||||||
|
|
||||||
document.head.querySelector("style.dashboardHider")?.remove();
|
document.head.querySelector("style.dashboardHider")?.remove();
|
||||||
}
|
}
|
||||||
@@ -397,15 +418,22 @@ async function handleDocuments(node: Element): Promise<void> {
|
|||||||
if (!settingsState.animations) return;
|
if (!settingsState.animations) return;
|
||||||
|
|
||||||
await waitForElm(".document", true, 10);
|
await waitForElm(".document", true, 10);
|
||||||
animate(
|
try {
|
||||||
".documents tbody tr.document",
|
const rows = document.querySelectorAll(".documents tbody tr.document");
|
||||||
{ opacity: [0, 1], y: [10, 0] },
|
if (rows.length) {
|
||||||
{
|
animate(
|
||||||
delay: stagger(0.05),
|
rows,
|
||||||
duration: 0.5,
|
{ opacity: [0, 1], y: [10, 0] },
|
||||||
ease: [0.22, 0.03, 0.26, 1],
|
{
|
||||||
},
|
delay: stagger(0.05),
|
||||||
);
|
duration: 0.5,
|
||||||
|
ease: [0.22, 0.03, 0.26, 1],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleReports(node: Element): Promise<void> {
|
async function handleReports(node: Element): Promise<void> {
|
||||||
@@ -413,15 +441,22 @@ async function handleReports(node: Element): Promise<void> {
|
|||||||
if (!settingsState.animations) return;
|
if (!settingsState.animations) return;
|
||||||
|
|
||||||
await waitForElm(".report", true, 10);
|
await waitForElm(".report", true, 10);
|
||||||
animate(
|
try {
|
||||||
".reports .item",
|
const items = document.querySelectorAll(".reports .item");
|
||||||
{ opacity: [0, 1], y: [10, 0] },
|
if (items.length) {
|
||||||
{
|
animate(
|
||||||
delay: stagger(0.05, { startDelay: 0.2 }),
|
items,
|
||||||
duration: 0.5,
|
{ opacity: [0, 1], y: [10, 0] },
|
||||||
ease: [0.22, 0.03, 0.26, 1],
|
{
|
||||||
},
|
delay: stagger(0.05, { startDelay: 0.2 }),
|
||||||
);
|
duration: 0.5,
|
||||||
|
ease: [0.22, 0.03, 0.26, 1],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function CheckNoticeTextColour(notice: any) {
|
function CheckNoticeTextColour(notice: any) {
|
||||||
@@ -446,6 +481,26 @@ function CheckNoticeTextColour(notice: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function tryLoad() {
|
export function tryLoad() {
|
||||||
|
if (isSeqtaEngageExperience()) {
|
||||||
|
updateIframesWithDarkMode();
|
||||||
|
window.addEventListener(
|
||||||
|
"load",
|
||||||
|
() => removeThemeTagsFromNotices(),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
window.addEventListener(
|
||||||
|
"load",
|
||||||
|
() => void finishLoad(),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
waitForElm(".login").then(() => void finishLoad());
|
||||||
|
waitForElm(".day-container").then(() => void finishLoad());
|
||||||
|
waitForElm(".code", true, 50).then((elm: any) => {
|
||||||
|
if (!elm.innerText.includes("BetterSEQTA")) void LoadPageElements();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
waitForElm(".login").then(() => {
|
waitForElm(".login").then(() => {
|
||||||
finishLoad();
|
finishLoad();
|
||||||
});
|
});
|
||||||
@@ -463,13 +518,10 @@ export function tryLoad() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
updateIframesWithDarkMode();
|
updateIframesWithDarkMode();
|
||||||
// Waits for page to call on load, run scripts
|
window.addEventListener(
|
||||||
document.addEventListener(
|
|
||||||
"load",
|
"load",
|
||||||
function () {
|
() => removeThemeTagsFromNotices(),
|
||||||
removeThemeTagsFromNotices();
|
{ once: true },
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +538,7 @@ function ReplaceMenuSVG(element: HTMLElement, svg: string) {
|
|||||||
const processedSymbol = Symbol("processed");
|
const processedSymbol = Symbol("processed");
|
||||||
|
|
||||||
export async function ObserveMenuItemPosition() {
|
export async function ObserveMenuItemPosition() {
|
||||||
|
if (isSeqtaEngageExperience()) return;
|
||||||
await waitForElm("#menu > ul > li");
|
await waitForElm("#menu > ul > li");
|
||||||
|
|
||||||
eventManager.register(
|
eventManager.register(
|
||||||
@@ -609,6 +662,15 @@ export function init() {
|
|||||||
if (settingsState.onoff) {
|
if (settingsState.onoff) {
|
||||||
console.info("[BetterSEQTA+] Enabled");
|
console.info("[BetterSEQTA+] Enabled");
|
||||||
if (settingsState.DarkMode) document.documentElement.classList.add("dark");
|
if (settingsState.DarkMode) document.documentElement.classList.add("dark");
|
||||||
|
if (settingsState.iconOnlySidebar) {
|
||||||
|
if (document.body) {
|
||||||
|
document.body.classList.add("icon-only-sidebar");
|
||||||
|
} else {
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
document.body?.classList.add("icon-only-sidebar");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelector(".legacy-root")?.classList.add("hidden");
|
document.querySelector(".legacy-root")?.classList.add("hidden");
|
||||||
ObserveMenuItemPosition();
|
ObserveMenuItemPosition();
|
||||||
@@ -616,12 +678,83 @@ export function init() {
|
|||||||
new StorageChangeHandler();
|
new StorageChangeHandler();
|
||||||
new MessageHandler();
|
new MessageHandler();
|
||||||
|
|
||||||
updateAllColors();
|
void updateAllColors();
|
||||||
|
|
||||||
|
window.addEventListener("hashchange", () => {
|
||||||
|
if (settingsState.adaptiveThemeColour) void updateAllColors();
|
||||||
|
});
|
||||||
loading();
|
loading();
|
||||||
InjectCustomIcons();
|
InjectCustomIcons();
|
||||||
HideMenuItems();
|
HideMenuItems();
|
||||||
tryLoad();
|
tryLoad();
|
||||||
|
|
||||||
|
// Auto-focus WISP direct online submission editor when pane opens
|
||||||
|
eventManager.register(
|
||||||
|
"wispassessmentAdded",
|
||||||
|
{
|
||||||
|
customCheck: (el) =>
|
||||||
|
el.classList.contains("wispassessment") ||
|
||||||
|
el.querySelector(".wispassessment") !== null,
|
||||||
|
},
|
||||||
|
(element) => {
|
||||||
|
const wispassessment = element.classList.contains("wispassessment")
|
||||||
|
? (element as Element)
|
||||||
|
: element.querySelector(".wispassessment");
|
||||||
|
if (!wispassessment) return;
|
||||||
|
|
||||||
|
const focusEditableBody = (iframe: HTMLIFrameElement) => {
|
||||||
|
try {
|
||||||
|
const doc = iframe.contentDocument;
|
||||||
|
const win = iframe.contentWindow;
|
||||||
|
if (doc?.body && win) {
|
||||||
|
const editable =
|
||||||
|
doc.body.querySelector(".cke_editable") || doc.body;
|
||||||
|
const el = editable as HTMLElement;
|
||||||
|
el.focus();
|
||||||
|
const range = doc.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
range.collapse(true);
|
||||||
|
const sel = win.getSelection();
|
||||||
|
if (sel) {
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusEditor = () => {
|
||||||
|
const iframe = wispassessment.querySelector(".cke_wysiwyg_frame");
|
||||||
|
if (iframe instanceof HTMLIFrameElement) {
|
||||||
|
if (focusEditableBody(iframe)) return;
|
||||||
|
iframe.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ckeditor = (window as any).CKEDITOR;
|
||||||
|
if (ckeditor?.instances?.editor1) {
|
||||||
|
try {
|
||||||
|
ckeditor.instances.editor1.focus();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const iframe = wispassessment.querySelector(".cke_wysiwyg_frame");
|
||||||
|
if (iframe instanceof HTMLIFrameElement) {
|
||||||
|
iframe.addEventListener(
|
||||||
|
"load",
|
||||||
|
() => focusEditableBody(iframe),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[1000, 1200, 1500].forEach((delay) =>
|
||||||
|
setTimeout(focusEditor, delay),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const legacyElement = document.querySelector(
|
const legacyElement = document.querySelector(
|
||||||
".outside-container .bottom-container",
|
".outside-container .bottom-container",
|
||||||
|
|||||||
Binary file not shown.
+2
-2
@@ -16,9 +16,9 @@ export async function main() {
|
|||||||
if (settingsState.onoff) {
|
if (settingsState.onoff) {
|
||||||
injectPageState();
|
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
|
// Rather permanent 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") {
|
if (import.meta.env.MODE === "development") {
|
||||||
import("../css/injected.scss");
|
import("@/css/injected.scss");
|
||||||
} else {
|
} else {
|
||||||
const injectedStyle = document.createElement("style");
|
const injectedStyle = document.createElement("style");
|
||||||
injectedStyle.textContent = injectedCSS;
|
injectedStyle.textContent = injectedCSS;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings";
|
import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings";
|
||||||
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||||
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
|
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
|
||||||
import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton";
|
import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton";
|
||||||
@@ -13,8 +14,11 @@ import { delay } from "@/seqta/utils/delay";
|
|||||||
let cachedUserInfo: any = null;
|
let cachedUserInfo: any = null;
|
||||||
|
|
||||||
let LightDarkModeSnakeEggButton = 0;
|
let LightDarkModeSnakeEggButton = 0;
|
||||||
|
let sidebarAccessibilityObserver: MutationObserver | null = null;
|
||||||
|
let sidebarTabOrderAnimationFrame: number | null = null;
|
||||||
|
let sidebarAccessibilityListenersAttached = false;
|
||||||
|
|
||||||
async function getUserInfo() {
|
export async function getUserInfo() {
|
||||||
if (cachedUserInfo) return cachedUserInfo;
|
if (cachedUserInfo) return cachedUserInfo;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -30,16 +34,20 @@ async function getUserInfo() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const responseData = await response.json();
|
cachedUserInfo = (await response.json()).payload;
|
||||||
cachedUserInfo = responseData.payload;
|
|
||||||
return cachedUserInfo;
|
return cachedUserInfo;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching user info:", error);
|
console.error("[BetterSEQTA+] Failed to get user info:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function AddBetterSEQTAElements() {
|
export async function AddBetterSEQTAElements() {
|
||||||
|
if (isSeqtaEngageExperience()) {
|
||||||
|
addExtensionSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (settingsState.onoff) {
|
if (settingsState.onoff) {
|
||||||
if (settingsState.DarkMode) {
|
if (settingsState.DarkMode) {
|
||||||
document.documentElement.classList.add("dark");
|
document.documentElement.classList.add("dark");
|
||||||
@@ -61,12 +69,13 @@ export async function AddBetterSEQTAElements() {
|
|||||||
handleStudentData(),
|
handleStudentData(),
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error initializing UI elements:", error);
|
console.error("[BetterSEQTA+] Failed to initialize UI elements:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
await addDarkLightToggle();
|
await addDarkLightToggle();
|
||||||
customizeMenuToggle();
|
customizeMenuToggle();
|
||||||
|
setupSidebarAccessibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
addExtensionSettings();
|
addExtensionSettings();
|
||||||
@@ -80,20 +89,18 @@ function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
|
|||||||
div.classList.add("titlebar");
|
div.classList.add("titlebar");
|
||||||
container.append(div);
|
container.append(div);
|
||||||
|
|
||||||
const NewButton = stringToHTML(
|
fragment.appendChild(
|
||||||
/* html */`<li class="item" data-key="home" id="homebutton" data-path="/home" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" /></svg><span>Home</span></label></li>`
|
stringToHTML(
|
||||||
|
/* html */ `<li class="item" data-key="home" id="homebutton" data-path="/home" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" /></svg><span>Home</span></label></li>`,
|
||||||
|
).firstChild!,
|
||||||
);
|
);
|
||||||
if (NewButton.firstChild) {
|
|
||||||
fragment.appendChild(NewButton.firstChild);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUserInfo() {
|
async function handleUserInfo() {
|
||||||
try {
|
try {
|
||||||
const info = await getUserInfo();
|
updateUserInfo(await getUserInfo());
|
||||||
updateUserInfo(info);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching and processing student data:", error);
|
console.error("[BetterSEQTA+] Failed to handle user info:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,31 +123,37 @@ function updateUserInfo(info: {
|
|||||||
userName: string | null;
|
userName: string | null;
|
||||||
}) {
|
}) {
|
||||||
const titlebar = document.getElementsByClassName("titlebar")[0];
|
const titlebar = document.getElementsByClassName("titlebar")[0];
|
||||||
|
const metadata = [info.meta.code, info.meta.governmentID]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.join(" // ");
|
||||||
|
const displayName = info.userDesc || info.userName || "";
|
||||||
|
|
||||||
const userInfo = stringToHTML(/* html */ `
|
titlebar.append(
|
||||||
<div class="userInfosvgdiv tooltip">
|
stringToHTML(/* html */ `
|
||||||
<svg class="userInfosvg" viewBox="0 0 24 24"><path fill="var(--text-primary)" d="M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"></path></svg>
|
<div class="userInfosvgdiv tooltip">
|
||||||
<div class="tooltiptext topmenutooltip" id="logouttooltip"></div>
|
<svg class="userInfosvg" viewBox="0 0 24 24"><path fill="var(--text-primary)" d="M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"></path></svg>
|
||||||
</div>
|
<div class="tooltiptext topmenutooltip" id="logouttooltip"></div>
|
||||||
`).firstChild;
|
|
||||||
titlebar.append(userInfo!);
|
|
||||||
|
|
||||||
const userinfo = stringToHTML(/* html */ `
|
|
||||||
<div class="userInfo">
|
|
||||||
<div class="userInfoText">
|
|
||||||
<div style="display: flex; align-items: center;">
|
|
||||||
<p class="userInfohouse userInfoCode"></p>
|
|
||||||
<p class="userInfoName">${info.userDesc}</p>
|
|
||||||
</div>
|
|
||||||
<p class="userInfoCode">${info.meta.code} // ${info.meta.governmentID}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
`).firstChild!,
|
||||||
`).firstChild;
|
);
|
||||||
titlebar.append(userinfo!);
|
|
||||||
|
|
||||||
var logoutbutton = document.getElementsByClassName("logout")[0];
|
titlebar.append(
|
||||||
var userInfosvgdiv = document.getElementById("logouttooltip")!;
|
stringToHTML(/* html */ `
|
||||||
userInfosvgdiv.appendChild(logoutbutton);
|
<div class="userInfo">
|
||||||
|
<div class="userInfoText">
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<p class="userInfohouse userInfoCode" style="display: none;"></p>
|
||||||
|
${displayName ? `<p class="userInfoName">${displayName}</p>` : ""}
|
||||||
|
</div>
|
||||||
|
${metadata ? `<p class="userInfoCode">${metadata}</p>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).firstChild!,
|
||||||
|
);
|
||||||
|
|
||||||
|
document
|
||||||
|
.getElementById("logouttooltip")!
|
||||||
|
.appendChild(document.getElementsByClassName("logout")[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStudentData() {
|
async function handleStudentData() {
|
||||||
@@ -156,57 +169,58 @@ async function handleStudentData() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const responseData = await response.json();
|
await updateStudentInfo((await response.json()).payload);
|
||||||
let students = responseData.payload;
|
|
||||||
await updateStudentInfo(students);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching and processing student data:", error);
|
console.error("[BetterSEQTA+] Failed to handle student data:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateStudentInfo(students: any) {
|
async function updateStudentInfo(students: any) {
|
||||||
const info = await getUserInfo();
|
const info = await getUserInfo();
|
||||||
var index = students.findIndex(function (person: any) {
|
const index = students.findIndex(
|
||||||
return (
|
(person: any) =>
|
||||||
person.firstname == info.userDesc.split(" ")[0] &&
|
person.firstname == info.userDesc.split(" ")[0] &&
|
||||||
person.surname == info.userDesc.split(" ")[1]
|
person.surname == info.userDesc.split(" ")[1],
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
let houseelement1 = document.getElementsByClassName("userInfohouse")[0];
|
const houseelement = document.getElementsByClassName(
|
||||||
const houseelement = houseelement1 as HTMLElement;
|
"userInfohouse",
|
||||||
|
)[0] as HTMLElement | undefined;
|
||||||
|
|
||||||
if (students[index]?.house) {
|
if (!houseelement) return;
|
||||||
if (students[index]?.house_colour) {
|
|
||||||
houseelement.style.background = students[index].house_colour;
|
const student = students[index] ?? {};
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
if (student.house) {
|
||||||
|
text = `${student.year ?? ""}${student.house}`;
|
||||||
|
|
||||||
|
if (student.house_colour) {
|
||||||
|
houseelement.style.background = student.house_colour;
|
||||||
try {
|
try {
|
||||||
let colorresult = GetThresholdOfColor(students[index]?.house_colour);
|
const colorresult = GetThresholdOfColor(student.house_colour);
|
||||||
houseelement.style.color =
|
houseelement.style.color =
|
||||||
colorresult && colorresult > 300 ? "black" : "white";
|
colorresult && colorresult > 300 ? "black" : "white";
|
||||||
houseelement.innerText = students[index].year + students[index].house;
|
} catch {
|
||||||
} catch (error) {
|
// Invalid color format, leave text color as default
|
||||||
houseelement.innerText = students[index].house;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (student.year) {
|
||||||
try {
|
text = student.year;
|
||||||
houseelement.innerText = students[index].year;
|
|
||||||
} catch (err) {
|
|
||||||
houseelement.innerText = "N/A";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
houseelement.innerText = text;
|
||||||
|
houseelement.style.display = text ? "block" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNewsButton(fragment: DocumentFragment, menu: HTMLElement) {
|
function createNewsButton(fragment: DocumentFragment, menu: HTMLElement) {
|
||||||
const NewsButtonStr =
|
fragment.appendChild(
|
||||||
'<li class="item" data-key="news" id="newsbutton" data-path="/news" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M20 3H4C2.89 3 2 3.89 2 5V19C2 20.11 2.89 21 4 21H20C21.11 21 22 20.11 22 19V5C22 3.89 21.11 3 20 3M5 7H10V13H5V7M19 17H5V15H19V17M19 13H12V11H19V13M19 9H12V7H19V9Z" /></svg><span>News</span></label></li>';
|
stringToHTML(
|
||||||
const NewsButton = stringToHTML(NewsButtonStr);
|
'<li class="item" data-key="news" id="newsbutton" data-path="/news" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M20 3H4C2.89 3 2 3.89 2 5V19C2 20.11 2.89 21 4 21H20C21.11 21 22 20.11 22 19V5C22 3.89 21.11 3 20 3M5 7H10V13H5V7M19 17H5V15H19V17M19 13H12V11H19V13M19 9H12V7H19V9Z" /></svg><span>News</span></label></li>',
|
||||||
|
).firstChild!,
|
||||||
|
);
|
||||||
|
|
||||||
if (NewsButton.firstChild) {
|
const iconCover = document.createElement("div");
|
||||||
fragment.appendChild(NewsButton.firstChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
let iconCover = document.createElement("div");
|
|
||||||
iconCover.classList.add("icon-cover");
|
iconCover.classList.add("icon-cover");
|
||||||
iconCover.id = "icon-cover";
|
iconCover.id = "icon-cover";
|
||||||
menu.appendChild(iconCover);
|
menu.appendChild(iconCover);
|
||||||
@@ -217,22 +231,27 @@ function setupEventListeners() {
|
|||||||
const homebutton = document.getElementById("homebutton");
|
const homebutton = document.getElementById("homebutton");
|
||||||
const newsbutton = document.getElementById("newsbutton");
|
const newsbutton = document.getElementById("newsbutton");
|
||||||
|
|
||||||
homebutton?.addEventListener("click", function () {
|
const activateMenuAction = (button: HTMLElement, action: () => void) => {
|
||||||
if (
|
if (
|
||||||
!homebutton.classList.contains("draggable") &&
|
button.classList.contains("draggable") ||
|
||||||
!homebutton.classList.contains("active")
|
button.classList.contains("active")
|
||||||
) {
|
) {
|
||||||
loadHomePage();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
|
||||||
|
homebutton?.addEventListener("click", function () {
|
||||||
|
activateMenuAction(homebutton, () => {
|
||||||
|
loadHomePage();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
newsbutton?.addEventListener("click", function () {
|
newsbutton?.addEventListener("click", function () {
|
||||||
if (
|
activateMenuAction(newsbutton, () => {
|
||||||
!newsbutton.classList.contains("draggable") &&
|
|
||||||
!newsbutton.classList.contains("active")
|
|
||||||
) {
|
|
||||||
SendNewsPage();
|
SendNewsPage();
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
menuCover?.addEventListener("click", function () {
|
menuCover?.addEventListener("click", function () {
|
||||||
@@ -245,46 +264,42 @@ function setupEventListeners() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createSettingsButton() {
|
async function createSettingsButton() {
|
||||||
let SettingsButton = stringToHTML(/* html */ `
|
document.getElementById("content")!.append(
|
||||||
<button class="addedButton tooltip" id="AddedSettings">
|
stringToHTML(/* html */ `
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
<button class="addedButton tooltip" id="AddedSettings">
|
||||||
<g><g><path d="M23.182,6.923c-.29,0-3.662,2.122-4.142,2.4l-2.8-1.555V4.511l4.257-2.456a.518.518,0,0,0,.233-.408.479.479,0,0,0-.233-.407,6.511,6.511,0,1,0-3.327,12.107,6.582,6.582,0,0,0,6.148-4.374,5.228,5.228,0,0,0,.333-1.542A.461.461,0,0,0,23.182,6.923Z"></path><path d="M9.73,10.418,7.376,12.883c-.01.01-.021.016-.03.025L1.158,19.1a2.682,2.682,0,1,0,3.793,3.793l4.583-4.582,0,0,4.1-4.005-.037-.037A9.094,9.094,0,0,1,9.73,10.418ZM3.053,21.888A.894.894,0,1,1,3.946,21,.893.893,0,0,1,3.053,21.888Z"></path></g></g>
|
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||||
</svg>
|
<g><g><path d="M23.182,6.923c-.29,0-3.662,2.122-4.142,2.4l-2.8-1.555V4.511l4.257-2.456a.518.518,0,0,0,.233-.408.479.479,0,0,0-.233-.407,6.511,6.511,0,1,0-3.327,12.107,6.582,6.582,0,0,0,6.148-4.374,5.228,5.228,0,0,0,.333-1.542A.461.461,0,0,0,23.182,6.923Z"></path><path d="M9.73,10.418,7.376,12.883c-.01.01-.021.016-.03.025L1.158,19.1a2.682,2.682,0,1,0,3.793,3.793l4.583-4.582,0,0,4.1-4.005-.037-.037A9.094,9.094,0,0,1,9.73,10.418ZM3.053,21.888A.894.894,0,1,1,3.946,21,.893.893,0,0,1,3.053,21.888Z"></path></g></g>
|
||||||
${settingsState.onoff ? '<div class="tooltiptext topmenutooltip">BetterSEQTA+ Settings</div>' : ""}
|
</svg>
|
||||||
</button>
|
${settingsState.onoff ? '<div class="tooltiptext topmenutooltip">BetterSEQTA+ Settings</div>' : ""}
|
||||||
`);
|
</button>
|
||||||
let ContentDiv = document.getElementById("content");
|
`).firstChild!,
|
||||||
ContentDiv!.append(SettingsButton.firstChild!);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GetLightDarkModeString() {
|
function GetLightDarkModeString() {
|
||||||
if (settingsState.DarkMode) {
|
return settingsState.DarkMode
|
||||||
return "Switch to light theme";
|
? "Switch to light theme"
|
||||||
} else {
|
: "Switch to dark theme";
|
||||||
return "Switch to dark theme";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addDarkLightToggle() {
|
async function addDarkLightToggle() {
|
||||||
const tooltipString = GetLightDarkModeString();
|
|
||||||
const SUN_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_80"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_80)"><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -4,-2.2100000381469727 -4,0 C-4,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
|
const SUN_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_80"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_80)"><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -4,-2.2100000381469727 -4,0 C-4,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
|
||||||
const MOON_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_263"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_263)"><g style="display: block;" transform="matrix(1.5,0,0,1.5,7,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -1.2920000553131104,-2.2100000381469727 -1.2920000553131104,0 C-1.2920000553131104,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(-1,0,0,-1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
|
const MOON_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_263"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_263)"><g style="display: block;" transform="matrix(1.5,0,0,1.5,7,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -1.2920000553131104,-2.2100000381469727 -1.2920000553131104,0 C-1.2920000553131104,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(-1,0,0,-1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
|
||||||
|
|
||||||
const initialSvgContent = settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG;
|
|
||||||
|
|
||||||
const LightDarkModeButton = stringToHTML(/* html */ `
|
document.getElementById("content")!.append(
|
||||||
<button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton">
|
stringToHTML(/* html */ `
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">${initialSvgContent}</svg>
|
<button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton">
|
||||||
<div class="tooltiptext topmenutooltip" id="darklighttooliptext">${tooltipString}</div>
|
<svg xmlns="http://www.w3.org/2000/svg">${settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG}</svg>
|
||||||
</button>
|
<div class="tooltiptext topmenutooltip" id="darklighttooliptext">${GetLightDarkModeString()}</div>
|
||||||
`);
|
</button>
|
||||||
|
`).firstChild!,
|
||||||
let ContentDiv = document.getElementById("content");
|
);
|
||||||
ContentDiv!.append(LightDarkModeButton.firstChild!);
|
|
||||||
|
|
||||||
updateAllColors();
|
updateAllColors();
|
||||||
|
|
||||||
const lightDarkModeButtonElement = document.getElementById("LightDarkModeButton")!;
|
const lightDarkModeButtonElement = document.getElementById(
|
||||||
|
"LightDarkModeButton",
|
||||||
|
)!;
|
||||||
|
|
||||||
lightDarkModeButtonElement.addEventListener("click", async () => {
|
lightDarkModeButtonElement.addEventListener("click", async () => {
|
||||||
const darklightText = document.getElementById("darklighttooliptext");
|
const darklightText = document.getElementById("darklighttooliptext");
|
||||||
@@ -296,7 +311,6 @@ async function addDarkLightToggle() {
|
|||||||
LightDarkModeSnakeEggButton = 0;
|
LightDarkModeSnakeEggButton = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
settingsState.originalDarkMode !== undefined &&
|
settingsState.originalDarkMode !== undefined &&
|
||||||
settingsState.selectedTheme
|
settingsState.selectedTheme
|
||||||
@@ -307,38 +321,237 @@ async function addDarkLightToggle() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!document.startViewTransition || !settingsState.animations || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
||||||
settingsState.DarkMode = !settingsState.DarkMode;
|
|
||||||
updateAllColors();
|
|
||||||
|
|
||||||
const newSvgContent = settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG;
|
|
||||||
const svgElement = lightDarkModeButtonElement.querySelector("svg");
|
|
||||||
if (svgElement) svgElement.innerHTML = newSvgContent;
|
|
||||||
darklightText!.innerText = GetLightDarkModeString();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsState.DarkMode = !settingsState.DarkMode;
|
settingsState.DarkMode = !settingsState.DarkMode;
|
||||||
|
updateAllColors();
|
||||||
updateAllColors();
|
|
||||||
|
const svgElement = lightDarkModeButtonElement.querySelector("svg")!;
|
||||||
const newSvgContent = settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG;
|
svgElement.innerHTML = settingsState.DarkMode
|
||||||
const svgElement = lightDarkModeButtonElement.querySelector("svg");
|
? SUN_ICON_SVG
|
||||||
if (svgElement) svgElement.innerHTML = newSvgContent;
|
: MOON_ICON_SVG;
|
||||||
|
|
||||||
darklightText!.innerText = GetLightDarkModeString();
|
darklightText!.innerText = GetLightDarkModeString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function customizeMenuToggle() {
|
function customizeMenuToggle() {
|
||||||
const menuToggle = document.getElementById("menuToggle");
|
const menuToggle = document.getElementById("menuToggle")!;
|
||||||
if (menuToggle) {
|
menuToggle.innerHTML = "";
|
||||||
menuToggle.innerHTML = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const line = document.createElement("div");
|
const line = document.createElement("div");
|
||||||
line.className = "hamburger-line";
|
line.className = "hamburger-line";
|
||||||
menuToggle!.appendChild(line);
|
menuToggle.appendChild(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupSidebarAccessibility() {
|
||||||
|
updateSidebarAccessibility();
|
||||||
|
|
||||||
|
const menu = document.getElementById("menu");
|
||||||
|
if (!menu) return;
|
||||||
|
|
||||||
|
sidebarAccessibilityObserver?.disconnect();
|
||||||
|
sidebarAccessibilityObserver = new MutationObserver(() => {
|
||||||
|
scheduleSidebarAccessibilityUpdate();
|
||||||
|
});
|
||||||
|
sidebarAccessibilityObserver.observe(menu, {
|
||||||
|
subtree: true,
|
||||||
|
childList: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class", "style"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sidebarAccessibilityListenersAttached) {
|
||||||
|
document.addEventListener("keydown", handleSidebarKeyboardActivation);
|
||||||
|
sidebarAccessibilityListenersAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSidebarAccessibilityUpdate() {
|
||||||
|
if (sidebarTabOrderAnimationFrame !== null) {
|
||||||
|
cancelAnimationFrame(sidebarTabOrderAnimationFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarTabOrderAnimationFrame = requestAnimationFrame(() => {
|
||||||
|
sidebarTabOrderAnimationFrame = null;
|
||||||
|
updateSidebarAccessibility();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSidebarKeyboardActivation(event: KeyboardEvent) {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const menuItem = target.closest("#menu li, #menu section") as
|
||||||
|
| HTMLElement
|
||||||
|
| null;
|
||||||
|
if (!menuItem || target !== menuItem) return;
|
||||||
|
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const menu = document.getElementById("menu");
|
||||||
|
if (!menu) return;
|
||||||
|
|
||||||
|
const visibleList = getVisibleSidebarList(menu);
|
||||||
|
if (!visibleList) return;
|
||||||
|
|
||||||
|
const visibleEntries = getDirectSidebarEntries(visibleList);
|
||||||
|
if (visibleEntries.length === 0) return;
|
||||||
|
|
||||||
|
const boundaryEntry = event.shiftKey
|
||||||
|
? visibleEntries[0]
|
||||||
|
: visibleEntries[visibleEntries.length - 1];
|
||||||
|
|
||||||
|
if (boundaryEntry !== menuItem) return;
|
||||||
|
|
||||||
|
const parentEntry = getSidebarListParentEntry(visibleList);
|
||||||
|
if (!parentEntry) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
parentEntry.classList.remove("active");
|
||||||
|
scheduleSidebarAccessibilityUpdate();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
parentEntry.focus();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const childSubmenu = menuItem.querySelector(":scope > .sub > ul") as
|
||||||
|
| HTMLElement
|
||||||
|
| null;
|
||||||
|
|
||||||
|
menuItem.click();
|
||||||
|
scheduleSidebarAccessibilityUpdate();
|
||||||
|
|
||||||
|
if (childSubmenu) {
|
||||||
|
focusFirstSidebarSubmenuEntry(menuItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSidebarAccessibility() {
|
||||||
|
const menu = document.getElementById("menu");
|
||||||
|
if (!menu) return;
|
||||||
|
|
||||||
|
const visibleEntries = new Set(getVisibleSidebarEntries(menu));
|
||||||
|
const menuEntries = menu.querySelectorAll("li.item, section.item, li, section");
|
||||||
|
|
||||||
|
for (const entry of menuEntries) {
|
||||||
|
if (!(entry instanceof HTMLElement)) continue;
|
||||||
|
|
||||||
|
const label = entry.querySelector(":scope > label") as HTMLLabelElement | null;
|
||||||
|
if (!label) continue;
|
||||||
|
|
||||||
|
const childSubmenu = entry.querySelector(":scope > .sub") as HTMLElement | null;
|
||||||
|
const isHidden =
|
||||||
|
entry.offsetParent === null ||
|
||||||
|
window.getComputedStyle(entry).display === "none" ||
|
||||||
|
window.getComputedStyle(label).display === "none" ||
|
||||||
|
!visibleEntries.has(entry);
|
||||||
|
|
||||||
|
if (isHidden) {
|
||||||
|
entry.tabIndex = -1;
|
||||||
|
label.tabIndex = -1;
|
||||||
|
entry.setAttribute("aria-hidden", "true");
|
||||||
|
label.setAttribute("aria-hidden", "true");
|
||||||
|
if (childSubmenu) {
|
||||||
|
childSubmenu.setAttribute("aria-hidden", "true");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.tabIndex = 0;
|
||||||
|
label.tabIndex = -1;
|
||||||
|
entry.removeAttribute("aria-hidden");
|
||||||
|
label.removeAttribute("aria-hidden");
|
||||||
|
|
||||||
|
if (!entry.hasAttribute("role")) {
|
||||||
|
entry.setAttribute("role", "button");
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessibleLabel = label.textContent?.trim();
|
||||||
|
if (accessibleLabel) {
|
||||||
|
entry.setAttribute("aria-label", accessibleLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childSubmenu) {
|
||||||
|
const isExpanded = entry.classList.contains("active");
|
||||||
|
entry.setAttribute("aria-expanded", String(isExpanded));
|
||||||
|
childSubmenu.setAttribute("aria-hidden", String(!isExpanded));
|
||||||
|
} else {
|
||||||
|
entry.removeAttribute("aria-expanded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleSidebarEntries(menu = document.getElementById("menu")) {
|
||||||
|
if (!menu) return [] as HTMLElement[];
|
||||||
|
|
||||||
|
const visibleList = getVisibleSidebarList(menu);
|
||||||
|
if (!visibleList) return [] as HTMLElement[];
|
||||||
|
|
||||||
|
return getDirectSidebarEntries(visibleList);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectSidebarEntries(list: HTMLElement) {
|
||||||
|
return Array.from(list.querySelectorAll(":scope > li, :scope > section")).filter(
|
||||||
|
(entry): entry is HTMLElement => entry instanceof HTMLElement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleSidebarList(menu: HTMLElement) {
|
||||||
|
let currentList = menu.querySelector(":scope > ul") as HTMLElement | null;
|
||||||
|
|
||||||
|
while (currentList) {
|
||||||
|
const activeSubmenuParent = currentList.querySelector(
|
||||||
|
":scope > li.hasChildren.active, :scope > section.hasChildren.active",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
if (!activeSubmenuParent) {
|
||||||
|
return currentList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextList = activeSubmenuParent.querySelector(
|
||||||
|
":scope > .sub > ul",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
if (!nextList) {
|
||||||
|
return currentList;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentList = nextList;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSidebarListParentEntry(list: HTMLElement) {
|
||||||
|
return list.closest(".sub")?.parentElement instanceof HTMLElement
|
||||||
|
? (list.closest(".sub")!.parentElement as HTMLElement)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusFirstSidebarSubmenuEntry(parentEntry: HTMLElement) {
|
||||||
|
const menu = document.getElementById("menu");
|
||||||
|
if (!menu) return;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!parentEntry.classList.contains("active")) return;
|
||||||
|
|
||||||
|
const visibleList = getVisibleSidebarList(menu);
|
||||||
|
if (!visibleList || getSidebarListParentEntry(visibleList) !== parentEntry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstEntry = getDirectSidebarEntries(visibleList).find(
|
||||||
|
(entry) =>
|
||||||
|
entry.offsetParent !== null &&
|
||||||
|
window.getComputedStyle(entry).display !== "none",
|
||||||
|
);
|
||||||
|
|
||||||
|
firstEntry?.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
import Color from "color";
|
||||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
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";
|
||||||
|
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
|
||||||
|
|
||||||
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
|
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
|
||||||
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
|
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
|
||||||
@@ -13,13 +15,7 @@ const setCSSVar = (varName: any, value: any) =>
|
|||||||
const applyProperties = (props: any) =>
|
const applyProperties = (props: any) =>
|
||||||
Object.entries(props).forEach(([key, value]) => setCSSVar(key, value));
|
Object.entries(props).forEach(([key, value]) => setCSSVar(key, value));
|
||||||
|
|
||||||
export function updateAllColors() {
|
function applyColorsWith(selectedColor: string) {
|
||||||
// Determine the color to use
|
|
||||||
const selectedColor =
|
|
||||||
settingsState.selectedColor !== ""
|
|
||||||
? settingsState.selectedColor
|
|
||||||
: "#007bff";
|
|
||||||
|
|
||||||
if (settingsState.transparencyEffects) {
|
if (settingsState.transparencyEffects) {
|
||||||
document.documentElement.classList.add("transparencyEffects");
|
document.documentElement.classList.add("transparencyEffects");
|
||||||
}
|
}
|
||||||
@@ -28,7 +24,7 @@ export function updateAllColors() {
|
|||||||
const commonProps = {
|
const commonProps = {
|
||||||
"--better-sub": "#161616",
|
"--better-sub": "#161616",
|
||||||
"--better-alert-highlight": "#c61851",
|
"--better-alert-highlight": "#c61851",
|
||||||
"--better-main": settingsState.selectedColor,
|
"--better-main": selectedColor,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mode-based properties, applied if storedSetting is provided
|
// Mode-based properties, applied if storedSetting is provided
|
||||||
@@ -79,3 +75,29 @@ export function updateAllColors() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toSoftGradient(hex: string): string {
|
||||||
|
const base = Color(hex);
|
||||||
|
const analogous = base.rotate(30).lighten(0.25).saturate(0.15);
|
||||||
|
const mid = base.mix(analogous, 0.5).hex();
|
||||||
|
return `linear-gradient(135deg, ${hex} 0%, ${mid} 50%, ${analogous.hex()} 100%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAllColors() {
|
||||||
|
let effectiveColor =
|
||||||
|
settingsState.selectedColor !== ""
|
||||||
|
? settingsState.selectedColor
|
||||||
|
: "#007bff";
|
||||||
|
|
||||||
|
if (settingsState.adaptiveThemeColour) {
|
||||||
|
const adaptiveColor = await getAdaptiveColour();
|
||||||
|
if (adaptiveColor) {
|
||||||
|
effectiveColor =
|
||||||
|
settingsState.adaptiveThemeGradient
|
||||||
|
? toSoftGradient(adaptiveColor)
|
||||||
|
: adaptiveColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyColorsWith(effectiveColor);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
interface ElementConfig {
|
interface ElementConfig {
|
||||||
selector: string;
|
selector: string;
|
||||||
action: (element: Element) => void;
|
action: (element: Element) => void;
|
||||||
|
/** When true, element is not added to processedElements so the action runs every time (e.g. overwriting container content) */
|
||||||
|
alwaysRun?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContentConfig {
|
interface ContentConfig {
|
||||||
@@ -77,6 +79,18 @@ const contentConfig: ContentConfig = {
|
|||||||
element.textContent = getRandomElement(mockData.assessmentTitles);
|
element.textContent = getRandomElement(mockData.assessmentTitles);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
assessmentTitleInTooltip: {
|
||||||
|
selector: ".assessmenttooltip .tooltiptext p",
|
||||||
|
action: (element) => {
|
||||||
|
element.textContent = getRandomElement(mockData.assessmentTitles);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assessmentTitleInDetail: {
|
||||||
|
selector: "[class*='AssessmentItem__title___'], .assessment-title",
|
||||||
|
action: (element) => {
|
||||||
|
element.textContent = getRandomElement(mockData.assessmentTitles);
|
||||||
|
},
|
||||||
|
},
|
||||||
assessmentSubject: {
|
assessmentSubject: {
|
||||||
selector: ".upcoming-assessment .upcoming-details h5",
|
selector: ".upcoming-assessment .upcoming-details h5",
|
||||||
action: (element) => {
|
action: (element) => {
|
||||||
@@ -92,7 +106,8 @@ const contentConfig: ContentConfig = {
|
|||||||
noticeContent: {
|
noticeContent: {
|
||||||
selector: ".notice .contents",
|
selector: ".notice .contents",
|
||||||
action: (element) => {
|
action: (element) => {
|
||||||
element.textContent = "Content has been redacted for privacy.";
|
element.textContent =
|
||||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
upcomingCheckboxes: {
|
upcomingCheckboxes: {
|
||||||
@@ -135,7 +150,7 @@ const contentConfig: ContentConfig = {
|
|||||||
selector:
|
selector:
|
||||||
'[class*="MessageList__recipients___"] [class*="MessageList__value___"]',
|
'[class*="MessageList__recipients___"] [class*="MessageList__value___"]',
|
||||||
action: (element) => {
|
action: (element) => {
|
||||||
element.textContent = "Recipient(s) Redacted";
|
element.textContent = getRandomElement(mockData.messages.recipients);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -175,16 +190,15 @@ const contentConfig: ContentConfig = {
|
|||||||
documentNames: {
|
documentNames: {
|
||||||
selector: ".document td.title",
|
selector: ".document td.title",
|
||||||
action: (element) => {
|
action: (element) => {
|
||||||
element.textContent = "Document Name Redacted";
|
element.textContent = getRandomElement(mockData.documentTitles);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
forumTopics: {
|
forumTopics: {
|
||||||
selector: "#menu .sub ul li:not([data-colour]):not(.hasChildren) label",
|
selector: "#menu .sub ul li:not([data-colour]):not(.hasChildren) label",
|
||||||
action: (element) => {
|
action: (element) => {
|
||||||
// Only redact if not in assessments section
|
|
||||||
const assessmentsSection = element.closest('[data-key="assessments"]');
|
const assessmentsSection = element.closest('[data-key="assessments"]');
|
||||||
if (!assessmentsSection) {
|
if (!assessmentsSection) {
|
||||||
element.textContent = "Forum Topic Redacted";
|
element.textContent = getRandomElement(mockData.forumTopics);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -210,25 +224,27 @@ const contentConfig: ContentConfig = {
|
|||||||
courseNames: {
|
courseNames: {
|
||||||
selector: "#menu .sub ul li[data-colour] label",
|
selector: "#menu .sub ul li[data-colour] label",
|
||||||
action: (element) => {
|
action: (element) => {
|
||||||
element.textContent = "Course Name Redacted";
|
element.textContent = getRandomElement(mockData.subjects);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
yearGroups: {
|
yearGroups: {
|
||||||
selector: "#menu .sub > ul > li > label",
|
selector: "#menu .sub > ul > li > label",
|
||||||
action: (element) => {
|
action: (element) => {
|
||||||
element.textContent = "Year Group Redacted";
|
const yearGroup = Math.floor(Math.random() * 5) + 8;
|
||||||
|
element.textContent = `Year ${yearGroup}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
newsArticleTitle: {
|
newsArticleTitle: {
|
||||||
selector: ".ArticleText a",
|
selector: ".ArticleText a",
|
||||||
action: (element) => {
|
action: (element) => {
|
||||||
element.textContent = "News Article Title Redacted";
|
element.textContent = getRandomElement(mockData.notices);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
newsArticleContent: {
|
newsArticleContent: {
|
||||||
selector: ".ArticleText p",
|
selector: ".ArticleText p",
|
||||||
action: (element) => {
|
action: (element) => {
|
||||||
element.textContent = "News Article Content Redacted";
|
element.textContent =
|
||||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
userHouse: {
|
userHouse: {
|
||||||
@@ -237,6 +253,45 @@ const contentConfig: ContentConfig = {
|
|||||||
element.textContent = "House";
|
element.textContent = "House";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Timetable page: replace class names, teachers, rooms with fake data
|
||||||
|
timetableEntryTitle: {
|
||||||
|
selector: ".timetablepage .entry .title",
|
||||||
|
action: (element) => {
|
||||||
|
element.textContent = getRandomElement(mockData.subjects);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timetableEntryTeacher: {
|
||||||
|
selector: ".timetablepage .entry .teacher, .timetablepage .quickbar .meta .teacher",
|
||||||
|
action: (element) => {
|
||||||
|
element.textContent = getRandomElement(mockData.teachers);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timetableEntryRoom: {
|
||||||
|
selector: ".timetablepage .entry .room, .timetablepage .quickbar .meta .room",
|
||||||
|
action: (element) => {
|
||||||
|
element.textContent = getRandomElement(mockData.classrooms);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
quickbarTitle: {
|
||||||
|
selector: ".timetablepage .quickbar .title",
|
||||||
|
action: (element) => {
|
||||||
|
element.textContent = getRandomElement(mockData.subjects);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Home page: replace entire day with mock schedule (care + 7 lessons 8:55–3:15)
|
||||||
|
homeDayContainer: {
|
||||||
|
selector: "#day-container",
|
||||||
|
alwaysRun: true,
|
||||||
|
action: (element) => {
|
||||||
|
const container = element as HTMLElement;
|
||||||
|
if (!container.closest(".timetable-container")) return; // only on home
|
||||||
|
const schedule = getMockDaySchedule();
|
||||||
|
container.innerHTML = schedule;
|
||||||
|
container.classList.remove("loading");
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockData = {
|
const mockData = {
|
||||||
@@ -367,7 +422,26 @@ const mockData = {
|
|||||||
"Field Trip",
|
"Field Trip",
|
||||||
"Cultural Festival",
|
"Cultural Festival",
|
||||||
],
|
],
|
||||||
|
documentTitles: [
|
||||||
|
"Course Outline",
|
||||||
|
"Assignment Brief",
|
||||||
|
"Study Guide",
|
||||||
|
"Reference Material",
|
||||||
|
"Worksheet",
|
||||||
|
"Reading List",
|
||||||
|
"Project Guidelines",
|
||||||
|
],
|
||||||
|
forumTopics: [
|
||||||
|
"General Discussion",
|
||||||
|
"Homework Help",
|
||||||
|
"Resource Share",
|
||||||
|
"Class Updates",
|
||||||
|
"Study Group",
|
||||||
|
"Q&A",
|
||||||
|
"Announcements",
|
||||||
|
],
|
||||||
messages: {
|
messages: {
|
||||||
|
recipients: ["Students", "Class", "Year Group", "Parents", "Guardians"],
|
||||||
subjects: [
|
subjects: [
|
||||||
"Mid-year Exams",
|
"Mid-year Exams",
|
||||||
"Science project due soon",
|
"Science project due soon",
|
||||||
@@ -573,6 +647,35 @@ Register through the PE department or see your house captains for more informati
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Mock day schedule for home timetable: care 8:30–8:55, then 7 lessons 8:55–3:15 (45m each), 20m recess, lunch. */
|
||||||
|
function getMockDaySchedule(): string {
|
||||||
|
const blocks: { title: string; teacher: string; room: string; from: string; until: string }[] = [
|
||||||
|
{ title: "Care Group", teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "8:30am", until: "8:55am" },
|
||||||
|
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "8:55am", until: "9:40am" },
|
||||||
|
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "9:40am", until: "10:25am" },
|
||||||
|
{ title: "Recess", teacher: "—", room: "—", from: "10:25am", until: "10:45am" },
|
||||||
|
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "10:45am", until: "11:30am" },
|
||||||
|
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "11:30am", until: "12:15pm" },
|
||||||
|
{ title: "Lunch", teacher: "—", room: "—", from: "12:15pm", until: "1:00pm" },
|
||||||
|
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "1:00pm", until: "1:45pm" },
|
||||||
|
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "1:45pm", until: "2:30pm" },
|
||||||
|
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "2:30pm", until: "3:15pm" },
|
||||||
|
];
|
||||||
|
const colours = ["#8e8e8e", "#4FBBFE", "#59F675", "#fa915d", "#9c27b0", "#2196f3", "#4caf50", "#ff9800", "#e91e63", "#673ab7"];
|
||||||
|
return blocks
|
||||||
|
.map(
|
||||||
|
(b, i) =>
|
||||||
|
`<div class="day" style="--item-colour: ${colours[i % colours.length]};">
|
||||||
|
<h2>${b.title}</h2>
|
||||||
|
<h3>${b.teacher}</h3>
|
||||||
|
<h3>${b.room}</h3>
|
||||||
|
<h4>${b.from} – ${b.until}</h4>
|
||||||
|
<h5> </h5>
|
||||||
|
</div>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
export function getMockNotices() {
|
export function getMockNotices() {
|
||||||
return {
|
return {
|
||||||
payload: mockData.noticesData
|
payload: mockData.noticesData
|
||||||
@@ -617,12 +720,15 @@ export function getMockAssessmentsData() {
|
|||||||
{ submitted: false, score: null, dayOffset: () => Math.floor(Math.random() * -3) - 1 }, // Recently overdue
|
{ submitted: false, score: null, dayOffset: () => Math.floor(Math.random() * -3) - 1 }, // Recently overdue
|
||||||
];
|
];
|
||||||
|
|
||||||
const assessments = Array.from({ length: 12 }, (_, i) => {
|
const currentYear = new Date().getFullYear();
|
||||||
|
const assessments = Array.from({ length: 14 }, (_, i) => {
|
||||||
const subj = subjects[i % subjects.length];
|
const subj = subjects[i % subjects.length];
|
||||||
const template = statusTemplates[i % statusTemplates.length];
|
const template = statusTemplates[i % statusTemplates.length];
|
||||||
const due = new Date();
|
const due = new Date();
|
||||||
due.setDate(due.getDate() + template.dayOffset());
|
due.setDate(due.getDate() + template.dayOffset());
|
||||||
|
if (i >= 10) due.setFullYear(currentYear - 1);
|
||||||
|
|
||||||
|
const types = ["Assignment", "Test", "Exam", "Project", "Presentation", "Report"];
|
||||||
const assessment: any = {
|
const assessment: any = {
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
title: mockData.assessmentTitles[i % mockData.assessmentTitles.length],
|
title: mockData.assessmentTitles[i % mockData.assessmentTitles.length],
|
||||||
@@ -631,6 +737,7 @@ export function getMockAssessmentsData() {
|
|||||||
metaclassID: subj.metaclass,
|
metaclassID: subj.metaclass,
|
||||||
due: due.toISOString(),
|
due: due.toISOString(),
|
||||||
submitted: template.submitted,
|
submitted: template.submitted,
|
||||||
|
type: types[i % types.length],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (template.score && typeof template.score === 'function') {
|
if (template.score && typeof template.score === 'function') {
|
||||||
@@ -650,13 +757,15 @@ export function getMockAssessmentsData() {
|
|||||||
const debouncedProcessElements = debounce(processNewElements, 1);
|
const debouncedProcessElements = debounce(processNewElements, 1);
|
||||||
|
|
||||||
function processNewElements() {
|
function processNewElements() {
|
||||||
Object.entries(contentConfig).forEach(([_, { selector, action }]) => {
|
Object.entries(contentConfig).forEach(([_, config]) => {
|
||||||
|
const { selector, action, alwaysRun } = config;
|
||||||
const elements = document.querySelectorAll(selector);
|
const elements = document.querySelectorAll(selector);
|
||||||
elements.forEach((element: Element) => {
|
elements.forEach((element: Element) => {
|
||||||
// Only process elements that haven't been processed before
|
if (alwaysRun || !processedElements.has(element)) {
|
||||||
if (!processedElements.has(element)) {
|
|
||||||
action(element);
|
action(element);
|
||||||
processedElements.add(element);
|
if (!alwaysRun) {
|
||||||
|
processedElements.add(element);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,21 +9,8 @@ import Settings from "@/interface/pages/settings.svelte";
|
|||||||
|
|
||||||
let isSettingsRendered = false;
|
let isSettingsRendered = false;
|
||||||
|
|
||||||
export function addExtensionSettings() {
|
function extensionOutsideClickHandler(extensionPopup: HTMLElement) {
|
||||||
const extensionPopup = document.createElement("div");
|
return (event: MouseEvent) => {
|
||||||
extensionPopup.classList.add("outside-container", "hide");
|
|
||||||
extensionPopup.id = "ExtensionPopup";
|
|
||||||
|
|
||||||
const extensionContainer = document.querySelector(
|
|
||||||
"#container",
|
|
||||||
) as HTMLDivElement;
|
|
||||||
if (extensionContainer) extensionContainer.appendChild(extensionPopup);
|
|
||||||
|
|
||||||
const container = document.getElementById("container");
|
|
||||||
|
|
||||||
new SettingsResizer();
|
|
||||||
|
|
||||||
container!.onclick = (event) => {
|
|
||||||
if (!SettingsClicked) return;
|
if (!SettingsClicked) return;
|
||||||
|
|
||||||
if (!(event.target as HTMLElement).closest("#AddedSettings")) {
|
if (!(event.target as HTMLElement).closest("#AddedSettings")) {
|
||||||
@@ -33,6 +20,24 @@ export function addExtensionSettings() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addExtensionSettings() {
|
||||||
|
if (document.getElementById("ExtensionPopup")) return;
|
||||||
|
|
||||||
|
const extensionPopup = document.createElement("div");
|
||||||
|
extensionPopup.classList.add("outside-container", "hide");
|
||||||
|
extensionPopup.id = "ExtensionPopup";
|
||||||
|
|
||||||
|
const extensionContainer =
|
||||||
|
document.querySelector("#container") ?? document.getElementById("container");
|
||||||
|
const mountParent = extensionContainer ?? document.body;
|
||||||
|
mountParent.appendChild(extensionPopup);
|
||||||
|
|
||||||
|
new SettingsResizer();
|
||||||
|
|
||||||
|
const handler = extensionOutsideClickHandler(extensionPopup);
|
||||||
|
(extensionContainer ?? document.body).addEventListener("click", handler, false);
|
||||||
|
}
|
||||||
|
|
||||||
export function renderSettingsIfNeeded() {
|
export function renderSettingsIfNeeded() {
|
||||||
if (isSettingsRendered) return;
|
if (isSettingsRendered) return;
|
||||||
|
|
||||||
@@ -41,7 +46,11 @@ export function renderSettingsIfNeeded() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const shadow = extensionPopup.attachShadow({ mode: "open" });
|
const shadow = extensionPopup.attachShadow({ mode: "open" });
|
||||||
requestIdleCallback(() => renderSvelte(Settings, shadow));
|
if ('requestIdleCallback' in window) {
|
||||||
|
requestIdleCallback(() => renderSvelte(Settings, shadow));
|
||||||
|
} else {
|
||||||
|
renderSvelte(Settings, shadow);
|
||||||
|
}
|
||||||
isSettingsRendered = true;
|
isSettingsRendered = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export function addShortcuts(shortcuts: any) {
|
|||||||
|
|
||||||
function createNewShortcut(link: any, icon: any, viewBox: any, title: any) {
|
function createNewShortcut(link: any, icon: any, viewBox: any, title: any) {
|
||||||
// Creates the stucture and element information for each seperate shortcut
|
// Creates the stucture and element information for each seperate shortcut
|
||||||
|
const container = document.getElementById("shortcuts");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
let shortcut = document.createElement("a");
|
let shortcut = document.createElement("a");
|
||||||
shortcut.setAttribute("href", link);
|
shortcut.setAttribute("href", link);
|
||||||
shortcut.setAttribute("target", "_blank");
|
shortcut.setAttribute("target", "_blank");
|
||||||
@@ -42,5 +45,5 @@ function createNewShortcut(link: any, icon: any, viewBox: any, title: any) {
|
|||||||
shortcutdiv.append(text);
|
shortcutdiv.append(text);
|
||||||
shortcut.append(shortcutdiv);
|
shortcut.append(shortcutdiv);
|
||||||
|
|
||||||
document.getElementById("shortcuts")!.appendChild(shortcut);
|
container.appendChild(shortcut);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import browser from "webextension-polyfill";
|
||||||
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
|
||||||
|
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
clientId: "bsplus_client_id",
|
||||||
|
accessToken: "bsplus_token",
|
||||||
|
refreshToken: "bsplus_refresh_token",
|
||||||
|
user: "bsplus_user",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CloudUser = {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
username?: string;
|
||||||
|
displayName?: string;
|
||||||
|
pfpUrl?: string;
|
||||||
|
admin_level?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CloudAuthState = {
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
user: CloudUser | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Callback invoked when auth state changes */
|
||||||
|
type Listener = { (state: CloudAuthState): void };
|
||||||
|
|
||||||
|
class CloudAuthService {
|
||||||
|
private static instance: CloudAuthService;
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
private _state: CloudAuthState = { isLoggedIn: false, user: null };
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
void this.loadFromStorage();
|
||||||
|
browser.storage.onChanged.addListener((changes, areaName) => {
|
||||||
|
if (
|
||||||
|
areaName === "local" &&
|
||||||
|
(changes[STORAGE_KEYS.accessToken] ||
|
||||||
|
changes[STORAGE_KEYS.user] ||
|
||||||
|
changes[STORAGE_KEYS.clientId])
|
||||||
|
) {
|
||||||
|
void this.loadFromStorage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): CloudAuthService {
|
||||||
|
if (!CloudAuthService.instance) {
|
||||||
|
CloudAuthService.instance = new CloudAuthService();
|
||||||
|
}
|
||||||
|
return CloudAuthService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get state(): CloudAuthState {
|
||||||
|
return this._state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribe(listener: Listener): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
listener(this._state);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadFromStorage(): Promise<void> {
|
||||||
|
const result = await browser.storage.local.get([
|
||||||
|
STORAGE_KEYS.accessToken,
|
||||||
|
STORAGE_KEYS.user,
|
||||||
|
]);
|
||||||
|
const token = result[STORAGE_KEYS.accessToken] as string | undefined;
|
||||||
|
const user = result[STORAGE_KEYS.user] as CloudUser | undefined;
|
||||||
|
this._state = {
|
||||||
|
isLoggedIn: !!token,
|
||||||
|
user: user ?? null,
|
||||||
|
};
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener(this._state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getStoredToken(): Promise<string | null> {
|
||||||
|
const result = await browser.storage.local.get(STORAGE_KEYS.accessToken);
|
||||||
|
return (result[STORAGE_KEYS.accessToken] as string) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getClientId(): Promise<string> {
|
||||||
|
let clientId = (settingsState as any)[STORAGE_KEYS.clientId] as string | undefined;
|
||||||
|
if (!clientId) {
|
||||||
|
const stored = await browser.storage.local.get(STORAGE_KEYS.clientId);
|
||||||
|
clientId = stored[STORAGE_KEYS.clientId] as string | undefined;
|
||||||
|
}
|
||||||
|
if (!clientId) {
|
||||||
|
const reserveResult = (await browser.runtime.sendMessage({
|
||||||
|
type: "cloudReserveClient",
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
})) as { client_id?: string; error?: string };
|
||||||
|
if (!reserveResult?.client_id) {
|
||||||
|
throw new Error(reserveResult?.error ?? "Failed to reserve client");
|
||||||
|
}
|
||||||
|
clientId = reserveResult.client_id;
|
||||||
|
(settingsState as any).setKey(STORAGE_KEYS.clientId, clientId);
|
||||||
|
}
|
||||||
|
return clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async login(
|
||||||
|
login: string,
|
||||||
|
password: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const clientId = await this.getClientId();
|
||||||
|
const result = (await browser.runtime.sendMessage({
|
||||||
|
type: "cloudLogin",
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
login: login.trim(),
|
||||||
|
password,
|
||||||
|
})) as {
|
||||||
|
access_token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
user?: CloudUser;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (result?.access_token && result?.refresh_token) {
|
||||||
|
(settingsState as any).setKey(STORAGE_KEYS.accessToken, result.access_token);
|
||||||
|
(settingsState as any).setKey(STORAGE_KEYS.refreshToken, result.refresh_token);
|
||||||
|
(settingsState as any).setKey(STORAGE_KEYS.user, result.user ?? null);
|
||||||
|
this._state = {
|
||||||
|
isLoggedIn: true,
|
||||||
|
user: result.user ?? null,
|
||||||
|
};
|
||||||
|
this.notify();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: result?.error ?? "Login failed",
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : "Login failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async logout(): Promise<void> {
|
||||||
|
await browser.storage.local.remove([
|
||||||
|
STORAGE_KEYS.accessToken,
|
||||||
|
STORAGE_KEYS.refreshToken,
|
||||||
|
STORAGE_KEYS.user,
|
||||||
|
"cloudAccessToken",
|
||||||
|
"cloudUsername",
|
||||||
|
]);
|
||||||
|
this._state = { isLoggedIn: false, user: null };
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refreshToken(): Promise<boolean> {
|
||||||
|
const result = await browser.storage.local.get([
|
||||||
|
STORAGE_KEYS.refreshToken,
|
||||||
|
STORAGE_KEYS.clientId,
|
||||||
|
]);
|
||||||
|
const refreshToken = result[STORAGE_KEYS.refreshToken] as string | undefined;
|
||||||
|
const clientId = result[STORAGE_KEYS.clientId] as string | undefined;
|
||||||
|
if (!refreshToken || !clientId) return false;
|
||||||
|
|
||||||
|
const refreshResult = (await browser.runtime.sendMessage({
|
||||||
|
type: "cloudRefresh",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: clientId,
|
||||||
|
})) as {
|
||||||
|
access_token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
user?: CloudUser;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (refreshResult?.access_token && refreshResult?.refresh_token) {
|
||||||
|
(settingsState as any).setKey(STORAGE_KEYS.accessToken, refreshResult.access_token);
|
||||||
|
(settingsState as any).setKey(STORAGE_KEYS.refreshToken, refreshResult.refresh_token);
|
||||||
|
(settingsState as any).setKey(STORAGE_KEYS.user, refreshResult.user ?? null);
|
||||||
|
this._state = {
|
||||||
|
isLoggedIn: true,
|
||||||
|
user: refreshResult.user ?? null,
|
||||||
|
};
|
||||||
|
this.notify();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cloudAuth = CloudAuthService.getInstance();
|
||||||
@@ -2,6 +2,9 @@ import stringToHTML from "../stringToHTML";
|
|||||||
|
|
||||||
export function CreateCustomShortcutDiv(element: any) {
|
export function CreateCustomShortcutDiv(element: any) {
|
||||||
// Creates the stucture and element information for each seperate shortcut
|
// Creates the stucture and element information for each seperate shortcut
|
||||||
|
const container = document.getElementById("shortcuts");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
var shortcut = document.createElement("a");
|
var shortcut = document.createElement("a");
|
||||||
shortcut.setAttribute("href", element.url);
|
shortcut.setAttribute("href", element.url);
|
||||||
shortcut.setAttribute("target", "_blank");
|
shortcut.setAttribute("target", "_blank");
|
||||||
@@ -45,5 +48,5 @@ export function CreateCustomShortcutDiv(element: any) {
|
|||||||
shortcutdiv.append(text);
|
shortcutdiv.append(text);
|
||||||
shortcut.append(shortcutdiv);
|
shortcut.append(shortcutdiv);
|
||||||
|
|
||||||
document.getElementById("shortcuts")!.append(shortcut);
|
container.append(shortcut);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import links from "@/seqta/content/links.json";
|
|
||||||
|
|
||||||
export function RemoveShortcutDiv(elements: any) {
|
|
||||||
if (elements.length === 0) return;
|
|
||||||
|
|
||||||
elements.forEach((element: any) => {
|
|
||||||
const shortcuts = document.querySelectorAll(".shortcut");
|
|
||||||
shortcuts.forEach((shortcut) => {
|
|
||||||
const anchorElement = shortcut.parentElement; // the <a> element is the parent
|
|
||||||
const textElement = shortcut.querySelector("p"); // <p> is a direct child of .shortcut
|
|
||||||
const title = textElement ? textElement.textContent : "";
|
|
||||||
|
|
||||||
const elementName = links[element.name as keyof typeof links]?.DisplayName || element.name;
|
|
||||||
|
|
||||||
let shouldRemove = title === elementName;
|
|
||||||
|
|
||||||
// Check href only if element.url exists
|
|
||||||
if (element.url) {
|
|
||||||
shouldRemove =
|
|
||||||
shouldRemove && anchorElement!.getAttribute("href") === element.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRemove) {
|
|
||||||
anchorElement!.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -4,12 +4,11 @@ import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
|||||||
import assessmentsicon from "@/seqta/icons/assessmentsIcon";
|
import assessmentsicon from "@/seqta/icons/assessmentsIcon";
|
||||||
import coursesicon from "@/seqta/icons/coursesIcon";
|
import coursesicon from "@/seqta/icons/coursesIcon";
|
||||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||||
import { addShortcuts } from "../Adders/AddShortcuts";
|
|
||||||
import { convertTo12HourFormat } from "../convertTo12HourFormat";
|
import { convertTo12HourFormat } from "../convertTo12HourFormat";
|
||||||
import { delay } from "../delay";
|
import { delay } from "../delay";
|
||||||
import { settingsState } from "../listeners/SettingsState";
|
import { settingsState } from "../listeners/SettingsState";
|
||||||
import stringToHTML from "../stringToHTML";
|
import stringToHTML from "../stringToHTML";
|
||||||
import { CreateCustomShortcutDiv } from "@/seqta/utils/CreateEnable/CreateCustomShortcutDiv";
|
import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
|
||||||
import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
|
import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
|
||||||
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
||||||
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
||||||
@@ -31,20 +30,17 @@ export async function loadHomePage() {
|
|||||||
element?.classList.add("active");
|
element?.classList.add("active");
|
||||||
|
|
||||||
const main = document.getElementById("main");
|
const main = document.getElementById("main");
|
||||||
if (!main) {
|
if (!main) return;
|
||||||
console.error("[BetterSEQTA+] Main element not found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const homeRoot = stringToHTML(`<div id="home-root" class="home-root"></div>`);
|
|
||||||
|
|
||||||
main.innerHTML = "";
|
main.innerHTML = "";
|
||||||
main.appendChild(homeRoot?.firstChild!);
|
main.appendChild(
|
||||||
|
stringToHTML(`<div id="home-root" class="home-root"></div>`).firstChild!,
|
||||||
|
);
|
||||||
|
|
||||||
const homeContainer = document.getElementById("home-root");
|
const homeContainer = document.getElementById("home-root");
|
||||||
if (!homeContainer) return;
|
if (!homeContainer) return;
|
||||||
|
|
||||||
const skeletonStructure = stringToHTML(/* html */`
|
const skeletonStructure = stringToHTML(/* html */ `
|
||||||
<div class="home-container" id="home-container">
|
<div class="home-container" id="home-container">
|
||||||
<div class="border shortcut-container">
|
<div class="border shortcut-container">
|
||||||
<div class="border shortcuts" id="shortcuts"></div>
|
<div class="border shortcuts" id="shortcuts"></div>
|
||||||
@@ -100,97 +96,21 @@ export async function loadHomePage() {
|
|||||||
|
|
||||||
const cleanup = setupTimetableListeners();
|
const cleanup = setupTimetableListeners();
|
||||||
|
|
||||||
try {
|
renderShortcuts();
|
||||||
addShortcuts(settingsState.shortcuts);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("[BetterSEQTA+] Error adding shortcuts:", err.message || err);
|
|
||||||
}
|
|
||||||
AddCustomShortcutsToPage();
|
|
||||||
|
|
||||||
const date = new Date();
|
const TodayFormatted = formatDate(new Date());
|
||||||
const TodayFormatted = formatDate(date);
|
|
||||||
|
|
||||||
const [timetablePromise, assessmentsPromise, classesPromise, prefsPromise] = [
|
|
||||||
fetch(`${location.origin}/seqta/student/load/timetable?`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
from: TodayFormatted,
|
|
||||||
until: TodayFormatted,
|
|
||||||
student: 69,
|
|
||||||
}),
|
|
||||||
}).then((res) => res.json()),
|
|
||||||
|
|
||||||
|
const [assessments, classes, prefs] = await Promise.all([
|
||||||
GetUpcomingAssessments(),
|
GetUpcomingAssessments(),
|
||||||
|
|
||||||
GetActiveClasses(),
|
GetActiveClasses(),
|
||||||
|
|
||||||
fetch(`${location.origin}/seqta/student/load/prefs?`, {
|
fetch(`${location.origin}/seqta/student/load/prefs?`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ asArray: true, request: "userPrefs" }),
|
body: JSON.stringify({ asArray: true, request: "userPrefs" }),
|
||||||
}).then((res) => res.json()),
|
}).then((res) => res.json()),
|
||||||
];
|
|
||||||
|
|
||||||
const [timetableData, assessments, classes, prefs] = await Promise.all([
|
|
||||||
timetablePromise,
|
|
||||||
assessmentsPromise,
|
|
||||||
classesPromise,
|
|
||||||
prefsPromise,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const dayContainer = document.getElementById("day-container");
|
callHomeTimetable(TodayFormatted, true);
|
||||||
if (dayContainer && timetableData.payload.items.length > 0) {
|
|
||||||
const lessonArray = timetableData.payload.items.sort((a: any, b: any) =>
|
|
||||||
a.from.localeCompare(b.from),
|
|
||||||
);
|
|
||||||
const colours = await GetLessonColours();
|
|
||||||
|
|
||||||
dayContainer.innerHTML = "";
|
|
||||||
for (let i = 0; i < lessonArray.length; i++) {
|
|
||||||
const lesson = lessonArray[i];
|
|
||||||
const subjectname = `timetable.subject.colour.${lesson.code}`;
|
|
||||||
const subject = colours.find(
|
|
||||||
(element: any) => element.name === subjectname,
|
|
||||||
);
|
|
||||||
|
|
||||||
lesson.colour = subject
|
|
||||||
? `--item-colour: ${subject.value};`
|
|
||||||
: "--item-colour: #8e8e8e;";
|
|
||||||
lesson.from = lesson.from.substring(0, 5);
|
|
||||||
lesson.until = lesson.until.substring(0, 5);
|
|
||||||
|
|
||||||
if (settingsState.timeFormat === "12") {
|
|
||||||
lesson.from = convertTo12HourFormat(lesson.from);
|
|
||||||
lesson.until = convertTo12HourFormat(lesson.until);
|
|
||||||
}
|
|
||||||
|
|
||||||
lesson.attendanceTitle = CheckUnmarkedAttendance(lesson.attendance);
|
|
||||||
|
|
||||||
const div = makeLessonDiv(lesson, i + 1);
|
|
||||||
if (GetThresholdOfColor(subject?.value) > 300) {
|
|
||||||
const firstChild = div.firstChild as HTMLElement;
|
|
||||||
if (firstChild) {
|
|
||||||
firstChild.classList.add("day-inverted");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dayContainer.appendChild(div.firstChild!);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentSelectedDate.getDate() === date.getDate()) {
|
|
||||||
for (let i = 0; i < lessonArray.length; i++) {
|
|
||||||
CheckCurrentLesson(lessonArray[i], i + 1);
|
|
||||||
}
|
|
||||||
CheckCurrentLessonAll(lessonArray);
|
|
||||||
}
|
|
||||||
} else if (dayContainer) {
|
|
||||||
dayContainer.innerHTML = `
|
|
||||||
<div class="day-empty">
|
|
||||||
<img src="${browser.runtime.getURL(LogoLight)}" />
|
|
||||||
<p>No lessons available.</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
dayContainer?.classList.remove("loading");
|
|
||||||
|
|
||||||
const activeClass = classes.find((c: any) => c.hasOwnProperty("active"));
|
const activeClass = classes.find((c: any) => c.hasOwnProperty("active"));
|
||||||
const activeSubjects = activeClass?.subjects || [];
|
const activeSubjects = activeClass?.subjects || [];
|
||||||
@@ -209,9 +129,9 @@ export async function loadHomePage() {
|
|||||||
.filter((item: any) => item.name === "notices.filters")
|
.filter((item: any) => item.name === "notices.filters")
|
||||||
.map((item: any) => item.value);
|
.map((item: any) => item.value);
|
||||||
|
|
||||||
if (labelArray.length > 0) {
|
const noticeContainer = document.getElementById("notice-container");
|
||||||
const noticeContainer = document.getElementById("notice-container");
|
if (noticeContainer) {
|
||||||
if (noticeContainer) {
|
if (labelArray.length > 0) {
|
||||||
const dateControl = document.querySelector(
|
const dateControl = document.querySelector(
|
||||||
'input[type="date"]',
|
'input[type="date"]',
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
@@ -220,6 +140,17 @@ export async function loadHomePage() {
|
|||||||
setupNotices(labelArray[0].split(" "), TodayFormatted);
|
setupNotices(labelArray[0].split(" "), TodayFormatted);
|
||||||
}
|
}
|
||||||
noticeContainer.classList.remove("loading");
|
noticeContainer.classList.remove("loading");
|
||||||
|
} else {
|
||||||
|
noticeContainer.classList.remove("loading");
|
||||||
|
noticeContainer.innerHTML = "";
|
||||||
|
const emptyState = document.createElement("div");
|
||||||
|
emptyState.classList.add("day-empty");
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = browser.runtime.getURL(LogoLight);
|
||||||
|
const text = document.createElement("p");
|
||||||
|
text.innerText = "No notices available.";
|
||||||
|
emptyState.append(img, text);
|
||||||
|
noticeContainer.append(emptyState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,20 +158,20 @@ export async function loadHomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function GetUpcomingAssessments() {
|
async function GetUpcomingAssessments() {
|
||||||
let func = fetch(
|
try {
|
||||||
`${location.origin}/seqta/student/assessment/list/upcoming?`,
|
return fetch(`${location.origin}/seqta/student/assessment/list/upcoming?`, {
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ student: 69 }),
|
body: JSON.stringify({ student: 69 }),
|
||||||
},
|
})
|
||||||
);
|
.then((result) => result.json())
|
||||||
|
.then((response) => response.payload);
|
||||||
return func
|
} catch (error) {
|
||||||
.then((result) => result.json())
|
console.error("[BetterSEQTA+] Failed to get upcoming assessments:", error);
|
||||||
.then((response) => response.payload);
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupTimetableListeners() {
|
function setupTimetableListeners() {
|
||||||
@@ -298,15 +229,10 @@ async function GetActiveClasses() {
|
|||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
return (await response.json()).payload;
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data.payload;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Oops! There was a problem fetching active classes:", error);
|
console.error("[BetterSEQTA+] Failed to get active classes:", error);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,28 +242,26 @@ function setupNotices(labelArray: string[], date: string) {
|
|||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
const fetchNotices = async (date: string) => {
|
const fetchNotices = async (date: string) => {
|
||||||
let data;
|
try {
|
||||||
|
const data = settingsState.mockNotices
|
||||||
|
? getMockNotices()
|
||||||
|
: await (
|
||||||
|
await fetch(`${location.origin}/seqta/student/load/notices?`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
body: JSON.stringify({ date }),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
|
||||||
if (settingsState.mockNotices) {
|
processNotices(data, labelArray);
|
||||||
data = getMockNotices();
|
} catch {
|
||||||
} else {
|
// Notices failed to load; processNotices will show empty state if container exists
|
||||||
const response = await fetch(
|
processNotices({ payload: [] }, labelArray);
|
||||||
`${location.origin}/seqta/student/load/notices?`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
||||||
body: JSON.stringify({ date }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
data = await response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
processNotices(data, labelArray);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const debouncedInputChange = debounce((e: Event) => {
|
const debouncedInputChange = debounce((e: Event) => {
|
||||||
const target = e.target as HTMLInputElement;
|
fetchNotices((e.target as HTMLInputElement).value);
|
||||||
fetchNotices(target.value);
|
|
||||||
}, 250);
|
}, 250);
|
||||||
|
|
||||||
dateControl?.addEventListener("input", debouncedInputChange);
|
dateControl?.addEventListener("input", debouncedInputChange);
|
||||||
@@ -358,37 +282,36 @@ function debounce<T extends (...args: any[]) => any>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function comparedate(obj1: any, obj2: any) {
|
function comparedate(obj1: any, obj2: any) {
|
||||||
if (obj1.date < obj2.date) {
|
return obj1.date < obj2.date ? -1 : obj1.date > obj2.date ? 1 : 0;
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (obj1.date > obj2.date) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function AddCustomShortcutsToPage() {
|
|
||||||
let customshortcuts: any = settingsState.customshortcuts;
|
|
||||||
if (customshortcuts.length > 0) {
|
|
||||||
for (let i = 0; i < customshortcuts.length; i++) {
|
|
||||||
const element = customshortcuts[i];
|
|
||||||
CreateCustomShortcutDiv(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function processNotices(response: any, labelArray: string[]) {
|
function processNotices(response: any, labelArray: string[]) {
|
||||||
const NoticeContainer = document.getElementById("notice-container");
|
const NoticeContainer = document.getElementById("notice-container");
|
||||||
if (!NoticeContainer) return;
|
if (!NoticeContainer) return;
|
||||||
|
|
||||||
NoticeContainer.innerHTML = "";
|
NoticeContainer.innerHTML = "";
|
||||||
|
|
||||||
const notices = response.payload;
|
const notices = response?.payload;
|
||||||
|
if (!Array.isArray(notices)) {
|
||||||
|
const emptyState = document.createElement("div");
|
||||||
|
emptyState.classList.add("day-empty");
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = browser.runtime.getURL(LogoLight);
|
||||||
|
const text = document.createElement("p");
|
||||||
|
text.innerText = "No notices for today.";
|
||||||
|
emptyState.append(img, text);
|
||||||
|
NoticeContainer.append(emptyState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!notices.length) {
|
if (!notices.length) {
|
||||||
const dummyNotice = document.createElement("div");
|
const emptyState = document.createElement("div");
|
||||||
dummyNotice.textContent = "No notices for today.";
|
emptyState.classList.add("day-empty");
|
||||||
dummyNotice.classList.add("dummynotice");
|
const img = document.createElement("img");
|
||||||
NoticeContainer.append(dummyNotice);
|
img.src = browser.runtime.getURL(LogoLight);
|
||||||
|
const text = document.createElement("p");
|
||||||
|
text.innerText = "No notices for today.";
|
||||||
|
emptyState.append(img, text);
|
||||||
|
NoticeContainer.append(emptyState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,14 +343,14 @@ function processNoticeColor(colour: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createNoticeElement(notice: any, colour: string | undefined): Node {
|
function createNoticeElement(notice: any, colour: string | undefined): Node {
|
||||||
const textPreview = notice.contents
|
const textPreview =
|
||||||
.replace(/<[^>]*>/g, "")
|
notice.contents
|
||||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
.replace(/<[^>]*>/g, "")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||||
.trim()
|
.replace(/\s+/g, " ")
|
||||||
.substring(0, 150)
|
.trim()
|
||||||
+ (notice.contents.length > 150 ? "..." : "");
|
.substring(0, 150) + (notice.contents.length > 150 ? "..." : "");
|
||||||
|
|
||||||
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
const htmlContent = `
|
const htmlContent = `
|
||||||
@@ -446,12 +369,10 @@ function createNoticeElement(notice: any, colour: string | undefined): Node {
|
|||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
|
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
|
||||||
if (element) {
|
element.addEventListener("click", () =>
|
||||||
element.addEventListener("click", () =>
|
openNoticeModal(notice, colour, element),
|
||||||
openNoticeModal(notice, colour, element),
|
);
|
||||||
);
|
return element;
|
||||||
}
|
|
||||||
return element!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNoticeModal(
|
function openNoticeModal(
|
||||||
@@ -463,15 +384,11 @@ function openNoticeModal(
|
|||||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||||
.replace(/ +/, " ");
|
.replace(/ +/, " ");
|
||||||
|
|
||||||
const existingModal = document.getElementById("notice-modal");
|
document.getElementById("notice-modal")?.remove();
|
||||||
if (existingModal) {
|
|
||||||
existingModal.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceRect = sourceElement.getBoundingClientRect();
|
const sourceRect = sourceElement.getBoundingClientRect();
|
||||||
let scrollY = Math.round(window.scrollY);
|
let scrollY = Math.round(window.scrollY);
|
||||||
let scrollX = Math.round(window.scrollX);
|
let scrollX = Math.round(window.scrollX);
|
||||||
|
|
||||||
let sourceLeft = sourceRect.left;
|
let sourceLeft = sourceRect.left;
|
||||||
let sourceTop = sourceRect.top;
|
let sourceTop = sourceRect.top;
|
||||||
let sourceWidth = sourceRect.width;
|
let sourceWidth = sourceRect.width;
|
||||||
@@ -551,9 +468,8 @@ function openNoticeModal(
|
|||||||
document.body.removeChild(tempMeasureDiv);
|
document.body.removeChild(tempMeasureDiv);
|
||||||
|
|
||||||
let targetHeight = Math.round(
|
let targetHeight = Math.round(
|
||||||
Math.min(Math.max(measuredHeight, 200), viewportHeight * 0.85),
|
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
|
||||||
);
|
);
|
||||||
|
|
||||||
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
|
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
|
||||||
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
|
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
|
||||||
|
|
||||||
@@ -662,13 +578,10 @@ function openNoticeModal(
|
|||||||
const newTargetWidth = Math.round(
|
const newTargetWidth = Math.round(
|
||||||
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
|
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Just measure the existing modal content
|
|
||||||
const currentHeight = unifiedContent.getBoundingClientRect().height;
|
const currentHeight = unifiedContent.getBoundingClientRect().height;
|
||||||
const newTargetHeight = Math.round(
|
const newTargetHeight = Math.round(
|
||||||
Math.min(Math.max(currentHeight, 200), newViewportHeight * 0.85),
|
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
|
||||||
);
|
);
|
||||||
|
|
||||||
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
|
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
|
||||||
const newTargetTop =
|
const newTargetTop =
|
||||||
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
|
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
|
||||||
@@ -733,115 +646,92 @@ function callHomeTimetable(date: string, change?: any) {
|
|||||||
xhr.setRequestHeader("Content-Type", "application/json; charset=utf-8");
|
xhr.setRequestHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
|
|
||||||
xhr.onreadystatechange = function () {
|
xhr.onreadystatechange = function () {
|
||||||
if (xhr.readyState === 4) {
|
if (xhr.readyState !== 4) return;
|
||||||
if (loadingTimeout) {
|
|
||||||
clearTimeout(loadingTimeout);
|
|
||||||
loadingTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DayContainer = document.getElementById("day-container")!;
|
if (loadingTimeout) {
|
||||||
|
clearTimeout(loadingTimeout);
|
||||||
|
loadingTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const DayContainer = document.getElementById("day-container")!;
|
||||||
var serverResponse = JSON.parse(xhr.response);
|
|
||||||
let lessonArray: Array<any> = [];
|
|
||||||
|
|
||||||
if (serverResponse.payload.items.length > 0) {
|
var serverResponse = JSON.parse(xhr.response);
|
||||||
if (DayContainer.innerText || change) {
|
let lessonArray: Array<any> = [];
|
||||||
for (let i = 0; i < serverResponse.payload.items.length; i++) {
|
|
||||||
lessonArray.push(serverResponse.payload.items[i]);
|
if (serverResponse.payload.items.length > 0) {
|
||||||
|
if (DayContainer.innerText || change) {
|
||||||
|
for (let i = 0; i < serverResponse.payload.items.length; i++) {
|
||||||
|
lessonArray.push(serverResponse.payload.items[i]);
|
||||||
|
}
|
||||||
|
lessonArray.sort(function (a, b) {
|
||||||
|
return a.from.localeCompare(b.from);
|
||||||
|
});
|
||||||
|
|
||||||
|
GetLessonColours().then((colours) => {
|
||||||
|
for (let i = 0; i < lessonArray.length; i++) {
|
||||||
|
let subjectname =
|
||||||
|
lessonArray[i].type == "tutorial"
|
||||||
|
? `timetable.tutor.${lessonArray[i].tutorID}`
|
||||||
|
: `timetable.subject.colour.${lessonArray[i].code}`;
|
||||||
|
let subject = colours.find(
|
||||||
|
(element: any) => element.name === subjectname,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!subject) {
|
||||||
|
lessonArray[i].colour = "--item-colour: #8e8e8e;";
|
||||||
|
} else {
|
||||||
|
lessonArray[i].colour = `--item-colour: ${subject.value};`;
|
||||||
|
if (GetThresholdOfColor(subject.value) > 300) {
|
||||||
|
lessonArray[i].invert = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
lessonArray.sort(function (a, b) {
|
|
||||||
return a.from.localeCompare(b.from);
|
|
||||||
});
|
|
||||||
|
|
||||||
GetLessonColours().then((colours) => {
|
lessonArray[i].from = lessonArray[i].from.substring(0, 5);
|
||||||
let subjects = colours;
|
lessonArray[i].until = lessonArray[i].until.substring(0, 5);
|
||||||
for (let i = 0; i < lessonArray.length; i++) {
|
|
||||||
let subjectname = `timetable.subject.colour.${lessonArray[i].code}`;
|
|
||||||
|
|
||||||
let subject = subjects.find(
|
if (settingsState.timeFormat === "12") {
|
||||||
(element: any) => element.name === subjectname,
|
lessonArray[i].from = convertTo12HourFormat(lessonArray[i].from);
|
||||||
);
|
lessonArray[i].until = convertTo12HourFormat(
|
||||||
if (!subject) {
|
lessonArray[i].until,
|
||||||
lessonArray[i].colour = "--item-colour: #8e8e8e;";
|
);
|
||||||
} else {
|
}
|
||||||
lessonArray[i].colour = `--item-colour: ${subject.value};`;
|
|
||||||
let result = GetThresholdOfColor(subject.value);
|
|
||||||
|
|
||||||
if (result > 300) {
|
lessonArray[i].attendanceTitle = CheckUnmarkedAttendance(
|
||||||
lessonArray[i].invert = true;
|
lessonArray[i].attendance,
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
lessonArray[i].from = lessonArray[i].from.substring(0, 5);
|
DayContainer.innerText = "";
|
||||||
lessonArray[i].until = lessonArray[i].until.substring(0, 5);
|
for (let i = 0; i < lessonArray.length; i++) {
|
||||||
|
const div = makeLessonDiv(lessonArray[i], i + 1);
|
||||||
if (settingsState.timeFormat === "12") {
|
if (lessonArray[i].invert) {
|
||||||
lessonArray[i].from = convertTo12HourFormat(
|
(div.firstChild! as HTMLElement).classList.add("day-inverted");
|
||||||
lessonArray[i].from,
|
}
|
||||||
);
|
DayContainer.append(div.firstChild as HTMLElement);
|
||||||
lessonArray[i].until = convertTo12HourFormat(
|
|
||||||
lessonArray[i].until,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
lessonArray[i].attendanceTitle = CheckUnmarkedAttendance(
|
|
||||||
lessonArray[i].attendance,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
DayContainer.innerText = "";
|
|
||||||
for (let i = 0; i < lessonArray.length; i++) {
|
|
||||||
var div = makeLessonDiv(lessonArray[i], i + 1);
|
|
||||||
|
|
||||||
if (lessonArray[i].invert) {
|
|
||||||
const div1 = div.firstChild! as HTMLElement;
|
|
||||||
div1.classList.add("day-inverted");
|
|
||||||
}
|
|
||||||
|
|
||||||
DayContainer.append(div.firstChild as HTMLElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
DayContainer.classList.remove("loading");
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
if (currentSelectedDate.getDate() == today.getDate()) {
|
|
||||||
for (let i = 0; i < lessonArray.length; i++) {
|
|
||||||
CheckCurrentLesson(lessonArray[i], i + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
CheckCurrentLessonAll(lessonArray);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
DayContainer.innerHTML = "";
|
|
||||||
var dummyDay = document.createElement("div");
|
|
||||||
dummyDay.classList.add("day-empty");
|
|
||||||
let img = document.createElement("img");
|
|
||||||
img.src = browser.runtime.getURL(LogoLight);
|
|
||||||
let text = document.createElement("p");
|
|
||||||
text.innerText = "No lessons available.";
|
|
||||||
dummyDay.append(img);
|
|
||||||
dummyDay.append(text);
|
|
||||||
DayContainer.append(dummyDay);
|
|
||||||
|
|
||||||
DayContainer.classList.remove("loading");
|
DayContainer.classList.remove("loading");
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading timetable data:", error);
|
|
||||||
|
|
||||||
DayContainer.classList.remove("loading");
|
const today = new Date();
|
||||||
|
if (currentSelectedDate.getDate() == today.getDate()) {
|
||||||
DayContainer.innerHTML = "";
|
for (let i = 0; i < lessonArray.length; i++) {
|
||||||
const errorDiv = document.createElement("div");
|
CheckCurrentLesson(lessonArray[i], i + 1);
|
||||||
errorDiv.classList.add("day-empty");
|
}
|
||||||
errorDiv.innerHTML = `
|
CheckCurrentLessonAll(lessonArray);
|
||||||
<img src="${browser.runtime.getURL(LogoLight)}" />
|
}
|
||||||
<p>Error loading lessons. Please try again.</p>
|
});
|
||||||
`;
|
|
||||||
DayContainer.append(errorDiv);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
DayContainer.innerHTML = "";
|
||||||
|
const dummyDay = document.createElement("div");
|
||||||
|
dummyDay.classList.add("day-empty");
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = browser.runtime.getURL(LogoLight);
|
||||||
|
const text = document.createElement("p");
|
||||||
|
text.innerText = "No lessons available.";
|
||||||
|
dummyDay.append(img, text);
|
||||||
|
DayContainer.append(dummyDay);
|
||||||
|
DayContainer.classList.remove("loading");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
xhr.send(
|
xhr.send(
|
||||||
@@ -931,8 +821,6 @@ async function CheckCurrentLesson(lesson: any, num: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeLessonDiv(lesson: any, num: number) {
|
function makeLessonDiv(lesson: any, num: number) {
|
||||||
if (!lesson) throw new Error("No lesson provided.");
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
code,
|
code,
|
||||||
colour,
|
colour,
|
||||||
@@ -945,33 +833,35 @@ function makeLessonDiv(lesson: any, num: number) {
|
|||||||
programmeID,
|
programmeID,
|
||||||
metaID,
|
metaID,
|
||||||
assessments,
|
assessments,
|
||||||
|
type,
|
||||||
} = lesson;
|
} = lesson;
|
||||||
|
|
||||||
let lessonString = `
|
let lessonString = `
|
||||||
<div class="day" id="${code + num}" style="${colour}">
|
<div class="day" id="${code + num}" style="${colour}">
|
||||||
<h2>${description || "Unknown"}</h2>
|
<h2>${type == "class" ? description : type == "tutorial" ? "Tutorial" : "Unknown"}</h2>
|
||||||
<h3>${staff || "Unknown"}</h3>
|
<h3>${staff || "Unknown"}</h3>
|
||||||
<h3>${room || "Unknown"}</h3>
|
<h3>${room || (type == "tutorial" ? "Unknown" : "N/A")}</h3>
|
||||||
<h4>${from || "Unknown"} - ${until || "Unknown"}</h4>
|
<h4>${from || "Unknown"} - ${until || "Unknown"}</h4>
|
||||||
<h5>${attendanceTitle || "Unknown"}</h5>
|
<h5>${attendanceTitle || "Unknown"}</h5>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (programmeID !== 0) {
|
if (type == "class") {
|
||||||
lessonString += `
|
if (programmeID !== 0) {
|
||||||
|
lessonString += `
|
||||||
<div class="day-button clickable" style="right: 5px;" onclick="location.href='${buildAssessmentURL(programmeID, metaID)}'">${assessmentsicon}</div>
|
<div class="day-button clickable" style="right: 5px;" onclick="location.href='${buildAssessmentURL(programmeID, metaID)}'">${assessmentsicon}</div>
|
||||||
<div class="day-button clickable" style="right: 35px;" onclick="location.href='../#?page=/courses/${programmeID}:${metaID}'">${coursesicon}</div>
|
<div class="day-button clickable" style="right: 35px;" onclick="location.href='../#?page=/courses/${programmeID}:${metaID}'">${coursesicon}</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assessments && assessments.length > 0) {
|
if (assessments && assessments.length > 0) {
|
||||||
const assessmentString = assessments
|
const assessmentString = assessments
|
||||||
.map(
|
.map(
|
||||||
(element: any) =>
|
(element: any) =>
|
||||||
`<p onclick="location.href = '${buildAssessmentURL(programmeID, metaID, element.id)}';">${element.title}</p>`,
|
`<p onclick="location.href = '${buildAssessmentURL(programmeID, metaID, element.id)}';">${element.title}</p>`,
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
lessonString += `
|
lessonString += `
|
||||||
<div class="fixed-tooltip assessmenttooltip">
|
<div class="fixed-tooltip assessmenttooltip">
|
||||||
<svg style="width:28px;height:28px;border-radius:0;" viewBox="0 0 24 24">
|
<svg style="width:28px;height:28px;border-radius:0;" viewBox="0 0 24 24">
|
||||||
<path fill="#ed3939" d="M16 2H4C2.9 2 2 2.9 2 4V20C2 21.11 2.9 22 4 22H16C17.11 22 18 21.11 18 20V4C18 2.9 17.11 2 16 2M16 20H4V4H6V12L8.5 9.75L11 12V4H16V20M20 15H22V17H20V15M22 7V13H20V7H22Z" />
|
<path fill="#ed3939" d="M16 2H4C2.9 2 2 2.9 2 4V20C2 21.11 2.9 22 4 22H16C17.11 22 18 21.11 18 20V4C18 2.9 17.11 2 16 2M16 20H4V4H6V12L8.5 9.75L11 12V4H16V20M20 15H22V17H20V15M22 7V13H20V7H22Z" />
|
||||||
@@ -979,6 +869,7 @@ function makeLessonDiv(lesson: any, num: number) {
|
|||||||
<div class="tooltiptext">${assessmentString}</div>
|
<div class="tooltiptext">${assessmentString}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lessonString += "</div>";
|
lessonString += "</div>";
|
||||||
@@ -995,64 +886,48 @@ function buildAssessmentURL(programmeID: any, metaID: any, itemID = "") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CheckUnmarkedAttendance(lessonattendance: any) {
|
function CheckUnmarkedAttendance(lessonattendance: any) {
|
||||||
if (lessonattendance) {
|
return lessonattendance ? lessonattendance.label : " ";
|
||||||
var lesson = lessonattendance.label;
|
|
||||||
} else {
|
|
||||||
lesson = " ";
|
|
||||||
}
|
|
||||||
return lesson;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function CreateUpcomingSection(assessments: any, activeSubjects: any) {
|
async function CreateUpcomingSection(assessments: any, activeSubjects: any) {
|
||||||
let upcomingitemcontainer = document.querySelector("#upcoming-items");
|
const upcomingitemcontainer = document.querySelector("#upcoming-items");
|
||||||
let overdueDates = [];
|
const overdueDates = [];
|
||||||
let upcomingDates = {};
|
const upcomingDates = {};
|
||||||
|
const Today = new Date();
|
||||||
var Today = new Date();
|
|
||||||
|
|
||||||
for (let i = 0; i < assessments.length; i++) {
|
for (let i = 0; i < assessments.length; i++) {
|
||||||
const assessment = assessments[i];
|
const assessmentdue = new Date(assessments[i].due);
|
||||||
let assessmentdue = new Date(assessment.due);
|
if (assessmentdue < Today && !CheckSpecialDay(Today, assessmentdue)) {
|
||||||
|
overdueDates.push(assessments[i]);
|
||||||
CheckSpecialDay(Today, assessmentdue);
|
assessments.splice(i, 1);
|
||||||
if (assessmentdue < Today) {
|
i--;
|
||||||
if (!CheckSpecialDay(Today, assessmentdue)) {
|
|
||||||
overdueDates.push(assessment);
|
|
||||||
assessments.splice(i, 1);
|
|
||||||
i--;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var TomorrowDate = new Date();
|
|
||||||
TomorrowDate.setDate(TomorrowDate.getDate() + 1);
|
|
||||||
|
|
||||||
const colours = await GetLessonColours();
|
const colours = await GetLessonColours();
|
||||||
|
|
||||||
let subjects = colours;
|
|
||||||
for (let i = 0; i < assessments.length; i++) {
|
for (let i = 0; i < assessments.length; i++) {
|
||||||
let subjectname = `timetable.subject.colour.${assessments[i].code}`;
|
const subject = colours.find(
|
||||||
|
(element: any) =>
|
||||||
let subject = subjects.find((element: any) => element.name === subjectname);
|
element.name === `timetable.subject.colour.${assessments[i].code}`,
|
||||||
|
);
|
||||||
if (!subject) {
|
if (!subject) {
|
||||||
assessments[i].colour = "--item-colour: #8e8e8e;";
|
assessments[i].colour = "--item-colour: #8e8e8e;";
|
||||||
} else {
|
} else {
|
||||||
assessments[i].colour = `--item-colour: ${subject.value};`;
|
assessments[i].colour = `--item-colour: ${subject.value};`;
|
||||||
GetThresholdOfColor(subject.value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < activeSubjects.length; i++) {
|
for (let i = 0; i < activeSubjects.length; i++) {
|
||||||
const element = activeSubjects[i];
|
const element = activeSubjects[i];
|
||||||
let subjectname = `timetable.subject.colour.${element.code}`;
|
const colour = colours.find(
|
||||||
let colour = colours.find((element: any) => element.name === subjectname);
|
(c: any) => c.name === `timetable.subject.colour.${element.code}`,
|
||||||
|
);
|
||||||
if (!colour) {
|
if (!colour) {
|
||||||
element.colour = "--item-colour: #8e8e8e;";
|
element.colour = "--item-colour: #8e8e8e;";
|
||||||
} else {
|
} else {
|
||||||
element.colour = `--item-colour: ${colour.value};`;
|
element.colour = `--item-colour: ${colour.value};`;
|
||||||
let result = GetThresholdOfColor(colour.value);
|
if (GetThresholdOfColor(colour.value) > 300) {
|
||||||
if (result > 300) {
|
|
||||||
element.invert = true;
|
element.invert = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1060,52 +935,34 @@ async function CreateUpcomingSection(assessments: any, activeSubjects: any) {
|
|||||||
|
|
||||||
CreateFilters(activeSubjects);
|
CreateFilters(activeSubjects);
|
||||||
|
|
||||||
let type;
|
|
||||||
let class_;
|
|
||||||
|
|
||||||
for (let i = 0; i < assessments.length; i++) {
|
for (let i = 0; i < assessments.length; i++) {
|
||||||
const element: any = assessments[i];
|
const element: any = assessments[i];
|
||||||
if (!upcomingDates[element.due as keyof typeof upcomingDates]) {
|
if (!upcomingDates[element.due as keyof typeof upcomingDates]) {
|
||||||
let dateObj: any = new Object();
|
const dateObj: any = {
|
||||||
dateObj.div = CreateElement(
|
div: CreateElement("div", "upcoming-date-container"),
|
||||||
(type = "div"),
|
assessments: [],
|
||||||
(class_ = "upcoming-date-container"),
|
};
|
||||||
);
|
|
||||||
dateObj.assessments = [];
|
|
||||||
(upcomingDates[element.due as keyof typeof upcomingDates] as any) =
|
(upcomingDates[element.due as keyof typeof upcomingDates] as any) =
|
||||||
dateObj;
|
dateObj;
|
||||||
}
|
}
|
||||||
let assessmentDateDiv =
|
const assessmentDateDiv =
|
||||||
upcomingDates[element.due as keyof typeof upcomingDates];
|
upcomingDates[element.due as keyof typeof upcomingDates];
|
||||||
|
|
||||||
if (assessmentDateDiv) {
|
if (assessmentDateDiv) {
|
||||||
(assessmentDateDiv as any).assessments.push(element);
|
(assessmentDateDiv as any).assessments.push(element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var date in upcomingDates) {
|
for (var date in upcomingDates) {
|
||||||
let assessmentdue = new Date(
|
const assessmentdue = new Date(
|
||||||
(
|
(upcomingDates[date as keyof typeof upcomingDates] as any).assessments[0]
|
||||||
upcomingDates[date as keyof typeof upcomingDates] as any
|
.due,
|
||||||
).assessments[0].due,
|
);
|
||||||
|
const specialcase = CheckSpecialDay(Today, assessmentdue);
|
||||||
|
const assessmentDate = createAssessmentDateDiv(
|
||||||
|
date,
|
||||||
|
upcomingDates[date as keyof typeof upcomingDates],
|
||||||
|
specialcase,
|
||||||
);
|
);
|
||||||
let specialcase = CheckSpecialDay(Today, assessmentdue);
|
|
||||||
let assessmentDate;
|
|
||||||
|
|
||||||
if (specialcase) {
|
|
||||||
let datecase: string = specialcase!;
|
|
||||||
assessmentDate = createAssessmentDateDiv(
|
|
||||||
date,
|
|
||||||
upcomingDates[date as keyof typeof upcomingDates],
|
|
||||||
|
|
||||||
datecase,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
assessmentDate = createAssessmentDateDiv(
|
|
||||||
date,
|
|
||||||
upcomingDates[date as keyof typeof upcomingDates],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (specialcase === "Yesterday") {
|
if (specialcase === "Yesterday") {
|
||||||
upcomingitemcontainer!.insertBefore(
|
upcomingitemcontainer!.insertBefore(
|
||||||
@@ -1117,80 +974,79 @@ async function CreateUpcomingSection(assessments: any, activeSubjects: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
FilterUpcomingAssessments(settingsState.subjectfilters);
|
FilterUpcomingAssessments(settingsState.subjectfilters);
|
||||||
|
|
||||||
|
if (assessments.length === 0) {
|
||||||
|
upcomingitemcontainer!.innerHTML = `
|
||||||
|
<div class="day-empty">
|
||||||
|
<img src="${browser.runtime.getURL(LogoLight)}" />
|
||||||
|
<p>No assessments available.</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAssessmentDateDiv(date: string, value: any, datecase?: any) {
|
function createAssessmentDateDiv(date: string, value: any, datecase?: any) {
|
||||||
var options = {
|
const options = {
|
||||||
weekday: "long" as "long",
|
weekday: "long" as "long",
|
||||||
month: "long" as "long",
|
month: "long" as "long",
|
||||||
day: "numeric" as "numeric",
|
day: "numeric" as "numeric",
|
||||||
};
|
};
|
||||||
const FormattedDate = new Date(date);
|
const FormattedDate = new Date(date);
|
||||||
|
|
||||||
const assessments = value.assessments;
|
const assessments = value.assessments;
|
||||||
const container = value.div;
|
const container = value.div;
|
||||||
|
|
||||||
let DateTitleDiv = document.createElement("div");
|
const DateTitleDiv = document.createElement("div");
|
||||||
DateTitleDiv.classList.add("upcoming-date-title");
|
DateTitleDiv.classList.add("upcoming-date-title");
|
||||||
|
|
||||||
if (datecase) {
|
if (datecase) {
|
||||||
let datetitle = document.createElement("h5");
|
const datetitle = document.createElement("h5");
|
||||||
datetitle.classList.add("upcoming-special-day");
|
datetitle.classList.add("upcoming-special-day");
|
||||||
datetitle.innerText = datecase;
|
datetitle.innerText = datecase;
|
||||||
DateTitleDiv.append(datetitle);
|
DateTitleDiv.append(datetitle);
|
||||||
container.setAttribute("data-day", datecase);
|
container.setAttribute("data-day", datecase);
|
||||||
}
|
}
|
||||||
|
|
||||||
let DateTitle = document.createElement("h5");
|
const DateTitle = document.createElement("h5");
|
||||||
DateTitle.innerText = FormattedDate.toLocaleDateString("en-AU", options);
|
DateTitle.innerText = FormattedDate.toLocaleDateString("en-AU", options);
|
||||||
DateTitleDiv.append(DateTitle);
|
DateTitleDiv.append(DateTitle);
|
||||||
|
|
||||||
container.append(DateTitleDiv);
|
container.append(DateTitleDiv);
|
||||||
|
|
||||||
let assessmentContainer = document.createElement("div");
|
const assessmentContainer = document.createElement("div");
|
||||||
assessmentContainer.classList.add("upcoming-date-assessments");
|
assessmentContainer.classList.add("upcoming-date-assessments");
|
||||||
|
|
||||||
for (let i = 0; i < assessments.length; i++) {
|
for (let i = 0; i < assessments.length; i++) {
|
||||||
const element = assessments[i];
|
const element = assessments[i];
|
||||||
let item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
item.classList.add("upcoming-assessment");
|
item.classList.add("upcoming-assessment");
|
||||||
item.setAttribute("data-subject", element.code);
|
item.setAttribute("data-subject", element.code);
|
||||||
item.id = `assessment${element.id}`;
|
item.id = `assessment${element.id}`;
|
||||||
|
|
||||||
item.style.cssText = element.colour;
|
item.style.cssText = element.colour;
|
||||||
|
|
||||||
let titlediv = document.createElement("div");
|
const titlediv = document.createElement("div");
|
||||||
titlediv.classList.add("upcoming-subject-title");
|
titlediv.classList.add("upcoming-subject-title");
|
||||||
|
titlediv.append(
|
||||||
let titlesvg =
|
|
||||||
stringToHTML(`<svg viewBox="0 0 24 24" style="width:35px;height:35px;fill:white;">
|
stringToHTML(`<svg viewBox="0 0 24 24" style="width:35px;height:35px;fill:white;">
|
||||||
<path d="M6 20H13V22H6C4.89 22 4 21.11 4 20V4C4 2.9 4.89 2 6 2H18C19.11 2 20 2.9 20 4V12.54L18.5 11.72L18 12V4H13V12L10.5 9.75L8 12V4H6V20M24 17L18.5 14L13 17L18.5 20L24 17M15 19.09V21.09L18.5 23L22 21.09V19.09L18.5 21L15 19.09Z"></path>
|
<path d="M6 20H13V22H6C4.89 22 4 21.11 4 20V4C4 2.9 4.89 2 6 2H18C19.11 2 20 2.9 20 4V12.54L18.5 11.72L18 12V4H13V12L10.5 9.75L8 12V4H6V20M24 17L18.5 14L13 17L18.5 20L24 17M15 19.09V21.09L18.5 23L22 21.09V19.09L18.5 21L15 19.09Z"></path>
|
||||||
</svg>`).firstChild;
|
</svg>`).firstChild!,
|
||||||
titlediv.append(titlesvg!);
|
);
|
||||||
|
|
||||||
let detailsdiv = document.createElement("div");
|
const detailsdiv = document.createElement("div");
|
||||||
detailsdiv.classList.add("upcoming-details");
|
detailsdiv.classList.add("upcoming-details");
|
||||||
let detailstitle = document.createElement("h5");
|
const detailstitle = document.createElement("h5");
|
||||||
detailstitle.innerText = `${element.subject} assessment`;
|
detailstitle.innerText = `${element.subject} assessment`;
|
||||||
let subject = document.createElement("p");
|
const subject = document.createElement("p");
|
||||||
subject.innerText = element.title;
|
subject.innerText = element.title;
|
||||||
subject.classList.add("upcoming-assessment-title");
|
subject.classList.add("upcoming-assessment-title");
|
||||||
subject.onclick = function () {
|
subject.onclick = function () {
|
||||||
document.querySelector("#menu ul")!.classList.add("noscroll");
|
document.querySelector("#menu ul")!.classList.add("noscroll");
|
||||||
location.href = `../#?page=/assessments/${element.programmeID}:${element.metaclassID}&item=${element.id}`;
|
location.href = `../#?page=/assessments/${element.programmeID}:${element.metaclassID}&item=${element.id}`;
|
||||||
};
|
};
|
||||||
detailsdiv.append(detailstitle);
|
detailsdiv.append(detailstitle, subject);
|
||||||
detailsdiv.append(subject);
|
item.append(titlediv, detailsdiv);
|
||||||
|
|
||||||
item.append(titlediv);
|
|
||||||
item.append(detailsdiv);
|
|
||||||
assessmentContainer.append(item);
|
assessmentContainer.append(item);
|
||||||
|
|
||||||
fetch(`${location.origin}/seqta/student/assessment/submissions/get`, {
|
fetch(`${location.origin}/seqta/student/assessment/submissions/get`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
assessment: element.id,
|
assessment: element.id,
|
||||||
metaclass: element.metaclassID,
|
metaclass: element.metaclassID,
|
||||||
@@ -1201,8 +1057,7 @@ function createAssessmentDateDiv(date: string, value: any, datecase?: any) {
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.payload.length > 0) {
|
if (response.payload.length > 0) {
|
||||||
const assessment = document.querySelector(`#assessment${element.id}`);
|
const assessment = document.querySelector(`#assessment${element.id}`);
|
||||||
|
const submittedtext = document.createElement("div");
|
||||||
let submittedtext = document.createElement("div");
|
|
||||||
submittedtext.classList.add("upcoming-submittedtext");
|
submittedtext.classList.add("upcoming-submittedtext");
|
||||||
submittedtext.innerText = "Submitted";
|
submittedtext.innerText = "Submitted";
|
||||||
assessment!.append(submittedtext);
|
assessment!.append(submittedtext);
|
||||||
@@ -1240,36 +1095,37 @@ function CheckSpecialDay(date1: Date, date2: Date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function GetLessonColours() {
|
async function GetLessonColours() {
|
||||||
let func = fetch(`${location.origin}/seqta/student/load/prefs?`, {
|
try {
|
||||||
method: "POST",
|
return fetch(`${location.origin}/seqta/student/load/prefs?`, {
|
||||||
headers: {
|
method: "POST",
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
},
|
body: JSON.stringify({ request: "userPrefs", asArray: true, user: 69 }),
|
||||||
body: JSON.stringify({ request: "userPrefs", asArray: true, user: 69 }),
|
})
|
||||||
});
|
.then((result) => result.json())
|
||||||
return func
|
.then((response) => response.payload);
|
||||||
.then((result) => result.json())
|
} catch (error) {
|
||||||
.then((response) => response.payload);
|
console.error("[BetterSEQTA+] Failed to get lesson colours:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateFilters(subjects: any) {
|
function CreateFilters(subjects: any) {
|
||||||
let filteroptions = settingsState.subjectfilters;
|
const filteroptions = settingsState.subjectfilters;
|
||||||
|
const filterdiv = document.querySelector("#upcoming-filters");
|
||||||
|
|
||||||
let filterdiv = document.querySelector("#upcoming-filters");
|
|
||||||
for (let i = 0; i < subjects.length; i++) {
|
for (let i = 0; i < subjects.length; i++) {
|
||||||
const element = subjects[i];
|
const element = subjects[i];
|
||||||
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(filteroptions, element.code)) {
|
if (!Object.prototype.hasOwnProperty.call(filteroptions, element.code)) {
|
||||||
filteroptions[element.code] = true;
|
filteroptions[element.code] = true;
|
||||||
settingsState.subjectfilters = filteroptions;
|
settingsState.subjectfilters = filteroptions;
|
||||||
}
|
}
|
||||||
let elementdiv = CreateSubjectFilter(
|
filterdiv!.append(
|
||||||
element.code,
|
CreateSubjectFilter(
|
||||||
element.colour,
|
element.code,
|
||||||
filteroptions[element.code],
|
element.colour,
|
||||||
|
filteroptions[element.code],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
filterdiv!.append(elementdiv);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1278,23 +1134,20 @@ function CreateSubjectFilter(
|
|||||||
itemcolour: string,
|
itemcolour: string,
|
||||||
checked: any,
|
checked: any,
|
||||||
) {
|
) {
|
||||||
let label = CreateElement("label", "upcoming-checkbox-container");
|
const label = CreateElement("label", "upcoming-checkbox-container");
|
||||||
label.innerText = subjectcode;
|
label.innerText = subjectcode;
|
||||||
let input1 = CreateElement("input");
|
const input = CreateElement("input") as HTMLInputElement;
|
||||||
const input = input1 as HTMLInputElement;
|
|
||||||
input.type = "checkbox";
|
input.type = "checkbox";
|
||||||
input.checked = checked;
|
input.checked = checked;
|
||||||
input.id = `filter-${subjectcode}`;
|
input.id = `filter-${subjectcode}`;
|
||||||
label.style.cssText = itemcolour;
|
label.style.cssText = itemcolour;
|
||||||
let span = CreateElement("span", "upcoming-checkmark");
|
const span = CreateElement("span", "upcoming-checkmark");
|
||||||
label.append(input);
|
label.append(input, span);
|
||||||
label.append(span);
|
|
||||||
|
|
||||||
input.addEventListener("change", function (change) {
|
input.addEventListener("change", function (change) {
|
||||||
let filters = settingsState.subjectfilters;
|
const filters = settingsState.subjectfilters;
|
||||||
let id = (change.target as HTMLInputElement)!.id.split("-")[1];
|
const id = (change.target as HTMLInputElement).id.split("-")[1];
|
||||||
filters[id] = (change.target as HTMLInputElement)!.checked;
|
filters[id] = (change.target as HTMLInputElement).checked;
|
||||||
|
|
||||||
settingsState.subjectfilters = filters;
|
settingsState.subjectfilters = filters;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,17 @@
|
|||||||
import stringToHTML from "../stringToHTML";
|
import stringToHTML from "../stringToHTML";
|
||||||
import { settingsState } from "../listeners/SettingsState";
|
import { settingsState } from "../listeners/SettingsState";
|
||||||
import { animate, stagger } from "motion";
|
import { openPopup } from "./PopupManager";
|
||||||
import { DeleteWhatsNew } from "../Whatsnew";
|
|
||||||
|
|
||||||
export function OpenAboutPage() {
|
export function OpenAboutPage() {
|
||||||
const background = document.createElement("div");
|
const header = stringToHTML(
|
||||||
background.id = "whatsnewbk";
|
|
||||||
background.classList.add("whatsnewBackground");
|
|
||||||
|
|
||||||
const container = document.createElement("div");
|
|
||||||
container.classList.add("whatsnewContainer");
|
|
||||||
|
|
||||||
var header: any = stringToHTML(
|
|
||||||
/* html */
|
/* html */
|
||||||
`<div class="whatsnewHeader">
|
`<div class="whatsnewHeader">
|
||||||
<h1>About</h1>
|
<h1>About</h1>
|
||||||
<p>About the extension</p>
|
<p>About the extension</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
).firstChild;
|
).firstChild as HTMLElement;
|
||||||
|
|
||||||
let text = stringToHTML(/* html */ `
|
const text = stringToHTML(/* html */ `
|
||||||
<div class="whatsnewTextContainer" style="overflow-y: hidden;">
|
<div class="whatsnewTextContainer" style="overflow-y: hidden;">
|
||||||
<img src="${settingsState.DarkMode ? "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/dark.jpg" : "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/light.jpg"}" class="aboutImg" />
|
<img src="${settingsState.DarkMode ? "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/dark.jpg" : "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/light.jpg"}" class="aboutImg" />
|
||||||
<p>BetterSEQTA+ is a fork of BetterSEQTA (originally developed by Nulkem), which was discontinued. BetterSEQTA+ continued development of BetterSEQTA, while incorporating a plethora of features. </p>
|
<p>BetterSEQTA+ is a fork of BetterSEQTA (originally developed by Nulkem), which was discontinued. BetterSEQTA+ continued development of BetterSEQTA, while incorporating a plethora of features. </p>
|
||||||
@@ -33,13 +25,13 @@ export function OpenAboutPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<div style="max-width: 600px; margin: auto;">
|
<div style="max-width: 600px; margin: auto;">
|
||||||
<img
|
<img
|
||||||
src="https://contrib.rocks/image?repo=BetterSEQTA/BetterSEQTA-Plus&columns=14"
|
src="https://contrib.rocks/image?repo=BetterSEQTA/BetterSEQTA-Plus&columns=10"
|
||||||
style="width: 100%; max-width: 500px; height: auto; object-fit: contain; display: block; margin: -110px auto 0;">
|
style="width: 100%; max-width: 500px; height: auto; object-fit: contain; display: block; margin: -80px auto 0;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).firstChild;
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
let footer = stringToHTML(/* html */ `
|
const footer = stringToHTML(/* html */ `
|
||||||
<div class="whatsnewFooter">
|
<div class="whatsnewFooter">
|
||||||
<div>
|
<div>
|
||||||
Resources and Feedback:
|
Resources and Feedback:
|
||||||
@@ -67,56 +59,10 @@ export function OpenAboutPage() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).firstChild;
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
let exitbutton = document.createElement("div");
|
openPopup({
|
||||||
exitbutton.id = "whatsnewclosebutton";
|
header,
|
||||||
|
content: [text, footer],
|
||||||
container.append(header);
|
|
||||||
container.append(text as ChildNode);
|
|
||||||
container.append(footer as ChildNode);
|
|
||||||
container.append(exitbutton);
|
|
||||||
|
|
||||||
background.append(container);
|
|
||||||
|
|
||||||
document.getElementById("container")!.append(background);
|
|
||||||
|
|
||||||
let bkelement = document.getElementById("whatsnewbk");
|
|
||||||
let popup = document.getElementsByClassName("whatsnewContainer")[0];
|
|
||||||
|
|
||||||
if (settingsState.animations) {
|
|
||||||
animate(
|
|
||||||
[popup, bkelement as HTMLElement],
|
|
||||||
{ scale: [0, 1] },
|
|
||||||
{
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 220,
|
|
||||||
damping: 18,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
animate(
|
|
||||||
".whatsnewTextContainer *",
|
|
||||||
{ opacity: [0, 1], y: [10, 0] },
|
|
||||||
{
|
|
||||||
delay: stagger(0.05, { startDelay: 0.1 }),
|
|
||||||
duration: 0.5,
|
|
||||||
ease: [0.22, 0.03, 0.26, 1],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
delete settingsState.justupdated;
|
|
||||||
|
|
||||||
bkelement!.addEventListener("click", function (event) {
|
|
||||||
// Check if the click event originated from the element itself and not any of its children
|
|
||||||
if (event.target === bkelement) {
|
|
||||||
DeleteWhatsNew();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var closeelement = document.getElementById("whatsnewclosebutton");
|
|
||||||
closeelement!.addEventListener("click", function () {
|
|
||||||
DeleteWhatsNew();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-98
@@ -1,24 +1,5 @@
|
|||||||
import { settingsState } from "./listeners/SettingsState";
|
import stringToHTML from "../stringToHTML";
|
||||||
import { animate, stagger } from "motion";
|
import { openPopup } from "./PopupManager";
|
||||||
import stringToHTML from "./stringToHTML";
|
|
||||||
|
|
||||||
export async function DeleteWhatsNew() {
|
|
||||||
const bkelement = document.getElementById("whatsnewbk");
|
|
||||||
const popup = document.querySelector(".whatsnewContainer") as HTMLElement;
|
|
||||||
|
|
||||||
if (!settingsState.animations) {
|
|
||||||
bkelement?.remove();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
animate(
|
|
||||||
[popup, bkelement!],
|
|
||||||
{ opacity: [1, 0], scale: [1, 0] },
|
|
||||||
{ ease: [0.22, 0.03, 0.26, 1] },
|
|
||||||
).then(() => {
|
|
||||||
bkelement?.remove();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpenMinecraftServerPopup() {
|
export function OpenMinecraftServerPopup() {
|
||||||
if (!document.querySelector('link[href*="minecraftia"]')) {
|
if (!document.querySelector('link[href*="minecraftia"]')) {
|
||||||
@@ -28,45 +9,36 @@ export function OpenMinecraftServerPopup() {
|
|||||||
document.head.appendChild(fontLink);
|
document.head.appendChild(fontLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
const background = document.createElement("div");
|
const header = stringToHTML(
|
||||||
background.id = "whatsnewbk";
|
|
||||||
background.classList.add("whatsnewBackground");
|
|
||||||
|
|
||||||
const container = document.createElement("div");
|
|
||||||
container.classList.add("whatsnewContainer");
|
|
||||||
|
|
||||||
var header: any = stringToHTML(
|
|
||||||
/* html */
|
/* html */
|
||||||
`<div class="whatsnewHeader">
|
`<div class="whatsnewHeader">
|
||||||
<h1>Minecraft Server</h1>
|
<h1>Minecraft Server</h1>
|
||||||
<p>The official BetterSEQTA+ Minecraft Server</p>
|
<p>The official BetterSEQTA+ Minecraft Server</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
).firstChild;
|
).firstChild as HTMLElement;
|
||||||
|
|
||||||
let imagecont = document.createElement("div");
|
const imageContainer = document.createElement("div");
|
||||||
imagecont.classList.add("whatsnewImgContainer");
|
imageContainer.classList.add("whatsnewImgContainer");
|
||||||
|
|
||||||
let video = document.createElement("video");
|
const video = document.createElement("video");
|
||||||
video.style.aspectRatio = "16/9";
|
video.style.aspectRatio = "16/9";
|
||||||
video.style.background = "black";
|
video.style.background = "black";
|
||||||
let source = document.createElement("source");
|
|
||||||
|
|
||||||
|
const source = document.createElement("source");
|
||||||
source.setAttribute(
|
source.setAttribute(
|
||||||
"src",
|
"src",
|
||||||
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/server-video.mp4",
|
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/server-video.mp4",
|
||||||
);
|
);
|
||||||
|
|
||||||
video.autoplay = true;
|
video.autoplay = true;
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
video.loop = true;
|
video.loop = true;
|
||||||
video.appendChild(source);
|
video.appendChild(source);
|
||||||
video.classList.add("whatsnewImg");
|
video.classList.add("whatsnewImg");
|
||||||
imagecont.appendChild(video);
|
imageContainer.appendChild(video);
|
||||||
|
|
||||||
let textcontainer = document.createElement("div");
|
const text = stringToHTML(/* html */ `
|
||||||
textcontainer.classList.add("whatsnewTextContainer");
|
<div class="whatsnewTextContainer" style="height: 50%; overflow-y: hidden;">
|
||||||
|
|
||||||
let text = stringToHTML(/* html */ `
|
|
||||||
<div class="whatsnewTextContainer" style="height: 50%; overflow-y: scroll;">
|
|
||||||
<h1>Join our community in Minecraft!</h1>
|
<h1>Join our community in Minecraft!</h1>
|
||||||
<p style="margin-left: 0;">Join the official BetterSEQTA+ Minecraft Server community now!</p>
|
<p style="margin-left: 0;">Join the official BetterSEQTA+ Minecraft Server community now!</p>
|
||||||
|
|
||||||
@@ -92,8 +64,7 @@ export function OpenMinecraftServerPopup() {
|
|||||||
-1px -1px 0 #000,
|
-1px -1px 0 #000,
|
||||||
1px -1px 0 #000,
|
1px -1px 0 #000,
|
||||||
-1px 1px 0 #000,
|
-1px 1px 0 #000,
|
||||||
1px 1px 0 #000;
|
1px 1px 0 #000;">
|
||||||
">
|
|
||||||
mc.betterseqta.org
|
mc.betterseqta.org
|
||||||
</p>
|
</p>
|
||||||
<p style="
|
<p style="
|
||||||
@@ -107,14 +78,13 @@ export function OpenMinecraftServerPopup() {
|
|||||||
-1px -1px 0 #000,
|
-1px -1px 0 #000,
|
||||||
1px -1px 0 #000,
|
1px -1px 0 #000,
|
||||||
-1px 1px 0 #000,
|
-1px 1px 0 #000,
|
||||||
1px 1px 0 #000;
|
1px 1px 0 #000;">
|
||||||
">
|
|
||||||
Version: 1.21.4
|
Version: 1.21.4
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`).firstChild;
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
let footer = stringToHTML(/* html */ `
|
const footer = stringToHTML(/* html */ `
|
||||||
<div class="whatsnewFooter">
|
<div class="whatsnewFooter">
|
||||||
<div>
|
<div>
|
||||||
Resources and Feedback:
|
Resources and Feedback:
|
||||||
@@ -144,59 +114,10 @@ export function OpenMinecraftServerPopup() {
|
|||||||
<div>
|
<div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).firstChild;
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
let exitbutton = document.createElement("div");
|
openPopup({
|
||||||
exitbutton.id = "whatsnewclosebutton";
|
|
||||||
|
|
||||||
container.append(
|
|
||||||
header,
|
header,
|
||||||
imagecont,
|
content: [imageContainer, text, footer],
|
||||||
text as HTMLElement,
|
|
||||||
footer as HTMLElement,
|
|
||||||
exitbutton,
|
|
||||||
);
|
|
||||||
|
|
||||||
background.append(container);
|
|
||||||
|
|
||||||
document.getElementById("container")!.append(background);
|
|
||||||
|
|
||||||
let bkelement = document.getElementById("whatsnewbk");
|
|
||||||
let popup = document.getElementsByClassName("whatsnewContainer")[0];
|
|
||||||
|
|
||||||
if (settingsState.animations) {
|
|
||||||
animate(
|
|
||||||
[popup, bkelement as HTMLElement],
|
|
||||||
{ scale: [0, 1] },
|
|
||||||
{
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 220,
|
|
||||||
damping: 18,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
animate(
|
|
||||||
".whatsnewTextContainer *",
|
|
||||||
{ opacity: [0, 1], y: [10, 0] },
|
|
||||||
{
|
|
||||||
delay: stagger(0.05, { startDelay: 0.1 }),
|
|
||||||
duration: 0.5,
|
|
||||||
ease: [0.22, 0.03, 0.26, 1],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
delete settingsState.justupdated;
|
|
||||||
|
|
||||||
bkelement!.addEventListener("click", function (event) {
|
|
||||||
// Check if the click event originated from the element itself and not any of its children
|
|
||||||
if (event.target === bkelement) {
|
|
||||||
DeleteWhatsNew();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var closeelement = document.getElementById("whatsnewclosebutton");
|
|
||||||
closeelement!.addEventListener("click", function () {
|
|
||||||
DeleteWhatsNew();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import stringToHTML from "../stringToHTML";
|
||||||
|
import { settingsState } from "../listeners/SettingsState";
|
||||||
|
import { openPopup } from "./PopupManager";
|
||||||
|
|
||||||
|
export function showPrivacyNotification() {
|
||||||
|
const lastUpdated = "2025-12-19";
|
||||||
|
|
||||||
|
if (document.getElementById("whatsnewbk")) return;
|
||||||
|
if (settingsState.privacyStatementShown) return;
|
||||||
|
if (settingsState.privacyStatementLastUpdated && new Date(settingsState.privacyStatementLastUpdated) > new Date(lastUpdated)) return;
|
||||||
|
|
||||||
|
const header = stringToHTML(
|
||||||
|
/* html */
|
||||||
|
`<div class="whatsnewHeader">
|
||||||
|
<h1>Privacy Statement</h1>
|
||||||
|
<p>Important Information</p>
|
||||||
|
</div>`,
|
||||||
|
).firstChild as HTMLElement;
|
||||||
|
|
||||||
|
const text = stringToHTML(/* html */ `
|
||||||
|
<div class="whatsnewTextContainer privacyStatement" style="overflow-y: auto; font-size: 1.2rem; line-height: 1.6;">
|
||||||
|
<img style="aspect-ratio: 16/5.8;" src="${settingsState.DarkMode ? "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/dark.jpg" : "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/light.jpg"}" class="aboutImg" />
|
||||||
|
<p>
|
||||||
|
<strong>Addressing Recent Concerns About BetterSEQTA+</strong><br>
|
||||||
|
We appreciate the feedback we've received from several schools regarding BetterSEQTA+. Transparency and trust are core to our mission, and we want to address these concerns directly.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Our Commitment to Privacy:</strong><br>
|
||||||
|
<span style="display: block; margin-left: 1em;">
|
||||||
|
• We do not collect, store, or share any personal information<br>
|
||||||
|
• All data processing happens locally on your device<br>
|
||||||
|
• Our code is open source and available for review
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>What We're Doing:</strong><br>
|
||||||
|
We're willing to actively work with school administrators to ensure BetterSEQTA+ meets both student needs and institutional requirements. If your school has specific concerns, we encourage them to contact us at <a href="mailto:betterseqta.plus@gmail.com" style="color: inherit; text-decoration: underline;">betterseqta.plus@gmail.com</a> or via github at <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">github.com/BetterSEQTA/BetterSEQTA-Plus</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
For complete details about our privacy practices, visit our <a href="https://betterseqta.org/privacy" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">privacy policy</a> or click the shield icon in settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
|
settingsState.privacyStatementLastUpdated = "2025-12-20";
|
||||||
|
settingsState.privacyStatementShown = true;
|
||||||
|
|
||||||
|
openPopup({
|
||||||
|
header,
|
||||||
|
content: [text],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import stringToHTML from "../stringToHTML";
|
||||||
|
import { openPopup } from "./PopupManager";
|
||||||
|
|
||||||
|
export function OpenPrivacyStatement() {
|
||||||
|
const header = stringToHTML(
|
||||||
|
/* html */
|
||||||
|
`<div class="whatsnewHeader">
|
||||||
|
<h1>Privacy Statement</h1>
|
||||||
|
<p>Our commitment to your privacy</p>
|
||||||
|
</div>`,
|
||||||
|
).firstChild as HTMLElement;
|
||||||
|
|
||||||
|
const text = stringToHTML(/* html */ `
|
||||||
|
<div class="whatsnewTextContainer" style="overflow-y: auto; max-height: 60vh;">
|
||||||
|
<h2 style="margin-top: 0;">Privacy Policy</h2>
|
||||||
|
<p>At BetterSEQTA+, we take your privacy seriously. We want to be completely transparent about how we handle your data.</p>
|
||||||
|
|
||||||
|
<h3>Data Collection</h3>
|
||||||
|
<p><strong>We never collect any information from you.</strong> BetterSEQTA+ is designed to work entirely on your device. All processing happens locally in your browser, and we do not send any data to external servers.</p>
|
||||||
|
|
||||||
|
<h3>What We Don't Do</h3>
|
||||||
|
<ul style="text-align: left; margin: 10px 0;">
|
||||||
|
<li>We do not track your browsing activity</li>
|
||||||
|
<li>We do not collect personal information</li>
|
||||||
|
<li>We do not store your SEQTA credentials</li>
|
||||||
|
<li>We do not send data to third-party services</li>
|
||||||
|
<li>We do not use analytics or tracking cookies</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Local Storage</h3>
|
||||||
|
<p>BetterSEQTA+ uses your browser's local storage to save your preferences and settings. This data remains on your device and is never transmitted anywhere. You can clear this data at any time through your browser's settings.</p>
|
||||||
|
|
||||||
|
<h3>Open Source</h3>
|
||||||
|
<p>BetterSEQTA+ is an open-source project. You can review our code on <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" style="color: inherit; text-decoration: underline;">GitHub</a> to verify our privacy practices. We believe in transparency and encourage you to inspect the code yourself.</p>
|
||||||
|
|
||||||
|
<h3>Our Commitment</h3>
|
||||||
|
<p>We are committed to providing the best features possible while respecting your privacy. We understand that schools and students have concerns about data privacy, and we want to assure you that BetterSEQTA+ is designed with privacy as a core principle.</p>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px; font-weight: bold;">If you have any questions or concerns about our privacy practices, please reach out to us through our <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" style="color: inherit; text-decoration: underline;">GitHub repository</a>.</p>
|
||||||
|
</div>
|
||||||
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
|
openPopup({
|
||||||
|
header,
|
||||||
|
content: [text],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,48 +1,22 @@
|
|||||||
import { settingsState } from "./listeners/SettingsState";
|
import stringToHTML from "../stringToHTML";
|
||||||
import { animate, stagger } from "motion";
|
|
||||||
import stringToHTML from "./stringToHTML";
|
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import kofi from "@/resources/kofi.png?base64";
|
import kofi from "@/resources/kofi.png?base64";
|
||||||
|
import { openPopup } from "./PopupManager";
|
||||||
export async function DeleteWhatsNew() {
|
|
||||||
const bkelement = document.getElementById("whatsnewbk");
|
|
||||||
const popup = document.getElementsByClassName("whatsnewContainer")[0];
|
|
||||||
|
|
||||||
if (!settingsState.animations) {
|
|
||||||
bkelement?.remove();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
animate(
|
|
||||||
[popup, bkelement!],
|
|
||||||
{ opacity: [1, 0], scale: [1, 0] },
|
|
||||||
{ ease: [0.22, 0.03, 0.26, 1] },
|
|
||||||
).then(() => {
|
|
||||||
bkelement?.remove();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpenWhatsNewPopup() {
|
export function OpenWhatsNewPopup() {
|
||||||
const background = document.createElement("div");
|
const header = stringToHTML(
|
||||||
background.id = "whatsnewbk";
|
|
||||||
background.classList.add("whatsnewBackground");
|
|
||||||
|
|
||||||
const container = document.createElement("div");
|
|
||||||
container.classList.add("whatsnewContainer");
|
|
||||||
|
|
||||||
var header: any = stringToHTML(
|
|
||||||
/* html */
|
/* html */
|
||||||
`<div class="whatsnewHeader">
|
`<div class="whatsnewHeader">
|
||||||
<h1>What's New</h1>
|
<h1>What's New</h1>
|
||||||
<p>BetterSEQTA+ V${browser.runtime.getManifest().version}</p>
|
<p>BetterSEQTA+ V${browser.runtime.getManifest().version}</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
).firstChild;
|
).firstChild as HTMLElement;
|
||||||
|
|
||||||
let imagecont = document.createElement("div");
|
const imageContainer = document.createElement("div");
|
||||||
imagecont.classList.add("whatsnewImgContainer");
|
imageContainer.classList.add("whatsnewImgContainer");
|
||||||
|
|
||||||
let video = document.createElement("video");
|
const video = document.createElement("video");
|
||||||
let source = document.createElement("source");
|
const source = document.createElement("source");
|
||||||
|
|
||||||
source.setAttribute(
|
source.setAttribute(
|
||||||
"src",
|
"src",
|
||||||
@@ -53,19 +27,84 @@ export function OpenWhatsNewPopup() {
|
|||||||
video.loop = true;
|
video.loop = true;
|
||||||
video.appendChild(source);
|
video.appendChild(source);
|
||||||
video.classList.add("whatsnewImg");
|
video.classList.add("whatsnewImg");
|
||||||
imagecont.appendChild(video);
|
imageContainer.appendChild(video);
|
||||||
|
|
||||||
/* let whatsnewimg = document.createElement("img");
|
const text = stringToHTML(/* html */ `
|
||||||
//whatsnewimg.src = "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/update-image.webp";
|
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
||||||
whatsnewimg.src = browser.runtime.getURL('../../resources/update-image.webp');
|
|
||||||
whatsnewimg.classList.add("whatsnewImg");
|
|
||||||
imagecont.appendChild(whatsnewimg); */
|
|
||||||
|
|
||||||
let textcontainer = document.createElement("div");
|
<h1>3.5.3 - Adaptive theme updates</h1>
|
||||||
textcontainer.classList.add("whatsnewTextContainer");
|
<li>Fixed adaptive theming on current-year course and assessment pages.</li>
|
||||||
|
|
||||||
|
<h1>3.5.2 - PDF & store compliance</h1>
|
||||||
|
<li>Put PDF.js with the extension so assessment weighting stays compatible with Chrome Web Store rules</li>
|
||||||
|
|
||||||
|
<h1>3.5.1 - QR & session link fix</h1>
|
||||||
|
<li>Fixed DesQTA Connect Mobile App QR generation on Chrome</li>
|
||||||
|
|
||||||
|
<h1>3.5.0 - Adaptive Theme, Timetable Editor & More</h1>
|
||||||
|
<li>Added adaptive theme colour</li>
|
||||||
|
<li>Added optional soft gradient for adaptive theme when viewing a class</li>
|
||||||
|
<li>Added timetable editor</li>
|
||||||
|
<li>Added icon-only sidebar option for a compact layout</li>
|
||||||
|
<li>Added empty states for notices and homepage cards</li>
|
||||||
|
<li>Added BetterSEQTA Cloud sign-in and improved store browsing details</li>
|
||||||
|
<li>Improved popup rendering to better handle floating UI elements</li>
|
||||||
|
<li>Fixed assessment colouring issues</li>
|
||||||
|
<li>Fixed icon loading on SEQTA pages and improved Windows dropdown styling</li>
|
||||||
|
<li>Fixed sidebar issues with compact mode, keyboard focus, and tab navigation</li>
|
||||||
|
<li>Fixed unnecessary notice modal scrolling and other popup styling issues</li>
|
||||||
|
<li>Added new kanban categories to the assessments overview</li>
|
||||||
|
<li>Added DesQTA QR code generation in settings for linking to the DesQTA (BetterSEQTA) mobile app</li>
|
||||||
|
<li>New store with improved theme browsing and backgrounds</li>
|
||||||
|
<li>Other minor bug fixes and improvements</li>
|
||||||
|
|
||||||
|
<h1>3.4.16 - Subject Averages Fixes</h1>
|
||||||
|
<li>Fixed subject averages not showing correctly with letter grades and weighted marks</li>
|
||||||
|
|
||||||
|
<h1>3.4.15 - SEQTA New UI Patch</h1>
|
||||||
|
<li>Fixed compatibility issues caused by the new SEQTA UI update</li>
|
||||||
|
<li>Adjusted styling to match updated SEQTA layout changes</li>
|
||||||
|
<li>Other minor bug fixes and stability improvements</li>
|
||||||
|
|
||||||
|
<h1>3.4.14 - Search & Assessments Update</h1>
|
||||||
|
<li>Global Search improvements: indexing progress, more accurate results, and now includes past assessments/assignments</li>
|
||||||
|
<li>Assessment Averages now parse weightings when possible for more accurate subject averages</li>
|
||||||
|
<li>Added weight labels to assessment items (including proper handling of 0% and missing weights)</li>
|
||||||
|
<li>Fixed homepage tutor lesson colours and assessments/courses visibility issues</li>
|
||||||
|
<li>Fixed upcoming lessons tutorial room not displaying</li>
|
||||||
|
<li>Fixed favicon not showing / race condition issues</li>
|
||||||
|
<li>Other minor styling and stability improvements</li>
|
||||||
|
|
||||||
|
<h1>3.4.13 - Bug Fixes & Styling Improvements</h1>
|
||||||
|
<li>Fixed house/year box hard failing when house_colour does not exist</li>
|
||||||
|
<li>Fixed message of the day being unreadable in light mode</li>
|
||||||
|
<li>Fixed global font styling issues due to SEQTA updates</li>
|
||||||
|
<li>Fixed styling issues with title bar and other elements</li>
|
||||||
|
<li>Other minor bug fixes and improvements</li>
|
||||||
|
|
||||||
|
<h1>3.4.12 - Privacy Updates & Bug Fixes</h1>
|
||||||
|
<li>Added privacy statement</li>
|
||||||
|
<li>Added disclaimer modal to assessment averages switch</li>
|
||||||
|
<li>Improved popup management system</li>
|
||||||
|
<li>Other minor bug fixes and improvements</li>
|
||||||
|
|
||||||
|
<h1>3.4.11 - New Features & Bug Fixes</h1>
|
||||||
|
<li>Added Background Music plugin</li>
|
||||||
|
<li>Added empty state for assessments on homepage</li>
|
||||||
|
<li>Added Colour Picker hex/rgba controls</li>
|
||||||
|
<li>Fixed custom shortcuts positioning (moved above regular shortcuts)</li>
|
||||||
|
<li>Fixed Go to popup not scrolling properly</li>
|
||||||
|
<li>Made theme edit mode more plain</li>
|
||||||
|
<li>Other minor bug fixes and improvements</li>
|
||||||
|
|
||||||
|
<h1>3.4.10 - Minor bug fixes</h1>
|
||||||
|
<li>Fixed UI file styling incorrectly applying to documents</li>
|
||||||
|
<li>Fixed missing styles in global search</li>
|
||||||
|
<li>Added icons for image files in file viewer</li>
|
||||||
|
<li>Added rounded corners when dragging calendar events</li>
|
||||||
|
<li>Improved performance of element scanning</li>
|
||||||
|
<li>Other minor improvements</li>
|
||||||
|
|
||||||
let text = stringToHTML(/* html */ `
|
|
||||||
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: scroll;">
|
|
||||||
<h1>3.4.9 - Bug Fixes and Performance Improvements</h1>
|
<h1>3.4.9 - Bug Fixes and Performance Improvements</h1>
|
||||||
<li>Fixed performance issues with large notices on the homepage</li>
|
<li>Fixed performance issues with large notices on the homepage</li>
|
||||||
<li>Improved performance when global search is disabled</li>
|
<li>Improved performance when global search is disabled</li>
|
||||||
@@ -115,7 +154,7 @@ export function OpenWhatsNewPopup() {
|
|||||||
<li>Fixed discord icon colour in light mode</li>
|
<li>Fixed discord icon colour in light mode</li>
|
||||||
<li>Fixed subject averages not showing up with letter grades</li>
|
<li>Fixed subject averages not showing up with letter grades</li>
|
||||||
<li>Tweaked compose UI</li>
|
<li>Tweaked compose UI</li>
|
||||||
|
|
||||||
<h1>3.4.4 - Bug Fixes and Improvements</h1>
|
<h1>3.4.4 - Bug Fixes and Improvements</h1>
|
||||||
<li>Added vertical zoom to the timetable</li>
|
<li>Added vertical zoom to the timetable</li>
|
||||||
<li>Fixed theme importing failing when images were included</li>
|
<li>Fixed theme importing failing when images were included</li>
|
||||||
@@ -129,15 +168,15 @@ export function OpenWhatsNewPopup() {
|
|||||||
<li>Fixed theme application in the creator</li>
|
<li>Fixed theme application in the creator</li>
|
||||||
<li>Performance improvements</li>
|
<li>Performance improvements</li>
|
||||||
<li>Other minor bug fixes</li>
|
<li>Other minor bug fixes</li>
|
||||||
|
|
||||||
<h1>3.4.3 - Minor Bug Fixes</h1>
|
<h1>3.4.3 - Minor Bug Fixes</h1>
|
||||||
<li>Fixed a bug where timetable colours couldn't be changed</li>
|
<li>Fixed a bug where timetable colours couldn't be changed</li>
|
||||||
<li>Other minor bug fixes</li>
|
<li>Other minor bug fixes</li>
|
||||||
|
|
||||||
<h1>3.4.2 - Minor Bug Fixes</h1>
|
<h1>3.4.2 - Minor Bug Fixes</h1>
|
||||||
<li>Fixed a bug where Assessment Average wasn't enabled by default</li>
|
<li>Fixed a bug where Assessment Average wasn't enabled by default</li>
|
||||||
<li>Fixed floating menus would sometimes be placed behind other elements</li>
|
<li>Fixed floating menus would sometimes be placed behind other elements</li>
|
||||||
|
|
||||||
<h1>3.4.1 - Bug Fixes and Performance Improvements</h1>
|
<h1>3.4.1 - Bug Fixes and Performance Improvements</h1>
|
||||||
<li>Added a new "Subject Average" section to the assessments page</li>
|
<li>Added a new "Subject Average" section to the assessments page</li>
|
||||||
<li>Fixed a bug where animations wouldn't play correctly</li>
|
<li>Fixed a bug where animations wouldn't play correctly</li>
|
||||||
@@ -146,7 +185,7 @@ export function OpenWhatsNewPopup() {
|
|||||||
<li>Improved animation performance</li>
|
<li>Improved animation performance</li>
|
||||||
<li>Better Animations!</li>
|
<li>Better Animations!</li>
|
||||||
<li>Minor style tweaks</li>
|
<li>Minor style tweaks</li>
|
||||||
|
|
||||||
<h1>3.4.0 - Major Performance Update</h1>
|
<h1>3.4.0 - Major Performance Update</h1>
|
||||||
<li>Completely rebuilt the extension popup using Svelte for dramatically improved performance</li>
|
<li>Completely rebuilt the extension popup using Svelte for dramatically improved performance</li>
|
||||||
<li>Added a brand new background store with search functionality and downloadable backgrounds</li>
|
<li>Added a brand new background store with search functionality and downloadable backgrounds</li>
|
||||||
@@ -155,10 +194,10 @@ export function OpenWhatsNewPopup() {
|
|||||||
<li>Smoother animations and improved scrolling</li>
|
<li>Smoother animations and improved scrolling</li>
|
||||||
<li>Fixed Firefox compatibility issues</li>
|
<li>Fixed Firefox compatibility issues</li>
|
||||||
<li>Other minor bug fixes and under the hood improvements</li>
|
<li>Other minor bug fixes and under the hood improvements</li>
|
||||||
|
|
||||||
<h1>3.3.1 - Hot Fix</h1>
|
<h1>3.3.1 - Hot Fix</h1>
|
||||||
<li>Fixed assessments not loading when no notices are available</li>
|
<li>Fixed assessments not loading when no notices are available</li>
|
||||||
|
|
||||||
<h1>3.3.0 - Overhauled Theming System</h1>
|
<h1>3.3.0 - Overhauled Theming System</h1>
|
||||||
<li>Added a theme store!</li>
|
<li>Added a theme store!</li>
|
||||||
<li>Added the new theme creator!</li>
|
<li>Added the new theme creator!</li>
|
||||||
@@ -172,12 +211,12 @@ export function OpenWhatsNewPopup() {
|
|||||||
<li>Made animations toggle apply to settings</li>
|
<li>Made animations toggle apply to settings</li>
|
||||||
<li>Small styling improvements</li>
|
<li>Small styling improvements</li>
|
||||||
<li>Other minor bug fixes</li>
|
<li>Other minor bug fixes</li>
|
||||||
|
|
||||||
|
|
||||||
<h1>3.2.7 - Minor Improvements</h1>
|
<h1>3.2.7 - Minor Improvements</h1>
|
||||||
<li>Improved performance!</li>
|
<li>Improved performance!</li>
|
||||||
<li>Fixed a bug where the icon wasn't showing up</li>
|
<li>Fixed a bug where the icon wasn't showing up</li>
|
||||||
|
|
||||||
<h1>3.2.6 - Bug fixes and performance improvements</h1>
|
<h1>3.2.6 - Bug fixes and performance improvements</h1>
|
||||||
<li>Improved contrast for notifications</li>
|
<li>Improved contrast for notifications</li>
|
||||||
<li>Added 12-hour time format toggle</li>
|
<li>Added 12-hour time format toggle</li>
|
||||||
@@ -191,7 +230,7 @@ export function OpenWhatsNewPopup() {
|
|||||||
<li>Enabled spellcheck inside of direct messages</li>
|
<li>Enabled spellcheck inside of direct messages</li>
|
||||||
<li>Fixed timetable dates being misaligned</li>
|
<li>Fixed timetable dates being misaligned</li>
|
||||||
<li>Other minor bug fixes and under the hood improvements</li>
|
<li>Other minor bug fixes and under the hood improvements</li>
|
||||||
|
|
||||||
<h1>3.2.5 - More Bug Fixes</h1>
|
<h1>3.2.5 - More Bug Fixes</h1>
|
||||||
<li>New direct message scroll animations</li>
|
<li>New direct message scroll animations</li>
|
||||||
<li>Added error message for brave browser shields breaking backgrounds</li>
|
<li>Added error message for brave browser shields breaking backgrounds</li>
|
||||||
@@ -200,7 +239,7 @@ export function OpenWhatsNewPopup() {
|
|||||||
<li>Made settings panel auto size to height of screen</li>
|
<li>Made settings panel auto size to height of screen</li>
|
||||||
<li>Fixed timetable dates not visible</li>
|
<li>Fixed timetable dates not visible</li>
|
||||||
<li>Other minor bug fixes</li>
|
<li>Other minor bug fixes</li>
|
||||||
|
|
||||||
<h1>3.2.4 - Bug Fixes</h1>
|
<h1>3.2.4 - Bug Fixes</h1>
|
||||||
<li>Added an open changelog button to settings</li>
|
<li>Added an open changelog button to settings</li>
|
||||||
<li>Fixed a memory overflow bug with Education Perfect</li>
|
<li>Fixed a memory overflow bug with Education Perfect</li>
|
||||||
@@ -208,74 +247,74 @@ export function OpenWhatsNewPopup() {
|
|||||||
<li>Fixed news feed not loading</li>
|
<li>Fixed news feed not loading</li>
|
||||||
<li>Fixed home items duplicating</li>
|
<li>Fixed home items duplicating</li>
|
||||||
<li>Fixed Upcoming assessments not showing</li>
|
<li>Fixed Upcoming assessments not showing</li>
|
||||||
|
|
||||||
<h1>3.2.2 - Minor Improvements</h1>
|
<h1>3.2.2 - Minor Improvements</h1>
|
||||||
<li>Added Settings open-close animation</li>
|
<li>Added Settings open-close animation</li>
|
||||||
<li>Minor Bug Fixes</li>
|
<li>Minor Bug Fixes</li>
|
||||||
|
|
||||||
<h1>3.2.0 - Custom Themes</h1>
|
<h1>3.2.0 - Custom Themes</h1>
|
||||||
<li>Added transparency (blur) effects</li>
|
<li>Added transparency (blur) effects</li>
|
||||||
<li>Added custom themes</li>
|
<li>Added custom themes</li>
|
||||||
<li>Added colour picker history</li>
|
<li>Added colour picker history</li>
|
||||||
<li>Heaps of bug fixes</li>
|
<li>Heaps of bug fixes</li>
|
||||||
|
|
||||||
<h1>3.1.3 - Custom Backgrounds</h1>
|
<h1>3.1.3 - Custom Backgrounds</h1>
|
||||||
<li>Added custom backgrounds with support for images and videos</li>
|
<li>Added custom backgrounds with support for images and videos</li>
|
||||||
<li>Overhauled topbar</li>
|
<li>Overhauled topbar</li>
|
||||||
<li>New animated hamburger icon</li>
|
<li>New animated hamburger icon</li>
|
||||||
<li>Minor bug fixes</li>
|
<li>Minor bug fixes</li>
|
||||||
|
|
||||||
<h1>3.1.2 - New settings menu!</h1>
|
<h1>3.1.2 - New settings menu!</h1>
|
||||||
<li>Overhauled the settings menu</li>
|
<li>Overhauled the settings menu</li>
|
||||||
<li>Added custom gradients</li>
|
<li>Added custom gradients</li>
|
||||||
<li>Added HEAPS of animations</li>
|
<li>Added HEAPS of animations</li>
|
||||||
<li>Fixed a bug where shortcuts don't show up</li>
|
<li>Fixed a bug where shortcuts don't show up</li>
|
||||||
<li>Other minor bugs fixed</li>
|
<li>Other minor bugs fixed</li>
|
||||||
|
|
||||||
<h1>3.1.1 - Minor Bug fixes</h1>
|
<h1>3.1.1 - Minor Bug fixes</h1>
|
||||||
<li>Fixed assessments overlapping</li>
|
<li>Fixed assessments overlapping</li>
|
||||||
<li>Fixed houses not displaying if they aren't a specific color</li>
|
<li>Fixed houses not displaying if they aren't a specific color</li>
|
||||||
<li>Fixed Chrome Webstore Link</li>
|
<li>Fixed Chrome Webstore Link</li>
|
||||||
|
|
||||||
<h1>3.1.0 - Design Improvements</h1>
|
<h1>3.1.0 - Design Improvements</h1>
|
||||||
<li>Minor UI improvements</li>
|
<li>Minor UI improvements</li>
|
||||||
<li>Added Animation Speed Slider</li>
|
<li>Added Animation Speed Slider</li>
|
||||||
<li>Animation now enables and disables without reloading SEQTA</li>
|
<li>Animation now enables and disables without reloading SEQTA</li>
|
||||||
<li>Changed logo</li>
|
<li>Changed logo</li>
|
||||||
|
|
||||||
<h1>3.0.0 - BetterSEQTA+ *Complete Overhaul*</h1>
|
<h1>3.0.0 - BetterSEQTA+ *Complete Overhaul*</h1>
|
||||||
<li>Redesigned appearance</li>
|
<li>Redesigned appearance</li>
|
||||||
<li>Upgraded to manifest V3 (longer support)</li>
|
<li>Upgraded to manifest V3 (longer support)</li>
|
||||||
<li>Fixed transitional glitches</li>
|
<li>Fixed transitional glitches</li>
|
||||||
<li>Under the hood improvements</li>
|
<li>Under the hood improvements</li>
|
||||||
<li>Fixed News Feed</li>
|
<li>Fixed News Feed</li>
|
||||||
|
|
||||||
<h1>2.0.7 - Added support to other domains + Minor bug fixes</h1>
|
<h1>2.0.7 - Added support to other domains + Minor bug fixes</h1>
|
||||||
<li>Fixed BetterSEQTA+ not loading on some pages</li>
|
<li>Fixed BetterSEQTA+ not loading on some pages</li>
|
||||||
<li>Fixed text colour of notices being unreadable</li>
|
<li>Fixed text colour of notices being unreadable</li>
|
||||||
<li>Fixed pages not reloading when saving changes</li>
|
<li>Fixed pages not reloading when saving changes</li>
|
||||||
|
|
||||||
<h1>2.0.2 - Minor bug fixes</h1>
|
<h1>2.0.2 - Minor bug fixes</h1>
|
||||||
<li>Fixed indicator for current lesson</li>
|
<li>Fixed indicator for current lesson</li>
|
||||||
<li>Fixed text colour for DM messages list in Light mode</li>
|
<li>Fixed text colour for DM messages list in Light mode</li>
|
||||||
<li>Fixed user info text colour</li>
|
<li>Fixed user info text colour</li>
|
||||||
|
|
||||||
<h1>Sleek New Layout</h1>
|
<h1>Sleek New Layout</h1>
|
||||||
<li>Updated with a new font and presentation, BetterSEQTA+ has never looked better.</li>
|
<li>Updated with a new font and presentation, BetterSEQTA+ has never looked better.</li>
|
||||||
|
|
||||||
<h1>New Updated Sidebar</h1>
|
<h1>New Updated Sidebar</h1>
|
||||||
<li>Condensed appearance with new updated icons.</li>
|
<li>Condensed appearance with new updated icons.</li>
|
||||||
|
|
||||||
<h1>Independent Light Mode and Dark Mode</h1>
|
<h1>Independent Light Mode and Dark Mode</h1>
|
||||||
<li>Dark mode and Light mode are now available to pick alongside your chosen Theme Colour. Your Theme Colour will now become an accent colour for the page.
|
<li>Dark mode and Light mode are now available to pick alongside your chosen Theme Colour. Your Theme Colour will now become an accent colour for the page.
|
||||||
Light/Dark mode can be toggled with the new button, found in the top-right of the menu bar.</li>
|
Light/Dark mode can be toggled with the new button, found in the top-right of the menu bar.</li>
|
||||||
|
|
||||||
<h1>Create Custom Shortcuts</h1>
|
<h1>Create Custom Shortcuts</h1>
|
||||||
<li>Found in the BetterSEQTA+ Settings menu, custom shortcuts can now be created with a name and URL of your choice.</li>
|
<li>Found in the BetterSEQTA+ Settings menu, custom shortcuts can now be created with a name and URL of your choice.</li>
|
||||||
</div>
|
</div>
|
||||||
`).firstChild;
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
let footer = stringToHTML(/* html */ `
|
const footer = stringToHTML(/* html */ `
|
||||||
<div class="whatsnewFooter">
|
<div class="whatsnewFooter">
|
||||||
<div>
|
<div>
|
||||||
Resources and Feedback:
|
Resources and Feedback:
|
||||||
@@ -303,63 +342,15 @@ export function OpenWhatsNewPopup() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a href="https://ko-fi.com/sethburkart" target="_blank" style="background: none !important; margin:0;margin-left:6px; padding:0; display: flex; align-items: center;">
|
<a href="https://ko-fi.com/sethburkart" target="_blank" style="background: none !important; margin:0;margin-left:6px;padding:0; display: flex; align-items: center;">
|
||||||
<img height="25" style="border:0px; height:25px; margin-right: -6px;" src="${kofi}" border="0" alt="Buy Me a Coffee at ko-fi.com" />
|
<img height="25" style="border:0px; height:25px; margin-right: -6px;" src="${kofi}" border="0" alt="Buy Me a Coffee at ko-fi.com" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).firstChild;
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
let exitbutton = document.createElement("div");
|
openPopup({
|
||||||
exitbutton.id = "whatsnewclosebutton";
|
header,
|
||||||
|
content: [imageContainer, text, footer],
|
||||||
container.append(header);
|
|
||||||
container.append(imagecont);
|
|
||||||
container.append(textcontainer);
|
|
||||||
container.append(text as ChildNode);
|
|
||||||
container.append(footer as ChildNode);
|
|
||||||
container.append(exitbutton);
|
|
||||||
|
|
||||||
background.append(container);
|
|
||||||
|
|
||||||
document.getElementById("container")!.append(background);
|
|
||||||
|
|
||||||
let bkelement = document.getElementById("whatsnewbk");
|
|
||||||
let popup = document.getElementsByClassName("whatsnewContainer")[0];
|
|
||||||
|
|
||||||
if (settingsState.animations) {
|
|
||||||
animate(
|
|
||||||
[popup, bkelement as HTMLElement],
|
|
||||||
{ scale: [0, 1] },
|
|
||||||
{
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 220,
|
|
||||||
damping: 18,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
animate(
|
|
||||||
".whatsnewTextContainer *",
|
|
||||||
{ opacity: [0, 1], y: [10, 0] },
|
|
||||||
{
|
|
||||||
delay: stagger(0.05, { startDelay: 0.1 }),
|
|
||||||
duration: 0.5,
|
|
||||||
ease: [0.22, 0.03, 0.26, 1],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
delete settingsState.justupdated;
|
|
||||||
|
|
||||||
bkelement!.addEventListener("click", function (event) {
|
|
||||||
// Check if the click event originated from the element itself and not any of its children
|
|
||||||
if (event.target === bkelement) {
|
|
||||||
DeleteWhatsNew();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var closeelement = document.getElementById("whatsnewclosebutton");
|
|
||||||
closeelement!.addEventListener("click", function () {
|
|
||||||
DeleteWhatsNew();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { settingsState } from "../listeners/SettingsState";
|
||||||
|
import { animate as motionAnimate, stagger } from "motion";
|
||||||
|
|
||||||
|
type AnimationTarget = string | Element | Element[] | NodeList | null;
|
||||||
|
|
||||||
|
let isClosing = false;
|
||||||
|
|
||||||
|
export async function closePopup() {
|
||||||
|
if (isClosing) return;
|
||||||
|
isClosing = true;
|
||||||
|
|
||||||
|
const background = document.getElementById("whatsnewbk");
|
||||||
|
const popup = document.getElementsByClassName("whatsnewContainer")[0] as
|
||||||
|
| HTMLElement
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (!background || !popup) {
|
||||||
|
isClosing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settingsState.animations) {
|
||||||
|
background.remove();
|
||||||
|
isClosing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await (motionAnimate as any)(
|
||||||
|
[popup, background],
|
||||||
|
{ opacity: [1, 0], scale: [1, 0.95] },
|
||||||
|
{ duration: 0.25, easing: [0.22, 0.03, 0.26, 1] },
|
||||||
|
);
|
||||||
|
|
||||||
|
background.remove();
|
||||||
|
isClosing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenPopupOptions {
|
||||||
|
header?: Node | null;
|
||||||
|
content?: (Node | null | undefined)[];
|
||||||
|
animateSelector?: AnimationTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openPopup({
|
||||||
|
header,
|
||||||
|
content = [],
|
||||||
|
animateSelector = ".whatsnewTextContainer *",
|
||||||
|
}: OpenPopupOptions = {}) {
|
||||||
|
const background = document.createElement("div");
|
||||||
|
background.id = "whatsnewbk";
|
||||||
|
background.classList.add("whatsnewBackground");
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.classList.add("whatsnewContainer");
|
||||||
|
|
||||||
|
if (header) container.append(header);
|
||||||
|
for (const node of content) if (node) container.append(node);
|
||||||
|
|
||||||
|
const closeButton = document.createElement("div");
|
||||||
|
closeButton.id = "whatsnewclosebutton";
|
||||||
|
container.append(closeButton);
|
||||||
|
|
||||||
|
background.append(container);
|
||||||
|
document.getElementById("container")!.append(background);
|
||||||
|
|
||||||
|
if (settingsState.animations) {
|
||||||
|
(motionAnimate as any)(
|
||||||
|
[container, background],
|
||||||
|
{ scale: [0, 1] },
|
||||||
|
{ type: "spring", stiffness: 220, damping: 18 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (animateSelector) {
|
||||||
|
const targets =
|
||||||
|
typeof animateSelector === "string"
|
||||||
|
? document.querySelectorAll(animateSelector)
|
||||||
|
: animateSelector;
|
||||||
|
|
||||||
|
(motionAnimate as any)(
|
||||||
|
targets!,
|
||||||
|
{ opacity: [0, 1], y: [10, 0] },
|
||||||
|
{
|
||||||
|
delay: stagger(0.05, { startDelay: 0.1 }),
|
||||||
|
duration: 0.5,
|
||||||
|
easing: [0.22, 0.03, 0.26, 1],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete settingsState.justupdated;
|
||||||
|
|
||||||
|
background.addEventListener("click", (event) => {
|
||||||
|
if (event.target === background) void closePopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
closeButton.addEventListener("click", () => void closePopup());
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user