Compare commits

..

175 Commits

Author SHA1 Message Date
Seth Burkart fda0071251 Merge pull request #257 from BetterSEQTA/main
Merge main into global-search
2025-05-03 18:58:46 +10:00
Seth Burkart 8a5100c06f Merge branch 'global-search' into main 2025-05-03 18:58:33 +10:00
Alphons Joseph cf2778951f feat: old scrolling sidebar system 2025-05-03 18:57:31 +10:00
StroepWafel 6fa1af2f68 Update feature_request.yml 2025-05-03 18:57:31 +10:00
StroepWafel b8286b6f22 fix validations indentation 2025-05-03 18:57:31 +10:00
StroepWafel 8466ef7691 fix feature_request.yml 2025-05-03 18:57:31 +10:00
StroepWafel d75959eeb1 fix feature_request.yml 2025-05-03 18:57:31 +10:00
StroepWafel 94a2f4ac34 Update and retype feature_request.md to feature_request.yml 2025-05-03 18:57:31 +10:00
StroepWafel 1647870186 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel b332de52ff Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel daf7ea8e83 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 341087b6a0 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel a0d8e05fd0 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 399f68c547 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 3ddf1d0c4f Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 10a6c458b1 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 33825843b7 Update and retyped bug_report.md to bug_report.yml 2025-05-03 18:57:31 +10:00
SethBurkart123 56dabc8fd5 fix: news not loading 2025-05-03 18:57:31 +10:00
SethBurkart123 0c1a71f398 fix: sidebar layout not being applied on pageload #249 2025-05-03 18:56:50 +10:00
SethBurkart123 bb6ee72159 bump(version): 3.4.6.1 + changelog 2025-05-03 18:56:50 +10:00
SethBurkart123 d52a59ae48 fix: storage always resetting to default 2025-05-03 18:56:50 +10:00
SethBurkart123 1c9e361f78 fix: builds running incorrectly 2025-05-03 18:56:50 +10:00
SethBurkart123 ec3396c52e fix: dynamic seqta classes failing to load #248 2025-05-03 18:56:50 +10:00
SethBurkart123 5b5dba69dc fix: handle failed sortmessagepageitems 2025-05-03 18:56:50 +10:00
Andrew R 449b54ae32 Update README.md 2025-05-03 18:56:19 +10:00
codefactor-io c29dc45697 [CodeFactor] Apply fixes to commit d412762 2025-05-01 11:34:34 +00:00
SethBurkart123 d4127626b1 feat: cleaned code + improved performance 2025-05-01 21:34:17 +10:00
Alphons Joseph 907f970018 feat: old scrolling sidebar system 2025-04-28 21:32:08 +08:00
codefactor-io d9b1482255 [CodeFactor] Apply fixes to commit 454ab28 2025-04-13 00:58:22 +00:00
SethBurkart123 454ab283ab feat: improve results and performance (syncronous) 2025-04-13 10:58:05 +10:00
Seth Burkart 0ef43eb9b5 Merge pull request #254 from NIDNHU/main
complete revamp of bug report system
2025-04-13 09:06:55 +10:00
StroepWafel ecbdffbbde Update feature_request.yml 2025-04-13 00:05:07 +09:30
StroepWafel 92344400e1 fix validations indentation 2025-04-13 00:04:34 +09:30
StroepWafel ca20ba4e07 fix feature_request.yml 2025-04-13 00:02:15 +09:30
StroepWafel 694d4ea0a1 fix feature_request.yml 2025-04-13 00:01:35 +09:30
StroepWafel 72a529ee1d Update and retype feature_request.md to feature_request.yml 2025-04-13 00:00:31 +09:30
StroepWafel 0a3ee5c666 Update bug_report.yml 2025-04-12 23:50:50 +09:30
StroepWafel ef6176b6a4 Update bug_report.yml 2025-04-12 23:50:32 +09:30
StroepWafel b3c395cca1 Update bug_report.yml 2025-04-12 23:47:13 +09:30
StroepWafel 8c2539f130 Update bug_report.yml 2025-04-12 23:46:44 +09:30
StroepWafel 442ea04a2f Update bug_report.yml 2025-04-12 23:45:57 +09:30
StroepWafel bd812ffdae Update bug_report.yml 2025-04-12 23:45:28 +09:30
StroepWafel 6377a0c909 Update bug_report.yml 2025-04-12 23:44:57 +09:30
StroepWafel d8829d5716 Update bug_report.yml 2025-04-12 23:43:21 +09:30
StroepWafel 7fd85a5529 Update and retyped bug_report.md to bug_report.yml 2025-04-12 23:42:25 +09:30
SethBurkart123 9562368157 feat: vector search worker improvements 2025-04-11 07:10:55 +10:00
codefactor-io ab867af57d [CodeFactor] Apply fixes to commit 886d0a9 2025-04-10 14:07:58 +00:00
SethBurkart123 886d0a95f1 feat: add working workers with builds 2025-04-11 00:07:29 +10:00
SethBurkart123 dd47deb954 fix: news not loading 2025-04-09 14:46:57 +10:00
SethBurkart123 fbf066cea8 fix: sidebar layout not being applied on pageload #249 2025-04-06 14:03:51 +10:00
SethBurkart123 eb2c665843 bump(version): 3.4.6.1 + changelog 2025-04-03 22:28:50 +11:00
SethBurkart123 45a16de405 fix: storage always resetting to default 2025-04-03 22:24:25 +11:00
SethBurkart123 048ccb248e fix: builds running incorrectly 2025-04-03 14:43:07 +11:00
SethBurkart123 363fbfa3c8 fix: dynamic seqta classes failing to load #248 2025-04-03 14:35:06 +11:00
SethBurkart123 0bf4ed8157 fix: handle failed sortmessagepageitems 2025-04-03 13:05:33 +11:00
codefactor-io 814647e835 [CodeFactor] Apply fixes 2025-04-01 12:16:13 +00:00
SethBurkart123 07aa9524aa feat: early vector search testing 2025-04-01 23:14:45 +11:00
SethBurkart123 13f830ee16 feat: add custom items 2025-04-01 22:01:18 +11:00
SethBurkart123 1b4708261d feat: improve scrolling with calculator 2025-04-01 19:25:27 +11:00
SethBurkart123 6a556b6940 feat: further interface tweaks 2025-04-01 18:20:17 +11:00
codefactor-io d0edad8134 [CodeFactor] Apply fixes 2025-04-01 06:46:45 +00:00
SethBurkart123 5e93ae6e4b feat: add bottom bar 2025-04-01 17:45:30 +11:00
SethBurkart123 0788b78e73 feat: improve units 2025-04-01 17:24:18 +11:00
SethBurkart123 e884b0526b feat: improve searchbar 2025-04-01 16:02:54 +11:00
SethBurkart123 ea77224c75 feat: add calculator 2025-04-01 15:27:46 +11:00
SethBurkart123 18441712c9 feat: complete fuzzy search rebuild 2025-04-01 13:51:45 +11:00
Seth Burkart 3dc77dd398 Merge pull request #245 from ar-cyber/patch-26
make some updates to readme
2025-04-01 08:55:31 +11:00
Andrew R e7c5357c64 Update README.md 2025-04-01 06:47:49 +10:30
SethBurkart123 8df138a374 feat: upgrade to flexsearch for better keyword search performance 2025-03-31 23:11:41 +11:00
SethBurkart123 068e4ab778 style: further UI tweaks 2025-03-31 23:06:56 +11:00
SethBurkart123 adbba730c4 style: search styling improvements 2025-03-31 23:03:45 +11:00
SethBurkart123 1f3354c47b feat: add global search UI 2025-03-31 22:54:03 +11:00
SethBurkart123 7a80dc2cc3 docs: api reference improvements 2025-03-31 20:20:26 +11:00
SethBurkart123 68e8c89b35 docs: improve plugin documentation 2025-03-31 19:25:06 +11:00
Seth Burkart 77582a4d00 Merge pull request #230 from BetterSEQTA/en-masse-upgrade
Upgrade every package to respective newer versions
2025-03-31 18:43:55 +11:00
SethBurkart123 3f97049451 feat: update changelog 2025-03-31 18:42:23 +11:00
SethBurkart123 ebc7baaacc chore: remove unnecessary log 2025-03-31 18:40:18 +11:00
SethBurkart123 35ca292c04 feat: improve bkslider migration 2025-03-31 18:39:22 +11:00
SethBurkart123 e928399066 feat: add auto migration 2025-03-31 18:27:53 +11:00
SethBurkart123 a4033862c9 feat: interface clean up + organisation 2025-03-30 13:20:42 +11:00
codefactor-io 22ddb4bc41 [CodeFactor] Apply fixes to commit b8d8b10 2025-03-30 02:17:33 +00:00
SethBurkart123 b8d8b108c3 feat: improve apis + add animated background and assessment average plugins 2025-03-30 13:17:19 +11:00
SethBurkart123 aeaf5d9e59 style: improve button 2025-03-30 12:33:33 +11:00
codefactor-io 1acda4f399 [CodeFactor] Apply fixes 2025-03-30 01:11:57 +00:00
SethBurkart123 121888c1c3 feat: hide and group settings based on plugin 2025-03-30 12:11:40 +11:00
SethBurkart123 647a32fbac fix: settings props not being correctly set 2025-03-30 12:04:39 +11:00
SethBurkart123 19cc1a5600 dev 2025-03-30 10:41:19 +11:00
SethBurkart123 e3f4b59d9c docs: cleanup and improvements 2025-03-30 09:14:29 +11:00
SethBurkart123 a07323499c feat: add more callback listeners to the theme system 2025-03-30 09:11:41 +11:00
SethBurkart123 600456f28e chore: remove dev themes file 2025-03-30 09:09:55 +11:00
SethBurkart123 3ecd7205ed feat: add global theme toggle 2025-03-30 08:49:13 +11:00
SethBurkart123 6147e96cc9 feat: remove theme toggle 2025-03-29 22:33:06 +11:00
SethBurkart123 09855c9ef5 fix: themes randomly disabling after quick succesive page loads 2025-03-29 21:56:46 +11:00
SethBurkart123 9542cb13f5 fix: initial install not loading seqta 2025-03-28 17:18:23 +11:00
SethBurkart123 d19f573093 fix: theme creator fullscreen view not working 2025-03-28 12:29:26 +11:00
SethBurkart123 7af6acaf38 fix: themes custom colour not being completely applied 2025-03-28 12:17:37 +11:00
SethBurkart123 c4c50f2c30 fix: themes somtimes override default custom accent colour 2025-03-28 11:50:52 +11:00
SethBurkart123 a33f4f3f00 fix: imported themes without images rendering incorrectly 2025-03-28 11:38:52 +11:00
SethBurkart123 1f023574b8 fix: selected colour being lost if page is reloaded with themecreator 2025-03-28 00:36:28 +11:00
SethBurkart123 dc4499e8a2 refactor: remove legacy theme handling and streamline plugin initialization 2025-03-28 00:19:40 +11:00
SethBurkart123 ad2ad4d456 feat: debounce creator + general improvements 2025-03-28 00:14:29 +11:00
SethBurkart123 5413286f56 feat: improve dom application method 2025-03-27 23:45:04 +11:00
SethBurkart123 f0c5b1dace feat: build themes into a centralised plugin 2025-03-27 21:31:41 +11:00
Seth Burkart ad14dc3aa5 Merge pull request #242 from NIDNHU/patch-2
Create pull_request_template.md
2025-03-26 20:39:28 +11:00
SethBurkart123 64bf1d88e8 fix: background type error 2025-03-26 17:38:19 +11:00
SethBurkart123 7196a85f7d fix: downgrade to tailwindcss v3 because of issues 2025-03-26 17:35:35 +11:00
SethBurkart123 f2b594a13b fix: crxjs plugin issues 2025-03-26 17:00:58 +11:00
Seth Burkart a17a9a50c1 Merge pull request #232 from ar-cyber/patch-25
Update README.md
2025-03-26 15:57:15 +11:00
StroepWafel 207832640f Create pull_request_template.md
add template for creating pull requests
2025-03-26 14:49:07 +10:30
Seth Burkart b76999cb13 Merge pull request #239 from NIDNHU/main
Update SECURITY.md - add quick-create link
2025-03-25 15:54:22 +11:00
StroepWafel fc0e491ea7 Update SECURITY.md - add quick-create link 2025-03-25 11:04:19 +10:30
SethBurkart123 68159ddd0e chore: hide test plugin 2025-03-21 17:59:28 +11:00
Seth Burkart 4696529964 Merge pull request #238 from NIDNHU/main
Create config.yml
2025-03-21 16:57:19 +11:00
StroepWafel a9e198ea68 Create config.yml 2025-03-21 09:39:51 +10:30
Seth Burkart 620d168d28 Update creating-plugins.md 2025-03-18 22:19:32 +11:00
SethBurkart123 1c63c06b72 feat: add docs and dev plugins 2025-03-18 22:15:44 +11:00
Alphons Joseph 7a76d3f4eb bugfix: Finally fix theme application 2025-03-18 19:03:23 +08:00
Alphons Joseph 8e34db4a67 feat: synchronise settingstate and theme properly 2025-03-18 18:45:09 +08:00
Alphons Joseph 9fc24767ec bugfix: theme defaultColor being overridden at all times by default betterseqta+ colour 2025-03-18 18:37:34 +08:00
Seth Burkart 331c9a9d81 Merge pull request #236 from BetterSEQTA/main
Merge main into dev branch
2025-03-18 20:47:15 +11:00
SethBurkart123 74e92ddb53 feat: add types to storage api 2025-03-18 20:45:29 +11:00
Seth Burkart 1a6dc9ebb9 Merge pull request #235 from NIDNHU/patch-1
Update SECURITY.md - modify vulnerability reporting method
2025-03-18 18:38:04 +11:00
Alphons Joseph be54816d83 Merge branch 'en-masse-upgrade' of https://github.com/BetterSEQTA/BetterSEQTA-Plus into en-masse-upgrade 2025-03-18 15:37:15 +08:00
Alphons Joseph b644dbbbc7 feat: convert base64 in browser to url reference 2025-03-18 15:37:12 +08:00
SethBurkart123 d06356101a feat: display plugin settings in interface 2025-03-18 18:31:20 +11:00
StroepWafel 7eacf345d0 Update SECURITY.md
change vulnerability reporting method
2025-03-18 18:00:36 +10:30
Alphons Joseph 9a71a5241a vuln-fix: removed image urls, relying on blobs now 2025-03-18 15:23:04 +08:00
Alphons Joseph f4ae9098d8 bug: change theme export to json to avoid accidental execution 2025-03-18 14:59:32 +08:00
Alphons Joseph 325f6c5f9b handle vulnerabilities privately through github instead of in issues 2025-03-18 14:49:56 +08:00
SethBurkart123 ea46ab41ce fix: update types 2025-03-18 07:54:50 +11:00
codefactor-io e6f36edabf [CodeFactor] Apply fixes to commit 587aa5e 2025-03-17 20:52:33 +00:00
SethBurkart123 587aa5eb89 feat: add plugin system 2025-03-18 07:52:16 +11:00
Alphons Joseph da3a680455 refactor: small code quality update 2025-03-17 20:54:40 +08:00
Alphons Joseph 77c3761947 codefix: comment out unused function (may be required later) 2025-03-17 20:35:17 +08:00
Alphons Joseph 6fb4ea5372 feat min: fix spelling mistake 2025-03-17 19:23:59 +08:00
SethBurkart123 5c0044a4d4 feat: cleanup work on plugins system 2025-03-17 15:06:26 +11:00
Andrew R dba688d3cd Update README.md 2025-03-17 13:49:14 +10:30
SethBurkart123 75446c6855 chore: clean up imports in monofile.ts 2025-03-17 13:55:29 +11:00
SethBurkart123 fe2fa87cb5 feat: add ReactAdaptor.svelte 2025-03-17 13:46:38 +11:00
SethBurkart123 9f7b46d2ad feat: add back react colour picker 2025-03-17 13:45:16 +11:00
SethBurkart123 ef890ee776 feat: add dev colourpicker with irojs 2025-03-17 13:33:25 +11:00
Alphons Joseph d42dc79415 feat: sourcemaps are an env variable now 2025-03-17 10:17:42 +08:00
Alphons Joseph e072b3f5c8 feat: remove sourcemaps from production build, add to new development build 2025-03-17 08:15:04 +08:00
Alphons Joseph e32218bf07 include sourcemaps for better debugging 2025-03-16 20:55:14 +08:00
Alphons Joseph 286375c662 remove last remnants of react 2025-03-12 22:56:24 +08:00
Alphons Joseph f2d197e8f0 Merge branch 'en-masse-upgrade' of https://github.com/BetterSEQTA/BetterSEQTA-Plus into en-masse-upgrade 2025-03-12 22:52:17 +08:00
Alphons Joseph 85beb62a37 remove react colour picker (@SethBurkart123 needs prettifying, works on basic level) 2025-03-12 22:52:14 +08:00
codefactor-io 0b908cb251 [CodeFactor] Apply fixes to commit c9f0f9c 2025-03-12 13:45:46 +00:00
Alphons Joseph c9f0f9cf16 start modularisation and breaking down the monofile 2025-03-12 21:45:23 +08:00
Alphons Joseph 3c65e6d6c5 dynamically import all plugins 2025-03-12 20:11:26 +08:00
Alphons Joseph 2cb607c5a9 commenting 2025-03-12 19:08:20 +08:00
codefactor-io 695357a639 [CodeFactor] Apply fixes 2025-03-12 11:02:58 +00:00
Alphons Joseph 8cb052f2ff Merge branch 'en-masse-upgrade' of https://github.com/BetterSEQTA/BetterSEQTA-Plus into en-masse-upgrade 2025-03-12 19:02:36 +08:00
Alphons Joseph 6b39f60db7 very very very basic plugin system works 2025-03-12 19:02:32 +08:00
SethBurkart123 1638dd4989 feat: remove postcss 2025-03-12 21:48:58 +11:00
SethBurkart123 ca7e6b9137 feat: upgrade to tailwindcss v4 2025-03-12 21:46:01 +11:00
SethBurkart123 1263c1c8ef feat: remove colour pallete flattening 2025-03-12 20:57:33 +11:00
SethBurkart123 5eb92bc87a fix: builds failing and css failing to load in frontend 2025-03-12 20:52:48 +11:00
Seth Burkart ecff10a991 Merge pull request #229 from NIDNHU/main
Edit bug reporting system
2025-03-12 16:09:44 +11:00
StroepWafel 4745df7ace Merge branch 'BetterSEQTA:main' into main 2025-03-12 10:19:27 +10:30
Alphons Joseph c7bdd86967 code commenting 2025-03-11 20:44:39 +08:00
Alphons Joseph f920980948 change to an eye icon 2025-03-11 20:20:42 +08:00
Alphons Joseph 8c2f36033f add support for hiding non-assessments (discord issue) 2025-03-11 20:13:44 +08:00
Alphons Joseph 75e687f934 bump every package, remove postcss 2025-03-11 19:53:56 +08:00
Seth Burkart 5cd0f47fe5 feat: move firefox out of experimental 2025-03-08 20:18:33 +11:00
StroepWafel 84cfaccded Update vulnerability.md
update example for issue explanation and make prompts a bit clearer
2025-03-08 17:59:25 +10:30
StroepWafel 0c55098bc7 Update feature_request.md
ask user to link to bug if exists (clean up bug report) and provide reference images for graphical changes
2025-03-08 17:56:58 +10:30
StroepWafel 50157f24fd Update bug_report.md
make the wording a bit more clear
2025-03-08 17:54:41 +10:30
Seth Burkart b77e2b2247 Merge pull request #226 from BlastedMeteor44/main
Update README.md
2025-03-06 17:33:03 +11:00
BlastedMeteor44 0c0fabe661 Update README.md 2025-03-06 10:34:47 +08:00
SethBurkart123 f39bfce5c3 feat: auto collapsing alignment toolbar in direct messages 2025-03-04 22:06:57 +11:00
SethBurkart123 2d26f729e3 fix: news source country starting with lowercase 2025-03-04 18:59:51 +11:00
SethBurkart123 d7b541c814 feat: remove hover animation for tabbedcontainer + fix publish script with detailed versioning 2025-03-04 18:54:54 +11:00
Seth Burkart 41bb5996df Merge pull request #220 from ar-cyber/patch-24
fix: remove ABC news from news page #219
2025-03-04 17:14:20 +11:00
Andrew R d3d7a1199f fix: remove ABC news from news page 2025-03-03 13:00:58 +10:30
138 changed files with 11292 additions and 4331 deletions
-23
View File
@@ -1,23 +0,0 @@
---
name: Report A bug.
about: Create a report of a present bug.
title: "[BUG]"
labels: bug
assignees: ''
---
**Bug Description**
Please provide a clear and concise description of the bug.
**Steps to Reproduce**
Please list the steps taken to reproduce the issue.
**Expected Behavior**
Please describe the expected behaviour clearly and concisely.
**Screenshots**
If applicable, please include any screenshots that may help clarify the issue.
**Additional Context**
Feel free to provide any additional context or information relevant to the problem.
+56
View File
@@ -0,0 +1,56 @@
name: Bug report
description: Report an issue with the modpack in its unmodified state. For other issues, use Discord.
labels: bug
title: "[BUG]"
body:
- type: markdown
attributes:
value: |
Before reporting an issue, [please search](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) to make sure it has not already been reported (make sure to search closed issues as well!).
- type: textarea
attributes:
label: Describe the bug
description: Describe your issue. For general issues and questions you'll get a faster answer [from our Discord.](https://discord.gg/YzmbnCDkat)
validations:
required: true
- type: input
attributes:
label: Extension version
description: What version of the extension are you using?
placeholder: Find it by opening the config menu and clicking the about icon in the top right.
validations:
required: true
- type: dropdown
attributes:
label: Browser
description: Which Browser are you using?
options:
- Chrome
- Firefox
- Brave
- Safari
- DuckDuckGO
- Microsoft Edge
- Other Chromium-Based Browser
- Other Non-Chromium-Based Browser
validations:
required: true
- type: checkboxes
attributes:
label: Confirm
options:
- label: This bug report is about an issue with the extension itself. I have not modified the extension nor added any unsupported plugins. If this is not the case, I know that I should post the issue to the extension's Discord support channel instead.
required: true
- type: textarea
attributes:
label: Additional context
description: Screenshots, video or any other information. Include photos of the console if possible
placeholder: |
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
+4
View File
@@ -0,0 +1,4 @@
contact_links:
- name: BetterSEQTA Community Support
url: https://discord.gg/YzmbnCDkat
about: Join our discord for community updates, discussion, and more!
-14
View File
@@ -1,14 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FR] "
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
@@ -0,0 +1,54 @@
name: Feature request
description: Suggest a new Feature to be added or replaced in BetterSeqtaPLUS
labels: enhancement
title: "[FR]"
body:
- type: checkboxes
attributes:
label: Confirm
options:
- label: "Is this feature request related to a Bug report?"
required: false
- type: input
attributes:
label: Bug report link
description: "If this feature request is related to a bug report, please insert the link to the bug report here"
placeholder: "https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/..."
validations:
required: false
- type: markdown
attributes:
value: |
## Feature details
Before you request a feature, [please search](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) if it has already been requested. (Make sure to check closed issues as well!)
- type: dropdown
attributes:
label: Feature type
multiple: false
options:
- Graphical
- Functional
- Not Sure
validations:
required: true
- type: input
attributes:
label: Feature Details
description: Please write, with as much detail as possible, what you would like to see from this mod.
placeholder: I would like to see...
validations:
required: false
- type: textarea
attributes:
label: Additional details
description: Anything else you want to add?
validations:
required: false
-17
View File
@@ -1,17 +0,0 @@
---
name: Vulnerability
about: Report a vulnerability in this extension.
title: "[VUL] "
labels: ''
assignees: ''
---
**What is the vulnerability?**
Describe the vulnerability in concise language.
**Where is the vulnerability found?**
Describe where the vulnerability is found.
**What does this affect?**
Explain what it affects. E.G: It opens up my school email to the world. etc.
@@ -0,0 +1,14 @@
## Description
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
Fixes # (issue)
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
-5
View File
@@ -1,5 +0,0 @@
{
"plugins": {
"tailwindcss": {}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"tabWidth": 2, "tabWidth": 2,
"useTabs": false, "useTabs": false,
"semi": false "semi": true
} }
+15
View File
@@ -3,6 +3,21 @@
When contributing to this repository, please first discuss the change you wish to make via issue, When contributing to this repository, please first discuss the change you wish to make via issue,
email, or any other method with the owners of this repository before making a change. email, or any other method with the owners of this repository before making a change.
## Community
Join our community channels to discuss the project, get help, and connect with other contributors:
- **Discord Server**: [Join our Discord](https://discord.gg/betterseqta)
- **GitHub Discussions**: For longer-form conversations
- **GitHub Issues**: For bug reports and feature requests
## Creating Plugins
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Creating Your First Plugin](./docs/plugins/creating-plugins.md)
- [Plugin API Reference](./docs/advanced/plugin-api.md)
## Pull Request Process ## Pull Request Process
1. It is recommended to start by opening an issue to discuss the change you wish to make. This will allow us to discuss the change and ensure it is a good fit for the project. 1. It is recommended to start by opening an issue to discuss the change you wish to make. This will allow us to discuss the change and ensure it is a good fit for the project.
+34 -34
View File
@@ -44,23 +44,18 @@
- Assessments - Assessments
- Options to remove certain items from the side menu - Options to remove certain items from the side menu
- Grades calculator - Grades calculator
- Fully customisable themes and an offical theme store - Fully customisable themes and an official theme store
- Notification for next lesson (sent 5 minutes before end of the lesson) - Notification for next lesson (sent 5 minutes before end of the lesson)
- Browser Support - Browser Support
- Chrome Supported - Chrome, Edge, Brave, Opera and other Chromium-Based browsers are supported
- Edge Supported - Firefox Supported: [here](https://addons.mozilla.org/en-US/firefox/addon/betterseqta-plus/)!
- Brave Supported - Safari (Experimental and not recommended - only available via compilation)
- Opera Supported
- Vivaldi Supported
- Chromium-based browsers are supported
- Firefox (Experimental - available [here](https://addons.mozilla.org/en-US/firefox/addon/betterseqta-plus/)
- Safari (Experimental - only available via compilation)
## Creating Custom Themes ## Creating Custom Themes
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://betterseqta.gitbook.io/betterseqta-docs). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator). If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://betterseqta.gitbook.io/betterseqta-docs). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
Don't worry- if you get stuck feel free to ask around in the discord. We're open and happy to help out! Happy creating :) Don't worry- if you get stuck feel free to ask around in the [discord](https://discord.gg/YzmbnCDkat). We're open and happy to help out! Happy creating :)
## Getting started ## Getting started
@@ -70,20 +65,43 @@ Don't worry- if you get stuck feel free to ask around in the discord. We're open
git clone https://github.com/BetterSEQTA/BetterSEQTA-Plus git clone https://github.com/BetterSEQTA/BetterSEQTA-Plus
``` ```
### Running Development
1. Install dependencies 1. Install dependencies
You may install the dependencies like below:
``` ```
npm install # or your preferred package manager like pnpm or yarn npm install # or your preferred package manager like pnpm or yarn
``` ```
But it is recommended to do it like this:
```
npm install --legacy-peer-deps # Only NPM supported
```
### Running Development
2. Run the dev script (it updates as you save files) 2. Run the dev script (it updates as you save files)
``` ```
npm run dev npm run dev # or use your perferred package manager
``` ```
### Building for production
2. Run the build script
```
npm run build # or use your perferred package manager
```
2.1. Package it up (optional)
```
npm run zip # This REQUIRES 7-Zip to be installed in order to work. You can also use your perferred package manager
```
3. Load the extension into chrome 3. Load the extension into chrome
- Go to `chrome://extensions` - Go to `chrome://extensions`
@@ -91,33 +109,15 @@ npm run dev
- Click `Load unpacked` - Click `Load unpacked`
- Select the `dist` folder - Select the `dist` folder
Just remember, in order to update changes to the extension, you need to click the refresh button on the extension in `chrome://extensions` whenever anything's changed. Just remember, in order to update changes to the extension if you are running in developer mode, you need to click the refresh button on the extension in `chrome://extensions` whenever anything's changed.
### Building for production
1. Install dependencies
```
npm install # or your preferred package manager like pnpm or yarn
```
2. Run the build script
```
npm run build
```
3. Package it up (optional)
```
npm run zip # This requires 7-Zip to be installed in order to work
```
## Folder Structure ## Folder Structure
The folder structure is as follows: The folder structure is as follows:
- The `src` folder contains source files that are compiled to the build directory. - The `src` folder contains source files that are compiled to the build directory.
-
- The `src/plugins` folder contains vital loaders required for BetterSEQTA+ functionality.
- The `src/interface` folder contains source React & Svelte files that are required for the Settings page. - The `src/interface` folder contains source React & Svelte files that are required for the Settings page.
@@ -136,4 +136,4 @@ This extension was initially developed by [Nulkem](https://github.com/Nulkem/bet
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=BetterSEQTA/BetterSEQTA-Plus&type=Date)](https://star-history.com/#sethburkart123/EvenBetterSEQTA&Date) [![Star History Chart](https://api.star-history.com/svg?repos=BetterSEQTA/BetterSEQTA-Plus&type=Date)](https://star-history.com/#BetterSEQTA/BetterSEQTA-Plus&Date)
+1 -1
View File
@@ -12,4 +12,4 @@ Below here is the supported versions of BetterSEQTA+. Anything older than this i
`*` May not work on other devices. `*` May not work on other devices.
## Reporting a Vulnerability ## Reporting a Vulnerability
If you find vulnerabilities, REPORT IT IMMEDIATELY. Make an issue and use the template provided for vulnerabilities. If you find vulnerabilities, REPORT IT IMMEDIATELY. open the [advisories tab](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories) on the left and click the green "report a vulnerability" button or use [this quick-link](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories/new) to create a new report
+50
View File
@@ -0,0 +1,50 @@
# BetterSEQTA+ Documentation
🚧 DOCS UNDER CONSTRUCTION! 🚧
Welcome to the BetterSEQTA+ documentation! This documentation will help you understand how BetterSEQTA+ works and how to extend it with plugins and new features.
## Table of Contents
### Getting Started
- [Project Overview](./README.md) - This file
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+
- [Contributing Guide](../CONTRIBUTING.md) - How to contribute to BetterSEQTA+
### Plugin System
- [Creating Your First Plugin](./plugins/README.md) - A comprehensive, beginner-friendly guide to creating plugins
- [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs
## Core Concepts
BetterSEQTA+ is built around several core concepts:
1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. Check out our [plugin guide](./plugins/README.md) to learn how to create your own!
2. **Type-Safe Settings**: Each plugin can define settings that are type-safe and automatically rendered in the settings UI. The settings system uses TypeScript decorators to make it easy to define settings with proper typing.
3. **Storage API**: Plugins can use the Storage API to persist data between sessions. The Storage API is also type-safe, ensuring that plugins can only access their own data.
4. **SEQTA Integration**: BetterSEQTA+ integrates with SEQTA Learn by injecting code into the page. This allows plugins to modify the SEQTA UI and add new features.
## Getting Help
If you need help with BetterSEQTA+, you can:
- [Open an Issue](https://github.com/SeqtaLearning/betterseqta-plus/issues) - Report bugs or request features
- [Join the Discord](https://discord.gg/YzmbnCDkat) - Chat with the community
- [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly
## Contributing to the Documentation
We welcome contributions to the documentation! If you find something unclear or missing, please open an issue or submit a pull request.
To contribute to the documentation:
1. Fork the repository
2. Make your changes to the documentation files
3. Submit a pull request with a clear description of your changes
## License
BetterSEQTA+ is licensed under the [MIT License](../LICENSE).
+262
View File
@@ -0,0 +1,262 @@
# Contributing to BetterSEQTA+
Thank you for your interest in contributing to BetterSEQTA+! This document provides guidelines and instructions for contributing to the project.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Setting Up Your Development Environment](#setting-up-your-development-environment)
- [Project Structure](#project-structure)
- [Contributing Code](#contributing-code)
- [Branching Strategy](#branching-strategy)
- [Pull Request Process](#pull-request-process)
- [Coding Standards](#coding-standards)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Features](#suggesting-features)
- [Writing Documentation](#writing-documentation)
- [Community](#community)
## Code of Conduct
BetterSEQTA+ is committed to providing a welcoming and inclusive environment for all contributors. We expect all participants to adhere to our Code of Conduct, which promotes respectful and harassment-free interaction.
Key points:
- Be respectful and inclusive
- Focus on what is best for the community
- Show empathy towards other community members
- Be open to constructive feedback
## Getting Started
### Setting Up Your Development Environment
1. **Fork the Repository**
Start by forking the BetterSEQTA+ repository to your GitHub account.
2. **Clone Your Fork**
```bash
git clone https://github.com/yourusername/betterseqta-plus.git
cd betterseqta-plus
```
3. **Install Dependencies**
```bash
npm install
```
4. **Set Up Development Environment**
```bash
npm run dev
```
5. **Install in Chrome/Firefox**
Follow the [installation instructions](./installation.md#development-installation) to load the development version into your browser.
### Project Structure
Understanding the project structure will help you navigate the codebase:
```
betterseqta-plus/
├── src/ # Source code
│ ├── plugins/ # Plugin system
│ │ ├── built-in/ # Built-in plugins
│ │ ├── core/ # Plugin core functionality
│ ├── settings/ # Settings system
│ ├── utils/ # Utility functions
│ ├── extension/ # Browser extension code
├── docs/ # Documentation
├── test/ # Test files
├── dist/ # Build output (generated)
├── package.json # Project dependencies
├── tsconfig.json # TypeScript configuration
└── README.md # Project README
```
## Contributing Code
### Branching Strategy
We follow a simple branching strategy:
- `main` - The main development branch
- `feature/*` - Feature branches
- `bugfix/*` - Bug fix branches
- `docs/*` - Documentation branches
Always create a new branch for your changes:
```bash
git checkout -b feature/my-new-feature
```
### Pull Request Process
1. **Keep PRs Focused**
Each pull request should address a single concern. If you're working on multiple features, create separate PRs for each.
2. **Write Clear Commit Messages**
Follow the conventional commits format:
```
feat: add new feature
fix: resolve bug with timetable
docs: update installation instructions
```
3. **Update Documentation**
If your changes require documentation updates, include them in the same PR.
4. **Run Tests**
Make sure all tests pass before submitting your PR:
```bash
npm test
```
5. **Submit Your PR**
When you're ready, push your branch and create a pull request on GitHub.
6. **Code Review**
All PRs will be reviewed by maintainers. Be responsive to feedback and make requested changes.
7. **Merge**
Once approved, a maintainer will merge your PR.
### Coding Standards
We follow TypeScript best practices and have a consistent code style:
1. **Use TypeScript**
All new code should be written in TypeScript with proper typing.
2. **Follow Existing Patterns**
Match the coding style of the existing codebase.
3. **Write Tests**
Add tests for new features and bug fixes.
4. **Document Your Code**
Add comments for complex logic and JSDoc comments for functions.
5. **Use Linters**
We use ESLint and Prettier. Run them before submitting your PR:
```bash
npm run lint
npm run format
```
## Reporting Bugs
If you find a bug, please report it by creating an issue on GitHub:
1. **Search Existing Issues**
Check if the bug has already been reported.
2. **Use the Bug Report Template**
Fill in all sections of the bug report template:
- Description
- Steps to reproduce
- Expected behavior
- Actual behavior
- Screenshots (if applicable)
- Environment (browser, OS, etc.)
3. **Be Specific**
The more details you provide, the easier it will be to fix the bug.
## Suggesting Features
We welcome feature suggestions! To suggest a new feature:
1. **Search Existing Suggestions**
Check if your idea has already been suggested.
2. **Use the Feature Request Template**
Fill in all sections of the feature request template:
- Description
- Use case
- Potential implementation
- Alternatives considered
3. **Be Patient**
Feature requests are evaluated based on alignment with project goals, feasibility, and maintainer bandwidth.
## Writing Documentation
Good documentation is crucial for the project. To contribute to documentation:
1. **Identify Gaps**
Look for areas where documentation is missing or unclear.
2. **Follow Documentation Style**
Maintain a consistent style and format.
3. **Use Clear Language**
Write in simple, clear English. Avoid jargon when possible.
4. **Include Examples**
Code examples and screenshots help users understand.
5. **Submit a PR**
Follow the same process as code contributions, but create a branch with a `docs/` prefix.
## Community
Join our community channels to discuss the project, get help, and connect with other contributors:
- **Discord Server**: [Join our Discord](https://discord.gg/betterseqta)
- **GitHub Discussions**: For longer-form conversations
- **GitHub Issues**: For bug reports and feature requests
## Creating Plugins
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Creating Your First Plugin](./plugins/creating-plugins.md)
- [Plugin API Reference](./advanced/plugin-api.md)
## Recognition
Contributors are recognized in several ways:
1. **CONTRIBUTORS.md**: All contributors are listed in this file
2. **Release Notes**: Significant contributions are highlighted in release notes
3. **Community Recognition**: Regular shout-outs in community channels
## Questions?
If you have any questions about contributing, please:
1. Check the documentation
2. Ask in the Discord server
3. Open a GitHub Discussion
Thank you for contributing to BetterSEQTA+! Your efforts help make SEQTA better for students and teachers everywhere.
+180
View File
@@ -0,0 +1,180 @@
# Installing BetterSEQTA+
This guide will walk you through the process of installing and setting up BetterSEQTA+ for development or usage.
## Prerequisites
Before you begin, make sure you have the following installed:
- [npm](https://www.npmjs.com/) (v7 or higher) or [Bun](https://bun.sh/) (recommended)
- A modern web browser (Chrome, Firefox, Edge, etc.)
## Installation Methods
There are two ways to install BetterSEQTA+:
1. **For Users**: Install the browser extension
2. **For Developers**: Clone the repository and set up the development environment
## For Users: Installing the Browser Extension
BetterSEQTA+ is available as a browser extension for Chrome, Firefox, and Edge.
### Chrome/Edge
1. Visit the [Chrome Web Store page for BetterSEQTA+](https://chrome.google.com/webstore/detail/betterseqta)
2. Click the "Add to Chrome" button
3. Confirm the installation when prompted
4. The extension will be installed and ready to use
### Firefox
1. Visit the [Firefox Add-ons page for BetterSEQTA+](https://addons.mozilla.org/en-US/firefox/addon/betterseqta)
2. Click the "Add to Firefox" button
3. Confirm the installation when prompted
4. The extension will be installed and ready to use
## For Developers: Setting Up the Development Environment
If you want to develop for BetterSEQTA+ or modify the code, follow these steps:
### 1. Clone the Repository
```bash
git clone https://github.com/SeqtaLearning/betterseqta-plus.git
cd betterseqta-plus
```
### 2. Install Dependencies
Using npm:
```bash
npm install --legacy-peer-deps
```
Using Bun (recommended):
```bash
bun install
```
### 3. Set Up Environment Variables - Only required for pushing to extension stores from the command line
Copy the example environment file:
```bash
cp .env.submit.example .env
```
Edit the `.env` file with your SEQTA credentials and settings.
### 4. Start the Development Server
Using npm:
```bash
npm run dev
```
Using Bun:
```bash
bun run dev
```
This will start a development server and build the extension in watch mode.
### 5. Load the Extension in Your Browser
#### Chrome/Edge
1. Open Chrome/Edge and navigate to `chrome://extensions` or `edge://extensions`
2. Enable "Developer mode" using the toggle in the top right
3. Click "Load unpacked" and select the `dist` folder in your BetterSEQTA+ directory
4. The extension should now appear in your extensions list
#### Firefox
1. Open Firefox and navigate to `about:debugging#/runtime/this-firefox`
2. Click "Load Temporary Add-on..."
3. Select the `manifest.json` file in the `dist` folder
4. The extension should now appear in your add-ons list
### 6. Test Your Changes
After making changes to the code, the development server will automatically rebuild the extension. However, you may need to reload the extension in your browser to see the changes:
1. Go to the extensions page in your browser
2. Find BetterSEQTA+ and click the reload icon
3. Refresh any SEQTA Learn pages you have open
## Troubleshooting Installation
### Common Issues
#### "Cannot find module" errors
If you see errors about missing modules, try:
```bash
rm -rf node_modules
npm install
```
Or with Bun:
```bash
rm -rf node_modules
bun install
```
#### Extension not appearing in SEQTA
Make sure:
- You're visiting a SEQTA Learn page
- The extension is enabled
- You've refreshed the page after installing the extension
#### Development build not updating
Try:
1. Stopping the development server
2. Clearing your browser cache
3. Removing the extension from your browser
4. Rebuilding the extension
5. Loading it again
## Updating BetterSEQTA+
### For Users
Browser extensions update automatically, but you can manually check for updates:
- **Chrome/Edge**: Go to `chrome://extensions` or `edge://extensions`, enable Developer mode, and click "Update"
- **Firefox**: Go to `about:addons`, click the gear icon, and select "Check for Updates"
### For Developers
If you're working on the code, pull the latest changes and reinstall dependencies:
```bash
git pull
npm install
npm run dev
```
Or with Bun:
```bash
git pull
bun install
bun run dev
```
## Next Steps
Now that you have BetterSEQTA+ installed, you can:
- [Getting Started with Plugins](./plugins/getting-started.md)
- [Contribute to the project](../CONTRIBUTING.md)
+257
View File
@@ -0,0 +1,257 @@
# Creating Plugins for BetterSEQTA+
Hey there! 👋 So you want to create a plugin for BetterSEQTA+? That's awesome! This guide will walk you through everything you need to know, from the very basics to more advanced features. Don't worry if you're new to this - we'll explain everything step by step.
## What is a Plugin?
In BetterSEQTA+, a plugin is like a mini-app that adds new features to SEQTA. Think of it as a piece of LEGO that you can snap onto SEQTA to make it do new things. For example, you could create a plugin that:
- Changes how SEQTA looks
- Adds new buttons or features
- Shows extra information on your timetable
- Collects notifications in a better way
- Really, anything you can imagine!
## Your First Plugin
Let's create a super simple plugin together. We'll make one that adds a friendly message to the SEQTA homepage. Here's what we'll need:
```typescript
import type { Plugin } from '@/plugins/core/types';
const myFirstPlugin: Plugin = {
// Every plugin needs these basic details
id: 'my-first-plugin',
name: 'My First Plugin',
description: 'Adds a friendly message to SEQTA',
version: '1.0.0',
// This tells BetterSEQTA+ that users can turn our plugin on/off
disableToggle: true,
// This is where the magic happens!
run: async (api) => {
// Wait for the homepage to load
api.seqta.onMount('.home-page', (homePage) => {
// Create our message
const message = document.createElement('div');
message.textContent = 'Hello from my first plugin! 🎉';
message.style.padding = '20px';
message.style.backgroundColor = '#e9f5ff';
message.style.borderRadius = '8px';
message.style.margin = '20px';
// Add it to the page
homePage.prepend(message);
});
// Return a cleanup function that removes our message when the plugin is disabled
return () => {
const message = document.querySelector('.home-page > div');
message?.remove();
};
}
};
export default myFirstPlugin;
```
Let's break down what's happening here:
1. First, we import the `Plugin` type that tells TypeScript what a plugin should look like
2. We create our plugin object with some basic information:
- `id`: A unique name for your plugin (use lowercase and dashes)
- `name`: A friendly name that users will see
- `description`: Explain what your plugin does
- `version`: Your plugin's version number
3. We set `disableToggle: true` so users can turn our plugin on/off in settings
4. The `run` function is where we put our plugin's code
5. We use `api.seqta.onMount` to wait for the homepage to load
6. We create and style a message element
7. We return a cleanup function that removes our changes when the plugin is disabled
## The Plugin API
When your plugin runs, it gets access to a powerful API that lets you do all sorts of things. Let's look at what you can do:
### SEQTA API (`api.seqta`)
This helps you interact with SEQTA's pages:
```typescript
// Wait for an element to appear on the page
api.seqta.onMount('.some-class', (element) => {
// Do something with the element
});
// Know when the user changes pages
api.seqta.onPageChange((page) => {
console.log('User went to:', page);
});
// Get the current page
const currentPage = api.seqta.getCurrentPage();
```
### Settings API (`api.settings`)
Want to let users customize your plugin? Use settings!
```typescript
import { BasePlugin } from '@/plugins/core/settings';
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
// Define your settings
const settings = defineSettings({
showMessage: booleanSetting({
default: true,
title: "Show Welcome Message",
description: "Show a friendly message on the homepage",
})
});
// Create a class for your plugin
class MyPluginClass extends BasePlugin<typeof settings> {
@Setting(settings.showMessage)
showMessage!: boolean;
}
// Create your plugin
const settingsInstance = new MyPluginClass();
const myPlugin: Plugin<typeof settings> = {
// ... other plugin details ...
settings: settingsInstance.settings,
run: async (api) => {
// Use the setting
if (api.settings.showMessage) {
// Show the message
}
// Listen for setting changes
api.settings.onChange('showMessage', (newValue) => {
if (newValue) {
// Show the message
} else {
// Hide the message
}
});
}
};
```
### Storage API (`api.storage`)
Need to save some data? The storage API has got you covered:
```typescript
// Save some data
await api.storage.set('lastVisit', new Date().toISOString());
// Get it back later
const lastVisit = await api.storage.get('lastVisit');
// Listen for changes
api.storage.onChange('lastVisit', (newValue) => {
console.log('Last visit updated:', newValue);
});
```
### Events API (`api.events`)
Want your plugin to be able to interface with other plugins? Then use events!
```typescript
// Listen for an event
api.events.on('myCustomEvent', (data) => {
console.log('Got event:', data);
});
// Send an event
api.events.emit('myCustomEvent', { some: 'data' });
```
## Adding Styles
Want to make your plugin look pretty? You can add CSS styles:
```typescript
const myPlugin: Plugin = {
// ... other plugin details ...
// Add your CSS here
styles: `
.my-plugin-message {
background: linear-gradient(135deg, #6e8efb, #a777e3);
color: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin: 20px;
animation: slide-in 0.3s ease-out;
}
@keyframes slide-in {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
`,
run: async (api) => {
// Your plugin code here
}
};
```
## Best Practices
Here are some tips to make your plugin awesome:
1. **Always Clean Up**: When your plugin is disabled, clean up any changes you made:
```typescript
run: async (api) => {
// Add stuff to the page
const element = document.createElement('div');
document.body.appendChild(element);
// Return a cleanup function
return () => {
element.remove();
};
}
```
2. **Use TypeScript**: It helps catch errors before they happen and makes your code easier to understand.
3. **Test Your Plugin**: Make sure it works in different situations:
- When SEQTA is loading
- When the user switches pages
- When the plugin is enabled/disabled
- When settings are changed
4. **Keep It Fast**: Don't slow down SEQTA:
- Use `onMount` instead of intervals or timeouts
- Clean up event listeners when they're not needed
- Don't do heavy calculations on the main thread
5. **Make It User-Friendly**:
- Add clear settings with good descriptions
- Use `disableToggle: true` so users can turn it off if needed
- Add helpful error messages if something goes wrong
## Examples
Want to see more examples? Check out our built-in plugins:
- [themes](../../src/plugins/built-in/themes/index.ts): Shows how to change SEQTA's appearance
- [notificationCollector](../../src/plugins/built-in/notificationCollector/index.ts): Shows how to work with SEQTA's notifications
- [timetable](../../src/plugins/built-in/timetable/index.ts): Shows how to modify SEQTA's timetable view
- [assessmentsAverage](../../src/plugins/built-in/assessmentsAverage/index.ts): Shows how to add new features to existing pages
## Need Help?
Got stuck? No worries! Here's where you can get help:
- Join our [Discord server](https://discord.gg/YzmbnCDkat)
- Check out the built-in plugins in the `src/plugins/built-in` folder
- Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues)
Happy coding and feel free to checkout the api reference [here](./api-reference.md)
+314
View File
@@ -0,0 +1,314 @@
# Plugin API Reference
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see [Creating Your First Plugin](./README.md).
## Plugin Structure
Here's how a plugin is structured:
```typescript
import type { Plugin } from '@/plugins/core/types';
import { BasePlugin } from '@/plugins/core/settings';
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
// First, define your settings
const settings = defineSettings({
enabled: booleanSetting({
default: true,
title: "Enable Feature",
description: "Turn this feature on or off",
})
});
// Create a class to handle your settings
class MyPluginClass extends BasePlugin<typeof settings> {
@Setting(settings.enabled)
enabled!: boolean;
}
// Create an instance of your settings
const settingsInstance = new MyPluginClass();
// Create your plugin
const myPlugin: Plugin<typeof settings> = {
id: 'my-plugin',
name: 'My Plugin',
description: 'A cool plugin that does things',
version: '1.0.0',
settings: settingsInstance.settings,
disableToggle: true,
run: async (api) => {
console.log('Plugin is running!');
// Do stuff when settings change
api.settings.onChange('enabled', (enabled) => {
if (enabled) {
console.log('Feature enabled!');
}
});
// Return a cleanup function
return () => {
console.log('Plugin cleanup');
};
}
};
export default myPlugin;
```
## SEQTA API
The SEQTA API helps you interact with SEQTA's pages:
```typescript
import type { Plugin } from '@/plugins/core/types';
const seqtaPlugin: Plugin<typeof settings> = {
id: 'seqta-example',
name: 'SEQTA Example',
description: 'Shows how to use the SEQTA API',
version: '1.0.0',
settings: {},
disableToggle: true,
run: async (api) => {
// Wait for elements to appear
const { unregister: timetableUnregister } = api.seqta.onMount('.timetable', (timetable) => {
const button = document.createElement('button');
button.textContent = 'Export';
timetable.appendChild(button);
});
// Track page changes
const { unregister: pageUnregister } = api.seqta.onPageChange((page) => {
console.log('User went to:', page);
});
// Clean up when disabled
return () => {
timetableUnregister();
pageUnregister();
};
}
};
export default seqtaPlugin;
```
## Settings API
Here's how to add settings to your plugin:
```typescript
import type { Plugin } from '@/plugins/core/types';
import { BasePlugin } from '@/plugins/core/settings';
import { booleanSetting, stringSetting, numberSetting, selectSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
// Define your settings
const settings = defineSettings({
darkMode: booleanSetting({
default: false,
title: "Dark Mode",
description: "Enable dark mode"
}),
userName: stringSetting({
default: "",
title: "User Name",
description: "Your display name",
placeholder: "Enter your name..."
}),
theme: selectSetting({
default: "light",
title: "Theme",
description: "Choose your theme",
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" }
]
})
});
// Create your settings class
class ThemePluginClass extends BasePlugin<typeof settings> {
@Setting(settings.darkMode)
darkMode!: boolean;
@Setting(settings.userName)
userName!: string;
@Setting(settings.theme)
theme!: string;
}
// Create the plugin
const themePlugin: Plugin<typeof settings> = {
id: 'theme-example',
name: 'Theme Example',
description: 'Shows how to use settings',
version: '1.0.0',
settings: new ThemePluginClass().settings,
disableToggle: true,
run: async (api) => {
// Apply initial settings
if (api.settings.darkMode) {
document.body.classList.add('dark');
}
// Listen for changes
const { unregister } = api.settings.onChange('darkMode', (enabled) => {
document.body.classList.toggle('dark', enabled);
});
return () => {
unregister();
document.body.classList.remove('dark');
};
}
};
export default themePlugin;
```
## Storage API
Here's how to use storage in your plugin:
```typescript
import type { Plugin } from '@/plugins/core/types';
const storagePlugin: Plugin<typeof settings> = {
id: 'storage-example',
name: 'Storage Example',
description: 'Shows how to use storage',
version: '1.0.0',
settings: {},
disableToggle: true,
run: async (api) => {
// Wait for storage to be ready
await api.storage.loaded;
// Save some data
await api.storage.set('lastVisit', new Date().toISOString());
// Get saved data
const lastVisit = await api.storage.get('lastVisit');
console.log('Last visit:', lastVisit);
// Listen for changes
const { unregister } = api.storage.onChange('lastVisit', (newValue) => {
console.log('Last visit updated:', newValue);
});
return () => {
unregister();
};
}
};
export default storagePlugin;
```
## Events API
Here's how to use events in your plugin:
```typescript
import type { Plugin } from '@/plugins/core/types';
const eventsPlugin: Plugin<typeof settings> = {
id: 'events-example',
name: 'Events Example',
description: 'Shows how to use events',
version: '1.0.0',
settings: {},
disableToggle: true,
run: async (api) => {
// Listen for theme changes
const { unregister: themeListener } = api.events.on('theme.changed', (theme) => {
console.log('Theme changed to:', theme);
});
// Listen for notifications
const { unregister: notifyListener } = api.events.on('notification.new', (notification) => {
console.log('New notification:', notification);
});
// Clean up listeners
return () => {
themeListener();
notifyListener();
};
}
};
export default eventsPlugin;
```
## Performance Tips
Here's how to write efficient plugins:
```typescript
import type { Plugin } from '@/plugins/core/types';
const efficientPlugin: Plugin<typeof settings> = {
id: 'efficient-example',
name: 'Efficient Example',
description: 'Shows performance best practices',
version: '1.0.0',
settings: {},
disableToggle: true,
run: async (api) => {
// ✅ Good: Use onMount
const { unregister } = api.seqta.onMount('.timetable', (el) => {
el.classList.add('enhanced');
});
// ❌ Bad: Don't use intervals
// const interval = setInterval(() => {
// const el = document.querySelector('.timetable');
// if (el) el.classList.add('enhanced');
// }, 100);
// ✅ Good: Cache DOM elements
const header = document.querySelector('.header');
if (header) {
// Reuse header instead of querying again
}
// ✅ Good: Batch DOM updates
const fragment = document.createDocumentFragment();
for (let i = 0; i < 10; i++) {
const div = document.createElement('div');
fragment.appendChild(div);
}
document.body.appendChild(fragment);
return () => {
unregister();
// clearInterval(interval); // If you used the bad approach
};
}
};
export default efficientPlugin;
```
Each plugin should be in its own file and exported as the default export. The plugin should:
1. Import necessary types and helpers
2. Define settings if needed
3. Create a settings class if using settings
4. Create the plugin object with proper type annotation
5. Export the plugin as default
Remember to always:
- Use proper TypeScript types
- Clean up when your plugin is disabled
- Handle errors gracefully
- Follow the plugin structure shown above
+37
View File
@@ -0,0 +1,37 @@
// vite-plugin-inline-worker-dev.ts
import { Plugin } from 'vite'
import fs from 'fs/promises'
import { build, transform } from 'esbuild'
export default function InlineWorkerDevPlugin(): Plugin {
return {
name: 'vite:inline-worker-dev',
async load(id) {
if (id.includes('?inlineWorker')) {
const [cleanPath] = id.split('?')
console.log('cleanPath', cleanPath)
const code = await fs.readFile(cleanPath, 'utf-8')
const result = await build({
entryPoints: [cleanPath],
bundle: true,
write: false,
platform: 'browser',
format: 'iife',
target: 'esnext',
})
const workerCode = result.outputFiles[0].text
const workerBlobCode = `
const code = ${JSON.stringify(workerCode)};
export default function InlineWorker() {
const blob = new Blob([code], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob), { type: 'module' });
}
`
return workerBlobCode
}
return null
}
}
}
-1
View File
@@ -34,7 +34,6 @@ export function updateManifestPlugin(): PluginOption {
} }
fs.watchFile(manifestPath, () => { fs.watchFile(manifestPath, () => {
console.log('** watchFile **');
try { try {
const manifestContents = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const manifestContents = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (manifestContents.web_accessible_resources?.some((resource: any) => resource.use_dynamic_url)) { if (manifestContents.web_accessible_resources?.some((resource: any) => resource.use_dynamic_url)) {
+24 -6
View File
@@ -5,26 +5,44 @@ const path = require('path');
function getLatestVersion(files) { function getLatestVersion(files) {
console.log('Files passed to getLatestVersion:', files); console.log('Files passed to getLatestVersion:', files);
const versions = files.map(file => { const versions = files.map(file => {
const match = file.match(/@(\d+\.\d+\.\d+)-/); const match = file.match(/@([\d\.]+)-/);
console.log('Matching file:', file, 'Version found:', match ? match[1] : 'None'); console.log('Matching file:', file, 'Version found:', match ? match[1] : 'None');
return match ? match[1] : null;
if (!match) return null;
const fullVersion = match[1]; // Original version (e.g., 3.4.5.1)
const semverVersion = fullVersion.split('.').slice(0, 3).join('.'); // Trim to 3.4.5
return { fullVersion, semverVersion };
}).filter(Boolean); }).filter(Boolean);
console.log('Extracted versions:', versions); console.log('Extracted versions:', versions.map(v => v.semverVersion));
const latestVersion = semver.maxSatisfying(versions, '*');
console.log('Latest version:', latestVersion); // Find latest version using the trimmed semver format
const latestSemver = semver.maxSatisfying(versions.map(v => v.semverVersion), '*');
console.log('Latest SemVer-compatible version:', latestSemver);
// Get the full version that matches the latest SemVer version
const latestVersion = versions.find(v => v.semverVersion === latestSemver)?.fullVersion || null;
console.log('Final selected latest version:', latestVersion);
return latestVersion; return latestVersion;
} }
function getLatestFiles(browser) { function getLatestFiles(browser) {
const pattern = `dist/betterseqtaplus@*-*${browser}.zip`; const pattern = `dist/betterseqtaplus@*-*${browser}.zip`;
console.log('Glob pattern:', pattern); console.log('Glob pattern:', pattern);
const files = glob.sync(pattern); const files = glob.sync(pattern);
console.log('Files found for browser', browser, ':', files); console.log('Files found for browser', browser, ':', files);
const latestVersion = getLatestVersion(files); const latestVersion = getLatestVersion(files);
const latestFile = files.find(file => file.includes(latestVersion)); // Find the exact file by matching the original full version
const latestFile = files.find(file => file.includes(`@${latestVersion}-`));
console.log('Latest file for browser', browser, ':', latestFile); console.log('Latest file for browser', browser, ':', latestFile);
return latestFile; return latestFile;
} }
+17
View File
@@ -0,0 +1,17 @@
import fs from 'fs';
export default function touchGlobalCSSPlugin() {
return {
name: 'touch-global-css',
handleHotUpdate({ modules }) {
// log all of the staticImportedUrls
const importers = modules[0]._clientModule.importers
importers.forEach((importer) => {
if (importer.file.includes('.css')) {
console.log("touching", importer.file)
fs.utimesSync(importer.file, new Date(), new Date())
}
})
}
};
}
+49 -41
View File
@@ -1,6 +1,6 @@
{ {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"version": "3.4.5", "version": "3.4.6.1",
"type": "module", "type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!", "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!",
"browserslist": "> 0.5%, last 2 versions, not dead", "browserslist": "> 0.5%, last 2 versions, not dead",
@@ -11,6 +11,7 @@
"build:chrome": "cross-env MODE=chrome vite build", "build:chrome": "cross-env MODE=chrome vite build",
"build:firefox": "cross-env MODE=firefox vite build", "build:firefox": "cross-env MODE=firefox vite build",
"build:safari": "cross-env MODE=safari vite build", "build:safari": "cross-env MODE=safari vite build",
"build:dev": "cross-env MODE=chrome SOURCEMAP=true vite build && cross-env MODE=firefox SOURCEMAP=true vite build",
"convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari", "convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari",
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg", "dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes", "release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
@@ -32,66 +33,73 @@
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/plugin-transform-runtime": "^7.25.9", "@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.7", "@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.85", "@bedframe/cli": "^0.0.91",
"@crxjs/vite-plugin": "2.0.0-beta.25", "@crxjs/vite-plugin": "2.0.0-beta.25",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@vitejs/plugin-react-swc": "^3.7.2", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dependency-cruiser": "^16.10.0", "dependency-cruiser": "^16.10.0",
"eslint": "^8.57.1", "eslint": "9.22.0",
"glob": "^11.0.1", "glob": "^11.0.1",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"prettier": "^3.4.2", "prettier": "^3.5.3",
"process": "^0.11.10", "process": "^0.11.10",
"publish-browser-extension": "^3.0.0", "publish-browser-extension": "^3.0.0",
"sass": "^1.83.4", "sass": "^1.85.1",
"sass-loader": "^13.3.3", "sass-loader": "^16.0.5",
"semver": "^7.7.1", "semver": "^7.7.1",
"tailwindcss": "3",
"url": "^0.11.4" "url": "^0.11.4"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-css": "^6.3.0", "@codemirror/autocomplete": "^6.18.6",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@codemirror/commands": "^6.8.0",
"@tailwindcss/forms": "^0.5.9", "@codemirror/lang-css": "^6.3.1",
"@codemirror/language": "^6.10.8",
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4", "@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.0.270", "@types/chrome": "^0.0.308",
"@types/color": "^3.0.6", "@types/color": "^4.2.0",
"@types/dompurify": "^3.2.0", "@types/lodash": "^4.17.16",
"@types/lodash": "^4.17.15", "@types/node": "^22.13.10",
"@types/node": "^20.17.17",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@types/uuid": "^9.0.8", "@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.10.7", "@types/webextension-polyfill": "^0.12.3",
"@uiw/codemirror-extensions-color": "^4.23.8", "@uiw/codemirror-extensions-color": "^4.23.10",
"@uiw/codemirror-theme-github": "^4.23.8", "@uiw/codemirror-theme-github": "^4.23.10",
"@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.21",
"autoprefixer": "^10.4.20", "client-vector-search": "../client-vector-search",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"color": "^4.2.3", "color": "^5.0.0",
"dompurify": "^3.1.6", "dompurify": "^3.2.4",
"embla-carousel-autoplay": "^8.3.1", "embla-carousel-autoplay": "^8.5.2",
"embla-carousel-svelte": "^8.3.1", "embla-carousel-svelte": "^8.5.2",
"fuse.js": "^7.0.0", "events": "^3.3.0",
"idb": "^8.0.0", "flexsearch": "^0.8.147",
"fuse.js": "^7.1.0",
"idb": "^8.0.2",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mathjs": "^14.4.0",
"million": "^3.1.11", "million": "^3.1.11",
"motion": "^11.12.0", "motion": "^12.4.12",
"postcss": "^8.4.45", "postcss": "^8.5.3",
"react": "17", "react": "17",
"react-best-gradient-color-picker": "^3.0.10", "react-best-gradient-color-picker": "3.0.11",
"react-dom": "17", "react-dom": "17",
"rss-parser": "^3.13.0", "rss-parser": "^3.13.0",
"sortablejs": "^1.15.3", "sortablejs": "^1.15.6",
"svelte": "^5.1.9", "svelte": "^5.22.6",
"tailwindcss": "^3.4.11", "typescript": "^5.8.2",
"typescript": "^5.6.2", "uuid": "^11.1.0",
"uuid": "^9.0.1", "vite": "^6.2.1",
"vite": "^5.4.14", "webextension-polyfill": "^0.12.0"
"webextension-polyfill": "^0.10.0"
} }
} }
+126
View File
@@ -0,0 +1,126 @@
--- a/Users/sethburkart/Documents/Coding/betterseqta-plus/src/plugins/core/settings.ts
+++ b/Users/sethburkart/Documents/Coding/betterseqta-plus/src/plugins/core/settings.ts
@@ -2,7 +2,7 @@
// Base interfaces for our settings
interface BaseSettingOptions {
- title: string;
+ readonly title: string; // Mark as readonly where appropriate
description?: string;
}
@@ -11,21 +11,21 @@
}
interface StringSettingOptions extends BaseSettingOptions {
- default: string;
+ readonly default: string;
maxLength?: number;
pattern?: string;
}
interface NumberSettingOptions extends BaseSettingOptions {
- default: number;
+ readonly default: number;
min?: number;
max?: number;
step?: number;
}
interface SelectSettingOptions<T extends string> extends BaseSettingOptions {
- default: T;
- options: readonly T[];
+ readonly default: T;
+ readonly options: readonly T[];
}
// The actual decorators
@@ -34,14 +34,16 @@
// Ensure the settings property exists on the constructor's prototype
const proto = target.constructor.prototype;
if (!proto.hasOwnProperty('settings')) {
- proto.settings = {};
+ // Initialize with a base type that can be extended
+ Object.defineProperty(proto, 'settings', {
+ value: {},
+ writable: true, // Allows adding properties
+ configurable: true,
+ enumerable: true
+ });
}
-
+
// Add the setting to the prototype's settings object with const assertion
proto.settings[propertyKey] = {
type: 'boolean' as const,
...options
};
- };
-}
-
-export function StringSetting(options: StringSettingOptions): PropertyDecorator {
- return (target: Object, propertyKey: string | symbol) => {
- // Ensure the settings property exists on the constructor's prototype
- const proto = target.constructor.prototype;
- if (!proto.hasOwnProperty('settings')) {
- proto.settings = {};
- }
-
- // Add the setting to the prototype's settings object with const assertion
- proto.settings[propertyKey] = {
- type: 'string' as const,
- ...options
- };
};
}
@@ -50,14 +52,16 @@
// Ensure the settings property exists on the constructor's prototype
const proto = target.constructor.prototype;
if (!proto.hasOwnProperty('settings')) {
- proto.settings = {};
+ Object.defineProperty(proto, 'settings', {
+ value: {},
+ writable: true,
+ configurable: true,
+ enumerable: true
+ });
}
-
+
// Add the setting to the prototype's settings object with const assertion
proto.settings[propertyKey] = {
type: 'number' as const,
...options
};
- };
-}
-
-export function SelectSetting<T extends string>(options: SelectSettingOptions<T>): PropertyDecorator {
- return (target: Object, propertyKey: string | symbol) => {
- // Ensure the settings property exists on the constructor's prototype
- const proto = target.constructor.prototype;
- if (!proto.hasOwnProperty('settings')) {
- proto.settings = {};
- }
-
- // Add the setting to the prototype's settings object with const assertion
- proto.settings[propertyKey] = {
- type: 'select' as const,
- ...options
- };
};
}
// Base plugin class that handles settings
export abstract class BasePlugin<T extends PluginSettings = PluginSettings> {
// The settings property will be populated by decorators
- settings!: T;
-
+ // Keep the instance property and constructor logic as is,
+ // as changing it would require changing animated-background/index.ts
+ settings!: T; // Use definite assignment assertion
+
constructor() {
// Copy settings from the prototype to the instance
// This ensures that each instance has its own settings object
+37 -3111
View File
File diff suppressed because it is too large Load Diff
+50 -41
View File
@@ -14,7 +14,7 @@ function reloadSeqtaPages() {
result.then(open, console.error) result.then(open, console.error)
} }
// Main message listener // @ts-ignore
browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (response?: any) => void) => { browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (response?: any) => void) => {
switch (request.type) { switch (request.type) {
@@ -38,7 +38,7 @@ browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (resp
sendResponse(response); sendResponse(response);
}); });
}); });
return true; // Keep message channel open for async response return true;
case 'githubTab': case 'githubTab':
browser.tabs.create({ url: 'github.com/BetterSEQTA/BetterSEQTA-Plus' }); browser.tabs.create({ url: 'github.com/BetterSEQTA/BetterSEQTA-Plus' });
@@ -49,13 +49,14 @@ browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (resp
break; break;
case 'sendNews': case 'sendNews':
fetchNews(request.source ?? 'australia', sendResponse); fetchNews(request.source ?? 'australia', sendResponse);
return true; return true;
default: default:
console.log('Unknown request type'); console.log('Unknown request type');
} }
return false;
}); });
const DefaultValues: SettingsState = { const DefaultValues: SettingsState = {
@@ -64,7 +65,6 @@ const DefaultValues: SettingsState = {
bksliderinput: "50", bksliderinput: "50",
transparencyEffects: false, transparencyEffects: false,
lessonalert: true, lessonalert: true,
notificationcollector: true,
defaultmenuorder: [], defaultmenuorder: [],
menuitems: { menuitems: {
assessments: { toggle: true }, assessments: { toggle: true },
@@ -154,54 +154,63 @@ function SetStorageValue(object: any) {
} }
} }
async function UpdateCurrentValues() { function convertBksliderToSpeed(bksliderinput: number): number {
try { const minBase = 50;
const items = await browser.storage.local.get(); const maxBase = 150;
const CurrentValues = items;
const NewValue = Object.assign({}, DefaultValues, CurrentValues); const scaledValue = 2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4;
const baseSpeed = 3;
function CheckInnerElement(element: any) { const speed = baseSpeed / scaledValue;
for (let i in element) { return speed;
if (typeof element[i] === 'object') { }
// @ts-expect-error
if (!Array.isArray(DefaultValues[i])) {
// @ts-expect-error
NewValue[i] = Object.assign({}, DefaultValues[i], CurrentValues[i]);
} else {
// @ts-expect-error
const length = DefaultValues[i].length;
// @ts-expect-error
NewValue[i] = Object.assign({}, DefaultValues[i], CurrentValues[i]);
let NewArray = [];
for (let j = 0; j < length; j++) {
NewArray.push(NewValue[i][j]);
}
NewValue[i] = NewArray;
}
}
}
}
CheckInnerElement(DefaultValues); async function migrateLegacySettings() {
const storage = await browser.storage.local.get(null) as unknown as SettingsState;
if (items['customshortcuts']) { // Animated Background Migration
NewValue['customshortcuts'] = items['customshortcuts']; if ('animatedbk' in storage || 'bksliderinput' in storage) {
} const animatedSettings = {
enabled: storage.animatedbk ?? true,
SetStorageValue(NewValue); speed: storage.bksliderinput ? convertBksliderToSpeed(parseFloat(storage.bksliderinput)) : 1
console.log('[BetterSEQTA+] Values updated successfully'); };
} catch (error) { await browser.storage.local.set({ 'plugin.animated-background.settings': animatedSettings });
console.error('[BetterSEQTA+] Error updating values:', error);
} }
// Assessments Average Migration
if ('assessmentsAverage' in storage || 'lettergrade' in storage) {
const assessmentsSettings = {
enabled: storage.assessmentsAverage ?? true,
lettergrade: storage.lettergrade ?? false
};
await browser.storage.local.set({ 'plugin.assessments-average.settings': assessmentsSettings });
}
if ('selectedTheme' in storage) {
const themesSettings = { enabled: true };
await browser.storage.local.set({ 'plugin.themes.settings': themesSettings });
}
if (storage.notificationCollector !== false) {
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: true } });
} else {
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: false } });
}
const keysToRemove = [
'animatedbk',
'bksliderinput',
'assessmentsAverage',
'lettergrade'
];
await browser.storage.local.remove(keysToRemove);
} }
browser.runtime.onInstalled.addListener(function (event) { browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.remove(['justupdated']); browser.storage.local.remove(['justupdated']);
browser.storage.local.remove(['data']); browser.storage.local.remove(['data']);
UpdateCurrentValues(); if ( event.reason == 'install' || event.reason == 'update' ) {
if ( event.reason == 'install', event.reason == 'update' ) {
browser.storage.local.set({ justupdated: true }); browser.storage.local.set({ justupdated: true });
migrateLegacySettings();
} }
}); });
+16 -10
View File
@@ -19,20 +19,25 @@ const rssFeedsByCountry: Record<string, string[]> = {
"https://www.npr.org/rss/rss.php", "https://www.npr.org/rss/rss.php",
], ],
taiwan: [ taiwan: [
"https://focustaiwan.tw/rss", "https://news.ltn.com.tw/rss/all.xml",
"https://www.taipeitimes.com/rss/all.xml", "https://www.taipeitimes.com/xml/index.rss",
"https://international.thenewslens.com/rss", "https://international.thenewslens.com/rss",
], ],
hong_kong: [ hong_kong: [
"https://news.rthk.hk/rthk/en/rss.htm", "https://rthk9.rthk.hk/rthk/news/rss/e_expressnews_elocal.xml",
"https://www.scmp.com/rss/91/feed", "https://www.scmp.com/rss/91/feed",
], ],
panama: [ panama: [
"http://www.panama-guide.com/backend.php", "https://critica.com.pa/rss.xml",
"https://www.panamaamerica.com.pa/rss.xml",
"https://noticiassin.com/feed/",
"https://elcapitalfinanciero.com/feed/"
], ],
canada: [ canada: [
"https://www.cbc.ca/cmlink/rss-topstories", "https://www.cbc.ca/cmlink/rss-topstories",
"https://www.theglobeandmail.com/?service=rss", "https://calgaryherald.com/feed",
"https://ottawacitizen.com/feed",
"https://www.montrealgazette.com/feed"
], ],
singapore: [ singapore: [
"https://www.straitstimes.com/news/singapore/rss.xml", "https://www.straitstimes.com/news/singapore/rss.xml",
@@ -43,19 +48,16 @@ const rssFeedsByCountry: Record<string, string[]> = {
"https://www.theguardian.com/uk/rss", "https://www.theguardian.com/uk/rss",
], ],
japan: [ japan: [
"https://www.japantimes.co.jp/feed/topstories.xml",
"https://www3.nhk.or.jp/nhkworld/en/news/feeds/", "https://www3.nhk.or.jp/nhkworld/en/news/feeds/",
"https://news.livedoor.com/topics/rss/int.xml"
], ],
netherlands: [ netherlands: [
"https://www.dutchnews.nl/feed/", "https://www.dutchnews.nl/feed/",
"http://feeds.nos.nl/nosnieuwsalgemeen", "https://www.nrc.nl/rss/"
], ],
}; };
export async function fetchNews(source: string, sendResponse: any) { export async function fetchNews(source: string, sendResponse: any) {
const parser = new Parser();
let feeds: string[];
if (source === "australia") { if (source === "australia") {
const date = new Date(); const date = new Date();
@@ -72,6 +74,10 @@ export async function fetchNews(source: string, sendResponse: any) {
return; return;
} }
const parser = new Parser();
let feeds: string[];
console.log('fetchNews', source)
if (rssFeedsByCountry[source.toLowerCase()]) { if (rssFeedsByCountry[source.toLowerCase()]) {
// If the source is a country, fetch from predefined feeds // If the source is a country, fetch from predefined feeds
feeds = rssFeedsByCountry[source.toLowerCase()]; feeds = rssFeedsByCountry[source.toLowerCase()];
+266 -145
View File
@@ -147,7 +147,7 @@ html {
border-radius: 17px 17px 0px 0 !important; border-radius: 17px 17px 0px 0 !important;
color: var(--text-color) !important; color: var(--text-color) !important;
} }
.LegacyModuleBody__LegacyModule___20YE2 { [class*="LegacyModuleBody__LegacyModule___"] {
background: transparent; background: transparent;
} }
#AddedSettings { #AddedSettings {
@@ -192,17 +192,17 @@ html {
} }
} }
.PillBox__PillBox___3GjAk { [class*="PillBox__PillBox___"] {
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
.PillBox__active___3Qpi9 { [class*="PillBox__active___"] {
background: rgba(0, 0, 0, 0.2) !important; background: rgba(0, 0, 0, 0.2) !important;
color: black !important; color: black !important;
} }
} }
.dark .PillBox__active___3Qpi9 { .dark [class*="PillBox__active___"] {
color: white; color: white;
} }
@@ -212,7 +212,7 @@ html {
background: var(--background-primary) !important; background: var(--background-primary) !important;
border: var(--background-secondary) !important; border: var(--background-secondary) !important;
overflow: clip; overflow: clip;
iframe { iframe {
background: transparent !important; background: transparent !important;
} }
@@ -261,7 +261,10 @@ html {
} }
.ais-btnSearch { .ais-btnSearch {
transition: background 200ms, color 200ms, box-shadow 200ms; transition:
background 200ms,
color 200ms,
box-shadow 200ms;
&:hover { &:hover {
background: rgba(0, 0, 0, 0.2) !important; background: rgba(0, 0, 0, 0.2) !important;
@@ -274,7 +277,7 @@ html {
font-family: Rubik, sans-serif !important; font-family: Rubik, sans-serif !important;
&::before { &::before {
font-size: 18px !important; font-size: 18px !important;
content: 'Search' !important; content: "Search" !important;
} }
} }
} }
@@ -383,8 +386,7 @@ ul.magicDelete > li.deleting {
width: 28px !important; width: 28px !important;
height: 28px !important; height: 28px !important;
} }
.notifications__items___2hCdv, [class*="notifications__items___"] {
#menu ul {
-ms-overflow-style: none !important; -ms-overflow-style: none !important;
scrollbar-width: none !important; scrollbar-width: none !important;
&::-webkit-scrollbar { &::-webkit-scrollbar {
@@ -468,7 +470,7 @@ html {
[data-type="student"] .header { [data-type="student"] .header {
color: black !important; color: black !important;
} }
ol:has(.MessageList__avatar___2wxyb svg) { ol:has([class*="MessageList__avatar___"] svg) {
transition-duration: 150ms !important; transition-duration: 150ms !important;
transition-delay: 0ms !important; transition-delay: 0ms !important;
} }
@@ -494,7 +496,7 @@ ol:has(.MessageList__avatar___2wxyb svg) {
background: var(--background-primary) !important; background: var(--background-primary) !important;
border-radius: 16px; border-radius: 16px;
} }
.MessageList__MessageList___3DxoC .footer { [class*="MessageList__MessageList___"] .footer {
background: var(--background-secondary) !important; background: var(--background-secondary) !important;
} }
.listWrapper { .listWrapper {
@@ -605,7 +607,7 @@ ol:has(.MessageList__avatar___2wxyb svg) {
&.above[data-yiq="dark"]::after { &.above[data-yiq="dark"]::after {
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
} }
&.above::after { &.above::after {
content: ""; content: "";
position: absolute; position: absolute;
@@ -756,15 +758,15 @@ ol > [data-label] {
margin-left: 4px; margin-left: 4px;
margin-bottom: 4px; margin-bottom: 4px;
} }
.Message__Message___3oJaU > .uiFrameWrapper .iframeWrapper { [class*="Message__Message___"] > .uiFrameWrapper .iframeWrapper {
background: transparent; background: transparent;
} }
.Viewer__newMessage___3ToUb { [class*="Viewer__newMessage___"] {
border-radius: 8px !important; border-radius: 8px !important;
font-size: 12.8px !important; font-size: 12.8px !important;
background: var(--background-primary) !important; background: var(--background-primary) !important;
} }
.MessageList__sender___32riy :last-child { [class*="MessageList__sender___"] :last-child {
white-space: nowrap; white-space: nowrap;
} }
[data-type="student"] [style="z-index: 30;"] .header:has(h1) { [data-type="student"] [style="z-index: 30;"] .header:has(h1) {
@@ -863,7 +865,7 @@ div > ol:has(.uiFileHandlerWrapper) {
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.LabelList__LabelList___2RJFf > li { [class*="LabelList__LabelList___"] > li {
border-radius: 8px !important; border-radius: 8px !important;
} }
} }
@@ -883,10 +885,10 @@ div > ol:has(.uiFileHandlerWrapper) {
.welcome .welcome
> .portalPageView > .portalPageView
> .powerPortalPage > .powerPortalPage
> .Body__body___3pGxr > [class*="Body__body___"]
> .Container__container___33GlY > [class*="Container__container___"]
> .Document__document___1KJCG > [class*="Document__document___"]
> .Canvas__canvas___OBdCZ { > [class*="Canvas__canvas___"] {
background-color: unset !important; background-color: unset !important;
background-image: unset !important; background-image: unset !important;
background-size: unset; background-size: unset;
@@ -896,7 +898,7 @@ div > ol:has(.uiFileHandlerWrapper) {
height: 100vh; height: 100vh;
color: var(--text-primary) !important; color: var(--text-primary) !important;
} }
.Module__wrapper___2sbOo { [class*="Module__wrapper___"] {
overflow: clip; overflow: clip;
background: var(--background-primary) !important; background: var(--background-primary) !important;
border-radius: 16px !important; border-radius: 16px !important;
@@ -908,10 +910,10 @@ div > ol:has(.uiFileHandlerWrapper) {
overflow: hidden; overflow: hidden;
} }
.composer .composer
> .Body__body___3pGxr > [class*="Body__body___"]
> .Container__container___33GlY > [class*="Container__container___"]
> .Document__document___1KJCG > [class*="Document__document___"]
> .Canvas__canvas___OBdCZ { > [class*="Canvas__canvas___"] {
background-color: transparent !important; background-color: transparent !important;
background-image: unset !important; background-image: unset !important;
color: white !important; color: white !important;
@@ -1007,34 +1009,6 @@ div > ol:has(.uiFileHandlerWrapper) {
margin-right: 157.5px; margin-right: 157.5px;
gap: 12px; gap: 12px;
} }
.bg {
animation: slide 3s ease-in-out infinite alternate;
background: var(--better-main);
bottom: 0;
left: -50%;
opacity: 0.5;
position: fixed;
right: -50%;
top: 0;
z-index: 0 !important;
overflow: hidden;
scale: 1.5;
}
.bg2 {
animation-direction: alternate-reverse;
animation-duration: 4s;
}
.bg3 {
animation-duration: 5s;
}
@keyframes slide {
0% {
transform: translate(50%) rotate(-60deg);
}
100% {
transform: translateX(5%) rotate(-60deg);
}
}
.home-root { .home-root {
width: 100%; width: 100%;
display: flex; display: flex;
@@ -1199,7 +1173,7 @@ div > ol:has(.uiFileHandlerWrapper) {
box-shadow: inset 0px 5px 20px 1px rgba(0, 0, 0, 0.3); box-shadow: inset 0px 5px 20px 1px rgba(0, 0, 0, 0.3);
background: var(--background-primary); background: var(--background-primary);
} }
.Empty__Empty___2F6rn { [class*="Empty__Empty___"] {
color: var(--text-primary); color: var(--text-primary);
} }
.shortcut-container { .shortcut-container {
@@ -1401,18 +1375,18 @@ div > ol:has(.uiFileHandlerWrapper) {
margin: 20px auto 0px; margin: 20px auto 0px;
cursor: pointer; cursor: pointer;
} }
.dark .notifications__detailsBody___2nU2k > .notifications__subtitle___1se8e { .dark [class*="notifications__detailsBody___"] > [class*="notifications__subtitle___"] {
color: #c1bcbc; color: #c1bcbc;
} }
.notifications__detailsBody___2nU2k > .notifications__subtitle___1se8e { [class*="notifications__detailsBody___"] > [class*="notifications__subtitle___"] {
font-size: 12px; font-size: 12px;
} }
.notifications__notifications___3mmLY.notifications__hasItems___gXxzx > button { [class*="notifications__notifications___"] > button {
background: white; background: white;
z-index: 21 !important; z-index: 21 !important;
color: var(--better-sub); color: var(--better-sub);
} }
.notifications__notifications___3mmLY > button { [class*="notifications__notifications___"] > button {
padding: 8px; padding: 8px;
} }
.legacy-root button > svg, .legacy-root button > svg,
@@ -1420,9 +1394,7 @@ div > ol:has(.uiFileHandlerWrapper) {
height: 25px; height: 25px;
width: 24px; width: 24px;
} }
.notifications__notifications___3mmLY [class*="notifications__notifications___"] > button > [class*="notifications__bubble___"] {
> button
> .notifications__bubble___1EkSQ {
background: var(--better-alert-highlight); background: var(--better-alert-highlight);
width: 25px; width: 25px;
height: 25px; height: 25px;
@@ -1440,16 +1412,16 @@ div > ol:has(.uiFileHandlerWrapper) {
.legacy-root button:not([disabled]):focus { .legacy-root button:not([disabled]):focus {
border-color: var(--better-sub); border-color: var(--better-sub);
} }
.notifications__list___rp2L2 { [class*="notifications__list___"] {
border: 4px solid var(--auto-background); border: 4px solid var(--auto-background);
background: var(--background-primary); background: var(--background-primary);
} }
.notifications__item___2ErJN { [class*="notifications__item___"] {
background: var(--background-primary) !important; background: var(--background-primary) !important;
border-left: 4px solid var(--better-main) !important; border-left: 4px solid var(--better-main) !important;
margin-bottom: 4px !important; margin-bottom: 4px !important;
> .notifications__dismiss___zveKV { > [class*="notifications__dismiss___"] {
background: rgba(0, 0, 0, 0.1) !important; background: rgba(0, 0, 0, 0.1) !important;
color: var(--text-primary); color: var(--text-primary);
margin: auto 0; margin: auto 0;
@@ -1474,7 +1446,7 @@ div > ol:has(.uiFileHandlerWrapper) {
#menu li:first-child { #menu li:first-child {
margin-top: 5px; margin-top: 5px;
} }
.notifications__actions___1UX7r { [class*="notifications__actions___"] {
background: var(--auto-background); background: var(--auto-background);
button { button {
@@ -1482,27 +1454,27 @@ div > ol:has(.uiFileHandlerWrapper) {
border: 1px solid white; border: 1px solid white;
} }
} }
.notifications__items___2hCdv { [class*="notifications__items___"] {
border-bottom: none; border-bottom: none;
height: 540px; height: 540px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.notifications__details___193F4 { [class*="notifications__details___"] {
max-width: 80%; max-width: 80%;
overflow: clip; overflow: clip;
} }
.notifications__details___193F4 div { [class*="notifications__details___"] div {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
#main > .messages { #main > .messages {
color: var(--text-primary); color: var(--text-primary);
} }
.Overview__details___2Zlnr { [class*="Overview__details___"] {
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
} }
.Viewer__sidebar___1Btu4 { [class*="Viewer__sidebar___"] {
color: var(--text-primary); color: var(--text-primary);
border-right: unset; border-right: unset;
background: unset; background: unset;
@@ -1511,14 +1483,14 @@ div > ol:has(.uiFileHandlerWrapper) {
background: unset; background: unset;
} }
} }
.MessageList__MessageList___3DxoC ::-webkit-scrollbar { [class*="MessageList__MessageList___"] ::-webkit-scrollbar {
width: 0px; width: 0px;
background: none; background: none;
} }
.MessageList__primary___1zTHa > :last-child { [class*="MessageList__primary___"] > :last-child {
display: none !important; display: none !important;
} }
.MessageList__MessageList___3DxoC ol .Button__Button___3SRFo::before { [class*="MessageList__MessageList___"] ol [class*="Button__Button___"]::before {
// plus icon // plus icon
content: ""; content: "";
font-size: 12px; font-size: 12px;
@@ -1527,7 +1499,7 @@ div > ol:has(.uiFileHandlerWrapper) {
pointer-events: none; pointer-events: none;
} }
.MessageList__MessageList___3DxoC ol .Button__Button___3SRFo { [class*="MessageList__MessageList___"] ol [class*="Button__Button___"] {
width: calc(100% - 32px); width: calc(100% - 32px);
border-radius: 16px; border-radius: 16px;
margin: 8px 16px; margin: 8px 16px;
@@ -1536,21 +1508,21 @@ div > ol:has(.uiFileHandlerWrapper) {
text-align: center; text-align: center;
} }
.dark .MessageList__MessageList___3DxoC .Button__Button___3SRFo { .dark [class*="MessageList__MessageList___"] [class*="Button__Button___"] {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
color: white !important; color: white !important;
} }
.MessageList__MessageList___3DxoC .Button__Button___3SRFo { [class*="MessageList__MessageList___"] [class*="Button__Button___"] {
background: rgba(0, 0, 0, 0.1); background: rgba(0, 0, 0, 0.1);
width: 100%; width: 100%;
min-height: 32px; min-height: 32px;
text-align: center; text-align: center;
} }
.MessageList__MessageList___3DxoC { [class*="MessageList__MessageList___"] {
background: var(--background-primary); background: var(--background-primary);
} }
.Input__Input___3RSTI::before, [class*="Input__Input___"]::before,
.ais-btnSearch::before { .ais-btnSearch::before {
content: ""; content: "";
/* Unicode for the search icon */ /* Unicode for the search icon */
@@ -1562,7 +1534,7 @@ div > ol:has(.uiFileHandlerWrapper) {
font-family: "IconFamily"; font-family: "IconFamily";
pointer-events: none; pointer-events: none;
} }
.Input__Input___3RSTI { [class*="Input__Input___"] {
transition: transition:
background-color 0.5s, background-color 0.5s,
border-color 0.5s; border-color 0.5s;
@@ -1587,15 +1559,15 @@ div > ol:has(.uiFileHandlerWrapper) {
height: 180px; height: 180px;
background: var(--background-primary); background: var(--background-primary);
} }
.Avatar__Avatar___gE5kx.Avatar__staff___4gVLs { [class*="Avatar__Avatar___"][class*="Avatar__staff___"] {
--person-colour: var(--better-light); --person-colour: var(--better-light);
background: var(--person-colour, var(--navy)); background: var(--person-colour, var(--navy));
} }
.LabelList__LabelList___2RJFf > li.LabelList__selected___3Egk7 { [class*="LabelList__LabelList___"] > li[class*="LabelList__selected___"] {
background: var(--background-primary); background: var(--background-primary);
color: var(--text-primary); color: var(--text-primary);
} }
.Message__Message___3oJaU { [class*="Message__Message___"] {
background: var(--background-primary); background: var(--background-primary);
border-radius: 16px !important; border-radius: 16px !important;
} }
@@ -1615,29 +1587,31 @@ iframe.userHTML {
background: var(--better-light); background: var(--better-light);
color: var(--text-color); color: var(--text-color);
} }
.Spinner__Spinner___CStEb > svg { [class*="Spinner__Spinner___"] > svg {
margin: 16px 0; margin: 16px 0;
} }
.Spinner__Spinner___CStEb > svg > path { [class*="Spinner__Spinner___"] > svg > path {
stroke: var(--text-primary) !important; stroke: var(--text-primary) !important;
} }
#main > .reports > .item > .report > .term { #main > .reports > .item > .report > .term {
color: var(--text-color); color: var(--text-color);
background: var(--better-main); background: var(--better-main);
} }
.Collapsible__Collapsible___3O8P3 > .Collapsible__header___-Afvq { [class*="Collapsible__Collapsible___"] > [class*="Collapsible__header__"] {
background: none; background: none !important;
} }
.Collapsible__Collapsible___3O8P3 > .Collapsible__content___2c6of.Collapsible__enterActive___3b2ow, [class*="Collapsible__Collapsible___"]
.Collapsible__Collapsible___3O8P3 > .Collapsible__content___2c6of.Collapsible__exitActive___3rFL1 { > [class*="Collapsible__content___"]
[class*="Collapsible__enterActive___"]
[class*="Collapsible__exitActive___"] {
animation-timing-function: ease-out !important; animation-timing-function: ease-out !important;
} }
.AssessmentList__AssessmentList___1GdCl [class*="AssessmentList__AssessmentList___"]
> .AssessmentList__searchFilter___3N70o > [class*="AssessmentList__searchFilter___"]
+ .AssessmentList__items___3LcmQ { + [class*="AssessmentList__items___"] {
color: var(--text-primary); color: var(--text-primary);
} }
.Thermoscore__Thermoscore___2tWMi { [class*="Thermoscore__Thermoscore___"] {
background-image: unset; background-image: unset;
background: var(--auto-background); background: var(--auto-background);
} }
@@ -1695,7 +1669,7 @@ body,
div, div,
ol, ol,
ul { ul {
scrollbar-width: thin !important; scrollbar-width: thin;
scrollbar-color: #babac0 #fff !important; scrollbar-color: #babac0 #fff !important;
} }
@@ -1704,7 +1678,7 @@ ul {
div, div,
ol, ol,
ul { ul {
scrollbar-width: thin !important; scrollbar-width: thin;
scrollbar-color: #333 #111 !important; scrollbar-color: #333 #111 !important;
} }
} }
@@ -1724,33 +1698,31 @@ ul {
#userActions > .details > .code { #userActions > .details > .code {
text-transform: initial; text-transform: initial;
} }
.SelectedAssessment__SelectedAssessment___3Bu5D { [class*="SelectedAssessment__SelectedAssessment___"] {
color: var(--text-primary); color: var(--text-primary);
} }
.SelectedAssessment__SelectedAssessment___3Bu5D [class*="SelectedAssessment__SelectedAssessment___"]
> .SelectedAssessment__meta___1gq_y > [class*="SelectedAssessment__meta___"]
> .SelectedAssessment__clearBtn___21D85 { > [class*="SelectedAssessment__clearBtn___"] {
background: var(--better-main); background: var(--better-main);
} }
.SelectedAssessment__SelectedAssessment___3Bu5D [class*="SelectedAssessment__SelectedAssessment___"]
> .SelectedAssessment__meta___1gq_y { > [class*="SelectedAssessment__meta___"] {
border-bottom: 1px solid var(--better-main); border-bottom: 1px solid var(--better-main);
} }
.TabSet__TabSet___Vo-SZ [class*="TabSet__TabSet___"] > ol[class*="TabSet__tabs___"] > li[class*="TabSet__selected___"] {
> ol.TabSet__tabs___1RRZk
> li.TabSet__selected___1psfF {
border-bottom-color: var(--better-main); border-bottom-color: var(--better-main);
} }
.TabSet__TabSet___Vo-SZ > ol.TabSet__tabs___1RRZk { [class*="TabSet__TabSet___"] > ol[class*="TabSet__tabs___"] {
border-bottom: none; border-bottom: none;
} }
.TabSet__TabSet___Vo-SZ > ol.TabSet__tabs___1RRZk > li:hover { [class*="TabSet__TabSet___"] > ol[class*="TabSet__tabs___"] > li:hover {
box-shadow: inset 0 -1px var(--better-main); box-shadow: inset 0 -1px var(--better-main);
} }
.TabSet__TabSet___Vo-SZ > .TabSet__tabContainer___3iIRe { [class*="TabSet__TabSet___"] > [class*="TabSet__tabContainer___"] {
background: unset; background: unset;
} }
.BasicPanel__BasicPanel___1GP6s { [class*="BasicPanel__BasicPanel___"] {
background: var(--background-primary); background: var(--background-primary);
} }
.back > svg { .back > svg {
@@ -1774,25 +1746,25 @@ ul {
} }
.mediaWrapper, .mediaWrapper,
.mediaRecorder, .mediaRecorder,
.MediaRecorder__MediaRecorder___2c2_M { [class*="MediaRecorder__MediaRecorder___"] {
border-top-left-radius: 16px; border-top-left-radius: 16px;
border-top-right-radius: 16px; border-top-right-radius: 16px;
overflow: hidden; overflow: hidden;
} }
.MediaRecorder__MediaRecorder___2c2_M { [class*="MediaRecorder__MediaRecorder___"] {
background: var(--background-primary); background: var(--background-primary);
} }
.legacy-root .uiFileHandler { .legacy-root .uiFileHandler {
background: var(--auto-background); background: var(--auto-background);
border-radius: 16px; border-radius: 16px;
} }
.ResourceList__ResourceList___2z-c1 .legacy-root .uiFileHandler { [class*="ResourceList__ResourceList___"] .legacy-root .uiFileHandler {
background: var(--background-primary); background: var(--background-primary);
} }
.legacy-root .uiFileHandler.dragTarget { .legacy-root .uiFileHandler.dragTarget {
background: var(--better-main); background: var(--better-main);
} }
.MenuButton__MenuPanel___2q42B { [class*="MenuButton__MenuPanel___"] {
background: var(--background-primary); background: var(--background-primary);
color: var(--text-primary); color: var(--text-primary);
border-radius: 16px; border-radius: 16px;
@@ -1920,11 +1892,13 @@ div.entry.class[style*="width: 46.5%"] {
.sources .uiButton { .sources .uiButton {
border-radius: 16px; border-radius: 16px;
} }
.MediaRecorder__preview___1hQqY, [class*="MediaRecorder__preview___"] {
.MediaRecorder__actions___3Jjvp {
background: var(--background-primary); background: var(--background-primary);
} }
.Rubric__Rubric___2AAKS > .Rubric__line___JCC3Y { [class*="MediaRecorder__actions___"] {
background: var(--background-primary);
}
[class*="Rubric__Rubric___"] > [class*="Rubric__line___"] {
background: unset; background: unset;
} }
#main > .course > .content > .header > .coverImage.blurred { #main > .course > .content > .header > .coverImage.blurred {
@@ -1990,6 +1964,22 @@ div.bar.flat {
} }
} }
.cke_toolbox > .cke_toolbar > .cke_combo > .cke_combo_button {
border-radius: 8px !important;
}
.cke_toolbox > .cke_toolbar > .cke_toolgroup > .cke_button {
&:last-child {
border-top-right-radius: 8px !important;
border-bottom-right-radius: 8px !important;
}
&:first-child {
border-top-left-radius: 8px !important;
border-bottom-left-radius: 8px !important;
}
}
.formattedText > .wrapper > .cke > .cke_inner > .cke_contents { .formattedText > .wrapper > .cke > .cke_inner > .cke_contents {
background: var(--background-primary); background: var(--background-primary);
border-radius: 16px; border-radius: 16px;
@@ -2026,8 +2016,8 @@ div.bar.flat {
border-radius: 16px !important; border-radius: 16px !important;
opacity: 0; opacity: 0;
} }
.document-width-micro .RootModule__root-module___2wT52, .document-width-micro [class*="RootModule__root-module___"],
.document-width-nano .RootModule__root-module___2wT52 { .document-width-nano [class*="RootModule__root-module___"] {
padding: 16px; padding: 16px;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -2068,9 +2058,11 @@ div.bar.flat {
background: black !important; background: black !important;
} }
} }
.quicktable { .quicktable {
border-radius: 12px; border-radius: 12px;
} }
.dark { .dark {
.cke_toolbox > .cke_toolbar .cke_combo_on > .cke_combo_button, .cke_toolbox > .cke_toolbar .cke_combo_on > .cke_combo_button,
.cke_toolbox > .cke_toolbar .cke_button_on { .cke_toolbox > .cke_toolbar .cke_button_on {
@@ -2081,6 +2073,7 @@ div.bar.flat {
} }
} }
} }
.legacy-root input.singleSelect { .legacy-root input.singleSelect {
padding-left: 8px; padding-left: 8px;
@@ -2150,8 +2143,9 @@ body {
> .entriesWrapper > .entriesWrapper
> .entry { > .entry {
padding: 3px; padding: 3px;
transition: opacity 0.2s ease-in-out;
} }
.Viewer__Viewer___32BH- { [class*="Viewer__Viewer___"] {
background: unset; background: unset;
} }
.weekend { .weekend {
@@ -2170,16 +2164,16 @@ body {
display: none; display: none;
} }
li.MessageList__unread___3imtO { [class*="MessageList__unread___"] {
position: relative; position: relative;
background: rgb(228 225 225); background: rgb(228 225 225);
} }
.dark li.MessageList__unread___3imtO { .dark [class*="MessageList__unread___"] {
background: rgba(0, 0, 0, 0.1); background: rgba(0, 0, 0, 0.1);
} }
.MessageList__MessageList___3DxoC > ol > li:hover { [class*="MessageList__MessageList___"] > ol > li:hover {
background: var(--theme-offset-bg-more); background: var(--theme-offset-bg-more);
} }
@@ -2187,18 +2181,17 @@ li.MessageList__unread___3imtO {
border-radius: 1600px; border-radius: 1600px;
} }
.MessageList__MessageList___3DxoC [class*="MessageList__MessageList___"] > ol > li[class*="MessageList__selected___"]
> ol [class*="MessageList__unread___"] {
> li.MessageList__selected___1SJNz.MessageList__unread___3imtO {
box-shadow: none; box-shadow: none;
} }
.Message__Message___3oJaU.Message__unread___23XIq > header { [class*="Message__Message___"] [class*="Message__unread___"] > header {
box-shadow: none; box-shadow: none;
} }
.MessageList__MessageList___3DxoC > ol > li.MessageList__unread___3imtO::before, [class*="MessageList__MessageList___"] > ol > li[class*="MessageList__unread___"]::before,
.MessageList__MessageList___3DxoC > ol > li::before { [class*="MessageList__MessageList___"] > ol > li::before {
content: ""; content: "";
position: absolute; position: absolute;
top: 0; top: 0;
@@ -2209,9 +2202,7 @@ li.MessageList__unread___3imtO {
transition: width 0.1s; transition: width 0.1s;
} }
.MessageList__MessageList___3DxoC [class*="MessageList__MessageList___"] > ol > li[class*="MessageList__unread___"]::before {
> ol
> li.MessageList__unread___3imtO::before {
width: 3px; width: 3px;
} }
.connectedNotificationsWrapper > div > button { .connectedNotificationsWrapper > div > button {
@@ -2286,13 +2277,13 @@ li.MessageList__unread___3imtO {
} }
.dark .dark
.MessageList__MessageList___3DxoC [class*="MessageList__MessageList___"]
> ol > ol
> li.MessageList__selected___1SJNz { > li[class*="MessageList__selected___"] {
background: var(--background-secondary); background: var(--background-secondary);
} }
.MessageList__MessageList___3DxoC > ol > li.MessageList__selected___1SJNz { [class*="MessageList__MessageList___"] > ol > li[class*="MessageList__selected___"] {
background: rgb(228 225 225); background: rgb(228 225 225);
color: var(--text-primary); color: var(--text-primary);
} }
@@ -2313,7 +2304,7 @@ li.MessageList__unread___3imtO {
text-decoration: underline; text-decoration: underline;
} }
} }
.ArticleText a { .ArticleText a {
padding: 10px 20px; padding: 10px 20px;
margin: 0; margin: 0;
@@ -2429,14 +2420,14 @@ li.MessageList__unread___3imtO {
animation: spin 3s linear infinite; animation: spin 3s linear infinite;
-moz-animation: spin 3s linear infinite; -moz-animation: spin 3s linear infinite;
} }
.dark .LabelList__name___-CHgq { .dark [class*="LabelList__name___"] {
text-shadow: 0 0 5px black; text-shadow: 0 0 5px black;
} }
.LabelList__name___-CHgq { [class*="LabelList__name___"] {
display: flex; display: flex;
align-items: center; align-items: center;
} }
[data-label="inbox"] > .LabelList__name___-CHgq::before { [data-label="inbox"] > [class*="LabelList__name___"]::before {
content: "\eb70"; content: "\eb70";
/* Unicode for the search icon */ /* Unicode for the search icon */
color: currentColor; color: currentColor;
@@ -2446,7 +2437,7 @@ li.MessageList__unread___3imtO {
font-family: "IconFamily"; font-family: "IconFamily";
pointer-events: none; pointer-events: none;
} }
[data-label="outbox"] > .LabelList__name___-CHgq::before { [data-label="outbox"] > [class*="LabelList__name___"]::before {
content: "\eca6"; content: "\eca6";
/* Unicode for the search icon */ /* Unicode for the search icon */
color: currentColor; color: currentColor;
@@ -2456,7 +2447,7 @@ li.MessageList__unread___3imtO {
font-family: "IconFamily"; font-family: "IconFamily";
pointer-events: none; pointer-events: none;
} }
[data-label="starred"] > .LabelList__name___-CHgq::before { [data-label="starred"] > [class*="LabelList__name___"]::before {
content: "\ece8"; content: "\ece8";
color: currentColor; color: currentColor;
font-size: 16px; font-size: 16px;
@@ -2465,7 +2456,7 @@ li.MessageList__unread___3imtO {
font-family: "IconFamily"; font-family: "IconFamily";
pointer-events: none; pointer-events: none;
} }
[data-label="trash"] > .LabelList__name___-CHgq::before { [data-label="trash"] > [class*="LabelList__name___"]::before {
content: "\ed2c"; content: "\ed2c";
/* Unicode for the search icon */ /* Unicode for the search icon */
color: currentColor; color: currentColor;
@@ -2762,7 +2753,7 @@ li.MessageList__unread___3imtO {
margin-top: 4px; margin-top: 4px;
&.container { &.container {
box-shadow: -2px 2px 30px 0px rgba(0,0,0,0.3) !important; box-shadow: -2px 2px 30px 0px rgba(0, 0, 0, 0.3) !important;
} }
table { table {
@@ -2775,7 +2766,7 @@ li.MessageList__unread___3imtO {
} }
} }
.MessageList__MessageList___3DxoC > header { [class*="MessageList__MessageList___"] > header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
@@ -2840,7 +2831,9 @@ li.MessageList__unread___3imtO {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
color: var(--text-primary); color: var(--text-primary);
transition: 200ms, background-color 0s; transition:
200ms,
background-color 0s;
border-radius: 16px; border-radius: 16px;
} }
.dark .upcoming-items { .dark .upcoming-items {
@@ -3221,9 +3214,10 @@ li.MessageList__unread___3imtO {
.loading { .loading {
&.upcoming-items, &.upcoming-items,
&.day-container { &.day-container {
background: linear-gradient(90deg, background: linear-gradient(
var(--background-primary) 0%, 90deg,
var(--background-secondary) 50%, var(--background-primary) 0%,
var(--background-secondary) 50%,
var(--background-primary) 100% var(--background-primary) 100%
); );
background-size: 1000px 100%; background-size: 1000px 100%;
@@ -3234,3 +3228,130 @@ li.MessageList__unread___3imtO {
height: 35em; height: 35em;
} }
} }
.pane .formattedText > .wrapper {
overflow: visible;
}
// Auto collapsing alignment toolbar
.cke_toolbar:has(.cke_button__seqta-align-left) {
overflow: visible !important;
.cke_toolgroup {
position: relative;
display: inline-block;
min-width: 32px;
.cke_button {
position: absolute;
top: 0;
left: 0;
visibility: hidden;
z-index: 100;
width: 32px;
margin: 0;
border: none !important;
border-radius: 8px !important;
transition:
transform 0.2s ease-out,
visibility 0s linear,
background 0.3s ease,
border-radius 0.3s ease !important;
&:first-child {
visibility: visible !important;
z-index: 101;
}
&.cke_button_on {
visibility: visible;
position: absolute;
transition: transform 0.3s ease;
z-index: 101;
& + .cke_button:first-child {
z-index: 100;
}
}
// Button icons
.cke_button_icon {
margin: 0 !important;
}
}
// menu background
&:before {
content: "";
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: calc(-300% - 10px);
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
background: var(--background-primary) !important;
z-index: 100;
transform: scale(0.65, 0.2);
transform-origin: 50% 6px;
visibility: hidden;
transition: all 0.2s ease-out;
}
// Dropdown behavior on hover
&:hover {
&:hover:before {
transform: scale(1);
border-radius: 16px;
visibility: visible;
}
.cke_button {
visibility: visible;
transition-delay: 0s;
// Stack buttons in dropdown with spacing
&:first-child {
transform: translateY(0);
border-top-left-radius: 12px !important;
border-top-right-radius: 12px !important;
}
&:nth-child(2) {
transform: translateY(calc(100% + 2px));
}
&:nth-child(3) {
transform: translateY(calc(200% + 4px));
}
&:nth-child(4) {
transform: translateY(calc(300% + 6px));
}
&:nth-child(5) {
transform: translateY(calc(400% + 6px));
}
&:nth-child(6) {
transform: translateY(calc(500% + 6px));
}
&:last-child {
border-bottom-left-radius: 12px !important;
border-bottom-right-radius: 12px !important;
}
}
}
// Add subtle animation when closing dropdown
&:not(:hover)
.cke_button:not(.cke_button_on):not(
.cke_button__seqta-align-left:first-child
) {
transform: translateY(0);
visibility: hidden;
transition:
transform 0.3s ease,
visibility 0s linear 0.3s;
}
}
}
.noscroll * {
-ms-overflow-style: none;
scrollbar-width: none !important;
}
+7 -7
View File
@@ -11,7 +11,7 @@ html.transparencyEffects:not(.dark) {
html.transparencyEffects { html.transparencyEffects {
/* Background Fixes */ /* Background Fixes */
.notifications__item___2ErJN, [class*="notifications__item___"],
#shortcuts { #shortcuts {
backdrop-filter: unset !important; backdrop-filter: unset !important;
} }
@@ -24,21 +24,21 @@ html.transparencyEffects {
/* Blurs */ /* Blurs */
.draggable, .draggable,
.notice, .notice,
.BasicPanel__BasicPanel___1GP6s, [class*="BasicPanel__BasicPanel___"],
.message.addMessage, .message.addMessage,
.singleSelect, .singleSelect,
.uiFileHandlerPanel, .uiFileHandlerPanel,
.Module__wrapper___2sbOo, [class*="Module__wrapper___"],
.notifications__list___rp2L2, [class*="notifications__list___"],
.thread, .thread,
.calendar, .calendar,
.navigator, .navigator,
#title, #title,
.LabelList__selected___3Egk7, [class*="LabelList__selected___"],
.buttonChecklist, .buttonChecklist,
.pane, .pane,
.legacy-root button, .legacy-root a, .legacy-root button, .legacy-root a,
.MessageList__MessageList___3DxoC { [class*="MessageList__MessageList___"] {
backdrop-filter: blur(80px); backdrop-filter: blur(80px);
} }
@@ -47,7 +47,7 @@ html.transparencyEffects {
} }
.whatsnewContainer, .whatsnewContainer,
.Message__Message___3oJaU { [class*="Message__Message___"] {
backdrop-filter: blur(50px); backdrop-filter: blur(50px);
} }
+5
View File
@@ -5,6 +5,11 @@ declare module '*.png';
declare module '*.html'; declare module '*.html';
declare module '*.svelte'; declare module '*.svelte';
declare module '*?inlineWorker' {
const value: () => Worker;
export default value;
}
declare module "*.png?base64" { declare module "*.png?base64" {
const value: string; const value: string;
export default value; export default value;
@@ -5,6 +5,7 @@
</script> </script>
<button <button
aria-label="Color Picker Swatch"
onclick={onClick} onclick={onClick}
style="background: {$settingsState.selectedColor}" style="background: {$settingsState.selectedColor}"
class="w-16 h-8 rounded-md" class="w-16 h-8 rounded-md"
+12 -5
View File
@@ -1,13 +1,20 @@
<script lang="ts"> <script lang="ts">
let { state, onChange } = $props<{ state: number, onChange: (value: number) => void }>(); let { state, onChange, min = 0, max = 100, step = 1 } = $props<{
let percentage = $derived((state / 100) * 100); state: number,
onChange: (value: number) => void,
min?: number,
max?: number,
step?: number
}>();
let percentage = $derived(((state - min) / (max - min)) * 100);
</script> </script>
<div class="relative w-full max-w-lg mx-auto"> <div class="relative mx-auto w-full max-w-lg">
<input <input
type="range" type="range"
min="0" min={min}
max="100" max={max}
step={step}
bind:value={state} bind:value={state}
style={`background: linear-gradient(to right, #30D259 ${percentage}%, #dddddd ${percentage}%)`} style={`background: linear-gradient(to right, #30D259 ${percentage}%, #dddddd ${percentage}%)`}
onchange={(e) => onChange(Number(e.currentTarget.value))} onchange={(e) => onChange(Number(e.currentTarget.value))}
@@ -5,7 +5,6 @@
let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>(); let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>();
let activeTab = $state(0); let activeTab = $state(0);
let hoveredTab = $state<number | null>(null);
let containerRef: HTMLElement | null = null; let containerRef: HTMLElement | null = null;
let tabWidth = $state(0); let tabWidth = $state(0);
@@ -24,10 +23,6 @@
return 0; return 0;
}; };
$effect(() => {
calcXPos(hoveredTab);
});
onMount(() => { onMount(() => {
updateTabWidth(); updateTabWidth();
@@ -45,26 +40,24 @@
</script> </script>
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<div bind:this={containerRef} class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 tab-width-container"> <div class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container">
<div class="relative flex"> <div bind:this={containerRef} class="flex relative">
<MotionDiv <MotionDiv
class="absolute top-0 left-0 z-0 h-full bg-[#DDDDDD] dark:bg-[#38373D] rounded-full opacity-40 tab-width" class="absolute top-0 left-0 z-0 h-full bg-[#DDDDDD] dark:bg-[#38373D] rounded-full opacity-40 tab-width"
animate={{ x: calcXPos(hoveredTab) }} animate={{ x: calcXPos(activeTab) }}
transition={springTransition} transition={springTransition}
/> />
{#each tabs as { title }, index} {#each tabs as { title }, index}
<button <button
class="relative z-10 flex-1 px-4 py-2 focus-visible:outline-none" class="relative z-10 flex-1 px-4 py-2 focus-visible:outline-none"
onclick={() => activeTab = index} onclick={() => activeTab = index}
onmouseenter={() => hoveredTab = index}
onmouseleave={() => hoveredTab = null}
> >
{title} {title}
</button> </button>
{/each} {/each}
</div> </div>
</div> </div>
<div class="h-full px-4 overflow-hidden"> <div class="overflow-hidden px-4 h-full">
<MotionDiv <MotionDiv
class="h-full" class="h-full"
animate={{ x: `${-activeTab * 100}%` }} animate={{ x: `${-activeTab * 100}%` }}
@@ -80,4 +73,4 @@
</div> </div>
</MotionDiv> </MotionDiv>
</div> </div>
</div> </div>
@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader'; import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader';
import { setTheme } from '@/seqta/ui/themes/setTheme';
import Spinner from '../Spinner.svelte'; import Spinner from '../Spinner.svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState' import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import Fuse from 'fuse.js'; import { Index } from 'flexsearch';
import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates' import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
const themeManager = ThemeManager.getInstance();
type Background = { id: string; category: string; type: string; lowResUrl: string; highResUrl: string; name: string; description: string; featured?: boolean }; type Background = { id: string; category: string; type: string; lowResUrl: string; highResUrl: string; name: string; description: string; featured?: boolean };
let { searchTerm } = $props<{ searchTerm: string }>(); let { searchTerm } = $props<{ searchTerm: string }>();
@@ -18,19 +20,12 @@
let savedBackgrounds = $state<string[]>([]); let savedBackgrounds = $state<string[]>([]);
let installingBackgrounds = $state<Set<string>>(new Set()); let installingBackgrounds = $state<Set<string>>(new Set());
let debugInfo = $state<string>(''); let debugInfo = $state<string>('');
let searchIndex = $state<Index | null>(null);
// New state variables // New state variables
let activeTab = $state<'all' | 'installed' | 'photos' | 'videos'>('all'); let activeTab = $state<'all' | 'installed' | 'photos' | 'videos'>('all');
let sortBy = $state<'newest' | 'popular' | 'name'>('newest'); let sortBy = $state<'newest' | 'popular' | 'name'>('newest');
// Add Fuse.js options
const fuseOptions = {
keys: ['name', 'description'],
threshold: 0.4,
ignoreLocation: true
};
let fuse: Fuse<Background>;
// Existing functions // Existing functions
const loadStore = async () => { const loadStore = async () => {
try { try {
@@ -41,7 +36,19 @@
} }
const data = await response.json(); const data = await response.json();
backgrounds = data.backgrounds; backgrounds = data.backgrounds;
fuse = new Fuse(backgrounds, fuseOptions);
// Initialize FlexSearch index
const index = new Index({
tokenize: "forward",
preset: "score"
});
// Add backgrounds to the index
backgrounds.forEach((bg, i) => {
index.add(i, bg.name + " " + bg.description);
});
searchIndex = index;
debugInfo = `Loaded ${backgrounds.length} backgrounds`; debugInfo = `Loaded ${backgrounds.length} backgrounds`;
await loadSavedBackgrounds(); await loadSavedBackgrounds();
} catch (e) { } catch (e) {
@@ -72,14 +79,10 @@
let filteredBackgrounds = $derived((() => { let filteredBackgrounds = $derived((() => {
let filtered = backgrounds; let filtered = backgrounds;
// Use Fuse.js search if there's a search term // Use FlexSearch if there's a search term
if (searchTerm.trim()) { if (searchTerm.trim() && searchIndex) {
// @ts-ignore const results = searchIndex.search(searchTerm) as number[];
if (fuse) { filtered = results.map(i => backgrounds[i]);
filtered = fuse.search(searchTerm).map((result: any) => result.item) ?? [];
} else {
filtered = backgrounds.filter(bg => bg.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
} }
// Apply category filtering // Apply category filtering
@@ -170,13 +173,13 @@
function selectNoBackground() { function selectNoBackground() {
selectedBackground = null; selectedBackground = null;
setTheme(''); themeManager.setTheme('');
} }
</script> </script>
<div class="flex h-full"> <div class="flex h-full">
<!-- Sidebar --> <!-- Sidebar -->
<div class="w-64 h-full p-4 border-r border-zinc-200 dark:border-zinc-700"> <div class="p-4 w-64 h-full border-r border-zinc-200 dark:border-zinc-700">
<div class="mb-8"> <div class="mb-8">
<h2 class="mb-4 text-lg font-semibold">Categories</h2> <h2 class="mb-4 text-lg font-semibold">Categories</h2>
<nav class="space-y-2"> <nav class="space-y-2">
@@ -208,15 +211,15 @@
</div> </div>
<!-- Main Content --> <!-- Main Content -->
<div class="flex-1 overflow-auto"> <div class="overflow-auto flex-1">
<!-- Header --> <!-- Header -->
<div class="sticky top-0 z-10 p-4 border-b bg-[#F1F1F3] dark:bg-zinc-900 dark:border-zinc-700"> <div class="sticky top-0 z-10 p-4 border-b bg-[#F1F1F3] dark:bg-zinc-900 dark:border-zinc-700">
<div class="flex items-center justify-between mb-4"> <div class="flex justify-between items-center mb-4">
<h1 class="text-2xl font-bold">Explore Backgrounds {searchTerm ? `- "${searchTerm}"` : ''}</h1> <h1 class="text-2xl font-bold">Explore Backgrounds {searchTerm ? `- "${searchTerm}"` : ''}</h1>
<div class="flex items-center gap-4"> <div class="flex gap-4 items-center">
<select <select
bind:value={sortBy} bind:value={sortBy}
class="p-2 border rounded-lg border-zinc-200 dark:border-zinc-700 dark:bg-zinc-800" class="p-2 rounded-lg border border-zinc-200 dark:border-zinc-700 dark:bg-zinc-800"
> >
<option value="newest">Newest</option> <option value="newest">Newest</option>
<option value="name">Name</option> <option value="name">Name</option>
@@ -230,7 +233,7 @@
<button <button
class={`px-4 py-2 text-sm font-medium transition-colors rounded-full class={`px-4 py-2 text-sm font-medium transition-colors rounded-full
${activeTab === tab.toLowerCase() ? 'bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700' : ${activeTab === tab.toLowerCase() ? 'bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700' :
'bg-zinc-100 dark:bg-transparent dark:outline dark:outline-1 dark:outline-zinc-700 hover:bg-zinc-200 dark:hover:bg-zinc-700/20'}`} 'bg-zinc-100 dark:bg-transparent dark:outline dark:outline-zinc-700 hover:bg-zinc-200 dark:hover:bg-zinc-700/20'}`}
onclick={() => activeTab = tab.toLowerCase() as typeof activeTab} onclick={() => activeTab = tab.toLowerCase() as typeof activeTab}
> >
{tab} {tab}
@@ -244,15 +247,15 @@
{#if isLoading} {#if isLoading}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each Array(9) as _} {#each Array(9) as _}
<div class="relative overflow-hidden rounded-lg animate-pulse"> <div class="overflow-hidden relative rounded-lg animate-pulse">
<!-- Image placeholder --> <!-- Image placeholder -->
<div class="w-full h-48 bg-zinc-200 dark:bg-zinc-800"></div> <div class="w-full h-48 bg-zinc-200 dark:bg-zinc-800"></div>
<!-- Gradient overlay --> <!-- Gradient overlay -->
<div class="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-zinc-300 dark:from-zinc-700 to-transparent"> <div class="absolute right-0 bottom-0 left-0 h-16 to-transparent bg-linear-to-t from-zinc-300 dark:from-zinc-700">
<!-- Title placeholder --> <!-- Title placeholder -->
<div class="absolute bottom-2 left-2 right-2"> <div class="absolute right-2 bottom-2 left-2">
<div class="w-2/3 h-4 rounded-full bg-zinc-200 dark:bg-zinc-800"></div> <div class="w-2/3 h-4 rounded-full bg-zinc-200 dark:bg-zinc-800"></div>
<div class="w-1/2 h-3 mt-2 rounded-full bg-zinc-200 dark:bg-zinc-800"></div> <div class="mt-2 w-1/2 h-3 rounded-full bg-zinc-200 dark:bg-zinc-800"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -271,7 +274,7 @@
return true; return true;
}) as background (background.id)} }) as background (background.id)}
<div <div
class="relative overflow-hidden rounded-lg shadow-lg cursor-pointer group" class="overflow-hidden relative rounded-lg shadow-lg cursor-pointer group"
onclick={() => toggleBackgroundInstallation(background)} onclick={() => toggleBackgroundInstallation(background)}
onkeydown={(event) => { onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
@@ -286,7 +289,7 @@
{:else} {:else}
<video src={background.lowResUrl} class="object-cover w-full h-48" muted loop autoplay></video> <video src={background.lowResUrl} class="object-cover w-full h-48" muted loop autoplay></video>
{/if} {/if}
<div class="absolute inset-0 flex items-center justify-center transition-opacity duration-300 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100"> <div class={`flex absolute inset-0 justify-center items-center opacity-0 transition-opacity duration-300 bg-black/50 group-hover:opacity-100 ${installingBackgrounds.has(background.id) ? 'opacity-100' : ''}`}>
{#if installingBackgrounds.has(background.id)} {#if installingBackgrounds.has(background.id)}
<Spinner /> <Spinner />
{:else if savedBackgrounds.includes(background.id)} {:else if savedBackgrounds.includes(background.id)}
@@ -27,9 +27,9 @@
</script> </script>
{#if coverThemes.length > 0} {#if coverThemes.length > 0}
<div class="relative w-full transition-opacity rounded-xl overflow-clip" transition:fade> <div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
<div <div
class="w-full aspect-[8/3]" class="w-full aspect-8/3"
use:emblaCarouselSvelte={{ options, plugins }} use:emblaCarouselSvelte={{ options, plugins }}
onemblaInit={onInit} onemblaInit={onInit}
> >
@@ -47,20 +47,20 @@
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2> <h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
<p class='text-lg text-white'>{theme.description}</p> <p class='text-lg text-white'>{theme.description}</p>
</div> </div>
<div class='absolute bottom-0 left-0 w-full h-1/2 bg-gradient-to-t from-black/80 to-transparent'></div> <div class='absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80'></div>
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
<!-- Navigation buttons --> <!-- Navigation buttons -->
<div class='absolute z-10 flex gap-2 bottom-2 right-2'> <div class='flex absolute right-2 bottom-2 z-10 gap-2'>
<button aria-label="Previous" onclick={slidePrev} class='flex items-center justify-center w-8 h-8 text-white bg-black bg-opacity-50 rounded-full dark:bg-zinc-800 dark:bg-opacity-50'> <button aria-label="Previous" onclick={slidePrev} class='flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800'>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
</svg> </svg>
</button> </button>
<button aria-label="Next" onclick={slideNext} class='flex items-center justify-center w-8 h-8 text-white bg-black bg-opacity-50 rounded-full dark:bg-zinc-800 dark:bg-opacity-50'> <button aria-label="Next" onclick={slideNext} class='flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800'>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg> </svg>
@@ -47,9 +47,7 @@
</label> </label>
</div> </div>
</div> </div>
<!-- Add similar sections for color, resolution, and orientation -->
<button <button
class="px-4 py-2 mt-4 text-white bg-red-500 rounded hover:bg-red-600" class="px-4 py-2 mt-4 text-white bg-red-500 rounded hover:bg-red-600"
onclick={clearFilters} onclick={clearFilters}
+4 -4
View File
@@ -20,8 +20,8 @@
</script> </script>
<header class="fixed top-0 z-50 w-full h-[4.25rem] bg-white border-b shadow-md border-b-white/10 dark:bg-zinc-950/90 backdrop-blur-xl dark:text-white"> <header class="fixed top-0 z-50 w-full h-[4.25rem] bg-white border-b shadow-md border-b-white/10 dark:bg-zinc-950/90 backdrop-blur-xl dark:text-white">
<div class="flex items-center justify-between px-4 py-1"> <div class="flex justify-between items-center px-4 py-1">
<div class="flex gap-4 cursor-pointer place-items-center" onkeydown={(e) => { if (e.key === 'Enter') clearSearch() }} onclick={clearSearch} role="button" tabindex="0"> <div class="flex gap-4 place-items-center cursor-pointer" onkeydown={(e) => { if (e.key === 'Enter') clearSearch() }} onclick={clearSearch} role="button" tabindex="0">
<img src={browser.runtime.getURL(logo)} class="h-14 {darkMode ? 'hidden' : ''}" alt="Logo" /> <img src={browser.runtime.getURL(logo)} class="h-14 {darkMode ? 'hidden' : ''}" alt="Logo" />
<img src={browser.runtime.getURL(logoDark)} class="h-14 {darkMode ? '' : 'hidden'}" alt="Dark Logo" /> <img src={browser.runtime.getURL(logoDark)} class="h-14 {darkMode ? '' : 'hidden'}" alt="Dark Logo" />
@@ -41,7 +41,7 @@
</button> </button>
</div> </div>
<div class="relative flex gap-2"> <div class="flex relative gap-2">
<input <input
type="text" type="text"
placeholder="Search themes..." placeholder="Search themes..."
@@ -49,7 +49,7 @@
oninput={(e: any) => setSearchTerm(e.target.value)} oninput={(e: any) => setSearchTerm(e.target.value)}
class="px-4 py-2 pl-10 text-lg transition bg-gray-100/80 rounded-lg ring-0 focus:bg-gray-100/0 dark:focus:bg-zinc-700/50 focus:ring-[1px] ring-zinc-200 dark:ring-zinc-600 dark:bg-zinc-700/80 dark:text-gray-100 focus:outline-none focus:border-transparent" /> class="px-4 py-2 pl-10 text-lg transition bg-gray-100/80 rounded-lg ring-0 focus:bg-gray-100/0 dark:focus:bg-zinc-700/50 focus:ring-[1px] ring-zinc-200 dark:ring-zinc-600 dark:bg-zinc-700/80 dark:text-gray-100 focus:outline-none focus:border-transparent" />
<svg <svg
class="absolute w-5 h-5 text-gray-400 transform -translate-y-1/2 left-3 top-1/2 dark:text-gray-200" class="absolute left-3 top-1/2 w-5 h-5 text-gray-400 transform -translate-y-1/2 dark:text-gray-200"
fill="none" fill="none"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
@@ -11,7 +11,7 @@
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white"> <div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white">
{theme.name} {theme.name}
</div> </div>
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-gradient-to-t to-transparent from-black/80'></div> <div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
<div class='w-full'> <div class='w-full'>
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" /> <img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
</div> </div>
@@ -54,7 +54,7 @@
</script> </script>
<div <div
class="flex fixed inset-0 z-50 justify-center items-end bg-black bg-opacity-70" class="flex fixed inset-0 z-50 justify-center items-end bg-black/70"
onclick={(e) => { onclick={(e) => {
if (e.target === e.currentTarget) hideModal(); if (e.target === e.currentTarget) hideModal();
}} }}
@@ -115,7 +115,7 @@
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white transition-all duration-500 group-hover:-translate-y-0.5"> <div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white transition-all duration-500 group-hover:-translate-y-0.5">
{relatedTheme.name} {relatedTheme.name}
</div> </div>
<div class="absolute bottom-0 z-0 w-full h-3/4 bg-gradient-to-t to-transparent from-black/80"></div> <div class="absolute bottom-0 z-0 w-full h-3/4 to-transparent from-black/80 bg-linear-to-t"></div>
<img src={relatedTheme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48" /> <img src={relatedTheme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48" />
</div> </div>
</button> </button>
@@ -15,7 +15,7 @@
onkeydown={onClick} onkeydown={onClick}
tabindex="-1" tabindex="-1"
role="button" role="button"
class="relative w-16 h-16 cursor-pointer rounded-xl transition ring dark:ring-zinc-500/50 ring-zinc-300 {isEditMode ? 'animate-shake' : ''} {isSelected ? 'dark:ring-4 ring-4' : 'ring-0'}" class="relative w-16 h-16 cursor-pointer rounded-xl transition ring-3 dark:ring-zinc-500/50 ring-zinc-300 {isEditMode ? 'animate-shake' : ''} {isSelected ? 'dark:ring-4 ring-4' : 'ring-0'}"
> >
{#if isEditMode} {#if isEditMode}
<div <div
@@ -1,16 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { CustomTheme, ThemeList } from '@/types/CustomThemes' import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
import { getAvailableThemes } from '@/seqta/ui/themes/getAvailableThemes'
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import { OpenThemeCreator } from '@/seqta/ui/ThemeCreator' import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import shareTheme from '@/seqta/ui/themes/shareTheme'
import { InstallTheme } from '@/seqta/ui/themes/downloadTheme'
import { disableTheme } from '@/seqta/ui/themes/disableTheme'
import { setTheme } from '@/seqta/ui/themes/setTheme'
import { deleteTheme } from '@/seqta/ui/themes/deleteTheme'
import { OpenStorePage } from '@/seqta/ui/renderStore' import { OpenStorePage } from '@/seqta/ui/renderStore'
import { themeUpdates } from '@/interface/hooks/ThemeUpdates' import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
import { closeExtensionPopup } from '@/SEQTA' import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
const themeManager = ThemeManager.getInstance();
let themes = $state<ThemeList | null>(null); let themes = $state<ThemeList | null>(null);
let { isEditMode } = $props<{ isEditMode: boolean }>(); let { isEditMode } = $props<{ isEditMode: boolean }>();
@@ -20,10 +17,10 @@
const handleThemeClick = async (theme: CustomTheme) => { const handleThemeClick = async (theme: CustomTheme) => {
if (isEditMode) return; if (isEditMode) return;
if (theme.id === themes?.selectedTheme) { if (theme.id === themes?.selectedTheme) {
await disableTheme(); await themeManager.disableTheme();
themes.selectedTheme = ''; themes.selectedTheme = '';
} else { } else {
await setTheme(theme.id); await themeManager.setTheme(theme.id);
if (!themes) return; if (!themes) return;
themes.selectedTheme = theme.id; themes.selectedTheme = theme.id;
} }
@@ -31,13 +28,13 @@
const handleThemeDelete = async (themeId: string) => { const handleThemeDelete = async (themeId: string) => {
try { try {
await deleteTheme(themeId); await themeManager.deleteTheme(themeId);
if (!themes) return; if (!themes) return;
themes.themes = themes.themes.filter(theme => theme.id !== themeId); themes.themes = themes.themes.filter(theme => theme.id !== themeId);
if (themeId === themes.selectedTheme) { if (themeId === themes.selectedTheme) {
themes.selectedTheme = ''; themes.selectedTheme = '';
await disableTheme(); await themeManager.disableTheme();
} }
} catch (error) { } catch (error) {
console.error('Error deleting theme:', error); console.error('Error deleting theme:', error);
@@ -46,7 +43,7 @@
const handleShareTheme = async (theme: CustomTheme) => { const handleShareTheme = async (theme: CustomTheme) => {
try { try {
await shareTheme(theme.id); await themeManager.shareTheme(theme.id);
} catch (error) { } catch (error) {
console.error('Error sharing theme:', error); console.error('Error sharing theme:', error);
} }
@@ -72,9 +69,10 @@
try { try {
const result = JSON.parse(event.target?.result as string); const result = JSON.parse(event.target?.result as string);
tempTheme = result; tempTheme = result;
await InstallTheme(result); await themeManager.installTheme(result);
await fetchThemes(); await fetchThemes();
} catch (error) { } catch (error) {
console.error('Error parsing file:', error);
alert('Error parsing file. Please upload a valid JSON theme file.'); alert('Error parsing file. Please upload a valid JSON theme file.');
} }
tempTheme = null; tempTheme = null;
@@ -83,7 +81,10 @@
} }
const fetchThemes = async () => { const fetchThemes = async () => {
themes = await getAvailableThemes(); themes = {
themes: await themeManager.getAvailableThemes(),
selectedTheme: themeManager.getSelectedThemeId() || '',
}
} }
onMount(async () => { onMount(async () => {
+2 -8
View File
@@ -4,14 +4,8 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root { button {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; @apply cursor-pointer;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BetterSEQTA+ Settings</title> <title>BetterSEQTA+ Settings</title>
</head> </head>
<body> <body class="h-[600px]">
<div id="app"></div> <div id="app" style="height: 100%;"></div>
<script type="module" src="./index.ts"></script> <script type="module" src="./index.ts"></script>
</body> </body>
</html> </html>
+2 -19
View File
@@ -1,25 +1,8 @@
import "./index.css" import "./index.css"
import { mount } from "svelte"
import type { ComponentType } from "svelte"
import Settings from "./pages/settings.svelte" import Settings from "./pages/settings.svelte"
import IconFamily from '@/resources/fonts/IconFamily.woff' import IconFamily from '@/resources/fonts/IconFamily.woff'
import browser from "webextension-polyfill" import browser from "webextension-polyfill"
import renderSvelte from "./main"
export default function renderSvelte(
Component: ComponentType | any,
mountPoint: ShadowRoot | HTMLElement,
props: Record<string, any> = {},
) {
const app = mount(Component, {
target: mountPoint,
props: {
standalone: true,
...props,
},
})
return app
}
function InjectCustomIcons() { function InjectCustomIcons() {
console.info('[BetterSEQTA+] Injecting Icons') console.info('[BetterSEQTA+] Injecting Icons')
@@ -43,4 +26,4 @@ if (!mountPoint) {
} }
InjectCustomIcons() InjectCustomIcons()
renderSvelte(Settings, mountPoint) renderSvelte(Settings, mountPoint, { standalone: true })
+6 -7
View File
@@ -1,9 +1,9 @@
import styles from "./index.css?inline"
import { mount } from "svelte" import { mount } from "svelte"
import type { ComponentType } from "svelte" import type { SvelteComponent } from "svelte"
import style from './index.css?inline'
export default function renderSvelte( export default function renderSvelte(
Component: ComponentType | any, Component: SvelteComponent | any,
mountPoint: ShadowRoot | HTMLElement, mountPoint: ShadowRoot | HTMLElement,
props: Record<string, any> = {}, props: Record<string, any> = {},
) { ) {
@@ -15,10 +15,9 @@ export default function renderSvelte(
}, },
}) })
const style = document.createElement("style") const styleElement = document.createElement('style')
style.setAttribute("type", "text/css") styleElement.textContent = style
style.innerHTML = styles mountPoint.appendChild(styleElement)
mountPoint.appendChild(style)
return app return app
} }
+5 -1
View File
@@ -9,7 +9,10 @@
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { initializeSettingsState, settingsState } from '@/seqta/utils/listeners/SettingsState' import { initializeSettingsState, settingsState } from '@/seqta/utils/listeners/SettingsState'
import { closeExtensionPopup, OpenAboutPage, OpenWhatsNewPopup } from "@/SEQTA" import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { OpenAboutPage } from "@/seqta/utils/Openers/OpenAboutPage"
import { OpenWhatsNewPopup } from "@/seqta/utils/Whatsnew"
import ColourPicker from '../components/ColourPicker.svelte' import ColourPicker from '../components/ColourPicker.svelte'
import { settingsPopup } from '../hooks/SettingsPopup' import { settingsPopup } from '../hooks/SettingsPopup'
@@ -56,6 +59,7 @@
if (!standalone) return; if (!standalone) return;
initializeSettingsState(); initializeSettingsState();
console.log('settingsState', $settingsState);
StandaloneStore.setStandalone(true); StandaloneStore.setStandalone(true);
}); });
</script> </script>
+140 -72
View File
@@ -5,12 +5,73 @@
import Select from "@/interface/components/Select.svelte" import Select from "@/interface/components/Select.svelte"
import browser from "webextension-polyfill" import browser from "webextension-polyfill"
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 hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent" import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent"
import { getAllPluginSettings } from "@/plugins"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting } from "@/plugins/core/types"
// Union type representing all possible settings
type SettingType =
(Omit<BooleanSetting, 'type'> & { type: 'boolean', id: string }) |
(Omit<StringSetting, 'type'> & { type: 'string', id: string }) |
(Omit<NumberSetting, 'type'> & { type: 'number', id: string }) |
(Omit<SelectSetting<string>, 'type'> & {
type: 'select',
id: string,
options: string[]
});
interface Plugin {
pluginId: string;
name: string;
description: string;
settings: Record<string, SettingType>;
}
const pluginSettings = getAllPluginSettings() as Plugin[];
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({});
async function loadPluginSettings() {
for (const plugin of pluginSettings) {
if (Object.keys(plugin.settings).length === 0) continue;
const storageKey = `plugin.${plugin.pluginId}.settings`;
const stored = await browser.storage.local.get(storageKey);
pluginSettingsValues[plugin.pluginId] = stored[storageKey] || {};
for (const [key, setting] of Object.entries(plugin.settings)) {
if (pluginSettingsValues[plugin.pluginId][key] === undefined) {
pluginSettingsValues[plugin.pluginId][key] = setting.default;
}
}
}
}
async function updatePluginSetting(pluginId: string, key: string, value: any) {
const storageKey = `plugin.${pluginId}.settings`;
if (!pluginSettingsValues[pluginId]) {
pluginSettingsValues[pluginId] = {};
}
pluginSettingsValues[pluginId][key] = value;
const stored = await browser.storage.local.get(storageKey);
const currentSettings = (stored[storageKey] || {}) as Record<string, any>;
currentSettings[key] = value;
await browser.storage.local.set({ [storageKey]: currentSettings });
}
$effect(() => {
loadPluginSettings();
})
const { showColourPicker } = $props<{ showColourPicker: () => void }>(); const { showColourPicker } = $props<{ showColourPicker: () => void }>();
</script> </script>
@@ -28,7 +89,6 @@
<div class="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700"> <div class="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700">
{#each [ {#each [
{ {
title: "Transparency Effects", title: "Transparency Effects",
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)", description: "Enables transparency effects on certain elements such as blur. (May impact battery life)",
@@ -39,26 +99,6 @@
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
} }
}, },
{
title: "Animated Background",
description: "Adds an animated background to BetterSEQTA. (May impact battery life)",
id: 2,
Component: Switch,
props: {
state: $settingsState.animatedbk,
onChange: (isOn: boolean) => settingsState.animatedbk = isOn
}
},
{
title: "Animated Background Speed",
description: "Controls the speed of the animated background.",
id: 3,
Component: Slider,
props: {
state: $settingsState.bksliderinput,
onChange: (value: number) => settingsState.bksliderinput = `${value}`
}
},
{ {
title: "Custom Theme Colour", title: "Custom Theme Colour",
description: "Customise the overall theme colour of SEQTA Learn.", description: "Customise the overall theme colour of SEQTA Learn.",
@@ -88,46 +128,6 @@
onChange: (isOn: boolean) => settingsState.animations = isOn onChange: (isOn: boolean) => settingsState.animations = isOn
} }
}, },
{
title: "Notification Collector",
description: "Uncaps the 9+ limit for notifications, showing the real number.",
id: 7,
Component: Switch,
props: {
state: $settingsState.notificationcollector,
onChange: (isOn: boolean) => settingsState.notificationcollector = isOn
}
},
{
title: "Assessment Average",
description: "Shows your subject average for assessments.",
id: 8,
Component: Switch,
props: {
state: $settingsState.assessmentsAverage,
onChange: (isOn: boolean) => settingsState.assessmentsAverage = isOn
}
},
{
title: "Letter Grade Averages",
description: "Shows the letter grade instead of the percentage in subject averages.",
id: 8,
Component: Switch,
props: {
state: $settingsState.lettergrade,
onChange: (isOn: boolean) => settingsState.lettergrade = isOn
}
},
{
title: "Lesson Alerts",
description: "Sends a native browser notification ~5 minutes prior to lessons.",
id: 8,
Component: Switch,
props: {
state: $settingsState.lessonalert,
onChange: (isOn: boolean) => settingsState.lessonalert = isOn
}
},
{ {
title: "12 Hour Time", title: "12 Hour Time",
description: "Prefer 12 hour time format for SEQTA", description: "Prefer 12 hour time format for SEQTA",
@@ -178,20 +178,88 @@
{ value: "netherlands", label: "Netherlands" } { value: "netherlands", label: "Netherlands" }
] ]
} }
},
{
title: "BetterSEQTA+",
description: "Enables BetterSEQTA+ features",
id: 12,
Component: Switch,
props: {
state: $settingsState.onoff,
onChange: (isOn: boolean) => settingsState.onoff = isOn
}
} }
] as option} ] as option}
{@render Setting(option)} {@render Setting(option)}
{/each} {/each}
{#each pluginSettings as plugin}
<div>
<!-- Always show enable toggle if disableToggle is true -->
{#if (plugin as any).disableToggle}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Enable {plugin.name}</h2>
<p class="text-xs">{plugin.description}</p>
</div>
<div>
<Switch
state={pluginSettingsValues[plugin.pluginId]?.enabled ?? true}
onChange={(value) => updatePluginSetting(plugin.pluginId, 'enabled', value)}
/>
</div>
</div>
{/if}
<!-- Only show other settings if plugin is enabled or has no disableToggle -->
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]}
<!-- Skip the 'enabled' setting if it's part of the settings object -->
{#if key !== 'enabled'}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">{setting.title || key}</h2>
<p class="text-xs">{setting.description || ''}</p>
</div>
<div>
{#if setting.type === 'boolean'}
<Switch
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/>
{:else if setting.type === 'number'}
<Slider
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
min={setting.min}
max={setting.max}
step={setting.step}
/>
{:else if setting.type === 'string'}
<input
type="text"
class="px-2 py-1 text-sm rounded-md dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white"
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
oninput={(e) => updatePluginSetting(plugin.pluginId, key, e.currentTarget.value)}
/>
{:else if setting.type === 'select'}
<Select
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
options={(setting.options as string[]).map(opt => ({
value: opt,
label: opt.charAt(0).toUpperCase() + opt.slice(1)
}))}
/>
{/if}
</div>
</div>
{/if}
{/each}
{/if}
</div>
{/each}
{@render Setting({
title: "BetterSEQTA+",
description: "Enables BetterSEQTA+ features",
id: 12,
Component: Switch,
props: {
state: $settingsState.onoff,
onChange: (isOn: boolean) => settingsState.onoff = isOn
}
})}
{#if $settingsState.devMode} {#if $settingsState.devMode}
<div class="flex items-center justify-between px-4 py-3 mt-4 pt-[1.75rem]"> <div class="flex items-center justify-between px-4 py-3 mt-4 pt-[1.75rem]">
@@ -66,7 +66,7 @@
</script> </script>
{#snippet Shortcuts([index, Shortcut]: [string, { name: string, enabled: boolean }]) } {#snippet Shortcuts([index, Shortcut]: [string, { name: string, enabled: boolean }]) }
<div class="flex items-center justify-between px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
<div class="pr-4"> <div class="pr-4">
<h2 class="text-sm">{Shortcut.name}</h2> <h2 class="text-sm">{Shortcut.name}</h2>
</div> </div>
@@ -95,7 +95,7 @@
class="w-full" class="w-full"
> >
<input <input
class="w-full p-2 transition border-0 rounded-lg placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600" class="p-2 w-full rounded-lg border-0 transition placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
type="text" type="text"
placeholder="Shortcut Name" placeholder="Shortcut Name"
bind:value={newTitle} bind:value={newTitle}
@@ -108,7 +108,7 @@
class="w-full" class="w-full"
> >
<input <input
class="w-full p-2 my-2 transition border-0 rounded-lg placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600" class="p-2 my-2 w-full rounded-lg border-0 transition placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
type="text" type="text"
placeholder="URL eg. https://google.com" placeholder="URL eg. https://google.com"
bind:value={newURL} bind:value={newURL}
@@ -142,9 +142,9 @@
<!-- Custom Shortcuts Section --> <!-- Custom Shortcuts Section -->
{#each $settingsState.customshortcuts as shortcut, index} {#each $settingsState.customshortcuts as shortcut, index}
<div class="flex items-center justify-between px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
{shortcut.name} {shortcut.name}
<button onclick={() => deleteCustomShortcut(index)}> <button aria-label="Delete Shortcut" onclick={() => deleteCustomShortcut(index)}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
+8 -9
View File
@@ -9,16 +9,15 @@
import type { Theme } from '../types/Theme' import type { Theme } from '../types/Theme'
import browser from 'webextension-polyfill' import browser from 'webextension-polyfill'
import ThemeModal from '../components/store/ThemeModal.svelte' import ThemeModal from '../components/store/ThemeModal.svelte'
import { StoreDownloadTheme } from '@/seqta/ui/themes/downloadTheme'
import { setTheme } from '@/seqta/ui/themes/setTheme'
import Header from '../components/store/Header.svelte' import Header from '../components/store/Header.svelte'
import { deleteTheme } from '@/seqta/ui/themes/deleteTheme'
import { getAvailableThemes } from '@/seqta/ui/themes/getAvailableThemes'
import { themeUpdates } from '../hooks/ThemeUpdates' import { themeUpdates } from '../hooks/ThemeUpdates'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { loadBackground } from '@/seqta/ui/ImageBackgrounds' import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
import Backgrounds from '../components/store/Backgrounds.svelte' import Backgrounds from '../components/store/Backgrounds.svelte'
const themeManager = ThemeManager.getInstance();
// State variables // State variables
let searchTerm = $state(''); let searchTerm = $state('');
let themes = $state<Theme[]>([]); let themes = $state<Theme[]>([]);
@@ -33,8 +32,8 @@
let selectedBackground = $state<string | null>(null); let selectedBackground = $state<string | null>(null);
const fetchCurrentThemes = async () => { const fetchCurrentThemes = async () => {
const themes = await getAvailableThemes(); const themes = await themeManager.getAvailableThemes();
currentThemes = themes.themes.filter(theme => theme !== null).map(theme => theme.id); currentThemes = themes.filter(theme => theme !== null).map(theme => theme.id);
}; };
const setDisplayTheme = (theme: Theme | null) => { const setDisplayTheme = (theme: Theme | null) => {
@@ -123,8 +122,8 @@
{setDisplayTheme} {setDisplayTheme}
onInstall={async () => { onInstall={async () => {
if (displayTheme) { if (displayTheme) {
await StoreDownloadTheme({themeContent: displayTheme}) await themeManager.downloadTheme(displayTheme);
setTheme(displayTheme.id); await themeManager.setTheme(displayTheme.id);
themeUpdates.triggerUpdate(); themeUpdates.triggerUpdate();
await fetchCurrentThemes(); await fetchCurrentThemes();
} }
@@ -132,7 +131,7 @@
onRemove={async () => { onRemove={async () => {
if (displayTheme?.id) { if (displayTheme?.id) {
console.debug('deleting theme', displayTheme.id); console.debug('deleting theme', displayTheme.id);
deleteTheme(displayTheme.id) await themeManager.deleteTheme(displayTheme.id);
themeUpdates.triggerUpdate(); themeUpdates.triggerUpdate();
await fetchCurrentThemes(); await fetchCurrentThemes();
} }
+30 -29
View File
@@ -7,7 +7,6 @@
import { type LoadedCustomTheme } from '@/types/CustomThemes' import { type LoadedCustomTheme } from '@/types/CustomThemes'
import { settingsState } from '@/seqta/utils/listeners/SettingsState' import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import { getTheme } from '@/seqta/ui/themes/getTheme'
import Divider from '@/interface/components/themeCreator/divider.svelte' import Divider from '@/interface/components/themeCreator/divider.svelte'
import Switch from '@/interface/components/Switch.svelte' import Switch from '@/interface/components/Switch.svelte'
@@ -22,14 +21,13 @@
handleImageVariableChange, handleImageVariableChange,
handleCoverImageUpload handleCoverImageUpload
} from '../utils/themeImageHandlers'; } from '../utils/themeImageHandlers';
import { ClearThemePreview, UpdateThemePreview } from '@/seqta/ui/themes/UpdateThemePreview' import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { saveTheme } from '@/seqta/ui/themes/saveTheme'
import { CloseThemeCreator } from '@/seqta/ui/ThemeCreator'
import { themeUpdates } from '../hooks/ThemeUpdates' import { themeUpdates } from '../hooks/ThemeUpdates'
import { disableTheme } from '@/seqta/ui/themes/disableTheme' import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { setTheme } from '@/seqta/ui/themes/setTheme'
const { themeID } = $props<{ themeID: string }>() const { themeID } = $props<{ themeID: string }>()
const themeManager = ThemeManager.getInstance();
let theme = $state<LoadedCustomTheme>({ let theme = $state<LoadedCustomTheme>({
id: uuidv4(), id: uuidv4(),
name: '', name: '',
@@ -53,7 +51,12 @@
codeEditorFullscreen = !codeEditorFullscreen; codeEditorFullscreen = !codeEditorFullscreen;
} }
function toggleAccordion(title: string) { function toggleAccordion(title: string, e: MouseEvent | KeyboardEvent) {
// if the target is the fullscreen button return
if (e.target instanceof HTMLButtonElement && e.target.classList.contains('fullscreen-toggle')) {
return;
}
if (closedAccordions.includes(title)) { if (closedAccordions.includes(title)) {
closedAccordions = closedAccordions.filter(t => t !== title); closedAccordions = closedAccordions.filter(t => t !== title);
} else { } else {
@@ -62,10 +65,10 @@
} }
onMount(async () => { onMount(async () => {
await disableTheme(); await themeManager.disableTheme();
if (themeID) { if (themeID) {
const tempTheme = await getTheme(themeID) const tempTheme = await themeManager.getTheme(themeID)
if (!tempTheme) return if (!tempTheme) return
@@ -73,16 +76,12 @@
const loadedTheme = { const loadedTheme = {
...tempTheme, ...tempTheme,
CustomImages: tempTheme.CustomImages.map(image => ({ CustomImages: tempTheme.CustomImages.map(image => ({
...image, ...image
url: image.blob ? URL.createObjectURL(image.blob) : null }))
})),
coverImageUrl: tempTheme.coverImage ? URL.createObjectURL(tempTheme.coverImage) : undefined
} }
if (tempTheme) { theme = loadedTheme
theme = loadedTheme themeLoaded = true
themeLoaded = true
}
} else { } else {
themeLoaded = true themeLoaded = true
} }
@@ -106,7 +105,7 @@
theme = await handleCoverImageUpload(event, theme); theme = await handleCoverImageUpload(event, theme);
} }
function submitTheme() { async function submitTheme() {
const themeClone = JSON.parse(JSON.stringify(theme)); const themeClone = JSON.parse(JSON.stringify(theme));
// re-insert blobs into themeClone // re-insert blobs into themeClone
@@ -116,15 +115,17 @@
})) }))
themeClone.coverImage = theme.coverImage themeClone.coverImage = theme.coverImage
ClearThemePreview(); themeManager.clearPreview();
saveTheme(themeClone); await themeManager.saveTheme(themeClone);
setTheme(themeClone.id); await themeManager.setTheme(themeClone.id);
themeUpdates.triggerUpdate(); themeUpdates.triggerUpdate();
CloseThemeCreator(); CloseThemeCreator();
} }
$effect(() => { $effect(() => {
UpdateThemePreview(theme); if (themeLoaded) {
void themeManager.updatePreviewDebounced(theme);
}
}); });
type SettingType = 'switch' | 'button' | 'slider' | 'colourPicker' | 'select' | 'codeEditor' | 'imageUpload' | 'conditional' | 'lightDarkToggle'; type SettingType = 'switch' | 'button' | 'slider' | 'colourPicker' | 'select' | 'codeEditor' | 'imageUpload' | 'conditional' | 'lightDarkToggle';
@@ -164,8 +165,8 @@
<div class="flex justify-between {item.direction === 'vertical' ? 'flex-col items-start' : 'items-center'} py-3"> <div class="flex justify-between {item.direction === 'vertical' ? 'flex-col items-start' : 'items-center'} py-3">
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
onclick={() => { item.direction === 'vertical' && toggleAccordion(item.title) }} onclick={(e) => { item.direction === 'vertical' && toggleAccordion(item.title, e) }}
onkeydown={(e) => { e.key === 'Enter' && item.direction === 'vertical' && toggleAccordion(item.title) }} onkeydown={(e) => { e.key === 'Enter' && item.direction === 'vertical' && toggleAccordion(item.title, e) }}
class="flex justify-between pr-4 {item.direction === 'vertical' ? 'cursor-pointer w-full select-none' : ''}"> class="flex justify-between pr-4 {item.direction === 'vertical' ? 'cursor-pointer w-full select-none' : ''}">
<div> <div>
@@ -177,7 +178,7 @@
<div class="flex justify-center items-center h-full text-xl font-light text-zinc-500 dark:text-zinc-300"> <div class="flex justify-center items-center h-full text-xl font-light text-zinc-500 dark:text-zinc-300">
{#if item.type === 'codeEditor'} {#if item.type === 'codeEditor'}
<!-- Fullscreen toggle button --> <!-- Fullscreen toggle button -->
<button onclick={toggleCodeEditorFullscreen} class="mr-2 text-lg font-IconFamily"> <button onclick={toggleCodeEditorFullscreen} class="px-2 mr-2 text-lg font-IconFamily fullscreen-toggle">
{'\uebdb'} {'\uebdb'}
</button> </button>
{/if} {/if}
@@ -210,14 +211,14 @@
{#each theme.CustomImages as image (image.id)} {#each theme.CustomImages as image (image.id)}
<div class="flex gap-2 items-center px-2 py-2 mb-4 h-16 bg-white rounded-lg shadow-lg dark:bg-zinc-700"> <div class="flex gap-2 items-center px-2 py-2 mb-4 h-16 bg-white rounded-lg shadow-lg dark:bg-zinc-700">
<div class="h-full"> <div class="h-full">
<img src={image.url} alt={image.variableName} class="object-contain h-full rounded" /> <img src={URL.createObjectURL(image.blob)} alt={image.variableName} class="object-contain h-full rounded" />
</div> </div>
<input <input
type="text" type="text"
bind:value={image.variableName} bind:value={image.variableName}
oninput={(e) => onImageVariableChange(image.id, e.currentTarget.value)} oninput={(e) => onImageVariableChange(image.id, e.currentTarget.value)}
placeholder="CSS Variable Name" placeholder="CSS Variable Name"
class="flex-grow flex-[3] w-full p-2 transition border-0 rounded-lg dark:placeholder-zinc-300 bg-zinc-200 dark:bg-zinc-600/50 focus:bg-zinc-300/50 dark:focus:bg-zinc-600" class="p-2 w-full rounded-lg border-0 transition grow flex-3 dark:placeholder-zinc-300 bg-zinc-200 dark:bg-zinc-600/50 focus:bg-zinc-300/50 dark:focus:bg-zinc-600"
/> />
<button onclick={() => onRemoveImage(image.id)} class="p-2 transition dark:text-white"> <button onclick={() => onRemoveImage(image.id)} class="p-2 transition dark:text-white">
<span class='text-xl font-IconFamily'>{'\ued8c'}</span> <span class='text-xl font-IconFamily'>{'\ued8c'}</span>
@@ -255,7 +256,7 @@
<div class='h-screen overflow-y-scroll {$settingsState.DarkMode && "dark"} no-scrollbar'> <div class='h-screen overflow-y-scroll {$settingsState.DarkMode && "dark"} no-scrollbar'>
{#if codeEditorFullscreen} {#if codeEditorFullscreen}
<div class="absolute inset-0 z-[10000] bg-white dark:bg-zinc-900 dark:text-white"> <div class="absolute inset-0 bg-white z-[10000] dark:bg-zinc-900 dark:text-white">
<div class="sticky top-0 px-2 h-screen"> <div class="sticky top-0 px-2 h-screen">
<div class="flex justify-between items-center my-4"> <div class="flex justify-between items-center my-4">
<h2 class="text-xl font-bold">Custom CSS</h2> <h2 class="text-xl font-bold">Custom CSS</h2>
@@ -310,7 +311,7 @@
{/if} {/if}
{#if theme.coverImage} {#if theme.coverImage}
<div class="absolute z-20 w-full h-full opacity-0 transition-opacity pointer-events-none group-hover:opacity-100 bg-black/20"></div> <div class="absolute z-20 w-full h-full opacity-0 transition-opacity pointer-events-none group-hover:opacity-100 bg-black/20"></div>
<img src={theme.coverImageUrl} alt='Cover' class="object-cover absolute z-0 w-full h-full rounded" /> <img src="{typeof theme.coverImage === 'string' ? theme.coverImage : URL.createObjectURL(theme.coverImage)}" alt='Cover' class="object-cover absolute z-0 w-full h-full rounded" />
{/if} {/if}
</div> </div>
+2 -2
View File
@@ -17,7 +17,7 @@ export function handleImageUpload(event: Event, theme: LoadedCustomTheme): Promi
const variableName = `custom-image-${theme.CustomImages.length}`; const variableName = `custom-image-${theme.CustomImages.length}`;
resolve({ resolve({
...theme, ...theme,
CustomImages: [...theme.CustomImages, { id: imageId, blob: imageBlob, variableName, url: URL.createObjectURL(imageBlob) }], CustomImages: [...theme.CustomImages, { id: imageId, blob: imageBlob, variableName, url: null }],
}); });
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
@@ -51,7 +51,7 @@ export function handleCoverImageUpload(event: Event, theme: LoadedCustomTheme):
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async () => { reader.onload = async () => {
const imageBlob = await fetch(reader.result as string).then(res => res.blob()); const imageBlob = await fetch(reader.result as string).then(res => res.blob());
resolve({ ...theme, coverImage: imageBlob, coverImageUrl: URL.createObjectURL(imageBlob) }); resolve({ ...theme, coverImage: imageBlob });
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
+1 -9
View File
@@ -32,15 +32,7 @@
], ],
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": ["*://*/*"], "resources": ["*/*", "resources/*", "seqta/utils/migration/migrate.html", "plugins/built-in/globalSearch/*"],
"matches": ["*://*/*"]
},
{
"resources": ["resources/icons/*"],
"matches": ["*://*/*"]
},
{
"resources": ["seqta/utils/migration/migrate.html"],
"matches": ["*://*/*"] "matches": ["*://*/*"]
} }
] ]
@@ -0,0 +1,78 @@
import { BasePlugin } from '../../core/settings';
import { type Plugin } from '@/plugins/core/types';
import { defineSettings, numberSetting, Setting } from '@/plugins/core/settingsHelpers';
import styles from './styles.css?inline';
const settings = defineSettings({
speed: numberSetting({
default: 1,
title: "Animation Speed",
description: "Controls how fast the background moves",
min: 0.1,
max: 2,
step: 0.05
})
});
class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
@Setting(settings.speed)
speed!: number;
}
const instance = new AnimatedBackgroundPluginClass();
const animatedBackgroundPlugin: Plugin<typeof settings> = {
id: 'animated-background',
name: 'Animated Background',
description: 'Adds an animated background to BetterSEQTA+',
version: '1.0.0',
disableToggle: true,
styles: styles,
settings: instance.settings,
run: async (api) => {
// Create the background elements
const container = document.getElementById("container");
const menu = document.getElementById("menu");
if (!container || !menu) {
return () => {};
}
const backgrounds = [
{ classes: ["bg"] },
{ classes: ["bg", "bg2"] },
{ classes: ["bg", "bg3"] }
];
backgrounds.forEach(({ classes }) => {
const bk = document.createElement("div");
classes.forEach(cls => bk.classList.add(cls));
container.insertBefore(bk, menu);
});
// Set initial speed
updateAnimationSpeed(api.settings.speed);
// Listen for speed changes
const speedUnregister = api.settings.onChange('speed', updateAnimationSpeed);
// Return cleanup function
return () => {
speedUnregister.unregister();
// Remove background elements
const backgrounds = document.getElementsByClassName('bg');
Array.from(backgrounds).forEach(element => element.remove());
};
}
};
function updateAnimationSpeed(speed: number) {
const bgElements = document.getElementsByClassName('bg');
Array.from(bgElements).forEach((element, index) => {
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`;
});
}
export default animatedBackgroundPlugin;
@@ -0,0 +1,31 @@
.bg {
animation: slide 3s ease-in-out infinite alternate;
background: var(--better-main);
bottom: 0;
left: -50%;
opacity: 0.5;
position: fixed;
right: -50%;
top: 0;
z-index: 0 !important;
overflow: hidden;
scale: 1.5;
}
.bg2 {
animation-direction: alternate-reverse;
animation-duration: 4s;
}
.bg3 {
animation-duration: 5s;
}
@keyframes slide {
0% {
transform: translate(50%) rotate(-60deg);
}
100% {
transform: translateX(5%) rotate(-60deg);
}
}
@@ -0,0 +1,24 @@
export function CreateBackground() {
const bkCheck = document.getElementsByClassName("bg");
if (bkCheck.length !== 0) {
return;
}
// Creating and inserting 3 divs containing the background applied to the pages
const container = document.getElementById("container");
const menu = document.getElementById("menu");
if (!container || !menu) return;
const backgrounds = [
{ classes: ["bg"] },
{ classes: ["bg", "bg2"] },
{ classes: ["bg", "bg3"] }
];
backgrounds.forEach(({ classes }) => {
const bk = document.createElement("div");
classes.forEach(cls => bk.classList.add(cls));
container.insertBefore(bk, menu);
});
}
@@ -0,0 +1,6 @@
export function RemoveBackground() {
const backgrounds = document.getElementsByClassName("bg");
// Convert HTMLCollection to Array and remove each element
Array.from(backgrounds).forEach(element => element.remove());
}
@@ -0,0 +1,150 @@
import { BasePlugin } from "@/plugins/core/settings";
import { booleanSetting, defineSettings, Setting } from "@/plugins/core/settingsHelpers";
import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
const settings = defineSettings({
lettergrade: booleanSetting({
default: false,
title: "Letter Grades",
description: "Display the average as a letter instead of a percentage"
}),
});
class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
@Setting(settings.lettergrade)
lettergrade!: boolean;
}
const instance = new AssessmentsAveragePluginClass();
const assessmentsAveragePlugin: Plugin<typeof settings> = {
id: "assessments-average",
name: "Assessment Averages",
description: "Adds an average grade to the Assessments page",
version: "1.0.0",
disableToggle: true,
settings: instance.settings,
run: async (api) => {
api.seqta.onMount(".assessmentsWrapper", async () => {
// Wait for any assessment item to load first
await waitForElm(
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
true,
10,
1000
);
// Helper function to find actual class names by their base pattern
const getClassByPattern = (element: Element | Document, basePattern: string): string => {
// Find all classes on the element
const classes = Array.from(element.querySelectorAll('*'))
.flatMap(el => Array.from(el.classList))
.filter(className => className.startsWith(basePattern));
return classes.length ? classes[0] : '';
};
// Find actual class names from the DOM
const sampleAssessmentItem = document.querySelector("[class*='AssessmentItem__AssessmentItem___']");
if (!sampleAssessmentItem) return;
// Extract all necessary class patterns from a sample assessment item
const assessmentItemClass = Array.from(sampleAssessmentItem.classList)
.find(c => c.startsWith('AssessmentItem__AssessmentItem___')) || '';
const metaContainerClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__metaContainer___');
const metaClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__meta___');
const simpleResultClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__simpleResult___');
const titleClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__title___');
// Get Thermoscore classes
const thermoscoreElement = document.querySelector("[class*='Thermoscore__Thermoscore___']");
if (!thermoscoreElement) return;
const thermoscoreClass = Array.from(thermoscoreElement.classList)
.find(c => c.startsWith('Thermoscore__Thermoscore___')) || '';
const fillClass = getClassByPattern(thermoscoreElement, 'Thermoscore__fill___');
const textClass = getClassByPattern(thermoscoreElement, 'Thermoscore__text___');
// Find assessment list
const assessmentsList = document.querySelector("#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']");
if (!assessmentsList) return;
const gradeElements = document.querySelectorAll("[class*='Thermoscore__text___']");
if (!gradeElements.length) return;
// Parse and average grades
const letterToNumber: Record<string, number> = {
"A+": 100, A: 95, "A-": 90,
"B+": 85, B: 80, "B-": 75,
"C+": 70, C: 65, "C-": 60,
"D+": 55, D: 50, "D-": 45,
"E+": 40, E: 35, "E-": 30,
F: 0,
};
function parseGrade(text: string): number {
const str = text.trim().toUpperCase();
if (str.includes("/")) {
const [raw, max] = str.split("/").map(n => parseFloat(n));
return (raw / max) * 100;
}
if (str.includes("%")) {
return parseFloat(str.replace("%", "")) || 0;
}
return letterToNumber[str] ?? 0;
}
let total = 0;
let count = 0;
gradeElements.forEach((el) => {
const grade = parseGrade(el.textContent || "");
if (grade > 0) {
total += grade;
count++;
}
});
if (!count) return;
const avg = total / count;
const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce((acc, [k, v]) => {
acc[v] = k;
return acc;
}, {} as Record<number, string>);
const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
// Prevent duplicate
const existing = assessmentsList.querySelector(`[class*='AssessmentItem__title___']`);
if (existing?.textContent === "Subject Average") return;
// Use the dynamic class names in the HTML template
const averageElement = stringToHTML(/* html */ `
<div class="${assessmentItemClass}">
<div class="${metaContainerClass}">
<div class="${metaClass}">
<div class="${simpleResultClass}">
<div class="${titleClass}">Subject Average</div>
</div>
</div>
</div>
<div class="${thermoscoreClass}">
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
<div class="${textClass}" title="${display}">${display}</div>
</div>
</div>
</div>
`).firstChild;
assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild);
});
}
};
export default assessmentsAveragePlugin;
@@ -0,0 +1,626 @@
# client-vector-search
A client side vector search library that can embed, search, and cache. Works on the browser and server side.
It outperforms OpenAI's text-embedding-ada-002 and is way faster than Pinecone and other VectorDBs.
I'm the founder of [searchbase.app](https://searchbase.app) and we needed this for our product and customers. We'll be using this library in production. You can be sure it'll be maintained and improved.
- Embed documents using transformers by default: gte-small (~30mb).
- Calculate cosine similarity between embeddings.
- Create an index and search on the client side
- Cache vectors with browser caching support.
Lots of improvements are coming!
## Roadmap
Our goal is to build a super simple, fast vector search that works with couple hundred to thousands vectors. ~1k vectors per user covers 99% of the use cases.
We'll initially keep things super simple and sub 100ms
### TODOs
- [ ] add HNSW index that works on node and browser env, don't rely on hnsw binder libs
- [ ] add a proper testing suite and ci/cd for the lib
- [ ] simple health tests
- [ ] mock the @xenova/transformers for jest, it's not happy with it
- [ ] performance tests, recall, memory usage, cpu usage etc.
## Installation
```bash
npm i client-vector-search
```
## Quickstart
This library provides a plug-and-play solution for embedding and vector search. It's designed to be easy to use, efficient, and versatile. Here's a quick start guide:
```ts
import { getEmbedding, EmbeddingIndex } from "client-vector-search";
// getEmbedding is an async function, so you need to use 'await' or '.then()' to get the result
const embedding = await getEmbedding("Apple"); // Returns embedding as number[]
// Each object should have an 'embedding' property of type number[]
const initialObjects = [
{ id: 1, name: "Apple", embedding: embedding },
{ id: 2, name: "Banana", embedding: await getEmbedding("Banana") },
{ id: 3, name: "Cheddar", embedding: await getEmbedding("Cheddar") },
{ id: 4, name: "Space", embedding: await getEmbedding("Space") },
{ id: 5, name: "database", embedding: await getEmbedding("database") },
];
const index = new EmbeddingIndex(initialObjects); // Creates an index
// The query should be an embedding of type number[]
const queryEmbedding = await getEmbedding("Fruit"); // Query embedding
const results = await index.search(queryEmbedding, { topK: 5 }); // Returns top similar objects
// specify the storage type
await index.saveIndex("indexedDB");
const results = await index.search([1, 2, 3], {
topK: 5,
useStorage: "indexedDB",
// storageOptions: { // use only if you overrode the defaults
// indexedDBName: 'clientVectorDB',
// indexedDBObjectStoreName: 'ClientEmbeddingStore',
// },
});
console.log(results);
await index.deleteIndexedDB(); // if you overrode default, specify db name
```
## Trouble-shooting
### NextJS
To use it inside NextJS projects you'll need to update the `next.config.js` file to include the following:
```js
module.exports = {
// Override the default webpack configuration
webpack: (config) => {
// See https://webpack.js.org/configuration/resolve/#resolvealias
config.resolve.alias = {
...config.resolve.alias,
sharp$: false,
"onnxruntime-node$": false,
};
return config;
},
};
```
#### Model load after page is loaded
You can initialize the model before using it to generate embeddings. This will ensure that the model is loaded before you use it and provide a better UX.
```js
import { initializeModel } from "client-vector-search"
...
useEffect(() => {
try {
initializeModel();
} catch (e) {
console.log(e);
}
}, []);
```
## Usage Guide
This guide provides a step-by-step walkthrough of the library's main features. It covers everything from generating embeddings for a string to performing operations on the index such as adding, updating, and removing objects. It also includes instructions on how to save the index to a database and perform search operations within it.
Until we have a reference documentation, you can find all the methods and their usage in this guide. Each step is accompanied by a code snippet to illustrate the usage of the method in question. Make sure to follow along and try out the examples in your own environment to get a better understanding of how everything works.
Let's get started!
### Step 1: Generate Embeddings for String
Generate embeddings for a given string using the `getEmbedding` method.
```ts
const embedding = await getEmbedding("Apple"); // Returns embedding as number[]
```
> **Note**: `getEmbedding` is asynchronous; make sure to use `await`.
---
### Step 2: Calculate Cosine Similarity
Calculate the cosine similarity between two embeddings.
```ts
const similarity = cosineSimilarity(embedding1, embedding2, 6);
```
> **Note**: Both embeddings should be of the same length.
---
### Step 3: Create an Index
Create an index with an initial array of objects. Each object must have an 'embedding' property.
```ts
const initialObjects = [...];
const index = new EmbeddingIndex(initialObjects);
```
---
### Step 4: Add to Index
Add an object to the index.
```ts
const objectToAdd = {
id: 6,
name: "Cat",
embedding: await getEmbedding("Cat"),
};
index.add(objectToAdd);
```
---
### Step 5: Update Index
Update an existing object in the index.
```ts
const vectorToUpdate = {
id: 6,
name: "Dog",
embedding: await getEmbedding("Dog"),
};
index.update({ id: 6 }, vectorToUpdate);
```
---
### Step 6: Remove from Index
Remove an object from the index.
```ts
index.remove({ id: 6 });
```
---
### Step 7: Retrieve from Index
Retrieve an object from the index.
```ts
const vector = index.get({ id: 1 });
```
---
### Step 8: Search the Index
Search the index with a query embedding.
```ts
const queryEmbedding = await getEmbedding("Fruit");
const results = await index.search(queryEmbedding, { topK: 5 });
```
---
### Step 9: Print the Index
Print the entire index to the console.
```ts
index.printIndex();
```
---
### Step 10: Save Index to IndexedDB (for browser)
Save the index to a persistent IndexedDB database. Note
```ts
await index.saveIndex("indexedDB", {
DBName: "clientVectorDB",
objectStoreName: "ClientEmbeddingStore",
});
```
---
### Important: Search in indexedDB
Perform a search operation in the IndexedDB.
````ts
const results = await index.search(queryEmbedding, {
topK: 5,
useStorage: "indexedDB",
storageOptions: { // only if you want to override the default options, defaults are below
indexedDBName: 'clientVectorDB',
indexedDBObjectStoreName: 'ClientEmbeddingStore'
}
});
---
### Delete Database
To delete an entire database.
```ts
await IndexedDbManager.deleteIndexedDB("clientVectorDB");
````
---
### Delete Object Store
To delete an object store from a database.
```ts
await IndexedDbManager.deleteIndexedDBObjectStore(
"clientVectorDB",
"ClientEmbeddingStore",
);
```
---
### Retrieve All Objects
To retrieve all objects from a specific object store.
```ts
const allObjects = await IndexedDbManager.getAllObjectsFromIndexedDB(
"clientVectorDB",
"ClientEmbeddingStore",
);
```
# THE MAIN INDEX.TS FILE THAT YOU ARE IMPORTING FROM
```index.ts
const DEFAULT_TOP_K = 3;
interface Filter {
[key: string]: any;
}
import Cache from './cache';
import { IndexedDbManager } from './indexedDB';
import { cosineSimilarity } from './utils';
export { ExperimentalHNSWIndex } from './hnsw';
// uncomment if you want to test indexedDB implementation in node env for faster dev cycle
// import { IDBFactory } from 'fake-indexeddb';
// const indexedDB = new IDBFactory();
export interface SearchResult {
similarity: number;
object: any;
}
type StorageOptions = 'indexedDB' | 'localStorage' | 'none';
/**
* Interface for search options in the EmbeddingIndex class.
* topK: The number of top similar items to return.
* filter: An optional filter to apply to the objects before searching.
* useStorage: A flag to indicate whether to use storage options like indexedDB or localStorage.
*/
interface SearchOptions {
topK?: number;
filter?: Filter;
useStorage?: StorageOptions;
storageOptions?: { indexedDBName: string; indexedDBObjectStoreName: string }; // TODO: generalize it to localStorage as well
}
const cacheInstance = Cache.getInstance();
let pipe: any;
let currentModel: string;
export const initializeModel = async (
model: string = 'Xenova/gte-small',
): Promise<void> => {
if (model !== currentModel) {
const transformersModule = await import('@xenova/transformers');
const pipeline = transformersModule.pipeline;
pipe = await pipeline('feature-extraction', model);
currentModel = model;
}
};
export const getEmbedding = async (
text: string,
precision: number = 7,
options = { pooling: 'mean', normalize: false },
model = 'Xenova/gte-small',
): Promise<number[]> => {
const cachedEmbedding = cacheInstance.get(text);
if (cachedEmbedding) {
return Promise.resolve(cachedEmbedding);
}
if (model !== currentModel) {
await initializeModel(model);
}
const output = await pipe(text, options);
const roundedOutput = Array.from(output.data as number[]).map(
(value: number) => parseFloat(value.toFixed(precision)),
);
cacheInstance.set(text, roundedOutput);
return Array.from(roundedOutput);
};
export class EmbeddingIndex {
private objects: Filter[];
private keys: string[];
constructor(initialObjects?: Filter[]) {
// TODO: add support for options while creating index such as {... indexedDB: true, ...}
this.objects = [];
this.keys = [];
if (initialObjects && initialObjects.length > 0) {
initialObjects.forEach((obj) => this.validateAndAdd(obj));
if (initialObjects[0]) {
this.keys = Object.keys(initialObjects[0]);
}
}
}
private findVectorIndex(filter: Filter): number {
return this.objects.findIndex((object) =>
Object.keys(filter).every((key) => object[key] === filter[key]),
);
}
private validateAndAdd(obj: Filter) {
if (!Array.isArray(obj.embedding) || obj.embedding.some(isNaN)) {
throw new Error(
'Object must have an embedding property of type number[]',
);
}
if (this.keys.length === 0) {
this.keys = Object.keys(obj);
} else if (!this.keys.every((key) => key in obj)) {
throw new Error(
'Object must have the same properties as the initial objects',
);
}
this.objects.push(obj);
}
add(obj: Filter) {
this.validateAndAdd(obj);
}
// Method to update an existing vector in the index
update(filter: Filter, vector: Filter) {
const index = this.findVectorIndex(filter);
if (index === -1) {
throw new Error('Vector not found');
}
if (vector.hasOwnProperty('embedding')) {
// Validate and add the new vector
this.validateAndAdd(vector);
}
// Replace the old vector with the new one
this.objects[index] = Object.assign(this.objects[index] as Filter, vector);
}
// Method to remove a vector from the index
remove(filter: Filter) {
const index = this.findVectorIndex(filter);
if (index === -1) {
throw new Error('Vector not found');
}
// Remove the vector from the index
this.objects.splice(index, 1);
}
// Method to remove multiple vectors from the index
removeBatch(filters: Filter[]) {
filters.forEach((filter) => {
const index = this.findVectorIndex(filter);
if (index !== -1) {
// Remove the vector from the index
this.objects.splice(index, 1);
}
});
}
// Method to retrieve a vector from the index
get(filter: Filter) {
const vector = this.objects[this.findVectorIndex(filter)];
return vector || null;
}
size(): number {
// Returns the size of the index
return this.objects.length;
}
clear() {
this.objects = [];
}
async search(
queryEmbedding: number[],
options: SearchOptions = {
topK: 3,
useStorage: 'none',
storageOptions: {
indexedDBName: 'clientVectorDB',
indexedDBObjectStoreName: 'ClientEmbeddingStore',
},
},
): Promise<SearchResult[]> {
const topK = options.topK || DEFAULT_TOP_K;
const filter = options.filter || {};
const useStorage = options.useStorage || 'none';
if (useStorage === 'indexedDB') {
const DBname = options.storageOptions?.indexedDBName || 'clientVectorDB';
const objectStoreName =
options.storageOptions?.indexedDBObjectStoreName ||
'ClientEmbeddingStore';
if (typeof indexedDB === 'undefined') {
console.error('IndexedDB is not supported');
throw new Error('IndexedDB is not supported');
}
const results = await this.loadAndSearchFromIndexedDB(
DBname,
objectStoreName,
queryEmbedding,
topK,
filter,
);
return results;
} else {
// Compute similarities
const similarities = this.objects
.filter((object) =>
Object.keys(filter).every((key) => object[key] === filter[key]),
)
.map((obj) => ({
similarity: cosineSimilarity(queryEmbedding, obj.embedding),
object: obj,
}));
// Sort by similarity and return topK results
return similarities
.sort((a, b) => b.similarity - a.similarity)
.slice(0, topK);
}
}
printIndex() {
console.log('Index Content:');
this.objects.forEach((obj, idx) => {
console.log(`Item ${idx + 1}:`, obj);
});
}
async saveIndex(
storageType: string,
options: { DBName: string; objectStoreName: string } = {
DBName: 'clientVectorDB',
objectStoreName: 'ClientEmbeddingStore',
},
) {
if (storageType === 'indexedDB') {
await this.saveToIndexedDB(options.DBName, options.objectStoreName);
} else {
throw new Error(
`Unsupported storage type: ${storageType} \n Supported storage types: "indexedDB"`,
);
}
}
async saveToIndexedDB(
DBname: string = 'clientVectorDB',
objectStoreName: string = 'ClientEmbeddingStore',
): Promise<void> {
if (typeof indexedDB === 'undefined') {
console.error('IndexedDB is not defined');
throw new Error('IndexedDB is not supported');
}
if (!this.objects || this.objects.length === 0) {
throw new Error('Index is empty. Nothing to save');
}
try {
const db = await IndexedDbManager.create(DBname, objectStoreName);
await db.addToIndexedDB(this.objects);
console.log(
`Index saved to database '${DBname}' object store '${objectStoreName}'`,
);
} catch (error) {
console.error('Error saving index to database:', error);
throw new Error('Error saving index to database');
}
}
async loadAndSearchFromIndexedDB(
DBname: string = 'clientVectorDB',
objectStoreName: string = 'ClientEmbeddingStore',
queryEmbedding: number[],
topK: number,
filter: { [key: string]: any },
): Promise<SearchResult[]> {
const db = await IndexedDbManager.create(DBname, objectStoreName);
const generator = db.dbGenerator();
const results: { similarity: number; object: any }[] = [];
for await (const record of generator) {
if (Object.keys(filter).every((key) => record[key] === filter[key])) {
const similarity = cosineSimilarity(queryEmbedding, record.embedding);
results.push({ similarity, object: record });
}
}
results.sort((a, b) => b.similarity - a.similarity);
return results.slice(0, topK);
}
async deleteIndexedDB(DBname: string = 'clientVectorDB'): Promise<void> {
if (typeof indexedDB === 'undefined') {
console.error('IndexedDB is not defined');
throw new Error('IndexedDB is not supported');
}
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(DBname);
request.onsuccess = () => {
console.log(`Database '${DBname}' deleted`);
resolve();
};
request.onerror = (event) => {
console.error('Failed to delete database', event);
reject(new Error('Failed to delete database'));
};
});
}
async deleteIndexedDBObjectStore(
DBname: string = 'clientVectorDB',
objectStoreName: string = 'ClientEmbeddingStore',
): Promise<void> {
const db = await IndexedDbManager.create(DBname, objectStoreName);
try {
await db.deleteIndexedDBObjectStoreFromDB(DBname, objectStoreName);
console.log(
`Object store '${objectStoreName}' deleted from database '${DBname}'`,
);
} catch (error) {
console.error('Error deleting object store:', error);
throw new Error('Error deleting object store');
}
}
async getAllObjectsFromIndexedDB(
DBname: string = 'clientVectorDB',
objectStoreName: string = 'ClientEmbeddingStore',
): Promise<any[]> {
const db = await IndexedDbManager.create(DBname, objectStoreName);
const objects: any[] = [];
for await (const record of db.dbGenerator()) {
objects.push(record);
}
return objects;
}
}
```
@@ -0,0 +1,50 @@
<script lang="ts">
import { highlightMatch, highlightSnippet, stripHtmlButKeepHighlights } from '../utils/highlight';
import type { DynamicContentItem } from '../utils/dynamicItems';
import type { FuseResultMatch } from '../core/types';
const { item, isSelected, searchTerm, matches } = $props<{
item: DynamicContentItem;
isSelected: boolean;
searchTerm: string;
matches?: readonly FuseResultMatch[];
}>();
</script>
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
>
<div class="flex items-center w-full">
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{item.metadata?.icon || '\ue924'}</div>
<span class="ml-4 text-lg truncate">
{@html stripHtmlButKeepHighlights(highlightMatch(item.text, searchTerm, matches))}
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
{item.category}
</span>
</div>
{#if item.content}
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
{@html stripHtmlButKeepHighlights(highlightSnippet(item.content, searchTerm, matches))}
</div>
{/if}
</button>
<style>
:global(.highlight) {
background-color: rgba(255, 213, 0, 0.3);
font-weight: 500;
border-radius: 2px;
padding: 0 1px;
margin: 0 -1px;
}
.dark :global(.highlight) {
background-color: rgba(255, 230, 100, 0.4);
}
.due-badge {
font-size: 0.65rem;
}
</style>
@@ -0,0 +1,135 @@
<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte';
import { unitFullNames } from './unitMap';
import * as math from 'mathjs';
let { searchTerm = '', isSelected = false } = $props<{ searchTerm: string, isSelected: boolean }>();
const dispatch = createEventDispatcher<{
hasResult: string | null;
}>();
let result = $state<string | null>(null);
let isCalculating = $state(false);
let inputUnit = $state<string>('');
let outputUnit = $state<string>('');
function detectUnit(expression: string): string {
try {
const unit = math.unit(expression);
if (unit) {
// Get the base unit name
const unitStr = unit.formatUnits();
return unitFullNames[unitStr] || unitStr;
}
} catch (e) {
// Not a unit or invalid expression
}
return '';
}
// Process the input with debounce to avoid unnecessary calculations
const processInput = (input: string) => {
try {
if (
!input.trim() ||
(input.trim().length <= 2 && !/\d/.test(input))
) {
result = null;
inputUnit = '';
outputUnit = '';
dispatch('hasResult', null);
return;
}
isCalculating = true;
// Let mathjs handle everything
const evaluated = math.evaluate(input.replace('**', '^'));
// Format the result
if (evaluated !== undefined) {
if (math.typeOf(evaluated) === 'Unit') {
// Handle unit conversion results
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
inputUnit = detectUnit(input);
outputUnit = detectUnit(result);
} else if (typeof evaluated === 'number') {
// Handle regular numbers
if (math.round(evaluated) === evaluated) {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
} else {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
}
inputUnit = '';
outputUnit = '';
} else {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
inputUnit = '';
outputUnit = '';
}
dispatch('hasResult', result);
} else {
result = null;
inputUnit = '';
outputUnit = '';
dispatch('hasResult', null);
}
} catch (e) {
// If mathjs throws an error, this isn't a valid expression
result = null;
inputUnit = '';
outputUnit = '';
dispatch('hasResult', null);
} finally {
isCalculating = false;
}
}
$effect(() => {
processInput(searchTerm);
});
onDestroy(() => {
dispatch('hasResult', null);
});
</script>
{#if result !== null}
<div class="p-2">
<p class="text-[0.85rem] p-1 pb-0.5 pt-0 font-semibold text-zinc-500 dark:text-zinc-400">Calculator</p>
<div class="flex items-center justify-between gap-8 rounded-lg border border-transparent {isSelected ? 'bg-zinc-900/5 dark:bg-white/10 border-zinc-900/5 dark:border-zinc-100/5' : ''}">
<div class="flex flex-col flex-1 items-center py-4 pl-4 min-w-0">
<div class="overflow-hidden py-2 w-full font-semibold text-center whitespace-nowrap text-zinc-900 dark:text-white text-ellipsis"
style="--char-count: {searchTerm?.length || 10}; font-size: min(2.5rem, max(1rem, calc(35vw / var(--char-count, 10))))">
{searchTerm}
</div>
<div class="px-3 py-1 mt-1 text-sm rounded-md text-zinc-900 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-100/10">
{inputUnit || 'Question'}
</div>
</div>
<div class="flex flex-col flex-shrink-0 justify-center items-center w-12">
<div class="h-8 w-[1px] bg-zinc-900/5 dark:bg-zinc-100/5"></div>
<div class="text-2xl text-zinc-900 dark:text-zinc-100">
</div>
<div class="h-8 w-[1px] bg-zinc-900/5 dark:bg-zinc-100/5"></div>
</div>
{#if !isCalculating}
<div class="flex flex-col flex-1 items-center py-4 pr-4 min-w-0">
<div class="overflow-hidden py-2 w-full font-semibold text-center whitespace-nowrap text-zinc-900 dark:text-white text-ellipsis"
style="--char-count: {result?.length || 10}; font-size: min(2.5rem, max(1rem, calc(30vw / var(--char-count, 10))))">
{result}
</div>
<div class="px-3 py-1 mt-1 text-sm rounded-md text-zinc-900 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-100/10">
{outputUnit || 'Result'}
</div>
</div>
{:else}
<div class="w-6 h-6 rounded-full border-2 animate-spin border-zinc-300 dark:border-zinc-700 border-t-zinc-600 dark:border-t-zinc-300"></div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,390 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import { fade, scale } from 'svelte/transition';
import { circOut, quintOut } from 'svelte/easing';
import { type StaticCommandItem } from '../core/commands';
import type { CombinedResult } from '../core/types';
import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils';
import { highlightMatch, highlightSnippet, stripHtmlButKeepHighlights } from '../utils/highlight';
import Fuse from 'fuse.js';
import Calculator from './Calculator.svelte';
import { actionMap } from '../indexing/actions';
import type { IndexItem, HydratedIndexItem } from '../indexing/types';
import debounce from 'lodash/debounce';
const {
transparencyEffects,
showRecentFirst
} = $props<{
transparencyEffects: boolean,
showRecentFirst: boolean
}>();
let commandsFuse = $state<Fuse<StaticCommandItem>>();
let dynamicContentFuse = $state<Fuse<HydratedIndexItem>>();
const dynamicIdToItemMap = $state(new Map<string, HydratedIndexItem>());
const commandIdToItemMap = $state(new Map<string, StaticCommandItem>());
let isIndexing = $state(false);
let completedJobs = $state(0);
let totalJobs = $state(0);
onMount(() => {
const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing } = event.detail;
completedJobs = completed;
totalJobs = total;
isIndexing = indexing;
};
window.addEventListener('indexing-progress', progressHandler as EventListener);
const itemsUpdatedHandler = () => {
console.log('Search Bar received items-updated event, re-indexing...');
setupSearchIndexes();
performSearch();
};
window.addEventListener('dynamic-items-updated', itemsUpdatedHandler);
return () => {
window.removeEventListener('indexing-progress', progressHandler as EventListener);
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
};
});
function setupSearchIndexes() {
const { commandsFuse: cfuse, dynamicContentFuse: dfuse, commands, dynamicItems } = createSearchIndexes();
commandsFuse = cfuse;
dynamicContentFuse = dfuse;
dynamicIdToItemMap.clear();
commandIdToItemMap.clear();
dynamicItems.forEach(item => dynamicIdToItemMap.set(item.id, item));
commands.forEach(item => commandIdToItemMap.set(item.id, item));
console.debug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`);
}
let commandPalleteOpen = $state(false);
let searchTerm = $state('');
let selectedIndex = $state(0);
let searchbar = $state<HTMLInputElement>();
let combinedResults = $state<CombinedResult[]>([]);
let isLoading = $state(false);
let prevSearchTerm = $state('');
let calculatorResult = $state<string | null>(null);
const updateCalculatorState = (hasResult: string | null) => {
calculatorResult = hasResult;
};
onMount(() => {
setupSearchIndexes();
// @ts-ignore - Intentionally adding to window
window.setCommandPalleteOpen = (open: boolean) => {
commandPalleteOpen = open;
};
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
commandPalleteOpen = true;
tick().then(() => searchbar?.focus());
}
if (e.key === 'Escape') {
commandPalleteOpen = false;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
});
const performSearch = async () => {
isLoading = true;
selectedIndex = 0;
const term = searchTerm.trim().toLowerCase();
if (commandsFuse && dynamicContentFuse) {
combinedResults = await doSearch(
term,
commandsFuse,
dynamicContentFuse,
commandIdToItemMap,
dynamicIdToItemMap,
showRecentFirst
);
} else {
combinedResults = [];
}
isLoading = false;
};
const debouncedPerformSearch = debounce(performSearch, 10);
$effect(() => {
if (commandPalleteOpen) {
if (searchTerm === '') {
performSearch();
} else {
debouncedPerformSearch();
}
tick().then(() => searchbar?.focus());
} else {
searchTerm = '';
selectedIndex = 0;
prevSearchTerm = '';
combinedResults = [];
}
});
$effect(() => {
if (combinedResults.length === 0 && calculatorResult && commandPalleteOpen) {
selectedIndex = 0;
}
});
const selectNext = () => {
const maxIndex = (calculatorResult ? 1 : 0) + combinedResults.length - 1;
if (selectedIndex < maxIndex) {
selectedIndex++;
}
};
const selectPrev = () => {
if (selectedIndex > 0) {
selectedIndex--;
}
};
function executeItemAction(item: StaticCommandItem | HydratedIndexItem) {
if ('action' in item && typeof item.action === 'function') {
(item as StaticCommandItem).action();
} else if ('actionId' in item && item.actionId && actionMap[item.actionId]) {
actionMap[item.actionId](item as IndexItem);
}
commandPalleteOpen = false;
}
const executeSelected = () => {
if (calculatorResult && selectedIndex === 0) {
navigator.clipboard.writeText(calculatorResult);
commandPalleteOpen = false;
} else {
const resultIndex = calculatorResult ? selectedIndex - 1 : selectedIndex;
const result = combinedResults[resultIndex];
if (result?.item) {
executeItemAction(result.item);
}
}
};
const handleKeyNav = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectNext();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectPrev();
} else if (e.key === 'Enter') {
e.preventDefault();
executeSelected();
} else if (e.key === 'Escape') {
commandPalleteOpen = false;
}
};
</script>
{#if commandPalleteOpen}
<div role="dialog" aria-modal="true" class={settingsState.DarkMode ? 'dark' : ''}>
<div
class="fixed inset-0 z-[50000] bg-zinc-900/40 dark:bg-black/60"
transition:fade={{ duration: 150, easing: quintOut }}
></div>
<div class="fixed inset-0 z-[50000] flex justify-center place-items-start p-8 sm:p-6 md:p-8 select-none"
onclick={() => commandPalleteOpen = false}
onkeydown={(e) => e.key === 'Escape' && (commandPalleteOpen = false)}
role="button"
tabindex="0">
<div
class="w-full max-w-2xl overflow-clip rounded-xl ring-1 shadow-2xl ring-black/5 dark:ring-white/10 { transparencyEffects ? 'bg-white/80 dark:bg-zinc-900/80 backdrop-blur' : 'bg-white dark:bg-zinc-900' }"
transition:scale={{ duration: 100, start: 0.95, opacity: 0, easing: circOut }}
onclick={(e) => {
e.stopPropagation();
}}
onkeydown={(e) => {
if (e.key === 'Escape') {
commandPalleteOpen = false;
}
}}
role="button"
tabindex="0">
<div class="relative p-2 border-b border-zinc-900/5 dark:border-zinc-100/5">
<div class="absolute top-1/2 translate-y-[calc(-50%-3px)] scale-105 left-5 w-6 h-6 text-[1.3rem] text-zinc-900 dark:text-zinc-400 text-opacity-40 pointer-events-none font-IconFamily">
{'\ueca5'}
</div>
<input
bind:this={searchbar}
bind:value={searchTerm}
onkeydown={handleKeyNav}
class="pr-4 pl-12 w-full h-10 text-lg bg-transparent border-0 outline-none placeholder-zinc-400 text-zinc-700 dark:placeholder-zinc-500 dark:text-white focus:ring-0 sm:text-xl"
placeholder="Search..."
/>
</div>
<ul class="overflow-y-auto max-h-[24rem] text-base scroll-py-2 p-1 gap-0.5 flex flex-col">
<Calculator
searchTerm={searchTerm}
isSelected={selectedIndex === 0}
on:hasResult={(e) => updateCalculatorState(e.detail)}
/>
{#if combinedResults.length > 0}
{#each combinedResults as result, i (result.id)}
{@const isSelected = selectedIndex === (calculatorResult ? i + 1 : i)}
{@const item = result.item}
<li>
{#if result.type === 'command'}
{@const staticItem = item as StaticCommandItem}
<button
class="w-full flex items-center px-2 py-1.5 rounded-lg select-none cursor-pointer group
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={() => executeItemAction(staticItem)}
>
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{staticItem.icon}</div>
<span class="ml-4 text-lg truncate">
{@html highlightMatch(staticItem.text, searchTerm, result.matches)}
</span>
{#if staticItem.keybindLabel}
<div class="flex-none ml-auto">
{@render Shortcut({ text: '', keybind: staticItem.keybindLabel })}
</div>
{/if}
</button>
{:else if result.type === 'dynamic'}
{@const dynamicItem = item as HydratedIndexItem}
{#if dynamicItem.renderComponent}
<dynamicItem.renderComponent
item={dynamicItem}
isSelected={isSelected}
searchTerm={searchTerm}
matches={result.matches}
on:click={() => executeItemAction(dynamicItem)}
/>
{:else}
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={() => executeItemAction(dynamicItem)}
>
<div class="flex items-center w-full">
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{dynamicItem.metadata?.icon || '\ue924'}</div>
<span class="ml-4 text-lg truncate">
{@html stripHtmlButKeepHighlights(highlightMatch(dynamicItem.text, searchTerm, result.matches))}
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
{dynamicItem.category}
</span>
</div>
{#if dynamicItem.content}
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
{@html stripHtmlButKeepHighlights(highlightSnippet(dynamicItem.content, searchTerm, result.matches))}
</div>
{/if}
</button>
{/if}
{/if}
</li>
{/each}
{:else if !calculatorResult}
<div class="px-8 py-16 text-center text-zinc-900 dark:text-zinc-200 sm:px-16">
{#if isLoading}
<div class="mx-auto w-8 h-8 rounded-full border-2 animate-spin border-zinc-300 dark:border-zinc-700 border-t-zinc-600 dark:border-t-zinc-300"></div>
<p class="mt-4 text-lg dark:text-zinc-300">Searching...</p>
{:else}
<svg class="mx-auto w-8 h-8 text-opacity-40 dark:text-opacity-60" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
<p class="mt-6 text-lg dark:text-zinc-300">No matches found. Try something else.</p>
{/if}
</div>
{/if}
</ul>
<div class="px-3 py-2 w-full border-t border-zinc-900/5 dark:border-zinc-100/5 bg-white/5">
{#if combinedResults.length > 0 || calculatorResult}
<div class="flex justify-between items-center h-5 text-sm text-zinc-500 dark:text-zinc-400">
<div class="flex gap-4 items-center">
{#if !calculatorResult}
{#if selectedIndex >= 0 && selectedIndex < combinedResults.length}
{@const item = combinedResults[selectedIndex].item}
{#if 'keybind' in item && item.keybind}
{@render Shortcut({ text: 'Shortcut', keybind: [ ...(item?.keybindLabel ?? []) ] })}
{/if}
{/if}
{/if}
</div>
<div>
<div class="flex gap-4 items-center">
{@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})}
{#if calculatorResult && selectedIndex === 0}
{@render Shortcut({ text: 'Copy result', keybind: ['↵']})}
{:else}
{@render Shortcut({ text: 'Select', keybind: ['↵']})}
{/if}
</div>
{#if isIndexing}
<div class="inset-x-0 top-0">
<div class="absolute right-2 -bottom-4 text-[10px] text-zinc-500 dark:text-zinc-400">
Indexing
</div>
<div class="overflow-hidden h-0.5 bg-zinc-200 dark:bg-zinc-700">
<div
class="h-full bg-blue-500 transition-all duration-300 ease-out"
style="width: {(completedJobs / totalJobs) * 100}%"
></div>
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
{#snippet Shortcut({ text, keybind }: { text: string, keybind: string[] }) }
<div class="flex gap-2 items-center">
<div class="flex gap-1 items-center">
{#each keybind as key}
<kbd class="px-1 py-0.5 text-[0.8rem] text-center align-middle rounded min-w-6 bg-zinc-100 dark:bg-zinc-100/10">{key}</kbd>
{/each}
</div>
<span>{text}</span>
</div>
{/snippet}
<style>
:global(.highlight) {
background-color: rgba(200, 200, 200, 0.3);
font-weight: 500;
border-radius: 2px;
padding: 0 2px;
margin: 0 -2px;
}
.dark :global(.highlight) {
background-color: rgba(79, 79, 79, 0.2);
}
</style>
@@ -0,0 +1,193 @@
export const unitFullNames: Record<string, string> = {
// --- Length ---
m: "Meters",
km: "Kilometers",
cm: "Centimeters",
mm: "Millimeters",
µm: "Micrometers",
nm: "Nanometers",
pm: "Picometers",
fm: "Femtometers",
am: "Attometers",
zm: "Zeptometers",
ym: "Yoctometers",
mi: "Miles",
yd: "Yards",
ft: "Feet",
in: "Inches",
nmi: "Nautical Miles",
angstrom: "Angstroms",
au: "Astronomical Units",
ly: "Light Years",
pc: "Parsecs",
// --- Mass ---
kg: "Kilograms",
g: "Grams",
mg: "Milligrams",
µg: "Micrograms",
ng: "Nanograms",
lb: "Pounds",
oz: "Ounces",
ton: "Tons (Imperial)",
tonne: "Tonnes (Metric)",
slug: "Slugs",
stone: "Stones",
// --- Time ---
s: "Seconds",
ms: "Milliseconds",
µs: "Microseconds",
ns: "Nanoseconds",
ps: "Picoseconds",
min: "Minutes",
h: "Hours",
day: "Days",
week: "Weeks",
month: "Months (30 days)",
year: "Years (365 days)",
fortnight: "Fortnights",
// --- Temperature ---
K: "Kelvin",
degC: "Degrees Celsius",
degF: "Degrees Fahrenheit",
degR: "Degrees Rankine",
// --- Volume ---
"m³": "Cubic Meters",
"cm³": "Cubic Centimeters",
"mm³": "Cubic Millimeters",
l: "Liters",
ml: "Milliliters",
gal: "Gallons (US)",
qt: "Quarts (US)",
pt: "Pints (US)",
cup: "Cups (US)",
floz: "Fluid Ounces (US)",
tbsp: "Tablespoons (US)",
tsp: "Teaspoons (US)",
// --- Area ---
"m²": "Square Meters",
"km²": "Square Kilometers",
"cm²": "Square Centimeters",
"mm²": "Square Millimeters",
ha: "Hectares",
acre: "Acres",
"ft²": "Square Feet",
"in²": "Square Inches",
"mi²": "Square Miles",
// --- Speed ---
"m/s": "Meters per Second",
"km/h": "Kilometers per Hour",
mph: "Miles per Hour",
knot: "Knots",
// --- Acceleration ---
"m/s²": "Meters per Second Squared",
// --- Force ---
N: "Newtons",
lbf: "Pound-Force",
dyn: "Dynes",
// --- Energy ---
J: "Joules",
kJ: "Kilojoules",
cal: "Calories",
kcal: "Kilocalories",
Wh: "Watt Hours",
kWh: "Kilowatt Hours",
BTU: "British Thermal Units",
erg: "Ergs",
eV: "Electronvolts",
// --- Power ---
W: "Watts",
kW: "Kilowatts",
MW: "Megawatts",
GW: "Gigawatts",
hp: "Horsepower",
// --- Pressure ---
Pa: "Pascals",
kPa: "Kilopascals",
bar: "Bar",
atm: "Atmospheres",
psi: "Pounds per Square Inch",
torr: "Torr",
mmHg: "Millimeters of Mercury",
// --- Frequency ---
Hz: "Hertz",
kHz: "Kilohertz",
MHz: "Megahertz",
GHz: "Gigahertz",
THz: "Terahertz",
// --- Electric ---
V: "Volts",
A: "Amperes",
C: "Coulombs",
Ω: "Ohms",
F: "Farads",
S: "Siemens",
H: "Henries",
Wb: "Webers",
T: "Teslas",
lx: "Lux",
// --- Angle & Rotation ---
rad: "Radians",
deg: "Degrees",
grad: "Gradians",
cycle: "Cycles",
turn: "Turns",
rev: "Revolutions",
// --- Charge & Capacitance ---
e: "Elementary Charges",
// --- Magnetic & Light ---
lm: "Lumens",
ph: "Photons",
// --- Miscellaneous / Dimensionless ---
"%": "Percent",
ppm: "Parts per Million",
ppb: "Parts per Billion",
pptr: "Parts per Trillion",
dB: "Decibels",
bit: "Bits",
byte: "Bytes",
// --- Digital Storage ---
b: "Bits",
B: "Bytes",
kb: "Kilobits",
kB: "Kilobytes",
Mb: "Megabits",
MB: "Megabytes",
Gb: "Gigabits",
GB: "Gigabytes",
Tb: "Terabits",
TB: "Terabytes",
// --- Currency ---
USD: "US Dollars",
EUR: "Euros",
GBP: "British Pounds",
AUD: "Australian Dollars",
CAD: "Canadian Dollars",
CHF: "Swiss Francs",
JPY: "Japanese Yen",
CNY: "Chinese Yuan",
INR: "Indian Rupees",
NZD: "New Zealand Dollars",
SEK: "Swedish Krona",
NOK: "Norwegian Krone",
SGD: "Singapore Dollars",
HKD: "Hong Kong Dollars",
};
@@ -0,0 +1,85 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
export interface BaseCommandItem {
id: string;
text: string;
category: string;
icon: string;
action: () => void;
keywords?: string[];
priority?: number;
}
export interface StaticCommandItem extends BaseCommandItem {
keybind?: string[];
keybindLabel?: string[];
}
const staticCommands: StaticCommandItem[] = [
{
id: "home",
icon: "\ueb4c",
category: "navigation",
text: "Home",
keybind: ["alt+h"],
keybindLabel: ["Alt", "H"],
action: () => {
window.location.hash = "?page=/home";
loadHomePage();
},
priority: 4,
},
{
id: "messages",
icon: "\uebfd",
category: "navigation",
text: "Direct Messages",
keybind: ["alt+m"],
keybindLabel: ["Alt", "M"],
action: () => {
window.location.hash = "?page=/messages";
},
priority: 4,
},
{
id: "timetable",
icon: "\ue9cd",
category: "navigation",
text: "Timetable",
keybind: ["alt+t"],
keybindLabel: ["Alt", "T"],
action: () => {
window.location.hash = "?page=/timetable";
},
priority: 4,
},
{
id: "assessments",
icon: "\ueac3",
category: "navigation",
text: "Assessments",
keybind: ["alt+a"],
keybindLabel: ["Alt", "A"],
action: () => {
window.location.hash = "?page=/assessments";
},
priority: 4,
},
{
id: "toggle-dark-mode",
icon: "\uecfe",
category: "action",
text: "Toggle Dark Mode",
action: () => (settingsState.DarkMode = !settingsState.DarkMode),
priority: 2,
keywords: ["theme", "appearance"],
},
];
/**
* Returns the predefined list of static commands.
*/
export const getStaticCommands = (): StaticCommandItem[] => {
return [...staticCommands];
};
@@ -0,0 +1,89 @@
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
defineSettings,
Setting,
stringSetting,
} from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { runIndexing } from "../indexing/indexer";
import { initVectorSearch } from "../search/vector/vectorSearch";
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
const settings = defineSettings({
searchHotkey: stringSetting({
default: "ctrl+k",
title: "Search Hotkey",
description: "Keyboard shortcut to open the search (cmd on Mac)",
}),
showRecentFirst: booleanSetting({
default: true,
title: "Show Recent First",
description: "Sort dynamic content by most recent first",
}),
transparencyEffects: booleanSetting({
default: true,
title: "Transparency Effects",
description: "Enable transparency effects for the search bar",
}),
runIndexingOnLoad: booleanSetting({
default: true,
title: "Index on Page Load",
description: "Run content indexing when SEQTA loads",
}),
});
class GlobalSearchPlugin extends BasePlugin<typeof settings> {
@Setting(settings.searchHotkey)
searchHotkey!: string;
@Setting(settings.showRecentFirst)
showRecentFirst!: boolean;
@Setting(settings.transparencyEffects)
transparencyEffects!: boolean;
@Setting(settings.runIndexingOnLoad)
runIndexingOnLoad!: boolean;
}
const settingsInstance = new GlobalSearchPlugin();
const globalSearchPlugin: Plugin<typeof settings> = {
id: "global-search",
name: "Global Search",
description: "Quick search for everything in SEQTA",
version: "1.0.0",
settings: settingsInstance.settings,
disableToggle: true,
styles: styles,
run: async (api) => {
const appRef = { current: null };
initVectorSearch();
if (api.settings.runIndexingOnLoad) {
setTimeout(async () => {
await runIndexing();
}, 2000);
}
const title = document.querySelector("#title");
if (title) {
mountSearchBar(title, api, appRef);
} else {
await waitForElm("#title", true, 100, 60);
mountSearchBar(document.querySelector("#title") as Element, api, appRef);
}
return () => {
cleanupSearchBar(appRef);
};
},
};
export default globalSearchPlugin;
@@ -0,0 +1,56 @@
import renderSvelte from "@/interface/main";
import SearchBar from "../components/SearchBar.svelte";
import { unmount } from "svelte";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
export function mountSearchBar(
titleElement: Element,
api: any,
appRef: { current: any }
) {
if (titleElement.querySelector(".search-trigger")) {
return;
}
const searchButton = document.createElement("div");
searchButton.className = "search-trigger";
searchButton.innerHTML = /* html */ `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<p>Quick search...</p>
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">K</span>
`;
titleElement.appendChild(searchButton);
const searchRoot = document.createElement("div");
document.body.appendChild(searchRoot);
const searchRootShadow = searchRoot.attachShadow({ mode: "open" });
searchButton.addEventListener("click", () => {
// @ts-ignore - Intentionally adding to window
window.setCommandPalleteOpen(true);
});
try {
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
transparencyEffects: api.settings.transparencyEffects ? true : false,
showRecentFirst: api.settings.showRecentFirst,
});
} catch (error) {
console.error("Error rendering Svelte component:", error);
}
}
export function cleanupSearchBar(appRef: { current: any }) {
const searchButton = document.querySelector(".search-trigger");
const searchRoot = document.querySelector(".global-search-root");
if (searchButton) searchButton.remove();
if (searchRoot) searchRoot.remove();
// Clean up workers
VectorWorkerManager.getInstance().terminate();
unmount(appRef.current);
}
@@ -0,0 +1,58 @@
.search-trigger {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
margin-left: 10px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
margin-right: auto !important;
padding: 3px 12px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
user-select: none;
svg {
opacity: 0.8;
}
p {
font-size: 14px;
margin-left: 8px;
margin-right: 48px;
height: 100%;
margin-bottom: 0;
line-height: 32px;
font-weight: 400;
}
}
/* Light mode styles */
.search-trigger {
background-color: rgba(248, 250, 252, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
color: #555 !important;
p {
color: #555 !important;
}
svg {
color: #555;
}
}
.dark .search-trigger {
background-color: rgba(0, 0, 0, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
color: #aaa !important;
p {
color: #aaa !important;
}
svg {
color: #aaa;
}
}
@@ -0,0 +1,28 @@
import type { StaticCommandItem } from "./commands";
import type { HydratedIndexItem } from "../indexing/types";
export interface MatchIndices {
readonly 0: number;
readonly 1: number;
}
export interface FuseResultMatch {
key?: string;
value?: string;
indices: readonly MatchIndices[];
}
export interface CombinedResult {
id: string;
type: "command" | "dynamic";
score: number;
item: StaticCommandItem | HydratedIndexItem;
matches?: readonly FuseResultMatch[];
}
export interface FuseResult<T> {
item: T;
refIndex: number;
score?: number;
matches?: readonly FuseResultMatch[];
}
@@ -0,0 +1,40 @@
import type { IndexItem } from "./types";
interface MessageMetadata {
messageId: number;
author: string;
senderId: number;
senderType: string;
timestamp: string;
hasAttachments: boolean;
attachmentCount: number;
read: boolean;
}
interface AssessmentMetadata {
assessmentId?: number;
messageId?: number;
subject?: string;
term?: string;
programmeId?: number;
metaclassId?: number;
timestamp: string;
isMessageBased?: boolean;
author?: string;
}
type ActionHandler<T = any> = (item: IndexItem & { metadata: T }) => void;
export const actionMap: Record<string, ActionHandler<any>> = {
message: ((item: IndexItem & { metadata: MessageMetadata }) => {
window.location.hash = `#?page=/messages&id=${item.metadata.messageId}`;
}) as ActionHandler<any>,
assessment: ((item: IndexItem & { metadata: AssessmentMetadata }) => {
if (item.metadata.isMessageBased) {
window.location.hash = `#?page=/messages&id=${item.metadata.messageId}`;
} else {
window.location.hash = `#?page=/assessments&id=${item.metadata.assessmentId}`;
}
}) as ActionHandler<any>,
};
@@ -0,0 +1,202 @@
const DB_NAME = "betterseqta-index";
const META_STORE = "meta";
const VERSION_KEY = "betterseqta-index-version";
let dbPromise: Promise<IDBDatabase> | null = null;
// Get the current version from localStorage or start at 1
function getCurrentVersion(): number {
const storedVersion = localStorage.getItem(VERSION_KEY);
return storedVersion ? parseInt(storedVersion, 10) : 1;
}
// Update the version in localStorage
function updateVersion(version: number) {
localStorage.setItem(VERSION_KEY, version.toString());
}
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
const currentVersion = getCurrentVersion();
dbPromise = new Promise((resolve, reject) => {
let request: IDBOpenDBRequest;
try {
request = indexedDB.open(DB_NAME, currentVersion);
} catch (e) {
// If there's a version error, try to delete the database and start fresh
console.warn("Database version conflict, recreating database...");
indexedDB.deleteDatabase(DB_NAME);
localStorage.removeItem(VERSION_KEY);
request = indexedDB.open(DB_NAME, 1);
updateVersion(1);
}
request.onupgradeneeded = (event) => {
const db = request.result;
const existingStores = Array.from(db.objectStoreNames);
// Always ensure META_STORE exists
if (!existingStores.includes(META_STORE)) {
db.createObjectStore(META_STORE);
}
// Update version in localStorage to match the database
updateVersion(event.newVersion || 1);
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => {
console.error("Error opening database:", request.error);
// If there's an error, try to recover by deleting and recreating
indexedDB.deleteDatabase(DB_NAME);
localStorage.removeItem(VERSION_KEY);
reject(request.error);
};
});
return dbPromise;
}
async function getStore(store: string, mode: IDBTransactionMode = "readonly") {
const db = await openDB();
// Create store dynamically if needed
if (!db.objectStoreNames.contains(store)) {
db.close();
await upgradeDB(store);
return getStore(store, mode);
}
const tx = db.transaction(store, mode);
return tx.objectStore(store);
}
function upgradeDB(newStore: string): Promise<void> {
return new Promise((resolve, reject) => {
const currentVersion = getCurrentVersion();
const newVersion = currentVersion + 1;
// Close any existing connections
if (dbPromise) {
dbPromise.then((db) => db.close());
dbPromise = null;
}
const request = indexedDB.open(DB_NAME, newVersion);
request.onupgradeneeded = (event) => {
const db = request.result;
if (!db.objectStoreNames.contains(newStore)) {
db.createObjectStore(newStore);
}
// Update version in localStorage
updateVersion(event.newVersion || newVersion);
};
request.onsuccess = () => {
dbPromise = Promise.resolve(request.result);
resolve();
};
request.onerror = () => {
console.error("Error upgrading database:", request.error);
reject(request.error);
};
});
}
export async function getAll(store: string): Promise<any[]> {
try {
const s = await getStore(store);
return new Promise((resolve, reject) => {
const req = s.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in getAll for store ${store}:`, error);
return [];
}
}
export async function get(store: string, key: string): Promise<any> {
try {
const s = await getStore(store);
return new Promise((resolve, reject) => {
const req = s.get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in get for store ${store}, key ${key}:`, error);
return null;
}
}
export async function put(
store: string,
value: any,
key?: string,
): Promise<void> {
try {
const s = await getStore(store, "readwrite");
return new Promise((resolve, reject) => {
const req = key ? s.put(value, key) : s.put(value);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in put for store ${store}:`, error);
throw error;
}
}
export async function remove(store: string, key: string): Promise<void> {
try {
const s = await getStore(store, "readwrite");
return new Promise((resolve, reject) => {
const req = s.delete(key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in remove for store ${store}, key ${key}:`, error);
throw error;
}
}
export async function clear(store: string): Promise<void> {
try {
const s = await getStore(store, "readwrite");
return new Promise((resolve, reject) => {
const req = s.clear();
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in clear for store ${store}:`, error);
throw error;
}
}
// Helper function to reset the database if needed
export async function resetDatabase(): Promise<void> {
if (dbPromise) {
const db = await dbPromise;
db.close();
dbPromise = null;
}
return new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(DB_NAME);
req.onsuccess = () => {
localStorage.removeItem(VERSION_KEY);
resolve();
};
req.onerror = () => reject(req.error);
});
}
@@ -0,0 +1,284 @@
import { clear, getAll, put, remove } from "./db";
import { jobs } from "./jobs";
import { renderComponentMap } from "./renderComponents";
import type { HydratedIndexItem, IndexItem, Job, JobContext } from "./types";
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
const META_STORE = "meta";
const LOCK_KEY = "bsq-indexer-lock";
const HEARTBEAT_INTERVAL = 10000;
const LOCK_TIMEOUT = 20000;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
function shouldRun(job: Job, lastRun?: number): boolean {
const now = Date.now();
if (job.frequency === "pageLoad") return true;
if (!lastRun) return true;
if (job.frequency.type === "interval") {
return now - lastRun >= job.frequency.ms;
}
if (job.frequency.type === "expiry") {
return now - lastRun >= job.frequency.afterMs;
}
return false;
}
function getLastRunMeta(jobId: string): Promise<number | undefined> {
return getAll(META_STORE).then((metaItems) => {
const match = metaItems.find((m: any) => m.jobId === jobId);
return match?.lastRun;
});
}
async function updateLastRunMeta(jobId: string): Promise<void> {
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
}
function shouldIndex(): boolean {
const last = parseInt(localStorage.getItem(LOCK_KEY) || "0", 10);
return isNaN(last) || Date.now() - last > LOCK_TIMEOUT;
}
function startHeartbeat() {
localStorage.setItem(LOCK_KEY, `${Date.now()}`);
heartbeatTimer = setInterval(() => {
localStorage.setItem(LOCK_KEY, `${Date.now()}`);
}, HEARTBEAT_INTERVAL);
}
function stopHeartbeat() {
if (heartbeatTimer) clearInterval(heartbeatTimer);
localStorage.removeItem(LOCK_KEY);
}
function dispatchProgress(completed: number, total: number, indexing: boolean, status?: string, detail?: string) {
const event = new CustomEvent("indexing-progress", {
detail: { completed, total, indexing, status, detail },
});
window.dispatchEvent(event);
}
export async function loadAllStoredItems(): Promise<HydratedIndexItem[]> {
const all: HydratedIndexItem[] = [];
const jobIds = Object.keys(jobs);
for (const jobId of jobIds) {
try {
const items = await getAll(jobId) as IndexItem[];
const job = jobs[jobId];
const renderComponent = renderComponentMap[job.renderComponentId];
if (!renderComponent) {
console.warn(`Render component not found for job ${jobId} (ID: ${job.renderComponentId})`);
}
for (const item of items) {
// Ensure item has all required fields before pushing
if (item && item.id && item.text && item.category && item.actionId && job.renderComponentId) {
all.push({
...item,
renderComponent: renderComponent || undefined, // Assign undefined if not found
});
} else {
console.warn(`Skipping invalid item from job ${jobId}:`, item);
}
}
} catch (error) {
console.error(`Error loading items for job ${jobId}:`, error);
}
}
console.debug(`[Indexer] Loaded ${all.length} items from non-vector storage.`);
return all;
}
export async function runIndexing(): Promise<void> {
if (!shouldIndex()) {
console.debug(
"%c[Indexer] Skipping indexing (another tab has the lock)",
"color: gray",
);
return;
}
startHeartbeat();
console.debug("%c[Indexer] Starting indexing...", "color: green");
const jobIds = Object.keys(jobs);
let completedJobs = 0;
// Add an extra step for vectorization
const totalSteps = jobIds.length + 1;
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs");
const allItemsFromJobs: HydratedIndexItem[] = [];
// --- Step 1: Run Fetching/Storing Jobs (Main Thread) ---
for (const jobId of jobIds) {
dispatchProgress(completedJobs, totalSteps, true, `Running job: ${jobs[jobId].label}`);
const job = jobs[jobId];
const lastRun = await getLastRunMeta(jobId);
if (!shouldRun(job, lastRun)) {
console.debug(
`%c[Indexer] Skipping job "${jobId}" (not due)`,
"color: gray",
);
completedJobs++;
dispatchProgress(completedJobs, totalSteps, true, `Skipped job: ${job.label}`);
continue;
}
// These DB operations happen on the main thread (acceptable per request)
const getStoredItems = async () => await getAll(jobId);
const setStoredItems = async (items: IndexItem[]) => {
await clear(jobId);
// Add validation before putting
const validItems = items.filter(i => i && i.id);
if (validItems.length !== items.length) {
console.warn(`[Indexer Job ${jobId}] Filtered out ${items.length - validItems.length} invalid items before storing.`);
}
await Promise.all(validItems.map((i) => put(jobId, i, i.id)));
};
const addItem = async (item: IndexItem) => {
if (item && item.id) { // Add validation
await put(jobId, item, item.id);
} else {
console.warn(`[Indexer Job ${jobId}] Attempted to add invalid item:`, item);
}
};
const removeItem = async (id: string) => {
await remove(jobId, id);
};
const ctx: JobContext = {
getStoredItems,
setStoredItems,
addItem,
removeItem,
};
console.debug(`%c[Indexer] Running job "${jobId}"...`, "color: #4ea1ff");
try {
const newItemsRaw = await job.run(ctx);
const stored = await getStoredItems();
let merged = mergeItems(stored, newItemsRaw);
if (job.purge) merged = job.purge(merged);
await setStoredItems(merged); // Store merged non-vector data
await updateLastRunMeta(jobId);
// Hydrate items for vector processing
const renderComponent = renderComponentMap[job.renderComponentId];
if (!renderComponent) {
console.warn(`Render component not found for job ${jobId} (ID: ${job.renderComponentId}) during hydration`);
}
const hydratedItems = merged
.filter(item => item && item.id && item.text && item.category && item.actionId && job.renderComponentId) // Filter invalid before hydrating
.map((item) => ({
...item,
renderComponent: renderComponent || undefined, // Assign undefined if not found
}));
if (hydratedItems.length !== merged.length) {
console.warn(`[Indexer Job ${jobId}] Filtered out ${merged.length - hydratedItems.length} invalid items during hydration.`);
}
allItemsFromJobs.push(...hydratedItems);
console.debug(
`%c[Indexer] ✅ ${job.label}: ${newItemsRaw.length} new items fetched, ${merged.length} total stored (non-vector).`,
"color: #00c46f",
);
} catch (err) {
console.debug(`%c[Indexer] ❌ ${job.label} failed:`, "color: red");
console.error(err);
}
completedJobs++;
dispatchProgress(completedJobs, totalSteps, true, `Finished job: ${job.label}`);
}
// --- Step 2: Delegate Vectorization to Worker (Off Main Thread) ---
if (allItemsFromJobs.length > 0) {
console.debug(
`%c[Indexer] Sending ${allItemsFromJobs.length} items to worker for vectorization...`,
"color: #4ea1ff",
);
dispatchProgress(completedJobs, totalSteps, true, "Starting vectorization");
try {
const workerManager = VectorWorkerManager.getInstance();
// Pass a progress callback to the worker manager
await workerManager.processItems(allItemsFromJobs, (progress) => {
// Update overall progress based on worker feedback
let detailMessage = progress.message || '';
if (progress.status === 'processing' && progress.total && progress.processed !== undefined) {
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
// You could potentially update the 'completed' count more granularly here
// For simplicity, we'll just update the detail message
} else if (progress.status === 'complete') {
detailMessage = "Vectorization complete";
// Mark the vectorization step as complete
dispatchProgress(totalSteps, totalSteps, true, "Vectorization finished");
} else if (progress.status === 'error') {
detailMessage = `Vectorization error: ${progress.message}`;
dispatchProgress(completedJobs, totalSteps, true, "Vectorization failed", detailMessage); // Show error
} else if (progress.status === 'started') {
detailMessage = `Vectorization started for ${progress.total} items`;
} else if (progress.status === 'cancelled') {
detailMessage = `Vectorization cancelled: ${progress.message}`;
dispatchProgress(completedJobs, totalSteps, true, "Vectorization cancelled", detailMessage);
}
// Update the status detail
dispatchProgress(completedJobs, totalSteps, true, "Vectorization in progress", detailMessage);
// When worker signals completion of *its* task, mark the final step complete
if (progress.status === 'complete') {
completedJobs++; // Increment completion count *after* vectorization finishes
dispatchProgress(completedJobs, totalSteps, false, "Indexing finished"); // Set indexing to false
} else if (progress.status === 'error' || progress.status === 'cancelled') {
// Don't increment completed count on failure/cancel, just stop indexing indicator
dispatchProgress(completedJobs, totalSteps, false, "Indexing stopped due to error/cancel");
}
});
console.debug("%c[Indexer] Vectorization task sent to worker.", "color: green");
// Note: runIndexing might return *before* vectorization is complete now.
// The progress updates will signal the true end state.
} catch (error) {
console.error(`%c[Indexer] ❌ Failed to send items to vector worker:`, "color: red", error);
dispatchProgress(completedJobs, totalSteps, false, "Vectorization failed", String(error)); // Stop indexing indicator
}
} else {
console.debug("%c[Indexer] No items to send for vectorization.", "color: gray");
// If no vectorization needed, indexing is done here.
completedJobs++; // Count the "skipped" vectorization step
dispatchProgress(completedJobs, totalSteps, false, "Indexing finished (no vectorization needed)");
}
// Stop heartbeat ONLY when all jobs *and* the vectorization dispatch are done.
// The actual *completion* of vectorization is now asynchronous.
stopHeartbeat();
// Final progress update might be handled by the worker callback now.
// dispatchProgress(completedJobs, totalSteps, false); // This might be premature
}
function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] {
const map = new Map<string, IndexItem>();
// Prioritize incoming items if IDs clash
for (const item of existing) {
if (item && item.id) map.set(item.id, item);
}
for (const item of incoming) {
if (item && item.id) map.set(item.id, item);
}
return Array.from(map.values());
}
@@ -0,0 +1,351 @@
import type { Job } from "./types";
import type { IndexItem } from "./types";
interface MessageNotification {
notificationID: number;
type: "message";
message: {
subtitle: string;
messageID: number;
title: string;
};
timestamp: string;
}
interface AssessmentNotification {
notificationID: number;
type: "coneqtassessments";
coneqtAssessments: {
programmeID: number;
metaclassID: number;
subtitle: string;
term: string;
title: string;
assessmentID: number;
subjectCode: string;
};
timestamp: string;
}
type Notification = MessageNotification | AssessmentNotification;
interface MessageListResponse {
payload: {
hasMore: boolean;
messages: {
date: string;
attachments: boolean;
attachmentCount: number;
read: number;
sender: string;
sender_id: number;
sender_type: string;
subject: string;
id: number;
participants: Array<{
name: string;
photo: string;
type: string;
}>;
}[];
ts: string;
};
status: string;
}
interface MessageContentResponse {
payload: {
date: string;
blind: boolean;
read: boolean;
subject: string;
sender_type: string;
sender_id: number;
starred: boolean;
contents: string;
sender: string;
files: any[];
id: number;
participants: Array<{
read: number;
name: string;
photo: string;
id: number;
type: string;
}>;
};
status: string;
}
// Helper to strip HTML tags from text
function stripHtmlTags(html: string): string {
return html.replace(/<[^>]*>/g, "");
}
// Helper to fetch messages with pagination
async function fetchMessages(
offset: number = 0,
limit: number = 100,
): Promise<MessageListResponse> {
const response = await fetch(
`${location.origin}/seqta/student/load/message`,
{
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
searchValue: "",
sortBy: "date",
sortOrder: "desc",
action: "list",
label: "inbox",
offset,
limit,
datetimeUntil: null,
}),
},
);
return await response.json();
}
// Helper to fetch message content
async function fetchMessageContent(
messageId: number,
): Promise<MessageContentResponse> {
const response = await fetch(
`${location.origin}/seqta/student/load/message`,
{
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
action: "message",
id: messageId,
}),
},
);
return await response.json();
}
// Helper to fetch notifications
async function fetchNotifications(): Promise<Notification[]> {
const response = await fetch(`${location.origin}/seqta/student/heartbeat?`, {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
timestamp: "1970-01-01 00:00:00.0",
hash: "#?page=/notifications",
}),
});
const json = await response.json();
return json.notifications ?? [];
}
export const jobs: Record<string, Job> = {
messages: {
id: "messages",
label: "Messages",
renderComponentId: "message",
frequency: { type: "expiry", afterMs: 1000 * 60 * 5 }, // every 5 minutes
run: async (ctx) => {
// Get existing items first
const existing = await ctx.getStoredItems();
const existingIds = new Set(existing.map((i) => i.id));
const newItems: IndexItem[] = [];
let offset = 0;
const limit = 100;
let hasMore = true;
let consecutiveExisting = 0;
// Fetch all messages with pagination
while (hasMore) {
try {
const response = await fetchMessages(offset, limit);
if (response.status !== "200") {
console.error("Failed to fetch messages:", response);
break;
}
const messages = response.payload.messages;
hasMore = response.payload.hasMore;
// Process each message
for (const message of messages) {
const id = message.id.toString();
// Skip if we already have this message
if (existingIds.has(id)) {
consecutiveExisting++;
// If we've found 20 consecutive existing messages, assume we've caught up
if (consecutiveExisting >= 20) {
console.debug(
"[Messages Job] Found 20 consecutive existing messages, stopping fetch",
);
hasMore = false;
break;
}
continue;
}
// Reset consecutive counter when we find a new message
consecutiveExisting = 0;
try {
// Fetch message content
const contentResponse = await fetchMessageContent(message.id);
if (contentResponse.status !== "200") {
console.error(
"Failed to fetch message content:",
contentResponse,
);
continue;
}
const content = stripHtmlTags(contentResponse.payload.contents);
newItems.push({
id,
text: message.subject,
category: "messages",
content: `From: ${message.sender}\n\n${content}`,
dateAdded: new Date(message.date).getTime(),
metadata: {
messageId: message.id,
author: message.sender,
senderId: message.sender_id,
senderType: message.sender_type,
timestamp: message.date,
hasAttachments: message.attachments,
attachmentCount: message.attachmentCount,
read: message.read === 1,
},
actionId: "message",
renderComponentId: "message",
});
// Add to existingIds as we process to prevent duplicates in the same run
existingIds.add(id);
} catch (error) {
console.error("Error fetching message content:", error);
continue;
}
}
offset += limit;
} catch (error) {
console.error("Error fetching messages:", error);
break;
}
// Small delay to avoid overwhelming the server
await new Promise((resolve) => setTimeout(resolve, 100));
}
console.debug(`[Messages Job] Found ${newItems.length} new messages`);
return newItems;
},
purge: (items) => {
// Keep messages from the last 30 days
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
return items.filter((i) => i.dateAdded >= cutoff);
},
},
assessments: {
id: "assessments",
label: "Assessments",
renderComponentId: "assessment",
frequency: { type: "expiry", afterMs: 1000 * 60 * 15 }, // every 15 minutes
run: async (ctx) => {
const notifications = await fetchNotifications();
const assessmentNotifications = notifications.filter(
(n): n is MessageNotification | AssessmentNotification =>
n.type === "coneqtassessments" ||
(n.type === "message" &&
n.message.title.toLowerCase().includes("assessment")),
);
const existing = await ctx.getStoredItems();
const existingIds = new Set(existing.map((i) => i.id));
const newItems: IndexItem[] = [];
for (const notification of assessmentNotifications) {
const id = notification.notificationID.toString();
if (existingIds.has(id)) continue;
if (notification.type === "coneqtassessments") {
const { coneqtAssessments: assessment } = notification;
newItems.push({
id,
text: assessment.title,
category: "assessments",
content: assessment.subtitle,
dateAdded: new Date(notification.timestamp).getTime(),
metadata: {
assessmentId: assessment.assessmentID,
subject: assessment.subjectCode,
term: assessment.term,
programmeId: assessment.programmeID,
metaclassId: assessment.metaclassID,
timestamp: notification.timestamp,
},
actionId: "assessment",
renderComponentId: "assessment",
});
} else {
// Handle message-based assessments
const { message } = notification;
newItems.push({
id,
text: message.title,
category: "assessments",
content: `From: ${message.subtitle}`,
dateAdded: new Date(notification.timestamp).getTime(),
metadata: {
messageId: message.messageID,
author: message.subtitle,
timestamp: notification.timestamp,
isMessageBased: true,
},
actionId: "assessment",
renderComponentId: "assessment",
});
}
}
return newItems;
},
purge: (items) => {
// Keep assessments from the current year
const date = new Date();
date.setMonth(0); // January
date.setDate(1);
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
const cutoff = date.getTime();
return items.filter((i) => i.dateAdded >= cutoff);
},
},
// We can add more job types here as needed:
// - notices
// - timetable changes
// - homework
// etc.
};
@@ -0,0 +1,10 @@
import type { SvelteComponent } from "svelte";
import AssessmentComponent from "../components/AssessmentItem.svelte";
// import other components as needed
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
assessment: AssessmentComponent as unknown as typeof SvelteComponent,
// messages: MessageComponent,
// subject: SubjectComponent,
// etc...
};
@@ -0,0 +1,37 @@
import type { SvelteComponent } from "svelte";
export interface IndexItem {
id: string;
text: string;
category: string;
content: string;
dateAdded: number;
metadata: Record<string, any>;
actionId: string;
renderComponentId: string;
}
export interface HydratedIndexItem extends IndexItem {
renderComponent: typeof SvelteComponent;
}
export type Frequency =
| "pageLoad"
| { type: "interval"; ms: number }
| { type: "expiry"; afterMs: number };
export interface JobContext {
getStoredItems: () => Promise<IndexItem[]>;
setStoredItems: (items: IndexItem[]) => Promise<void>;
addItem: (item: IndexItem) => Promise<void>;
removeItem: (id: string) => Promise<void>;
}
export interface Job {
id: string;
label: string;
frequency: Frequency;
renderComponentId: string;
run: (ctx: JobContext) => Promise<IndexItem[]>;
purge?: (items: IndexItem[]) => IndexItem[];
}
@@ -0,0 +1,419 @@
import {
EmbeddingIndex,
getEmbedding,
initializeModel,
} from "client-vector-search";
import type { HydratedIndexItem } from "../types";
let vectorIndex: EmbeddingIndex | null = null;
let isInitialized = false;
let currentAbortController: AbortController | null = null;
async function initWorker() {
if (isInitialized) {
console.debug("Vector worker already initialized.");
return;
}
console.debug("Initializing vector worker...");
try {
await initializeModel();
vectorIndex = new EmbeddingIndex([]);
const stored = await vectorIndex.getAllObjectsFromIndexedDB();
if (stored.length > 0) {
stored.forEach((item) => vectorIndex!.add(item));
console.debug(
`Vector index loaded ${stored.length} items from IndexedDB.`,
);
} else {
console.debug("No existing vector index found in IndexedDB.");
}
isInitialized = true;
console.debug("Vector worker initialized successfully.");
} catch (e) {
console.error("Failed to initialize vector worker:", e);
// Set as initialized even on error to prevent retries, but index will be null
isInitialized = true;
vectorIndex = null; // Ensure index is null on error
}
}
async function vectorizeItem(
item: HydratedIndexItem,
): Promise<(HydratedIndexItem & { embedding: number[] }) | null> {
// Simplified for brevity - assumes embedding function doesn't need cancellation signal
try {
const textToEmbed = [
item.text,
item.content,
item.category,
item.metadata?.author,
item.metadata?.subject,
]
.filter(Boolean)
.join(" ");
const embedding = await getEmbedding(textToEmbed);
return { ...item, embedding };
} catch (error) {
console.error(`Error vectorizing item ${item.id}:`, error);
return null; // Return null if vectorization fails for an item
}
}
async function processItems(items: HydratedIndexItem[], signal: AbortSignal) {
console.debug("Worker received process request.");
if (!vectorIndex) {
console.warn(
"Processing requested but vector index not ready. Attempting init.",
);
await initWorker(); // Attempt initialization if not ready
if (!vectorIndex) {
// Check again after attempt
self.postMessage({
type: "progress",
data: {
status: "error",
message:
"Vector index not available for processing after init attempt.",
},
});
return;
}
}
// Find items we haven't processed yet by checking against the index instance
const unprocessedItems = items.filter((item) => {
if (signal.aborted) return false; // Check cancellation during filtering
try {
// Check if the item ID already exists in the index (loaded or added)
return !vectorIndex!.get({ id: item.id });
} catch (e) {
// If get throws (e.g., item not found), it means it's unprocessed
return true;
}
});
if (signal.aborted) {
console.debug("Processing cancelled before starting.");
self.postMessage({
type: "progress",
data: {
status: "cancelled",
message: "Processing cancelled before start",
},
});
return;
}
if (unprocessedItems.length === 0) {
console.debug("No new items to process.");
self.postMessage({
type: "progress",
data: { status: "complete", message: "No new items to process" },
});
return;
}
console.debug(`Starting processing of ${unprocessedItems.length} items.`);
self.postMessage({
type: "progress",
data: {
status: "started",
total: unprocessedItems.length,
processed: 0,
},
});
const BATCH_SIZE = 5;
let processedCount = 0;
for (let i = 0; i < unprocessedItems.length; i += BATCH_SIZE) {
if (signal.aborted) {
console.debug("Processing cancelled during batching.");
self.postMessage({
type: "progress",
data: {
status: "cancelled",
message: "Processing cancelled during batching",
},
});
return;
}
const batch = unprocessedItems.slice(i, i + BATCH_SIZE);
// Vectorize batch
const vectorizationResults = await Promise.all(batch.map(vectorizeItem));
const successfullyVectorized = vectorizationResults.filter(
(result) => result !== null,
) as (HydratedIndexItem & { embedding: number[] })[];
if (signal.aborted) {
console.debug("Processing cancelled after vectorization batch.");
self.postMessage({
type: "progress",
data: {
status: "cancelled",
message: "Processing cancelled after vectorization",
},
});
return;
}
// Add successfully vectorized items to index
if (successfullyVectorized.length > 0) {
try {
successfullyVectorized.forEach((item) => vectorIndex!.add(item));
} catch (e) {
console.error("Error adding batch to index:", e);
self.postMessage({
type: "progress",
data: { status: "error", message: `Error adding to index: ${e}` },
});
// Decide whether to continue or stop on error
// return; // Example: Stop processing if adding fails
}
}
if (signal.aborted) {
console.debug("Processing cancelled before saving batch.");
self.postMessage({
type: "progress",
data: {
status: "cancelled",
message: "Processing cancelled before saving",
},
});
return;
}
// Save index after processing the batch
try {
await vectorIndex!.saveIndex("indexedDB");
console.debug(`Saved index after processing batch ${i / BATCH_SIZE + 1}`);
} catch (e) {
console.error("Error saving index batch:", e);
self.postMessage({
type: "progress",
data: { status: "error", message: `Error saving index batch: ${e}` },
});
// Continue processing next batch even if saving failed? Or stop?
// return; // Example: Stop if saving fails
}
processedCount = Math.min(i + BATCH_SIZE, unprocessedItems.length);
// Report progress
self.postMessage({
type: "progress",
data: {
status: "processing",
total: unprocessedItems.length,
processed: processedCount,
},
});
// Yield control briefly to allow other messages (like cancellation) to be processed
await new Promise((resolve) => setTimeout(resolve, 0));
}
if (!signal.aborted) {
console.debug("Processing completed successfully.");
self.postMessage({
type: "progress",
data: { status: "complete", message: "All items processed successfully" },
});
} else {
console.debug("Processing completed, but was cancelled.");
// No need to send 'cancelled' again if already sent during batching
// self.postMessage({ type: 'progress', data: { status: 'cancelled', message: 'Processing finished but was cancelled' }});
}
}
async function search(
query: string,
topK: number,
signal: AbortSignal,
messageId: string,
) {
console.debug(
`Worker received search request (ID: ${messageId}): "${query}"`,
);
if (!vectorIndex) {
console.warn(
`Search (ID: ${messageId}) requested but vector index not ready. Attempting init.`,
);
await initWorker(); // Attempt initialization
// Re-check after waiting/init attempt
if (!vectorIndex) {
console.error(
`Search (ID: ${messageId}) failed: Vector index unavailable after init attempt.`,
);
self.postMessage({
type: "searchError",
data: { messageId, error: "Vector index not available." },
});
return;
}
console.debug(
`Vector index ready after init for search (ID: ${messageId}).`,
);
}
if (signal.aborted) {
console.debug(`Search (ID: ${messageId}) cancelled before starting.`);
self.postMessage({ type: "searchCancelled", data: { messageId } });
return;
}
try {
console.debug(`Getting embedding for query (ID: ${messageId})...`);
const queryEmbedding = await getEmbedding(query);
if (signal.aborted) {
console.debug(`Search (ID: ${messageId}) cancelled after embedding.`);
self.postMessage({ type: "searchCancelled", data: { messageId } });
return;
}
console.debug(`Performing vector search (ID: ${messageId})...`);
// Await the search and let TypeScript infer the type
const results = await vectorIndex!.search(queryEmbedding, {
topK,
useStorage: "indexedDB", // Ensure we search the stored index
});
console.debug(
`Vector search (ID: ${messageId}) completed with ${results.length} results.`,
);
if (signal.aborted) {
console.debug(
`Search (ID: ${messageId}) cancelled after search completed, discarding results.`,
);
self.postMessage({ type: "searchCancelled", data: { messageId } });
return;
}
// Post results back to the main thread
self.postMessage({ type: "searchResults", data: { messageId, results } });
} catch (error) {
console.error(`Vector search error in worker (ID: ${messageId}):`, error);
// Ensure signal isn't checked *after* an error occurred before posting error message
if (!signal.aborted) {
// Only post error if not cancelled
self.postMessage({
type: "searchError",
data: {
messageId,
error: error instanceof Error ? error.message : String(error),
},
});
} else {
console.debug(
`Search (ID: ${messageId}) encountered error but was cancelled, suppressing error message.`,
);
self.postMessage({ type: "searchCancelled", data: { messageId } }); // Still notify of cancellation
}
}
}
// Handle messages from the main thread
self.addEventListener("message", async (e) => {
// Make sure data and type exist
if (!e.data || !e.data.type) {
console.warn("Worker received message with no data or type.");
return;
}
const { type, data, messageId } = e.data; // messageId used for requests needing response/cancellation tracking
// Cancel previous long-running operation (process or search) if a new one starts
if (type === "process" || type === "search") {
if (currentAbortController) {
console.debug(
`Worker cancelling previous operation due to new '${type}' request.`,
);
currentAbortController.abort(`New '${type}' operation requested`);
}
currentAbortController = new AbortController();
console.debug(`Worker starting new '${type}' operation.`);
}
// Use the signal from the *current* controller for the task being started
const signal = currentAbortController?.signal;
switch (type) {
case "process":
if (signal && data?.items) {
await processItems(data.items, signal);
} else if (!signal) {
console.error(
"Process message received but no abort signal available.",
);
} else if (!data?.items) {
console.error("Process message received without 'items' data.");
self.postMessage({
type: "progress",
data: {
status: "error",
message: "Process command received without items.",
},
});
}
break;
case "search":
if (signal && messageId && typeof data?.query === "string") {
await search(data.query, data.topK ?? 10, signal, messageId);
} else {
const errorReason = !signal
? "Missing signal"
: !messageId
? "Missing messageId"
: "Missing or invalid query";
console.error(`Search message received invalid: ${errorReason}.`, {
data,
messageId,
signalExists: !!signal,
});
// Send an error back if messageId exists
if (messageId) {
self.postMessage({
type: "searchError",
data: { messageId, error: `Worker internal error: ${errorReason}` },
});
}
}
break;
case "init":
// Init should not be cancellable in the same way, it's foundational
// Check if already initialized before potentially running it again
if (!isInitialized) {
await initWorker();
self.postMessage({ type: "ready" }); // Signal ready *after* init attempt
} else {
console.debug("Received init message, but worker already initialized.");
self.postMessage({ type: "ready" }); // Signal ready anyway
}
break;
// No explicit 'cancel' case needed as new tasks auto-cancel previous ones
default:
console.warn("Unknown message type received by vector worker:", type);
}
});
// Initial check or trigger for initialization when the worker starts
initWorker()
.then(() => {
self.postMessage({ type: "ready" });
})
.catch((err) => {
console.error("Initial worker initialization failed:", err);
// Still need to signal readiness, perhaps with an error state?
// Or rely on the first 'process' or 'search' to retry init.
// For now, just signal ready, but the index might be null.
self.postMessage({ type: "ready" });
});
@@ -0,0 +1,221 @@
import type { HydratedIndexItem } from '../types';
import vectorWorker from './vectorWorker.ts?inlineWorker';
import type { SearchResult } from 'client-vector-search';
export type ProgressCallback = (data: {
status: 'started' | 'processing' | 'complete' | 'error' | 'cancelled';
total?: number;
processed?: number;
message?: string;
}) => void;
export class VectorWorkerManager {
private static instance: VectorWorkerManager;
private worker: Worker | null = null;
private isInitialized = false;
private readyPromise: Promise<void> | null = null; // To await initialization
private progressCallback: ProgressCallback | null = null;
private searchPromises = new Map<string, { resolve: (value: SearchResult[]) => void, reject: (reason?: any) => void, timer: NodeJS.Timeout }>();
private debounceTimer: NodeJS.Timeout | null = null;
private lastSearchParams: { query: string; topK: number; resolve: (results: SearchResult[]) => void, reject: (reason?: any) => void } | null = null;
private constructor() {
// Start initialization immediately, but allow awaiting it
this.readyPromise = this.initWorker();
}
static getInstance(): VectorWorkerManager {
if (!VectorWorkerManager.instance) {
VectorWorkerManager.instance = new VectorWorkerManager();
}
return VectorWorkerManager.instance;
}
private async initWorker(): Promise<void> {
// If already initialized or initializing, return the existing promise
if (this.isInitialized) return Promise.resolve();
if (this.readyPromise) return this.readyPromise;
return new Promise<void>((resolve, reject) => {
// Create the worker
this.worker = vectorWorker();
const timeout = setTimeout(() => {
console.error('Vector worker initialization timed out');
this.worker?.terminate(); // Clean up worker if it exists
this.worker = null;
this.isInitialized = false; // Ensure state reflects failure
this.readyPromise = null; // Allow retrying init later
reject(new Error('Worker initialization timed out'));
}, 10000); // Increased timeout
// Set up message handling
this.worker!.addEventListener('message', (e) => {
const { type, data } = e.data;
console.debug("Message from vector worker:", type, data);
switch (type) {
case 'ready':
this.isInitialized = true;
clearTimeout(timeout);
console.debug('Vector worker initialized and ready.');
resolve(); // Resolve the init promise
break;
case 'progress':
if (this.progressCallback) {
this.progressCallback(data);
}
break;
case 'searchResults':
const searchInfo = this.searchPromises.get(data.messageId);
if (searchInfo) {
clearTimeout(searchInfo.timer); // Clear timeout on success
searchInfo.resolve(data.results);
this.searchPromises.delete(data.messageId);
} else {
console.warn('Received search results for unknown messageId:', data.messageId);
}
break;
case 'searchError':
const errorInfo = this.searchPromises.get(data.messageId);
if (errorInfo) {
clearTimeout(errorInfo.timer); // Clear timeout on error
errorInfo.reject(new Error(data.error));
this.searchPromises.delete(data.messageId);
} else {
console.warn('Received search error for unknown messageId:', data.messageId);
}
break;
case 'searchCancelled':
const cancelledInfo = this.searchPromises.get(data.messageId);
if (cancelledInfo) {
clearTimeout(cancelledInfo.timer); // Clear timeout on cancel
// Reject with a specific cancellation error or resolve with empty? Let's reject.
cancelledInfo.reject(new Error('Search cancelled by worker'));
this.searchPromises.delete(data.messageId);
} else {
console.debug('Received cancellation for unknown messageId:', data.messageId);
}
break;
default:
console.warn('Unknown message from worker:', type, data);
}
});
// Initialize the worker
this.worker!.postMessage({ type: 'init' });
});
}
// Ensures worker is ready before proceeding
private async ensureReady() {
if (!this.readyPromise) {
// If init wasn't called or failed, try again
console.warn("Worker not initialized, attempting init...");
this.readyPromise = this.initWorker();
}
await this.readyPromise;
if (!this.isInitialized || !this.worker) {
throw new Error("Vector Worker is not available after initialization attempt.");
}
}
async processItems(items: HydratedIndexItem[], onProgress?: ProgressCallback) {
await this.ensureReady(); // Wait for worker to be ready
this.progressCallback = onProgress || null;
// Cancel any ongoing search when starting processing
this.cancelAllSearches("Processing started");
console.debug(`Sending ${items.length} items to worker for processing.`);
this.worker!.postMessage({
type: 'process',
data: { items }
});
}
// Public search method
public async search(query: string, topK: number = 10): Promise<SearchResult[]> {
await this.ensureReady();
return new Promise((resolve, reject) => {
this.lastSearchParams = { query, topK, resolve, reject };
const messageId = crypto.randomUUID();
if (this.lastSearchParams && this.worker) {
const currentParams = this.lastSearchParams; // Capture current params
this.lastSearchParams = null; // Clear last params *before* posting
this.debounceTimer = null;
// Set a timeout for the search operation itself
const searchTimeout = 10000; // e.g., 10 seconds
const searchTimer = setTimeout(() => {
if (this.searchPromises.has(messageId)) {
console.error(`Search timed out for messageId: ${messageId}`);
currentParams.reject(new Error(`Search timed out after ${searchTimeout}ms`));
this.searchPromises.delete(messageId);
}
}, searchTimeout);
this.searchPromises.set(messageId, { resolve: currentParams.resolve, reject: currentParams.reject, timer: searchTimer });
console.debug(`Sending search request (ID: ${messageId}) to worker: "${currentParams.query}"`);
this.worker.postMessage({
type: "search",
data: { query: currentParams.query, topK: currentParams.topK },
messageId
});
} else if (this.lastSearchParams) {
// This case might happen if ensureReady failed but didn't throw
console.error("Worker unavailable when trying to send search request.");
this.lastSearchParams.reject(new Error("Worker unavailable for search"));
this.lastSearchParams = null;
this.debounceTimer = null;
}
});
}
// Method to cancel all pending/debounced searches
private cancelAllSearches(reason: string = "Cancelled") {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
if (this.lastSearchParams) {
this.lastSearchParams.reject(new Error(`Search cancelled: ${reason}`));
this.lastSearchParams = null;
}
}
// We might also want to tell the worker to cancel its *current* search
// if it supports it, but this requires worker modification.
// For now, just reject pending promises in the manager.
for (const [messageId, promiseInfo] of this.searchPromises.entries()) {
clearTimeout(promiseInfo.timer);
promiseInfo.reject(new Error(`Search cancelled: ${reason}`));
this.searchPromises.delete(messageId);
}
}
terminate() {
console.debug("Terminating Vector Worker Manager...");
this.cancelAllSearches("Worker terminated"); // Cancel pending searches
if (this.worker) {
this.worker.terminate();
this.worker = null;
}
this.isInitialized = false;
this.readyPromise = null; // Reset init promise
this.progressCallback = null;
// Clear the static instance? Or assume app lifecycle handles this?
// VectorWorkerManager.instance = null; // Uncomment if needed
}
}
@@ -0,0 +1,215 @@
import Fuse, { type FuseResult } from "fuse.js";
import { getStaticCommands, type StaticCommandItem } from "../core/commands";
import { getDynamicItems } from "../utils/dynamicItems";
import type { CombinedResult } from "../core/types";
import type { HydratedIndexItem } from "../indexing/types";
import { searchVectors } from "./vector/vectorSearch";
import type { VectorSearchResult } from "./vector/vectorTypes";
export function createSearchIndexes() {
const commands = getStaticCommands();
const dynamicItems = getDynamicItems();
const commandOptions = {
keys: ["text", "category", "keywords"],
includeScore: true,
includeMatches: true,
threshold: 0.6,
minMatchCharLength: 1,
ignoreLocation: true,
useExtendedSearch: false,
};
const dynamicOptions = {
keys: [
"text",
"content",
"category",
"metadata.author",
"metadata.subject",
],
includeScore: true,
includeMatches: true,
threshold: 0.6,
minMatchCharLength: 3,
distance: 50,
useExtendedSearch: false,
};
return {
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
dynamicContentFuse: new Fuse(
dynamicItems,
dynamicOptions,
) as Fuse<HydratedIndexItem>,
commands,
dynamicItems,
};
}
export function searchCommands(
commandsFuse: Fuse<StaticCommandItem>,
query: string,
commandIdToItemMap: Map<string, StaticCommandItem>,
limit = 10,
): CombinedResult[] {
if (!commandsFuse) return [];
if (!query.trim()) {
return Array.from(commandIdToItemMap.values())
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) // Sort by priority when no query
.slice(0, limit) // Limit results even when no query
.map((item) => ({
id: item.id,
type: "command" as const,
score: 100 + (item.priority ?? 0),
item,
}));
}
const searchResults = commandsFuse.search(query, { limit });
return searchResults.map((result: FuseResult<StaticCommandItem>) => {
const item = result.item;
const fuseScore = 15 * (1 - (result.score || 0.5));
const score = fuseScore + (item.priority ?? 0);
return {
id: item.id,
type: "command" as const,
score,
item,
matches: result.matches,
};
});
}
export function searchDynamicItems(
dynamicContentFuse: Fuse<HydratedIndexItem>,
query: string,
dynamicIdToItemMap: Map<string, HydratedIndexItem>,
limit = 10,
sortByRecent: boolean = true, // Added option to control sorting
): CombinedResult[] {
if (!dynamicContentFuse) return [];
if (!query.trim()) {
let items = Array.from(dynamicIdToItemMap.values());
if (sortByRecent) {
items = items.sort((a, b) => b.dateAdded - a.dateAdded);
}
return items.slice(0, limit).map((item) => ({
id: item.id,
type: "dynamic" as const,
score: 80, // Assign a default score for non-searched items
item,
}));
}
const now = Date.now();
const searchResults = dynamicContentFuse.search(query, { limit });
return searchResults.map((result: FuseResult<HydratedIndexItem>) => {
const item = result.item;
const fuseScore = 10 * (1 - (result.score || 0.5));
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0; // Apply boost only if sorting by recent
const score = fuseScore + recencyBoost;
return {
id: item.id,
type: "dynamic" as const,
score,
item,
matches: result.matches,
};
});
}
export async function performSearch(
query: string,
commandsFuse: Fuse<StaticCommandItem>,
dynamicContentFuse: Fuse<HydratedIndexItem>,
commandIdToItemMap: Map<string, StaticCommandItem>,
dynamicIdToItemMap: Map<string, HydratedIndexItem>,
showRecentFirst: boolean,
): Promise<CombinedResult[]> {
const startTime = performance.now();
// Get all results first
const commandResults = searchCommands(
commandsFuse,
query,
commandIdToItemMap,
);
const commandEndTime = performance.now();
const dynamicResults = searchDynamicItems(
dynamicContentFuse,
query,
dynamicIdToItemMap,
10,
showRecentFirst,
);
const fuseEndTime = performance.now();
// Get vector results in parallel
let vectorResults: VectorSearchResult[] = [];
try {
vectorResults = await searchVectors(query, 10);
} catch (e) {}
const vectorEndTime = performance.now();
console.log("Vector results:", vectorResults);
// Log timings
console.log(`Command search took ${commandEndTime - startTime} milliseconds`);
console.log(
`Dynamic search took ${fuseEndTime - commandEndTime} milliseconds`,
);
console.log(`Vector search took ${vectorEndTime - fuseEndTime} milliseconds`);
// Create a map to store our final results, using ID as key to avoid duplicates
const resultMap = new Map<string, CombinedResult>();
// Add command results first (they keep their original scores)
commandResults.forEach((r) => resultMap.set(r.id, r));
// Process dynamic results and vector results together
const seenIds = new Set<string>();
// Add dynamic results first
dynamicResults.forEach((r) => {
seenIds.add(r.id);
const vectorMatch = vectorResults.find((v) => v.object.id === r.id);
if (vectorMatch) {
// If we found it in both searches, combine the scores
resultMap.set(r.id, {
...r,
score: r.score + vectorMatch.similarity * 0.6, // Boost exact matches
});
} else {
// If only in Fuse results, keep as is
resultMap.set(r.id, r);
}
});
// Now add any vector results we haven't seen yet
vectorResults.forEach((v) => {
const id = v.object.id;
if (!seenIds.has(id)) {
// This is a semantic match that Fuse missed - add it with the vector similarity as score
resultMap.set(id, {
id,
type: "dynamic" as const,
score: v.similarity * 0.9, // High base score for semantic matches
item: v.object,
});
}
});
// Convert to array and sort by score
const results = Array.from(resultMap.values());
results.sort((a, b) => b.score - a.score);
return results;
}
@@ -0,0 +1,33 @@
import { EmbeddingIndex, getEmbedding, initializeModel } from 'client-vector-search';
import type { HydratedIndexItem } from '../../indexing/types';
import type { SearchResult } from 'client-vector-search';
let vectorIndex: EmbeddingIndex | null = null;
export async function initVectorSearch() {
try {
await initializeModel();
vectorIndex = new EmbeddingIndex([]);
vectorIndex.preloadIndexedDB();
} catch (e) {
console.error('Error initializing vector search', e);
}
}
export interface VectorSearchResult extends SearchResult {
object: HydratedIndexItem & { embedding: number[] };
}
export async function searchVectors(query: string, topK: number = 10): Promise<VectorSearchResult[]> {
if (!vectorIndex) await initVectorSearch();
const queryEmbedding = await getEmbedding(query.slice(0, 100));
const results = await vectorIndex!.search(queryEmbedding, {
topK,
useStorage: 'indexedDB',
dedupeEntries: true
});
return results as VectorSearchResult[];
}
@@ -0,0 +1,7 @@
import type { SearchResult } from "client-vector-search";
import type { HydratedIndexItem } from "../../indexing/types";
export interface VectorSearchResult extends SearchResult {
object: HydratedIndexItem & { embedding: number[] };
}
@@ -0,0 +1,30 @@
import type { SvelteComponent } from "svelte";
import type { HydratedIndexItem } from "./indexing/types";
export interface DynamicContentItem {
id: string;
text: string;
category: string;
content: string;
dateAdded: number;
metadata: Record<string, any>;
actionId: string;
renderComponentId: string;
renderComponent?: typeof SvelteComponent;
}
let dynamicItems: HydratedIndexItem[] = [];
/**
* Loads a new set of dynamic items.
*/
export function loadDynamicItems(items: HydratedIndexItem[]) {
dynamicItems = items;
}
/**
* Returns all currently loaded dynamic items.
*/
export function getDynamicItems(): HydratedIndexItem[] {
return dynamicItems;
}
@@ -0,0 +1,274 @@
import type { FuseResultMatch, MatchIndices } from "./core/types";
/**
* Simple utility to remove HTML tags from a string.
*/
export function stripHtmlTags(html: string): string {
if (!html) return "";
return html.replace(/<[^>]*>/g, "").replace("\n", " ");
}
/**
* Removes HTML tags from a string, but preserves <span class="highlight"> tags.
*/
export function stripHtmlButKeepHighlights(html: string): string {
if (!html) return "";
// Use a placeholder for highlight tags, strip others, then restore placeholders.
const highlightOpenPlaceholder = "__HIGHLIGHT_OPEN__";
const highlightClosePlaceholder = "__HIGHLIGHT_CLOSE__";
let processed = html.replace(
/<span class="highlight">/g,
highlightOpenPlaceholder,
);
processed = processed.replace(/<\/span>/g, (match, offset, fullString) => {
// Only replace </span> if it likely corresponds to our highlight span
// This is imperfect but helps avoid replacing unrelated spans.
// Look backwards for the nearest opening placeholder.
const lastPlaceholder = fullString.lastIndexOf(
highlightOpenPlaceholder,
offset,
);
if (lastPlaceholder !== -1) {
// Check if there's another opening tag between the placeholder and the closing span
const interveningContent = fullString.substring(
lastPlaceholder + highlightOpenPlaceholder.length,
offset,
);
if (!/<span/i.test(interveningContent)) {
return highlightClosePlaceholder;
}
}
return match; // Keep the original </span> if unsure
});
// Strip all remaining HTML tags
processed = processed.replace(/<[^>]*>/g, "");
// Restore the highlight tags
processed = processed.replace(
new RegExp(highlightOpenPlaceholder, "g"),
'<span class="highlight">',
);
processed = processed.replace(
new RegExp(highlightClosePlaceholder, "g"),
"</span>",
);
return processed;
}
export function highlightMatch(
text: string,
term: string,
matches?: readonly FuseResultMatch[],
): string {
if (!term.trim() || !matches || matches.length === 0) return text;
try {
// Find matches for the text field or allContent that contains the text
const fieldMatches = matches.find(
(match) =>
match.key === "text" ||
(match.key === "allContent" && match.value?.includes(text)),
);
if (
!fieldMatches ||
!fieldMatches.indices ||
fieldMatches.indices.length === 0
) {
return text;
}
// Create a map of character positions to mark which ones need highlighting
const highlightMap = new Array(text.length).fill(false);
fieldMatches.indices.forEach((indices: MatchIndices) => {
const start = indices[0];
const end = indices[1];
if (fieldMatches.key === "allContent") {
// Find where our text appears in the allContent
const allContent = fieldMatches.value;
const textPos = allContent?.indexOf(text) ?? -1;
// Only highlight if the match overlaps with our text
if (textPos >= 0) {
// Adjust start and end to be relative to our text field
const relStart = start - textPos;
const relEnd = end - textPos;
// Only highlight if the match actually overlaps with our text field
if (relEnd >= 0 && relStart < text.length) {
// Mark the overlapping characters
for (
let i = Math.max(0, relStart);
i <= Math.min(text.length - 1, relEnd);
i++
) {
highlightMap[i] = true;
}
}
}
} else {
// Regular text field match - ensure indices are within bounds
if (start >= 0 && end < text.length) {
for (let i = start; i <= end; i++) {
highlightMap[i] = true;
}
}
}
});
let result = "";
let inHighlight = false;
for (let i = 0; i < text.length; i++) {
if (highlightMap[i] && !inHighlight) {
result += '<span class="highlight">';
inHighlight = true;
} else if (!highlightMap[i] && inHighlight) {
result += "</span>";
inHighlight = false;
}
result += text.charAt(i);
}
if (inHighlight) {
result += "</span>";
}
return result;
} catch (e) {
console.error("Error highlighting match:", e);
return text;
}
}
// Function to extract and highlight content snippet using Fuse matches
export function highlightSnippet(
content: string,
term: string,
matches?: readonly FuseResultMatch[],
): string {
if (!content || !term.trim() || !matches || matches.length === 0)
return content;
try {
// Find matches for content field or allContent that contains the content
const contentMatches = matches.find(
(match) =>
match.key === "content" ||
(match.key === "allContent" && match.value?.includes(content)),
);
if (
!contentMatches ||
!contentMatches.indices ||
contentMatches.indices.length === 0
) {
// No content matches, return plain content
return content.length > 100 ? content.substring(0, 100) + "..." : content;
}
// Find the match indices
let allIndices: MatchIndices[] = contentMatches.indices as MatchIndices[];
// If matching against allContent, adjust indices to be relative to content
if (contentMatches.key === "allContent") {
const allContent = contentMatches.value;
const contentPos = allContent?.indexOf(content) ?? -1;
if (contentPos >= 0) {
// Adjust indices to be relative to the content field
allIndices = allIndices
.map(
(indices) =>
[
indices[0] - contentPos,
indices[1] - contentPos,
] as MatchIndices,
)
.filter((indices) => indices[1] >= 0 && indices[0] < content.length);
}
}
if (allIndices.length === 0) {
return content.length > 100 ? content.substring(0, 100) + "..." : content;
}
// Find a good center point for our snippet (average of first match)
const firstMatch = allIndices[0];
const matchCenter = Math.floor((firstMatch[0] + firstMatch[1]) / 2);
// Extract a window around the match
const windowSize = 100;
const start = Math.max(0, matchCenter - windowSize / 2);
const end = Math.min(content.length, matchCenter + windowSize / 2);
// Create the basic snippet
let snippet = content.substring(start, end);
if (start > 0) snippet = "..." + snippet;
if (end < content.length) snippet += "...";
// Create a highlighting map for the snippet
const snippetLength = snippet.length;
const highlightMap = new Array(snippetLength).fill(false);
// Calculate offset for the highlighting
const startOffset = start > 0 ? start - 3 : start; // Account for '...' if present
// Mark each matched character in the snippet
allIndices.forEach((indices: MatchIndices) => {
const matchStart = indices[0];
const matchEnd = indices[1];
// Skip matches outside our snippet window
if (matchEnd < start || matchStart > end) return;
// Adjust match indices to be relative to snippet
const snippetMatchStart = Math.max(0, matchStart - startOffset);
const snippetMatchEnd = Math.min(
snippetLength - 1,
matchEnd - startOffset,
);
// Mark characters for highlighting
for (let i = snippetMatchStart; i <= snippetMatchEnd; i++) {
if (i >= 0 && i < snippetLength) {
highlightMap[i] = true;
}
}
});
// Build the highlighted snippet
let result = "";
let inHighlight = false;
for (let i = 0; i < snippetLength; i++) {
// If highlighting state changes, add appropriate tags
if (highlightMap[i] && !inHighlight) {
result += '<span class="highlight">';
inHighlight = true;
} else if (!highlightMap[i] && inHighlight) {
result += "</span>";
inHighlight = false;
}
// Add the current character
result += snippet.charAt(i);
}
// Close highlight tag if we're still in one at the end
if (inHighlight) {
result += "</span>";
}
return result;
} catch (e) {
console.error("Error highlighting snippet:", e);
return content.length > 100 ? content.substring(0, 100) + "..." : content;
}
}
@@ -0,0 +1,91 @@
import type { Plugin } from '../../core/types';
interface NotificationCollectorStorage {
lastNotificationCount: number;
lastCheckedTime: string;
}
const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
id: 'notificationCollector',
name: 'Notification Collector',
description: 'Collects and displays SEQTA notifications',
version: '1.0.0',
settings: {},
disableToggle: true,
run: async (api) => {
let pollInterval: number | null = null;
// Store last notification count in storage
if (!api.storage.lastNotificationCount) {
api.storage.lastNotificationCount = 0;
}
const checkNotifications = async () => {
try {
const alertDiv = document.querySelector("[class*='notifications__bubble___']") as HTMLElement;
if (api.storage.lastNotificationCount !== 0) {
alertDiv.textContent = api.storage.lastNotificationCount.toString();
}
const response = await fetch(`${location.origin}/seqta/student/heartbeat?`, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8'
},
body: JSON.stringify({
timestamp: "1970-01-01 00:00:00.0",
hash: "#?page=/home",
})
});
const data = await response.json();
// Store notification count for history
const notificationCount = data.payload.notifications.length;
api.storage.lastNotificationCount = notificationCount;
api.storage.lastCheckedTime = new Date().toISOString();
if (alertDiv) {
alertDiv.textContent = notificationCount.toString();
} else {
console.info("[BetterSEQTA+] No notifications currently");
}
} catch (error) {
console.error("[BetterSEQTA+] Error fetching notifications:", error);
}
};
const startPolling = () => {
if (pollInterval) return; // Already polling
checkNotifications();
pollInterval = window.setInterval(checkNotifications, 30000);
};
const stopPolling = () => {
if (pollInterval) {
window.clearInterval(pollInterval);
pollInterval = null;
const alertDiv = document.querySelector("[class*='notifications__bubble___']") as HTMLElement;
if (alertDiv) {
if (api.storage.lastNotificationCount > 9) {
alertDiv.textContent = "9+";
} else {
alertDiv.textContent = api.storage.lastNotificationCount.toString();
}
}
}
};
api.seqta.onMount("[class*='notifications__bubble___']", (_) => {
startPolling();
});
return () => {
stopPolling();
};
}
};
export default notificationCollectorPlugin;
+52
View File
@@ -0,0 +1,52 @@
import type { Plugin } from '@/plugins/core/types';
import { BasePlugin } from '@/plugins/core/settings';
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
// Step 1: Define settings with proper typing
const settings = defineSettings({
someSetting: booleanSetting({
default: true,
title: "Test Plugin",
description: "Some random setting",
})
});
// Step 2: Create the plugin class with @Setting decorators
class TestPluginClass extends BasePlugin<typeof settings> {
@Setting(settings.someSetting)
someSetting!: boolean;
}
// Step 3: Instantiate and plug it in
const settingsInstance = new TestPluginClass();
const testPlugin: Plugin<typeof settings> = {
id: 'test',
name: 'Test Plugin',
description: 'A test plugin for BetterSEQTA+',
version: '1.0.0',
settings: settingsInstance.settings,
disableToggle: true,
run: async (api) => {
console.log('Test plugin running');
api.events.on('ping', (data) => {
console.log('Ping received! Page changed to: ', data);
});
const { unregister } = api.seqta.onPageChange((page) => {
//console.log('Page changed to', page);
api.events.emit('ping', page);
console.log('Current setting value:', api.settings.someSetting);
});
return () => {
console.log('Test plugin stopped');
unregister();
}
}
};
export default testPlugin;
@@ -1,9 +1,11 @@
import renderSvelte from "@/interface/main" import renderSvelte from "@/interface/main"
import themeCreator from "@/interface/pages/themeCreator.svelte" import themeCreator from "@/interface/pages/themeCreator.svelte"
import { unmount } from "svelte" import { unmount } from "svelte"
import { ClearThemePreview } from "./themes/UpdateThemePreview" import { ThemeManager } from "@/plugins/built-in/themes/theme-manager"
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
let themeCreatorSvelteApp: any = null let themeCreatorSvelteApp: any = null
const themeManager = ThemeManager.getInstance();
/** /**
* Open the Theme Creator sidebar, it is an embedded page loaded similar to the extension popup * Open the Theme Creator sidebar, it is an embedded page loaded similar to the extension popup
@@ -13,6 +15,12 @@ let themeCreatorSvelteApp: any = null
export function OpenThemeCreator(themeID: string = "") { export function OpenThemeCreator(themeID: string = "") {
CloseThemeCreator() CloseThemeCreator()
// Only store original color if we're not editing an existing theme
localStorage.setItem('themeCreatorOpen', 'true');
if (!themeID) {
localStorage.setItem('originalPreviewColor', settingsState.selectedColor);
}
const width = "310px" const width = "310px"
const themeCreatorDiv: HTMLDivElement = document.createElement("div") const themeCreatorDiv: HTMLDivElement = document.createElement("div")
@@ -33,7 +41,7 @@ export function OpenThemeCreator(themeID: string = "") {
closeButton.textContent = "×" closeButton.textContent = "×"
closeButton.addEventListener("click", () => { closeButton.addEventListener("click", () => {
CloseThemeCreator() CloseThemeCreator()
ClearThemePreview() themeManager.clearPreview()
}) })
document.body.appendChild(closeButton) document.body.appendChild(closeButton)
@@ -82,6 +90,9 @@ export function OpenThemeCreator(themeID: string = "") {
* @returns void * @returns void
*/ */
export function CloseThemeCreator() { export function CloseThemeCreator() {
// Remove the stored flag
localStorage.removeItem('themeCreatorOpen');
const themeCreator = document.getElementById("themeCreator") const themeCreator = document.getElementById("themeCreator")
const closeButton = document.querySelector( const closeButton = document.querySelector(
".themeCloseButton", ".themeCloseButton",
+17
View File
@@ -0,0 +1,17 @@
import type { Plugin } from '../../core/types';
import { ThemeManager } from './theme-manager';
const themesPlugin: Plugin = {
id: 'themes',
name: 'Themes',
description: 'Adds a theme selector to the settings page',
version: '1.0.0',
settings: {},
run: async (_) => {
const themeManager = ThemeManager.getInstance();
await themeManager.initialize();
}
};
export default themesPlugin;
@@ -0,0 +1,738 @@
import localforage from 'localforage';
import type { CustomTheme, LoadedCustomTheme } from '@/types/CustomThemes';
import { settingsState } from '@/seqta/utils/listeners/SettingsState';
import debounce from '@/seqta/utils/debounce';
type ThemeContent = {
id: string;
name: string;
coverImage?: string; // base64, optional
description: string;
defaultColour?: string;
CanChangeColour?: boolean;
CustomCSS?: string;
hideThemeName?: boolean;
forceDark?: boolean;
images: { id: string, variableName: string, data: string }[]; // data: base64
};
export class ThemeManager {
private static instance: ThemeManager;
private currentTheme: CustomTheme | null = null;
private styleElement: HTMLStyleElement | null = null;
private previewStyleElement: HTMLStyleElement | null = null;
private previousImageVariableNames: string[] = [];
private originalPreviewColor: string | null = null;
private originalPreviewTheme: boolean | null = null;
private imageUrlCache: Map<string, string> = new Map();
private constructor() {
console.debug('[ThemeManager] Initializing...');
}
public static getInstance(): ThemeManager {
if (!ThemeManager.instance) {
ThemeManager.instance = new ThemeManager();
}
return ThemeManager.instance;
}
/**
* Get the currently active theme
*/
public getCurrentTheme(): CustomTheme | null {
return this.currentTheme;
}
/**
* Get a theme by ID from storage
*/
public async getTheme(themeId: string): Promise<CustomTheme | null> {
console.debug('[ThemeManager] Getting theme:', themeId);
try {
const theme = await localforage.getItem(themeId) as CustomTheme;
return theme;
} catch (error) {
console.error('[ThemeManager] Error getting theme:', error);
return null;
}
}
/**
* Get the ID of the currently selected theme
*/
public getSelectedThemeId(): string {
return settingsState.selectedTheme;
}
/**
* Disable the current theme without deleting it
*/
public async disableTheme(): Promise<void> {
console.debug('[ThemeManager] Disabling current theme');
try {
if (!this.currentTheme) {
console.debug('[ThemeManager] No theme to disable');
return;
}
await this.removeTheme(this.currentTheme);
this.currentTheme = null;
settingsState.selectedTheme = '';
console.debug('[ThemeManager] Theme disabled successfully');
} catch (error) {
console.error('[ThemeManager] Error disabling theme:', error);
}
}
/**
* Initialize the theme system and restore previous state
*/
public async initialize(): Promise<void> {
console.debug('[ThemeManager] Starting initialization');
try {
// Check if theme creator was open during reload
const themeCreatorOpen = localStorage.getItem('themeCreatorOpen');
if (themeCreatorOpen === 'true') {
console.debug('[ThemeManager] Theme creator was open, clearing preview state');
this.clearPreview();
// Clean up the flag
localStorage.removeItem('themeCreatorOpen');
}
if (settingsState.selectedTheme) {
console.debug('[ThemeManager] Found selected theme, restoring:', settingsState.selectedTheme);
await this.setTheme(settingsState.selectedTheme);
}
} catch (error) {
console.error('[ThemeManager] Error during initialization:', error);
}
}
/**
* Clean up theme system resources
*/
public async cleanup(): Promise<void> {
console.debug('[ThemeManager] Cleaning up resources');
try {
if (this.currentTheme) {
await this.removeTheme(this.currentTheme, false);
}
} catch (error) {
console.error('[ThemeManager] Error during cleanup:', error);
}
}
/**
* Set and apply a theme by ID
*/
public async setTheme(themeId: string): Promise<void> {
console.debug('[ThemeManager] Setting theme:', themeId);
try {
const theme = await localforage.getItem(themeId) as CustomTheme;
if (!theme) {
console.error('[ThemeManager] Theme not found:', themeId);
return;
}
// Store original settings before applying new theme
if (!settingsState.selectedTheme) {
console.debug('[ThemeManager] Storing original settings');
settingsState.originalSelectedColor = settingsState.selectedColor;
settingsState.originalDarkMode = settingsState.DarkMode;
}
// Remove current theme if exists
if (this.currentTheme) {
console.debug('[ThemeManager] Removing current theme');
await this.removeTheme(this.currentTheme);
}
// Apply new theme
await this.applyTheme(theme);
this.currentTheme = theme;
settingsState.selectedTheme = themeId;
} catch (error) {
console.error('[ThemeManager] Error setting theme:', error);
}
}
/**
* Apply theme components (CSS, images, settings)
*/
private async applyTheme(theme: CustomTheme): Promise<void> {
console.debug('[ThemeManager] Applying theme:', theme.name);
try {
// Apply custom CSS
if (theme.CustomCSS) {
console.debug('[ThemeManager] Applying custom CSS');
this.applyCustomCSS(theme.CustomCSS);
}
// Apply custom images
if (theme.CustomImages) {
console.debug('[ThemeManager] Applying custom images');
theme.CustomImages.forEach((image) => {
const imageUrl = URL.createObjectURL(image.blob);
document.documentElement.style.setProperty('--' + image.variableName, `url(${imageUrl})`);
});
}
// Apply theme settings
if (theme.forceDark !== undefined) {
console.debug('[ThemeManager] Setting dark mode:', theme.forceDark);
settingsState.DarkMode = theme.forceDark;
}
// Use the stored selected color if available, otherwise use the default
if (theme.selectedColor) {
console.debug('[ThemeManager] Restoring saved color:', theme.selectedColor);
settingsState.selectedColor = theme.selectedColor;
} else if (theme.defaultColour) {
console.debug('[ThemeManager] Using default color:', theme.defaultColour);
settingsState.selectedColor = theme.defaultColour;
}
} catch (error) {
console.error('[ThemeManager] Error applying theme:', error);
}
}
/**
* Remove theme and restore original settings
*/
private async removeTheme(theme: CustomTheme, clearSelectedTheme: boolean = true): Promise<void> {
console.debug('[ThemeManager] Removing theme:', theme.name);
try {
// Remove custom CSS
if (this.styleElement) {
console.debug('[ThemeManager] Removing custom CSS');
this.styleElement.remove();
this.styleElement = null;
}
// Remove custom images
if (theme.CustomImages) {
console.debug('[ThemeManager] Removing custom images');
theme.CustomImages.forEach((image) => {
const value = document.documentElement.style.getPropertyValue('--' + image.variableName);
if (value) {
URL.revokeObjectURL(value.slice(4, -1)); // Remove url() wrapper
}
document.documentElement.style.removeProperty('--' + image.variableName);
});
}
if (this.currentTheme) {
// Store the current color with the theme before removing it
await localforage.setItem(this.currentTheme.id, {
...this.currentTheme,
selectedColor: settingsState.selectedColor
});
}
// Restore original settings
if (settingsState.originalSelectedColor) {
console.debug('[ThemeManager] Restoring original color:', settingsState.originalSelectedColor);
settingsState.selectedColor = settingsState.originalSelectedColor;
}
if (settingsState.originalDarkMode !== undefined) {
console.debug('[ThemeManager] Restoring original dark mode:', settingsState.originalDarkMode);
settingsState.DarkMode = settingsState.originalDarkMode;
settingsState.originalDarkMode = undefined;
}
this.currentTheme = null;
if (clearSelectedTheme) {
settingsState.selectedTheme = '';
}
} catch (error) {
console.error('[ThemeManager] Error removing theme:', error);
}
}
/**
* Apply custom CSS to the document
*/
private applyCustomCSS(css: string): void {
console.debug('[ThemeManager] Applying custom CSS');
try {
if (!this.styleElement) {
this.styleElement = document.createElement('style');
this.styleElement.id = 'custom-theme';
document.head.appendChild(this.styleElement);
}
this.styleElement.textContent = css;
} catch (error) {
console.error('[ThemeManager] Error applying custom CSS:', error);
}
}
/**
* Get list of available themes
*/
public async getAvailableThemes(): Promise<CustomTheme[]> {
console.debug('[ThemeManager] Getting available themes');
try {
const themeIds = await localforage.getItem('customThemes') as string[] | null;
if (!themeIds) {
return [];
}
const themes = await Promise.all(
themeIds.map(async (id) => {
return await localforage.getItem(id) as CustomTheme;
})
);
return themes.filter(theme => theme !== null);
} catch (error) {
console.error('[ThemeManager] Error getting available themes:', error);
return [];
}
}
/**
* Save or update a theme
*/
public async saveTheme(theme: LoadedCustomTheme): Promise<void> {
console.debug('[ThemeManager] Saving theme:', theme.name);
try {
await localforage.setItem(theme.id, theme);
const themeIds = await localforage.getItem('customThemes') as string[] | null;
if (themeIds) {
if (!themeIds.includes(theme.id)) {
themeIds.push(theme.id);
await localforage.setItem('customThemes', themeIds);
}
} else {
await localforage.setItem('customThemes', [theme.id]);
}
} catch (error) {
console.error('[ThemeManager] Error saving theme:', error);
}
}
/**
* Delete a theme
*/
public async deleteTheme(themeId: string): Promise<void> {
console.debug('[ThemeManager] Deleting theme:', themeId);
try {
const theme = await localforage.getItem(themeId) as CustomTheme;
if (theme) {
if (this.currentTheme?.id === themeId) {
await this.removeTheme(theme);
}
await localforage.removeItem(themeId);
const themeIds = await localforage.getItem('customThemes') as string[] | null;
if (themeIds) {
const updatedThemeIds = themeIds.filter(id => id !== themeId);
await localforage.setItem('customThemes', updatedThemeIds);
}
}
} catch (error) {
console.error('[ThemeManager] Error deleting theme:', error);
}
}
/**
* Download and install a theme from the store
*/
public async downloadTheme(themeContent: { id: string; name: string; description: string; coverImage: string; }): Promise<void> {
console.debug('[ThemeManager] Downloading theme:', themeContent.name);
try {
if (!themeContent.id) return;
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes/${themeContent.id}/theme.json`);
const themeData = await response.json() as ThemeContent;
await this.installTheme(themeData);
} catch (error) {
console.error('[ThemeManager] Error downloading theme:', error);
}
}
/**
* Install a theme from theme data
*/
public async installTheme(themeData: ThemeContent): Promise<void> {
console.debug('[ThemeManager] Installing theme:', themeData.name);
try {
// Validate required fields
if (!themeData.id || !themeData.name) {
throw new Error('Theme is missing required fields (id or name)');
}
// Handle cover image (optional)
let coverImageBlob = null;
if (themeData.coverImage) {
try {
const strippedCoverImage = this.stripBase64Prefix(themeData.coverImage);
coverImageBlob = this.base64ToBlob(strippedCoverImage);
} catch (e) {
console.warn('[ThemeManager] Failed to process cover image:', e);
// Continue without cover image
}
}
// Handle images (optional)
const images = themeData.images?.map((image) => {
try {
if (!image.id || !image.variableName || !image.data) {
console.warn('[ThemeManager] Skipping invalid image:', image);
return null;
}
return {
...image,
blob: this.base64ToBlob(this.stripBase64Prefix(image.data))
};
} catch (e) {
console.warn('[ThemeManager] Failed to process image:', e);
return null;
}
}).filter(img => img !== null) ?? [];
// Create theme with defaults for optional fields
const theme: LoadedCustomTheme = {
id: themeData.id,
name: themeData.name,
description: themeData.description || '',
webURL: themeData.id,
coverImage: coverImageBlob,
CustomImages: images,
CustomCSS: themeData.CustomCSS || '',
defaultColour: themeData.defaultColour || 'rgba(0, 123, 255, 1)',
CanChangeColour: themeData.CanChangeColour ?? true,
allowBackgrounds: true,
isEditable: false,
hideThemeName: themeData.hideThemeName ?? false,
forceDark: themeData.forceDark
};
await this.saveTheme(theme);
} catch (error) {
console.error('[ThemeManager] Error installing theme:', error);
throw error; // Re-throw to handle in UI
}
}
/**
* Share a theme by exporting it
*/
public async shareTheme(themeId: string): Promise<void> {
console.debug('[ThemeManager] Sharing theme:', themeId);
try {
const theme = await localforage.getItem(themeId) as LoadedCustomTheme;
if (!theme) {
console.error('[ThemeManager] Theme not found');
return;
}
// Extract only the fields we want to share
const {
CustomImages = [],
coverImage,
webURL,
isEditable,
selectedColor,
allowBackgrounds,
...themeBasics
} = theme;
// Convert images to base64
const finalImages = await Promise.all(CustomImages.map(async (image) => ({
id: image.id,
variableName: image.variableName,
data: await this.blobToBase64(image.blob)
})));
// Convert cover image to base64
const coverImageBase64 = coverImage ? await this.blobToBase64(coverImage) : null;
// Create shareable theme data with only necessary fields
const shareableTheme = {
...themeBasics,
images: finalImages,
coverImage: coverImageBase64
};
// Save theme file
this.saveThemeFile(shareableTheme, theme.name || 'Unnamed_Theme');
} catch (error) {
console.error('[ThemeManager] Error sharing theme:', error);
}
}
/**
* Preview a theme without applying it
*/
public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
console.debug('[ThemeManager] Previewing theme:', theme.name);
try {
const { CustomCSS, CustomImages, defaultColour, forceDark } = theme;
// Store original settings only if this is a new theme
if (!theme.webURL) {
if (this.originalPreviewColor === null) {
this.originalPreviewColor = settingsState.selectedColor;
localStorage.setItem('originalPreviewColor', settingsState.selectedColor);
}
if (this.originalPreviewTheme === null) {
this.originalPreviewTheme = settingsState.DarkMode;
}
}
// Apply custom CSS
if (CustomCSS) {
this.applyPreviewCSS(CustomCSS);
}
// Apply custom images
const newImageVariableNames = CustomImages.map(image => image.variableName);
// Remove old preview images
this.previousImageVariableNames.forEach(variableName => {
if (!newImageVariableNames.includes(variableName)) {
this.removeImageFromDocument(variableName);
}
});
// Apply new images
CustomImages.forEach((image) => {
const imageUrl = URL.createObjectURL(image.blob);
document.documentElement.style.setProperty(`--${image.variableName}`, `url(${imageUrl})`);
});
// Update previousImageVariableNames
this.previousImageVariableNames = newImageVariableNames;
// Apply theme settings
if (forceDark !== undefined) {
settingsState.DarkMode = forceDark;
}
if (defaultColour) {
settingsState.selectedColor = defaultColour;
}
} catch (error) {
console.error('[ThemeManager] Error previewing theme:', error);
}
}
/**
* Update the preview of a theme in real-time (for theme creator)
*/
public async updatePreview(theme: Partial<LoadedCustomTheme>): Promise<void> {
console.debug('[ThemeManager] Updating theme preview');
try {
// Only store original settings if this is a new theme (not editing)
// We can tell it's a new theme if it has no webURL (which is set when a theme is saved/loaded)
if (!theme.webURL) {
if (this.originalPreviewColor === null) {
this.originalPreviewColor = settingsState.selectedColor;
}
if (this.originalPreviewTheme === null) {
this.originalPreviewTheme = settingsState.DarkMode;
}
}
// Apply CSS if changed
if (theme.CustomCSS !== undefined) {
this.applyPreviewCSS(theme.CustomCSS);
}
// Handle images if present
if (theme.CustomImages) {
const newImageVariableNames = theme.CustomImages.map(image => image.variableName);
// Remove old preview images that are no longer present
this.previousImageVariableNames.forEach(variableName => {
if (!newImageVariableNames.includes(variableName)) {
this.removeImageFromDocument(variableName);
// Clean up cached URL
this.imageUrlCache.delete(variableName);
}
});
// Apply or update images
theme.CustomImages.forEach((image) => {
const existingUrl = this.imageUrlCache.get(image.variableName);
if (!existingUrl) {
// Only create new URL if one doesn't exist
const imageUrl = URL.createObjectURL(image.blob);
this.imageUrlCache.set(image.variableName, imageUrl);
document.documentElement.style.setProperty(`--${image.variableName}`, `url(${imageUrl})`);
} else {
// Reuse existing URL
document.documentElement.style.setProperty(`--${image.variableName}`, `url(${existingUrl})`);
}
});
this.previousImageVariableNames = newImageVariableNames;
}
// Always apply dark mode setting
if (theme.forceDark !== undefined) {
settingsState.DarkMode = theme.forceDark;
}
// Only apply color if this is a new theme
if (!theme.webURL && theme.defaultColour) {
settingsState.selectedColor = theme.defaultColour;
}
} catch (error) {
console.error('[ThemeManager] Error updating theme preview:', error);
}
}
/**
* Update the preview of a theme (debounced)
* @param theme - The theme to update the preview of
*/
public updatePreviewDebounced = debounce((theme: Partial<LoadedCustomTheme>): void => {
this.updatePreview(theme);
}, 2);
/**
* Clear theme preview
*/
public clearPreview(): void {
console.debug('[ThemeManager] Clearing theme preview');
try {
// Remove preview images and revoke URLs
this.previousImageVariableNames.forEach(variableName => {
this.removeImageFromDocument(variableName);
});
// Clear all cached URLs
this.imageUrlCache.forEach(url => URL.revokeObjectURL(url));
this.imageUrlCache.clear();
this.previousImageVariableNames = [];
// Remove preview CSS
if (this.previewStyleElement) {
this.previewStyleElement.remove();
this.previewStyleElement = null;
}
// Restore original settings
const storedColor = localStorage.getItem('originalPreviewColor');
if (storedColor) {
settingsState.selectedColor = storedColor;
localStorage.removeItem('originalPreviewColor');
} else if (this.originalPreviewColor !== null) {
console.debug('[ThemeManager] Restoring color from memory:', this.originalPreviewColor);
settingsState.selectedColor = this.originalPreviewColor;
console.debug('[ThemeManager] Color after restore:', settingsState.selectedColor);
} else {
console.debug('[ThemeManager] No color to restore found');
}
this.originalPreviewColor = null;
if (this.originalPreviewTheme !== null) {
console.debug('[ThemeManager] Restoring dark mode:', this.originalPreviewTheme);
settingsState.DarkMode = this.originalPreviewTheme;
this.originalPreviewTheme = null;
}
} catch (error) {
console.error('[ThemeManager] Error clearing preview:', error);
}
}
// Utility methods
private stripBase64Prefix(base64String: string): string {
if (!base64String) return '';
const prefixRegex = /^data:[^;]+;base64,/;
try {
return prefixRegex.test(base64String) ? base64String.replace(prefixRegex, '') : base64String;
} catch(err) {
console.error('[ThemeManager] Error stripping base64 prefix:', err);
return '';
}
}
private base64ToBlob(base64: string): Blob {
try {
const byteString = atob(base64);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ab], { type: 'image/png' });
} catch(err) {
console.error('[ThemeManager] Error converting base64 to blob:', err);
return new Blob();
}
}
private async blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
const base64Data = base64String.split(',')[1];
resolve(base64Data);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
private saveThemeFile(data: object, fileName: string): void {
try {
const fileData = JSON.stringify(data, null, 2);
const blob = new Blob([fileData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName}.theme.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch(err) {
console.error('[ThemeManager] Error saving theme file:', err);
}
}
private removeImageFromDocument(variableName: string): void {
try {
const value = document.documentElement.style.getPropertyValue('--' + variableName);
if (value) {
const url = this.imageUrlCache.get(variableName);
if (url) {
URL.revokeObjectURL(url);
this.imageUrlCache.delete(variableName);
}
}
document.documentElement.style.removeProperty('--' + variableName);
} catch(err) {
console.error('[ThemeManager] Error removing image from document:', err);
}
}
private applyPreviewCSS(css: string): void {
console.debug('[ThemeManager] Applying preview CSS');
try {
if (!this.previewStyleElement) {
this.previewStyleElement = document.createElement('style');
this.previewStyleElement.id = 'custom-theme-preview';
document.head.appendChild(this.previewStyleElement);
}
this.previewStyleElement.textContent = css;
} catch (error) {
console.error('[ThemeManager] Error applying preview CSS:', error);
}
}
}
+268
View File
@@ -0,0 +1,268 @@
import { settingsState } from '@/seqta/utils/listeners/SettingsState';
import type { Plugin } from '../../core/types';
import { convertTo12HourFormat } from '@/seqta/utils/convertTo12HourFormat';
import { waitForElm } from '@/seqta/utils/waitForElm';
const timetablePlugin: Plugin<{}, {}> = {
id: 'timetable',
name: 'Timetable Enhancer',
description: 'Adds extra features to the timetable view',
version: '1.0.0',
settings: {},
disableToggle: true,
run: async (api) => {
const { unregister } = api.seqta.onMount('.timetablepage', handleTimetable)
return () => {
// Call the unregister function to remove the mount listener
unregister();
const timetablePage = document.querySelector('.timetablepage')
if (timetablePage) {
const zoomControls = document.querySelector('.timetable-zoom-controls')
if (zoomControls) zoomControls.remove()
const hideControls = document.querySelector('.timetable-hide-controls')
if (hideControls) hideControls.remove()
resetTimetableStyles()
}
}
}
};
// Store event handlers globally for cleanup
const zoomHandlers = new WeakMap<Element, { zoomIn: () => void; zoomOut: () => void }>()
function resetTimetableStyles(): void {
const firstDayColumn = document.querySelector(".dailycal .content .days td") as HTMLElement
if (!firstDayColumn) return
const baseContainerHeight = parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight
const dayColumns = document.querySelectorAll(".dailycal .content .days td")
dayColumns.forEach((td: Element) => {
(td as HTMLElement).style.height = `${baseContainerHeight}px`
})
const timeColumn = document.querySelector(".times")
if (timeColumn) {
const times = timeColumn.querySelectorAll(".time")
const timeHeight = baseContainerHeight / times.length
times.forEach((time: Element) => {
(time as HTMLElement).style.height = `${timeHeight}px`
})
}
const lessons = document.querySelectorAll(".dailycal .lesson")
lessons.forEach((lesson: Element) => {
const lessonEl = lesson as HTMLElement
const originalHeight = lessonEl.getAttribute('data-original-height')
if (originalHeight) {
lessonEl.style.height = `${originalHeight}px`
}
})
const entries = document.querySelectorAll(".entry")
entries.forEach((entry: Element) => {
const entryEl = entry as HTMLElement
entryEl.style.opacity = '1'
})
const zoomControls = document.querySelector('.timetable-zoom-controls')
if (zoomControls) {
const handlers = zoomHandlers.get(zoomControls)
if (handlers) {
const zoomIn = zoomControls.querySelector('.timetable-zoom:nth-child(2)')
const zoomOut = zoomControls.querySelector('.timetable-zoom:nth-child(1)')
if (zoomIn) zoomIn.removeEventListener('click', handlers.zoomIn)
if (zoomOut) zoomOut.removeEventListener('click', handlers.zoomOut)
zoomHandlers.delete(zoomControls)
}
}
}
async function handleTimetable(): Promise<void> {
await waitForElm(".time", true, 10)
// Store original heights when timetable loads
const lessons = document.querySelectorAll(".dailycal .lesson")
lessons.forEach((lesson: Element) => {
const lessonEl = lesson as HTMLElement
lessonEl.setAttribute(
"data-original-height",
lessonEl.offsetHeight.toString(),
)
})
// Existing time format code
if (settingsState.timeFormat == "12") {
const times = document.querySelectorAll(".timetablepage .times .time")
for (const time of times) {
if (!time.textContent) continue
time.textContent = convertTo12HourFormat(time.textContent, true)
}
}
handleTimetableZoom()
handleTimetableAssessmentHide()
}
function handleTimetableZoom(): void {
console.log("Initializing timetable zoom controls")
// Lazy initialize state variables only when function is first called
let timetableZoomLevel = 1
let baseContainerHeight: number | null = null
const originalEntryPositions = new Map<
Element,
{ topRatio: number; heightRatio: number }
>()
// Create zoom controls
const zoomControls = document.createElement("div")
zoomControls.className = "timetable-zoom-controls"
const zoomIn = document.createElement("button")
zoomIn.className = "uiButton timetable-zoom iconFamily"
zoomIn.innerHTML = "&#xed93;" // Unicode for zoom in icon (custom iconfamily)
const zoomOut = document.createElement("button")
zoomOut.className = "uiButton timetable-zoom iconFamily"
zoomOut.innerHTML = "&#xed94;" // Unicode for zoom out icon (custom iconfamily)
zoomControls.appendChild(zoomOut)
zoomControls.appendChild(zoomIn)
const toolbar = document.getElementById("toolbar")
toolbar?.appendChild(zoomControls)
// Store event listener references
const zoomInHandler = () => {
if (timetableZoomLevel < 2) {
timetableZoomLevel += 0.2
updateZoom()
}
}
const zoomOutHandler = () => {
if (timetableZoomLevel > 0.6) {
timetableZoomLevel -= 0.2
updateZoom()
}
}
zoomIn.addEventListener("click", zoomInHandler)
zoomOut.addEventListener("click", zoomOutHandler)
// Store references for cleanup
zoomHandlers.set(zoomControls, { zoomIn: zoomInHandler, zoomOut: zoomOutHandler })
const initializePositions = () => {
// Get the base container height from the first TD
const firstDayColumn = document.querySelector(
".dailycal .content .days td",
) as HTMLElement
if (!firstDayColumn) return false
baseContainerHeight =
parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight
// Store original ratios
const entries = document.querySelectorAll(".entriesWrapper .entry")
entries.forEach((entry: Element) => {
const entryEl = entry as HTMLElement
// Calculate ratios relative to detected base height
if (baseContainerHeight === null) return
const topRatio = parseInt(entryEl.style.top) / baseContainerHeight
const heightRatio = parseInt(entryEl.style.height) / baseContainerHeight
originalEntryPositions.set(entry, { topRatio, heightRatio })
})
return true
}
const updateZoom = () => {
// Initialize positions if not already done
if (baseContainerHeight === null && !initializePositions()) {
console.error("Failed to initialize positions")
return
}
console.debug(`Updating zoom level to: ${timetableZoomLevel}`)
// Calculate new container height
if (baseContainerHeight === null) return
const newContainerHeight = baseContainerHeight * timetableZoomLevel
// Update all day columns (TDs)
const dayColumns = document.querySelectorAll(".dailycal .content .days td")
dayColumns.forEach((td: Element) => {
(td as HTMLElement).style.height = `${newContainerHeight}px`
})
// Update all entries using stored ratios
const entries = document.querySelectorAll(".entriesWrapper .entry")
entries.forEach((entry: Element) => {
const entryEl = entry as HTMLElement
const originalRatios = originalEntryPositions.get(entry)
if (originalRatios) {
// Calculate new positions from original ratios
const newTop = originalRatios.topRatio * newContainerHeight
const newHeight = originalRatios.heightRatio * newContainerHeight
// Apply new values
entryEl.style.top = `${Math.round(newTop)}px`
entryEl.style.height = `${Math.round(newHeight)}px`
}
})
// Update time column to match
const timeColumn = document.querySelector(".times")
if (timeColumn) {
const times = timeColumn.querySelectorAll(".time")
const timeHeight = newContainerHeight / times.length
times.forEach((time: Element) => {
(time as HTMLElement).style.height = `${timeHeight}px`
})
}
entries[Math.round((entries.length - 1) / 2)].scrollIntoView({
behavior: "instant",
block: "center",
})
}
}
function handleTimetableAssessmentHide(): void {
const hideControls = document.createElement("div")
hideControls.className = "timetable-hide-controls"
const hideOn = document.createElement("button")
hideOn.className = "uiButton timetable-hide iconFamily"
hideOn.innerHTML = "&#128065;"
hideControls.appendChild(hideOn)
const toolbar = document.getElementById("toolbar")
toolbar?.appendChild(hideControls)
function hideElements(): void {
const entries = document.querySelectorAll(".entry")
entries.forEach((entry: Element) => {
const entryEl = entry as HTMLElement
if (!entryEl.classList.contains("assessment")) {
entryEl.style.opacity = entryEl.style.opacity === "0.3" ? "1" : "0.3"
}
})
}
hideOn.addEventListener("click", hideElements)
}
export default timetablePlugin;
+258
View File
@@ -0,0 +1,258 @@
import type { EventsAPI, Plugin, PluginAPI, PluginSettings, SEQTAAPI, SettingsAPI, SettingValue, StorageAPI } from './types';
import { eventManager } from '@/seqta/utils/listeners/EventManager';
import ReactFiber from '@/seqta/utils/ReactFiber';
import browser from 'webextension-polyfill';
function createSEQTAAPI(): SEQTAAPI {
return {
onMount: (selector, callback) => {
return eventManager.register(
`${selector}Added`,
{
customCheck: (element) => element.matches(selector),
},
callback
);
},
getFiber: (selector) => {
return ReactFiber.find(selector);
},
getCurrentPage: () => {
const path = window.location.hash.split('?page=/')[1] || '';
return path.split('/')[0];
},
onPageChange: (callback) => {
const handler = () => {
const page = window.location.hash.split('?page=/')[1] || '';
callback(page.split('/')[0]);
};
window.addEventListener('hashchange', handler);
// Return an unregister function
return {
unregister: () => {
window.removeEventListener('hashchange', handler);
}
};
}
};
}
function createSettingsAPI<T extends PluginSettings>(plugin: Plugin<T>): SettingsAPI<T> & { loaded: Promise<void> } {
const storageKey = `plugin.${plugin.id}.settings`;
const listeners = new Map<keyof T, Set<(value: any) => void>>();
// Initialize with default values
const settingsWithMeta: any = {
onChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => {
if (!listeners.has(key)) {
listeners.set(key, new Set());
}
listeners.get(key)!.add(callback);
return {
unregister: () => {
listeners.get(key)!.delete(callback);
}
};
},
offChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => {
listeners.get(key)?.delete(callback);
},
loaded: Promise.resolve() // will be replaced below
};
// Fill with defaults first
for (const key in plugin.settings) {
settingsWithMeta[key] = plugin.settings[key].default;
}
// Load stored settings and override defaults
const loaded = (async () => {
try {
const stored = await browser.storage.local.get(storageKey);
const storedSettings = stored[storageKey] as Partial<Record<keyof T, any>>;
if (storedSettings) {
for (const key in storedSettings) {
if (key in settingsWithMeta) {
settingsWithMeta[key] = storedSettings[key];
listeners.get(key as keyof T)?.forEach(cb => cb(storedSettings[key]));
}
}
}
} catch (error) {
console.error(`[BetterSEQTA+] Error loading settings for plugin ${plugin.id}:`, error);
}
})();
settingsWithMeta.loaded = loaded;
// Listen for storage changes and update settingsWithMeta
const handleStorageChange = (changes: { [key: string]: browser.Storage.StorageChange }, area: string) => {
if (area !== 'local' || !(storageKey in changes)) return;
const newValue = changes[storageKey].newValue as Partial<Record<keyof T, any>> | undefined;
if (!newValue) return;
for (const key in newValue) {
const typedKey = key as keyof T;
settingsWithMeta[typedKey] = newValue[typedKey];
listeners.get(typedKey)?.forEach(cb => cb(newValue[typedKey]));
}
};
browser.storage.onChanged.addListener(handleStorageChange);
const proxy = new Proxy(settingsWithMeta, {
get(target, prop) {
return target[prop];
},
set(target, prop, value) {
if (['onChange', 'offChange', 'loaded'].includes(prop as string)) return false;
target[prop] = value;
// Reconstruct just the data keys for storage (excluding metadata methods)
const dataToStore: any = {};
for (const key in plugin.settings) {
dataToStore[key] = target[key];
}
browser.storage.local.set({ [storageKey]: dataToStore });
listeners.get(prop as keyof T)?.forEach(cb => cb(value));
return true;
}
}) as SettingsAPI<T> & { loaded: Promise<void> };
return proxy;
}
function createStorageAPI<T = any>(pluginId: string): StorageAPI<T> & { [K in keyof T]: T[K] } {
const prefix = `plugin.${pluginId}.storage.`;
const cache: Record<string, any> = {};
const listeners = new Map<string, Set<(value: any) => void>>();
const storageListeners = new Set<(changes: { [key: string]: any }, area: string) => void>();
// Load all existing storage values for this plugin
const loadStoragePromise = (async () => {
try {
const allStorage = await browser.storage.local.get(null);
// Filter for this plugin's storage keys and populate cache
Object.entries(allStorage).forEach(([key, value]) => {
if (key.startsWith(prefix)) {
const shortKey = key.slice(prefix.length);
cache[shortKey] = value;
}
});
} catch (error) {
console.error(`[BetterSEQTA+] Error loading storage for plugin ${pluginId}:`, error);
}
})();
// Listen for storage changes
const handleStorageChange = (changes: { [key: string]: any }, area: string) => {
if (area === 'local') {
Object.entries(changes).forEach(([key, change]) => {
if (key.startsWith(prefix)) {
const shortKey = key.slice(prefix.length);
cache[shortKey] = change.newValue;
// Notify listeners
listeners.get(shortKey)?.forEach(callback => callback(change.newValue));
}
});
}
};
browser.storage.onChanged.addListener(handleStorageChange);
storageListeners.add(handleStorageChange);
// Create the proxy for direct property access
return new Proxy(cache, {
get(target, prop: string) {
if (prop === 'onChange') {
return (key: keyof T, callback: (value: T[keyof T]) => void) => {
if (!listeners.has(key as string)) {
listeners.set(key as string, new Set());
}
listeners.get(key as string)!.add(callback);
return {
unregister: () => {
listeners.get(key as string)?.delete(callback);
}
};
};
}
if (prop === 'offChange') {
return (key: keyof T, callback: (value: T[keyof T]) => void) => {
listeners.get(key as string)?.delete(callback);
};
}
if (prop === 'loaded') {
return loadStoragePromise;
}
// Direct property access
return target[prop];
},
set(target, prop: string, value: any) {
if (['onChange', 'offChange', 'loaded'].includes(prop)) {
return false;
}
// Update cache and store in browser storage
target[prop] = value;
browser.storage.local.set({ [prefix + prop]: value });
// Notify listeners
listeners.get(prop)?.forEach(callback => callback(value));
return true;
}
}) as StorageAPI<T> & { [K in keyof T]: T[K] };
}
function createEventsAPI(pluginId: string): EventsAPI {
const prefix = `plugin.${pluginId}.`;
const eventListeners = new Map<string, Set<{ callback: (...args: any[]) => void, listener: EventListener }>>();
return {
on: (event, callback) => {
const fullEventName = prefix + event;
const listener = ((e: CustomEvent) => {
callback(...(e.detail || []));
}) as EventListener;
document.addEventListener(fullEventName, listener);
if (!eventListeners.has(event)) {
eventListeners.set(event, new Set());
}
eventListeners.get(event)!.add({ callback, listener });
return {
unregister: () => {
document.removeEventListener(fullEventName, listener);
eventListeners.get(event)?.delete({ callback, listener });
}
};
},
emit: (event, ...args) => {
document.dispatchEvent(
new CustomEvent(prefix + event, {
detail: args.length > 0 ? args : null
})
);
},
};
}
export function createPluginAPI<T extends PluginSettings, S = any>(plugin: Plugin<T, S>): PluginAPI<T, S> {
return {
seqta: createSEQTAAPI(),
settings: createSettingsAPI(plugin),
storage: createStorageAPI<S>(plugin.id),
events: createEventsAPI(plugin.id),
};
}
+269
View File
@@ -0,0 +1,269 @@
import type { BooleanSetting, NumberSetting, Plugin, PluginSettings, SelectSetting, StringSetting } from './types';
import { createPluginAPI } from './createAPI';
import browser from 'webextension-polyfill';
interface PluginSettingsStorage {
enabled?: boolean;
[key: string]: any;
}
interface StorageChange<T = any> {
oldValue?: T;
newValue?: T;
}
export class PluginManager {
private static instance: PluginManager;
private plugins: Map<string, Plugin<any, any>> = new Map();
private runningPlugins: Map<string, boolean> = new Map();
private eventBacklog: Map<string, any[]> = new Map();
private cleanupFunctions: Map<string, () => void> = new Map();
private listeners: Map<string, Set<(...args: any[]) => void>> = new Map();
private styleElements: Map<string, HTMLStyleElement> = new Map();
private constructor() {
this.setupPluginStateListener();
}
public static getInstance(): PluginManager {
if (!PluginManager.instance) {
PluginManager.instance = new PluginManager();
}
return PluginManager.instance;
}
public dispatchPluginEvent(pluginId: string, event: string, args?: any) {
const fullEventName = `plugin.${pluginId}.${event}`;
// Dispatch plugin event if it's running otherwise queue it
if (this.runningPlugins.get(pluginId)) {
document.dispatchEvent(new CustomEvent(fullEventName, { detail: args }));
} else {
const key = `${pluginId}:${event}`;
if (!this.eventBacklog.has(key)) {
this.eventBacklog.set(key, []);
}
this.eventBacklog.get(key)!.push(args);
}
}
private async processBackloggedEvents(pluginId: string) {
for (const [key, argsList] of this.eventBacklog.entries()) {
const [eventPluginId, event] = key.split(':');
if (eventPluginId === pluginId) {
for (const args of argsList) {
this.dispatchPluginEvent(pluginId, event, args);
}
this.eventBacklog.delete(key);
}
}
}
public registerPlugin<T extends PluginSettings, S>(plugin: Plugin<T, S>): void {
if (this.plugins.has(plugin.id)) {
throw new Error(`Plugin with id "${plugin.id}" is already registered`);
}
this.plugins.set(plugin.id, plugin);
}
public async startPlugin(pluginId: string): Promise<void> {
const plugin = this.plugins.get(pluginId);
if (!plugin) {
throw new Error(`Plugin "${pluginId}" not found`);
}
if (this.runningPlugins.get(pluginId)) {
console.warn(`Plugin "${pluginId}" is already running`);
return;
}
try {
const api = createPluginAPI(plugin);
// Check if plugin is enabled before starting
if (plugin.disableToggle) {
const settings = await browser.storage.local.get(`plugin.${pluginId}.settings`);
const pluginSettings = settings[`plugin.${pluginId}.settings`] as PluginSettingsStorage | undefined;
const enabled = pluginSettings?.enabled ?? true;
if (!enabled) {
console.info(`Plugin "${pluginId}" is disabled, skipping initialization`);
return;
}
}
// Inject plugin styles if provided
if (plugin.styles) {
const styleElement = document.createElement('style');
styleElement.textContent = plugin.styles;
document.head.appendChild(styleElement);
this.styleElements.set(pluginId, styleElement);
}
// Wait for both settings and storage to be loaded before starting the plugin
await Promise.all([
(api.settings as any).loaded,
api.storage.loaded
]);
const result = await plugin.run(api);
if (typeof result === 'function') {
this.cleanupFunctions.set(plugin.id, result);
}
this.runningPlugins.set(pluginId, true);
console.info(`Plugin "${pluginId}" started successfully`);
// Process any backlogged events
await this.processBackloggedEvents(pluginId);
} catch (error) {
console.error(`[BetterSEQTA+] Failed to start plugin ${pluginId}:`, error);
throw error;
}
}
public async startAllPlugins(): Promise<void> {
const startPromises = Array.from(this.plugins.keys()).map(id =>
this.startPlugin(id).catch(error => {
console.error(`Failed to start plugin "${id}":`, error);
return Promise.reject(error);
})
);
await Promise.allSettled(startPromises);
}
public async stopPlugin(pluginId: string): Promise<void> {
// Remove plugin styles
const styleElement = this.styleElements.get(pluginId);
if (styleElement) {
styleElement.remove();
this.styleElements.delete(pluginId);
}
const cleanup = this.cleanupFunctions.get(pluginId);
if (cleanup) {
cleanup();
this.cleanupFunctions.delete(pluginId);
}
this.runningPlugins.set(pluginId, false);
console.info(`Plugin "${pluginId}" stopped`);
this.emit('plugin.stopped', pluginId);
}
public stopAllPlugins(): void {
Array.from(this.plugins.keys()).forEach(id => this.stopPlugin(id));
}
public getPlugin(pluginId: string): Plugin | undefined {
return this.plugins.get(pluginId);
}
public getAllPlugins(): Plugin[] {
return Array.from(this.plugins.values());
}
public getAllPluginSettings(): Array<{
pluginId: string;
name: string;
description: string;
settings: {
[key: string]: (Omit<BooleanSetting, 'type'> & { type: 'boolean', id: string }) |
(Omit<StringSetting, 'type'> & { type: 'string', id: string }) |
(Omit<NumberSetting, 'type'> & { type: 'number', id: string }) |
(Omit<SelectSetting<string>, 'type'> & { type: 'select', id: string, options: Array<{ value: string, label: string }> });
}
}> {
return Array.from(this.plugins.entries()).map(([id, plugin]) => {
const settingsEntries = Object.entries(plugin.settings).map(([key, setting]) => {
const settingObj = setting as any;
// Create a copy of the setting object without any functions
const result: any = Object.fromEntries(
Object.entries(settingObj)
.filter(([_, value]) => typeof value !== 'function')
);
// Ensure required properties are present
result.id = key;
result.title = result.title || key;
result.description = result.description || '';
return [key, result];
});
if (plugin.disableToggle) {
settingsEntries.push([
'enabled', {
id: 'enabled',
title: plugin.name,
description: plugin.description,
type: 'boolean',
default: true
}
])
}
return {
pluginId: id,
name: plugin.name,
description: plugin.description,
settings: Object.fromEntries(settingsEntries),
disableToggle: plugin.disableToggle
};
});
}
public isPluginRunning(pluginId: string): boolean {
return this.runningPlugins.get(pluginId) || false;
}
private emit(event: string, ...args: any[]): void {
const listeners = this.listeners.get(event);
if (listeners) {
listeners.forEach(listener => listener(...args));
}
}
public on(event: string, callback: (...args: any[]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
}
public off(event: string, callback: (...args: any[]) => void): void {
const listeners = this.listeners.get(event);
if (listeners) {
listeners.delete(callback);
}
}
// Add handler for plugin enable/disable state changes
private async handlePluginStateChange(pluginId: string, enabled: boolean): Promise<void> {
if (enabled) {
await this.startPlugin(pluginId);
} else {
await this.stopPlugin(pluginId);
}
}
// Add listener for plugin settings changes
private setupPluginStateListener(): void {
browser.storage.onChanged.addListener((changes: { [key: string]: StorageChange }, area: string) => {
if (area !== 'local') return;
for (const [key, change] of Object.entries(changes)) {
const match = key.match(/^plugin\.(.+)\.settings$/);
if (!match) continue;
const pluginId = match[1];
const plugin = this.plugins.get(pluginId);
if (!plugin?.disableToggle) continue;
const enabled = (change.newValue as PluginSettingsStorage)?.enabled ?? true;
const wasEnabled = (change.oldValue as PluginSettingsStorage)?.enabled ?? true;
if (enabled !== wasEnabled) {
this.handlePluginStateChange(pluginId, enabled);
}
}
});
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { PluginSettings } from './types';
export function Setting(settingDef: any): PropertyDecorator {
return (target, propertyKey) => {
const proto = target.constructor.prototype;
if (!proto.hasOwnProperty('settings')) {
Object.defineProperty(proto, 'settings', {
value: {},
writable: true,
configurable: true,
enumerable: true
});
}
proto.settings[propertyKey] = settingDef;
};
}
// Base plugin class that handles settings
export abstract class BasePlugin<T extends PluginSettings = PluginSettings> {
// The settings property will be populated by decorators
// Keep the instance property and constructor logic as is,
// as changing it would require changing animated-background/index.ts
settings!: T; // Use definite assignment assertion
constructor() {
// Copy settings from the prototype to the instance
// This ensures that each instance has its own settings object
// IMPORTANT: Ensure the prototype actually HAS settings before copying
if (this.constructor.prototype.hasOwnProperty('settings')) {
// Deep clone might be safer if settings objects become complex,
// but a shallow clone is usually fine for this structure.
this.settings = { ...this.constructor.prototype.settings } as T;
} else {
// Fallback if decorators somehow didn't run or add the property
this.settings = {} as T;
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { BooleanSetting, NumberSetting, SelectSetting, StringSetting } from './types';
export function numberSetting(options: Omit<NumberSetting, 'type'>): NumberSetting {
return {
type: 'number',
...options
};
}
export function booleanSetting(options: Omit<BooleanSetting, 'type'>): BooleanSetting {
return {
type: 'boolean',
...options
};
}
export function stringSetting(options: Omit<StringSetting, 'type'>): StringSetting {
return {
type: 'string',
...options
};
}
export function selectSetting<T extends string>(options: Omit<SelectSetting<T>, 'type'>): SelectSetting<T> {
return {
type: 'select',
...options
};
}
export function defineSettings<T extends Record<string, any>>(settings: T): T {
return settings;
}
export function Setting(settingDef: any): PropertyDecorator {
return (target, propertyKey) => {
const proto = target.constructor.prototype;
if (!proto.hasOwnProperty('settings')) {
Object.defineProperty(proto, 'settings', {
value: {},
writable: true,
configurable: true,
enumerable: true
});
}
proto.settings[propertyKey] = settingDef;
};
}
+102
View File
@@ -0,0 +1,102 @@
import ReactFiber from '@/seqta/utils/ReactFiber';
export interface BooleanSetting {
type: 'boolean';
default: boolean;
title: string;
description?: string;
}
export interface StringSetting {
type: 'string';
default: string;
title: string;
description?: string;
maxLength?: number;
pattern?: string;
}
export interface NumberSetting {
type: 'number';
default: number;
title: string;
description?: string;
min?: number;
max?: number;
step?: number;
}
export interface SelectSetting<T extends string> {
type: 'select';
options: readonly T[];
default: T;
title: string;
description?: string;
}
export type PluginSetting = BooleanSetting | StringSetting | NumberSetting | SelectSetting<string>;
export type PluginSettings = {
[key: string]: PluginSetting;
}
// Helper type to extract the actual value type from a setting
export type SettingValue<T extends PluginSetting> = T extends BooleanSetting ? boolean :
T extends StringSetting ? string :
T extends NumberSetting ? number :
T extends SelectSetting<infer O> ? O :
never;
export type SettingsAPI<T extends PluginSettings> = {
[K in keyof T]: SettingValue<T[K]>;
} & {
onChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => { unregister: () => void };
offChange: <K extends keyof T>(key: K, callback: (value: SettingValue<T[K]>) => void) => void;
loaded: Promise<void>;
}
export interface SEQTAAPI {
onMount: (selector: string, callback: (element: Element) => void) => { unregister: () => void };
getFiber: (selector: string) => ReactFiber;
getCurrentPage: () => string;
onPageChange: (callback: (page: string) => void) => { unregister: () => void };
}
export interface StorageAPI<T = any> {
/**
* Register a callback to be called when a storage value changes
*/
onChange: <K extends keyof T>(key: K, callback: (value: T[K]) => void) => { unregister: () => void };
/**
* Promise that resolves when storage values are loaded
*/
loaded: Promise<void>;
}
export type TypedStorageAPI<T> = StorageAPI<T> & {
[K in keyof T]: T[K];
}
export interface EventsAPI {
on: (event: string, callback: (...args: any[]) => void) => { unregister: () => void };
emit: (event: string, ...args: any[]) => void;
}
export interface PluginAPI<T extends PluginSettings, S = any> {
seqta: SEQTAAPI;
settings: SettingsAPI<T>;
storage: TypedStorageAPI<S>;
events: EventsAPI;
}
export interface Plugin<T extends PluginSettings = PluginSettings, S = any> {
id: string;
name: string;
description: string;
version: string;
settings: T;
styles?: string; // Optional CSS styles for the plugin
disableToggle?: boolean; // Optional flag to show/hide the plugin's enable/disable toggle in settings
run: (api: PluginAPI<T, S>) => void | Promise<void> | (() => void) | Promise<(() => void)>;
}
+34
View File
@@ -0,0 +1,34 @@
import { PluginManager } from './core/manager';
// plugins
import timetablePlugin from './built-in/timetable';
import notificationCollectorPlugin from './built-in/notificationCollector';
import themesPlugin from './built-in/themes';
import animatedBackgroundPlugin from './built-in/animatedBackground';
import assessmentsAveragePlugin from './built-in/assessmentsAverage';
import globalSearchPlugin from './built-in/globalSearch/src/core';
import testPlugin from './built-in/test';
// Initialize plugin manager
const pluginManager = PluginManager.getInstance();
// Register built-in plugins
pluginManager.registerPlugin(themesPlugin);
pluginManager.registerPlugin(animatedBackgroundPlugin);
pluginManager.registerPlugin(assessmentsAveragePlugin);
pluginManager.registerPlugin(notificationCollectorPlugin);
pluginManager.registerPlugin(timetablePlugin);
pluginManager.registerPlugin(globalSearchPlugin);
pluginManager.registerPlugin(testPlugin);
export { init as Monofile } from './monofile';
export async function initializePlugins(): Promise<void> {
await pluginManager.startAllPlugins();
}
export { pluginManager };
export function getAllPluginSettings() {
return pluginManager.getAllPluginSettings();
}
+676
View File
@@ -0,0 +1,676 @@
// Third-party libraries
import browser from "webextension-polyfill"
import { animate, stagger } from "motion"
// Internal utilities and functions
import { ChangeMenuItemPositions, MenuOptionsOpen } from "@/seqta/utils/Openers/OpenMenuOptions"
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour"
import { waitForElm } from "@/seqta/utils/waitForElm"
import { delay } from "@/seqta/utils/delay"
import stringToHTML from "@/seqta/utils/stringToHTML"
import { MessageHandler } from "@/seqta/utils/listeners/MessageListener"
import {
settingsState,
} from "@/seqta/utils/listeners/SettingsState"
import { StorageChangeHandler } from "@/seqta/utils/listeners/StorageChanges"
import { eventManager } from "@/seqta/utils/listeners/EventManager"
// UI and theme management
import RegisterClickListeners from "@/seqta/utils/listeners/ClickListeners"
import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements"
import { updateAllColors } from "@/seqta/ui/colors/Manager"
import loading from "@/seqta/ui/Loading"
import { SendNewsPage } from "@/seqta/utils/SendNewsPage"
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"
import { OpenWhatsNewPopup } from "@/seqta/utils/Whatsnew"
// JSON content
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json"
// Icons and fonts
import IconFamily from "@/resources/fonts/IconFamily.woff"
// Stylesheets
import iframeCSS from "@/css/iframe.scss?raw"
function SetDisplayNone(ElementName: string) {
return `li[data-key=${ElementName}]{display:var(--menuHidden) !important; transition: 1s;}`
}
async function HideMenuItems(): Promise<void> {
try {
let stylesheetInnerText: string = ""
for (const [menuItem, { toggle }] of Object.entries(
settingsState.menuitems,
)) {
if (!toggle) {
stylesheetInnerText += SetDisplayNone(menuItem)
console.info(`[BetterSEQTA+] Hiding ${menuItem} menu item`)
}
}
const menuItemStyle: HTMLStyleElement = document.createElement("style")
menuItemStyle.innerText = stylesheetInnerText
document.head.appendChild(menuItemStyle)
} catch (error) {
console.error("[BetterSEQTA+] An error occurred:", error)
}
}
export function hideSideBar() {
const sidebar = document.getElementById("menu") // The sidebar element to be closed
const main = document.getElementById("main") // The main content element that must be resized to fill the page
const currentMenuWidth = window.getComputedStyle(sidebar!).width // Get the styles of the different elements
const currentContentPosition = window.getComputedStyle(main!).position
if (currentMenuWidth != "0") {
// Actually modify it to collapse the sidebar
sidebar!.style.width = "0"
} else {
sidebar!.style.width = "100%"
}
if (currentContentPosition != "relative") {
main!.style.position = "relative"
} else {
main!.style.position = "absolute"
}
}
export async function finishLoad() {
try {
document.querySelector(".legacy-root")?.classList.remove("hidden")
const loadingbk = document.getElementById("loading")
loadingbk?.classList.add("closeLoading")
await delay(501)
loadingbk?.remove()
} catch (err) {
console.error("Error during loading cleanup:", err)
}
if (settingsState.justupdated && !document.getElementById("whatsnewbk")) {
OpenWhatsNewPopup()
}
}
export function GetCSSElement(file: string) {
const cssFile = browser.runtime.getURL(file)
const fileref = document.createElement("link")
fileref.setAttribute("rel", "stylesheet")
fileref.setAttribute("type", "text/css")
fileref.setAttribute("href", cssFile)
return fileref
}
function removeThemeTagsFromNotices() {
// Grabs an array of the notice iFrames
const userHTMLArray = document.getElementsByClassName("userHTML")
// Iterates through the array, applying the iFrame css
for (const item of userHTMLArray) {
// Grabs the HTML of the body tag
const item1 = item as HTMLIFrameElement
const body = item1.contentWindow!.document.querySelectorAll("body")[0]
if (body) {
// Replaces the theme tag with nothing
const bodyText = body.innerHTML
body.innerHTML = bodyText
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " ")
}
}
}
async function updateIframesWithDarkMode(): Promise<void> {
const cssLink = document.createElement("style")
cssLink.classList.add("iframecss")
const cssContent = document.createTextNode(iframeCSS)
cssLink.appendChild(cssContent)
eventManager.register(
"iframeAdded",
{
elementType: "iframe",
customCheck: (element: Element) =>
!element.classList.contains("iframecss"),
},
(element) => {
const iframe = element as HTMLIFrameElement
try {
applyDarkModeToIframe(iframe, cssLink)
if (element.classList.contains("cke_wysiwyg_frame")) {
(async () => {
await delay(100)
iframe.contentDocument?.body.setAttribute("spellcheck", "true")
})()
}
} catch (error) {
console.error("Error applying dark mode:", error)
}
},
)
}
function applyDarkModeToIframe(
iframe: HTMLIFrameElement,
cssLink: HTMLStyleElement,
): void {
const iframeDocument = iframe.contentDocument
if (!iframeDocument) return
iframe.onload = () => {
applyDarkModeToIframe(iframe, cssLink)
}
if (settingsState.DarkMode) {
iframeDocument.documentElement.classList.add("dark")
}
const head = iframeDocument.head
if (head && !head.innerHTML.includes("iframecss")) {
head.innerHTML += cssLink.outerHTML
}
}
function SortMessagePageItems(messagesParentElement: any) {
try {
let filterbutton = document.createElement("div")
filterbutton.classList.add("messages-filterbutton")
filterbutton.innerText = "Filter"
let header = document.querySelector(
"[class*='MessageList__MessageList___']",
) as HTMLElement
header.append(filterbutton)
messagesParentElement
} catch (error) {
console.error("Error sorting message page items:", error)
}
}
async function LoadPageElements(): Promise<void> {
await AddBetterSEQTAElements()
const sublink: string | undefined = window.location.href.split("/")[4]
eventManager.register(
"messagesAdded",
{
elementType: "div",
className: "messages",
},
handleMessages,
)
eventManager.register(
"noticesAdded",
{
elementType: "div",
className: "notices",
},
CheckNoticeTextColour,
)
eventManager.register(
"dashboardAdded",
{
elementType: "div",
className: "dashboard",
},
handleDashboard,
)
eventManager.register(
"documentsAdded",
{
elementType: "div",
className: "documents",
},
handleDocuments,
)
eventManager.register(
"reportsAdded",
{
elementType: "div",
className: "reports",
},
handleReports,
)
/* eventManager.register(
"timetableAdded",
{
elementType: "div",
className: "timetablepage",
},
handleTimetable,
) */
eventManager.register(
"noticesAdded",
{
elementType: "div",
className: "notice",
},
handleNotices,
)
RegisterClickListeners()
await handleSublink(sublink)
}
async function handleNotices(node: Element): Promise<void> {
if (!(node instanceof HTMLElement)) return
if (!settingsState.animations) return
node.style.opacity = "0"
// get index of node in relation to parent
const index = Array.from(node.parentElement!.children).indexOf(node)
animate(
node,
{ opacity: [0, 1], y: [50, 0], scale: [0.99, 1] },
{
delay: 0.1 * index,
type: "spring",
stiffness: 250,
damping: 20,
},
)
}
async function handleSublink(sublink: string | undefined): Promise<void> {
switch (sublink) {
case "news":
await handleNewsPage()
break
case undefined:
window.location.replace(`${location.origin}/#?page=/${settingsState.defaultPage}`)
if (settingsState.defaultPage === "home") loadHomePage()
if (settingsState.defaultPage === "documents")
handleDocuments(document.querySelector(".documents")!)
if (settingsState.defaultPage === "reports")
handleReports(document.querySelector(".reports")!)
if (settingsState.defaultPage === "messages")
handleMessages(document.querySelector(".messages")!)
finishLoad()
break
case "home":
window.location.replace(`${location.origin}/#?page=/home`)
console.info("[BetterSEQTA+] Started Init")
if (settingsState.onoff) loadHomePage()
finishLoad()
break
default:
await handleDefault()
break
}
}
async function handleNewsPage(): Promise<void> {
console.info("[BetterSEQTA+] Started Init")
if (settingsState.onoff) {
SendNewsPage()
finishLoad()
}
}
async function handleDefault(): Promise<void> {
finishLoad()
}
async function handleMessages(node: Element): Promise<void> {
if (!(node instanceof HTMLElement)) return
const element = document.getElementById("title")!.firstChild as HTMLElement
element.innerText = "Direct Messages"
document.title = "Direct Messages ― SEQTA Learn"
SortMessagePageItems(node)
if (!settingsState.animations) return
// Hides messages on page load
const style = document.createElement("style")
style.classList.add("messageHider")
style.innerHTML = "[data-message]{opacity: 0 !important;}"
document.head.append(style)
await waitForElm("[data-message]", true, 10)
const messages = Array.from(
document.querySelectorAll("[data-message]"),
).slice(0, 35)
animate(
messages,
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.03),
duration: 0.5,
ease: [0.22, 0.03, 0.26, 1],
},
)
document.head.querySelector("style.messageHider")?.remove()
}
async function handleDashboard(node: Element): Promise<void> {
if (!(node instanceof HTMLElement)) return
if (!settingsState.animations) return
const style = document.createElement("style")
style.classList.add("dashboardHider")
style.innerHTML = ".dashboard{opacity: 0 !important;}"
document.head.append(style)
await waitForElm(".dashlet", true, 10)
animate(
".dashboard > *",
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.1),
duration: 0.5,
ease: [0.22, 0.03, 0.26, 1],
},
)
document.head.querySelector("style.dashboardHider")?.remove()
}
async function handleDocuments(node: Element): Promise<void> {
if (!(node instanceof HTMLElement)) return
if (!settingsState.animations) return
await waitForElm(".document", true, 10)
animate(
".documents tbody tr.document",
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.05),
duration: 0.5,
ease: [0.22, 0.03, 0.26, 1],
},
)
}
async function handleReports(node: Element): Promise<void> {
if (!(node instanceof HTMLElement)) return
if (!settingsState.animations) return
await waitForElm(".report", true, 10)
animate(
".reports .item",
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.05, { startDelay: 0.2 }),
duration: 0.5,
ease: [0.22, 0.03, 0.26, 1],
},
)
}
function CheckNoticeTextColour(notice: any) {
eventManager.register(
"noticeAdded",
{
elementType: "div",
className: "notice",
parentElement: notice,
},
(node) => {
var hex = (node as HTMLElement).style.cssText.split(" ")[1]
if (hex) {
const hex1 = hex.slice(0, -1)
var threshold = GetThresholdOfColor(hex1)
if (settingsState.DarkMode && threshold < 100) {
(node as HTMLElement).style.cssText = "--color: undefined;"
}
}
},
)
}
export function tryLoad() {
waitForElm(".login").then(() => {
finishLoad()
})
waitForElm(".day-container").then(() => {
finishLoad()
})
waitForElm("[data-key=welcome]").then((elm: any) => {
elm.classList.remove("active")
})
waitForElm(".code", true, 50).then((elm: any) => {
if (!elm.innerText.includes("BetterSEQTA")) LoadPageElements()
})
updateIframesWithDarkMode()
// Waits for page to call on load, run scripts
document.addEventListener(
"load",
function () {
removeThemeTagsFromNotices()
},
true,
)
}
function ReplaceMenuSVG(element: HTMLElement, svg: string) {
let item = element.firstChild as HTMLElement
item!.firstChild!.remove()
item.innerHTML = `<span>${item.innerHTML}</span>`
let newsvg = stringToHTML(svg).firstChild
item.insertBefore(newsvg as Node, item.firstChild)
}
const processedSymbol = Symbol('processed')
export async function ObserveMenuItemPosition() {
await waitForElm("#menu > ul > li")
eventManager.register(
"menuList",
{
parentElement: document.querySelector("#menu")!.firstChild as Element,
},
(element: Element) => {
const node = element as HTMLElement
// Only process top-level menu items and skip everything else
if (!node.classList.contains('item') ||
node.nodeName !== 'LI' ||
node.parentElement?.parentElement?.id !== 'menu') {
return
}
// Early exit if already processed
if ((element as any)[processedSymbol]) {
return
}
if (!node?.dataset?.checked && !MenuOptionsOpen) {
const key =
MenuitemSVGKey[node?.dataset?.key! as keyof typeof MenuitemSVGKey]
if (key) {
ReplaceMenuSVG(
node,
MenuitemSVGKey[node.dataset.key as keyof typeof MenuitemSVGKey],
)
} else if (node?.firstChild?.nodeName === "LABEL") {
const label = node.firstChild as HTMLElement
let textNode = label.lastChild as HTMLElement
if (
textNode.nodeType === 3 &&
textNode.parentNode &&
textNode.parentNode.nodeName !== "SPAN"
) {
const span = document.createElement("span")
span.textContent = textNode.nodeValue
label.replaceChild(span, textNode)
}
}
ChangeMenuItemPositions(settingsState.menuorder);
(element as any)[processedSymbol] = true
}
},
)
}
export function showConflictPopup() {
if (document.getElementById("conflict-popup")) return
document.body.classList.remove("hidden")
const background = document.createElement("div")
background.id = "conflict-popup"
background.classList.add("whatsnewBackground")
background.style.zIndex = "10000000"
const container = document.createElement("div")
container.classList.add("whatsnewContainer")
container.style.height = "auto"
const headerHTML = /* html */ `
<div class="whatsnewHeader">
<h1>Extension Conflict Detected</h1>
<p>Legacy BetterSEQTA Installed</p>
</div>
`
const header = stringToHTML(headerHTML).firstChild
const textHTML = /* html */ `
<div class="whatsnewTextContainer" style="overflow-y: auto; font-size: 1.3rem;">
<p>
It appears that you have the legacy BetterSEQTA extension installed alongside BetterSEQTA+.
This conflict may cause unexpected behavior. (and breaks the extension)
</p>
<p>
Please remove the older BetterSEQTA extension to ensure that BetterSEQTA+ works correctly.
</p>
</div>
`
const text = stringToHTML(textHTML).firstChild
const exitButton = document.createElement("div")
exitButton.id = "whatsnewclosebutton"
if (header) container.append(header)
if (text) container.append(text)
container.append(exitButton)
background.append(container)
document.getElementById("container")?.append(background)
if (settingsState.animations) {
animate([background as HTMLElement], { opacity: [0, 1] })
}
background.addEventListener("click", (event) => {
if (event.target === background) {
background.remove()
}
})
exitButton.addEventListener("click", () => {
background.remove()
})
}
export function init() {
const handleDisabled = () => {
waitForElm(".code", true, 50).then(AppendElementsToDisabledPage)
}
if (settingsState.onoff) {
console.info("[BetterSEQTA+] Enabled")
if (settingsState.DarkMode) document.documentElement.classList.add("dark");
document.querySelector(".legacy-root")?.classList.add("hidden")
ObserveMenuItemPosition();
new StorageChangeHandler()
new MessageHandler()
updateAllColors()
loading()
InjectCustomIcons()
HideMenuItems()
tryLoad()
setTimeout(() => {
const legacyElement = document.querySelector(
".outside-container .bottom-container",
)
if (legacyElement) {
console.log("Legacy extension detected")
showConflictPopup()
}
}, 1000)
} else {
handleDisabled()
window.addEventListener("load", handleDisabled)
}
}
function InjectCustomIcons() {
console.info("[BetterSEQTA+] Injecting Icons")
const style = document.createElement("style")
style.setAttribute("type", "text/css")
style.innerHTML = `
@font-face {
font-family: 'IconFamily';
src: url('${browser.runtime.getURL(IconFamily)}') format('woff');
font-weight: normal;
font-style: normal;
}`
document.head.appendChild(style)
}
export function AppendElementsToDisabledPage() {
console.info("[BetterSEQTA+] Appending elements to disabled page")
AddBetterSEQTAElements()
let settingsStyle = document.createElement("style")
settingsStyle.innerHTML = /* css */ `
.addedButton {
position: absolute !important;
right: 50px;
width: 35px;
height: 35px;
padding: 6px !important;
overflow: unset !important;
border-radius: 50%;
margin: 7px !important;
cursor: pointer;
color: white !important;
}
.addedButton svg {
margin: 6px;
}
.outside-container {
top: 48px !important;
}
#ExtensionPopup {
border-radius: 1rem;
box-shadow: 0px 0px 20px -2px rgba(0, 0, 0, 0.6);
transform-origin: 70% 0;
}
`
document.head.append(settingsStyle)
}
+3 -2
View File
@@ -1,6 +1,7 @@
export default { module.exports = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, }
} }
+42
View File
@@ -0,0 +1,42 @@
// Third-party libraries
import browser from "webextension-polyfill"
// Internal utilities and functions
import {
settingsState,
} from "@/seqta/utils/listeners/SettingsState"
// UI and theme management
import pageState from "@/pageState.js?url"
// Stylesheets
import injectedCSS from "@/css/injected.scss?inline"
export async function main() {
return new Promise(async (resolve, reject) => {
try {
if (settingsState.onoff) {
injectPageState()
// TEMP FIX for bug! -> this is a hack to get the injected.css file to have HMR in development mode as this import system is currently broken with crxjs
if (import.meta.env.MODE === "development") {
import("../css/injected.scss")
} else {
const injectedStyle = document.createElement("style")
injectedStyle.textContent = injectedCSS
document.head.appendChild(injectedStyle)
}
}
resolve(true)
} catch (error: any) {
console.error(error)
reject(error)
}
})
}
function injectPageState() {
const mainScript = document.createElement("script")
mainScript.src = browser.runtime.getURL(pageState)
document.head.appendChild(mainScript)
}
+7 -8
View File
@@ -1,5 +1,10 @@
import { addExtensionSettings, enableAnimatedBackground, GetThresholdOfColor, loadHomePage, SendNewsPage, setupSettingsButton } from "@/SEQTA"; import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings";
import { updateBgDurations } from "./Animation"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { appendBackgroundToUI } from "./ImageBackgrounds"; import { appendBackgroundToUI } from "./ImageBackgrounds";
import stringToHTML from "@/seqta/utils/stringToHTML"; import stringToHTML from "@/seqta/utils/stringToHTML";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
@@ -35,7 +40,6 @@ async function getUserInfo() {
export async function AddBetterSEQTAElements() { export async function AddBetterSEQTAElements() {
if (settingsState.onoff) { if (settingsState.onoff) {
initializeSettings();
if (settingsState.DarkMode) { if (settingsState.DarkMode) {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
} }
@@ -69,11 +73,6 @@ export async function AddBetterSEQTAElements() {
setupSettingsButton(); setupSettingsButton();
} }
function initializeSettings() {
enableAnimatedBackground();
updateBgDurations();
}
function createHomeButton(fragment: DocumentFragment, menuList: HTMLElement) { function createHomeButton(fragment: DocumentFragment, menuList: HTMLElement) {
const container = document.getElementById('content')!; const container = document.getElementById('content')!;
const div = document.createElement('div'); const div = document.createElement('div');
-37
View File
@@ -1,37 +0,0 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
/**
* Update the background animation durations based on the slider input.
* @param {Object} item - The object containing the bksliderinput property.
* @param {number} [minDuration=1] - The minimum animation duration in seconds.
* @param {number} [maxDuration=10] - The maximum animation duration in seconds.
*/
export function updateBgDurations() {
// Class names to look for
const bgClasses = ['bg', 'bg2', 'bg3'];
// Function to calculate animation duration
const calcDuration = (
baseValue: number,
offset = 0,
minBase = 50,
maxBase = 150,
) => {
const scaledValue = 2 + ((maxBase - baseValue) / (maxBase - minBase)) ** 4;
return scaledValue + offset;
};
// Iterate through each class name to update its animation duration
bgClasses.forEach((className, index) => {
const elements = document.getElementsByClassName(className);
if (elements.length === 0) {
return;
}
const offset = index * 0.05;
const duration = calcDuration(parseInt(settingsState.bksliderinput), offset);
(elements[0] as HTMLElement).style.animationDuration = `${duration}s`;
(elements[0] as HTMLElement).style.animationDelay = `${offset * 5}s`;
});
}
+1 -1
View File
@@ -1,5 +1,5 @@
import browser from 'webextension-polyfill' import browser from 'webextension-polyfill'
import { GetThresholdOfColor } from '@/SEQTA'; import { GetThresholdOfColor } from '@/seqta/ui/colors/getThresholdColour';
import { lightenAndPaleColor } from './lightenAndPaleColor'; import { lightenAndPaleColor } from './lightenAndPaleColor';
import ColorLuminance from './ColorLuminance'; import ColorLuminance from './ColorLuminance';
import { settingsState } from '@/seqta/utils/listeners/SettingsState'; import { settingsState } from '@/seqta/utils/listeners/SettingsState';
+38
View File
@@ -0,0 +1,38 @@
import Color from "color"
export function GetThresholdOfColor(color: any) {
if (!color) return 0
// Case-insensitive regular expression for matching RGBA colors
const rgbaRegex = /rgba?\(([^)]+)\)/gi
// Check if the color string is a gradient (linear or radial)
if (color.includes("gradient")) {
let gradientThresholds = []
// Find and replace all instances of RGBA in the gradient
let match
while ((match = rgbaRegex.exec(color)) !== null) {
// Extract the individual components (r, g, b, a)
const rgbaString = match[1]
const [r, g, b] = rgbaString.split(",").map((str) => str.trim())
// Compute the threshold using your existing algorithm
const threshold = Math.sqrt(
parseInt(r) ** 2 + parseInt(g) ** 2 + parseInt(b) ** 2,
)
// Store the computed threshold
gradientThresholds.push(threshold)
}
// Calculate the average threshold
const averageThreshold =
gradientThresholds.reduce((acc, val) => acc + val, 0) /
gradientThresholds.length
return averageThreshold
} else {
// Handle the color as a simple RGBA (or hex, or whatever the Color library supports)
const rgb = Color.rgb(color).object()
return Math.sqrt(rgb.r ** 2 + rgb.g ** 2 + rgb.b ** 2)
}
}
+6 -6
View File
@@ -77,26 +77,26 @@ const contentConfig: ContentConfig = {
}, },
messageSubject: { messageSubject: {
selector: '.MessageList__subject___1NV5O', selector: '[class*="MessageList__subject___"]',
action: (element) => { element.textContent = getRandomElement(mockData.messages.subjects); } action: (element) => { element.textContent = getRandomElement(mockData.messages.subjects); }
}, },
messageSender: { messageSender: {
selector: '.MessageList__value___1sN24', selector: '[class*="MessageList__value___"]',
action: (element) => { element.textContent = getRandomElement(mockData.messages.sender); } action: (element) => { element.textContent = getRandomElement(mockData.messages.sender); }
}, },
messageRecipients: { messageRecipients: {
selector: '.MessageList__recipients___3hqpE .MessageList__value___1sN24', selector: '[class*="MessageList__recipients___"] [class*="MessageList__value___"]',
action: (element) => { element.textContent = 'Recipient(s) Redacted'; } action: (element) => { element.textContent = 'Recipient(s) Redacted'; }
}, },
messageDate: { messageDate: {
selector: '.MessageList__date___7muMb', selector: '[class*="MessageList__date___"]',
action: (element) => { element.textContent = getRandomDate().toLocaleDateString('en-US', { weekday: 'long', day: 'numeric', month: 'long' }); } action: (element) => { element.textContent = getRandomDate().toLocaleDateString('en-US', { weekday: 'long', day: 'numeric', month: 'long' }); }
}, },
avatarImage: { avatarImage: {
selector: '.Avatar__Avatar___gE5kx', selector: '[class*="Avatar__Avatar___"]',
action: (element) => { action: (element) => {
if (element instanceof HTMLElement) { if (element instanceof HTMLElement) {
element.style.removeProperty('background-image'); element.style.removeProperty('background-image');
@@ -105,7 +105,7 @@ const contentConfig: ContentConfig = {
} }
}, },
notificationCount: { notificationCount: {
selector: '.notifications__bubble___1EkSQ', selector: '[class*="notifications__bubble___"]',
action: (element) => { element.textContent = Math.floor(Math.random() * 100).toString(); } action: (element) => { element.textContent = Math.floor(Math.random() * 100).toString(); }
}, },
schoolName: { schoolName: {

Some files were not shown because too many files have changed in this diff Show More