Compare commits

...

217 Commits

Author SHA1 Message Date
SethBurkart123 a0038ac871 chore: bump version to 3.6.2 2026-04-20 15:53:20 +10:00
SethBurkart123 49824e9eab refactor: remove alarms permission, throttle cloud sync to once per day on page load 2026-04-20 15:47:22 +10:00
SethBurkart123 01eeb18638 perf: throttle theme update check to daily and remove redundant cloud poll on page load 2026-04-20 15:39:37 +10:00
SethBurkart123 3702443ece chore: bump version to 3.6.1 2026-04-20 15:33:04 +10:00
SethBurkart123 87ba75ff41 feat: simplify startup popups and convert engage announcement to toast
Remove auto-showing privacy statement and BS Cloud announcement from
startup queue. Convert SEQTA Engage announcement from a blocking modal
to a subtle bottom-right dismissable toast.
2026-04-20 14:59:24 +10:00
SethBurkart123 f9406fb469 feat: redesign Cloud settings UI and switch to OAuth redirect login
- Move Cloud section inline with other settings, remove dedicated header bar
- Replace in-extension login form with browser redirect to accounts.betterseqta.org
- Background script intercepts OAuth callback URL to capture tokens
- Add animated CloudPanel overlay (same pattern as ColourPicker)
- Hide cloud sync details and profile picture setting when not signed in
- Simplify CloudSettingsSync UI, reduce text verbosity
- Fix settings download to merge keys instead of clear+set
- Add legacy-to-plugin settings migration for cloud sync
- Shorten profile picture and default page descriptions
- Make DisclaimerModal title/message dynamic
- Update CloudHeader button styling to match other buttons
2026-04-20 13:42:49 +10:00
AdenMGB 690792fd62 chore: update chaneglog 2026-04-17 15:59:04 +09:30
AdenMGB f6ac112329 fix: fix the timetable edit plugin 2026-04-17 15:55:32 +09:30
AdenMGB ec68cec0ca feat: add smooth animation to notifications opening like settings 2026-04-17 15:51:25 +09:30
AdenMGB 44a029057a feat: auto download settings upon login 2026-04-13 19:42:11 +09:30
AdenMGB 249d1c1b4a feat: auto sync popoup 2026-04-13 19:39:57 +09:30
AdenMGB 6748b15024 feat: tweak engage popup 2026-04-13 19:33:28 +09:30
Aden Lindsay 8b26d07865 Merge pull request #426 from StroepWafel/main
update video (and edit notes)
2026-04-13 19:12:35 +09:30
Aden Lindsay 55d96a5e8f Update theme name in WhatsNewPopup 2026-04-13 19:12:18 +09:30
StroepWafel 133a5197aa Update update-video.webm 2026-04-13 19:10:32 +09:30
StroepWafel c0fa1576f3 Update update-video.webm 2026-04-12 23:28:30 +09:30
AdenMGB 48fbcde6ae fix: fix last two engage bugs 2026-04-12 20:14:52 +09:30
AdenMGB 89f50f774f feat: image full screen overlay for popoups 2026-04-12 20:06:03 +09:30
AdenMGB 1d9b8f3747 feat: queue popups and new engage popup 2026-04-12 19:54:43 +09:30
StroepWafel 0a5359df72 update video (and edit notes) 2026-04-12 19:19:19 +09:30
AdenMGB 2e9a643a8c fix: fix engage not always applying 2026-04-10 21:21:19 +09:30
AdenMGB 39d0b60024 feat: engage homepag 2026-04-08 11:27:38 +09:30
AdenMGB c7a6bf051c chore: bump ver and changelog 2026-04-08 08:49:37 +09:30
AdenMGB ea4a2c1ff0 feat: auto sync for cloud and fix some firefox weirdness 2026-04-08 08:29:25 +09:30
AdenMGB 71b7c9eb64 tweak: tweak cloud UI a lil bit 2026-04-08 07:40:20 +09:30
Aden Lindsay 0cac3022f5 Merge pull request #425 from StroepWafel/cloud-settings-backup
Cloud settings backup
2026-04-08 07:25:09 +09:30
StroepWafel 8b16a21d48 tweaks and fixes to UI 2026-04-07 22:39:09 +09:30
Aden Lindsay 2085ebe189 Upgrade project to Vite 8 + some cleanup (#424)
* Upgrade project to Vite 8

* Fix vite
2026-04-07 21:29:20 +09:30
AdenMGB 01c657d247 Merge branch 'main' of https://github.com/BetterSEQTA/BetterSEQTA-Plus 2026-04-07 21:25:15 +09:30
AdenMGB 1f26fb26d7 fix: somehwat fix overlap with vanilla seqta averages 2026-04-07 21:25:07 +09:30
Jones8683 dbd8e2be8e Merge branch 'main' of https://github.com/Jones8683/BetterSEQTA-Plus 2026-04-07 21:22:47 +09:30
Jones8683 6b5f00add0 Fix vite 2026-04-07 21:22:45 +09:30
Jones Jankovic 1f5eef2fb1 Merge branch 'BetterSEQTA:main' into main 2026-04-07 21:21:42 +09:30
StroepWafel 1e6e57ddcd feat: bs cloud header in settings, cloud pfp as local pfp option & doc updates
updates to docs and also profile
2026-04-07 21:15:29 +09:30
Aden Lindsay e18853ba3a Merge pull request #422 from StroepWafel/patch-2
Update README.md
2026-04-07 21:10:43 +09:30
Aden Lindsay 140cd66c9b chore: update old and non existant links 2026-04-07 21:09:55 +09:30
Jones8683 5684857456 Upgrade project to Vite 8 2026-04-07 20:13:47 +09:30
StroepWafel 24fee7a743 updates to docs and also profile 2026-04-07 20:02:30 +09:30
StroepWafel cef99b7278 Update README.md 2026-04-07 19:55:06 +09:30
StroepWafel aad5bcd97e Update README.md 2026-04-07 19:50:24 +09:30
StroepWafel 423aaa6b84 Update README.md 2026-04-07 19:41:45 +09:30
AdenMGB 97a1226eaf feat(engage): add icon-only sidebar support and Engage-specific titlebar styles
Made-with: Cursor
2026-04-07 15:11:26 +09:30
AdenMGB 72cab5905e fix(engage): disable assessments overview plugin on Engage
Made-with: Cursor
2026-04-07 15:11:15 +09:30
AdenMGB 088b745600 feat(engage): inject settings button, dark/light toggle, and user info with logout
Made-with: Cursor
2026-04-07 15:11:07 +09:30
AdenMGB 8801d5a435 feat(engage): fix loading screen hang and handle login/logout flow
Made-with: Cursor
2026-04-07 15:10:58 +09:30
Aden Lindsay b63a3d95f3 fix today's lessons tweaking out (#420) 2026-04-07 09:14:08 +09:30
StroepWafel eca8327420 fix today's lessons tweaking out 2026-04-07 09:05:13 +09:30
AdenMGB 05cf380e86 feat: some messed up stuff to fix some stuff 2026-04-07 08:46:28 +09:30
StroepWafel 73f005d645 Better theme displaying (#419)
* add more data

add more info for users about themes and also make related themes actually show related themes

* sorting and similar
2026-04-07 08:22:56 +09:30
StroepWafel f2fa9c39a9 Merge pull request #418 from StroepWafel/theme-updates
Theme updates
2026-04-07 08:13:27 +09:30
Aden Lindsay 783ff65fb5 Merge pull request #417 from StroepWafel/updated-sign-in
feat: Unified portaled sign-in overlay
2026-04-06 17:22:10 +09:30
Aden Lindsay 3e7ea3bc03 Adaptive custom themes (#416)
* feat: Smooth change in colour, no hard cut

Added option smoothing on colour change so there is no hard cut made when switching subjects

* feat: Themes can adapt to colour

* quick fix to forced mode as well

* [CodeFactor] Apply fixes

---------

Co-authored-by: codefactor-io <support@codefactor.io>
2026-04-06 17:21:38 +09:30
StroepWafel 398029eecd Merge branch 'main' into adaptive-custom-themes 2026-04-06 15:01:38 +09:30
StroepWafel a55cb84a69 feat: Smooth change in colour, no hard cut (#415)
Added option smoothing on colour change so there is no hard cut made when switching subjects
2026-04-06 14:58:09 +09:30
StroepWafel 94d54f65bf feat: Unified portaled sign-in overlay
Updated the sign-in overlay to be unified across the site, improving UX
2026-04-06 14:48:03 +09:30
codefactor-io 8123c5dd33 [CodeFactor] Apply fixes 2026-04-06 04:55:44 +00:00
StroepWafel ac1ee702ae quick fix to forced mode as well 2026-04-06 14:24:08 +09:30
StroepWafel e657152e3f feat: Themes can adapt to colour 2026-04-06 14:11:19 +09:30
StroepWafel f667ff9e9b feat: Smooth change in colour, no hard cut
Added option smoothing on colour change so there is no hard cut made when switching subjects
2026-04-06 13:39:25 +09:30
AdenMGB 3c613f4938 chore: bump ver 2026-04-03 10:55:41 +10:30
AdenMGB 04843a90fe fix: fix adaptive themeing to support current year subjects 2026-04-03 10:47:56 +10:30
AdenMGB 834d585ac7 chore: release ntoe 2026-03-29 20:26:19 +10:30
AdenMGB 343fa7ca9f feat: migrate pdfjs to local & bump ver 2026-03-29 20:25:06 +10:30
AdenMGB e049f34a5e feat: WIP Engage progress 2026-03-28 09:06:54 +10:30
AdenMGB d692f60291 fix: fix qr code to use safer methoed & bump ver 2026-03-25 08:48:47 +10:30
Jones Jankovic a0367be686 feat: Group settings better and make their descriptions more consistent (#405)
* Reorder and make description mroe consistent of settings

* Make soft gradient description consistent

* Consistent description for music plugin descriptionns

* Update src/plugins/built-in/profilePicture/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/backgroundMusic/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/backgroundMusic/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/backgroundMusic/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/backgroundMusic/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/backgroundMusic/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/backgroundMusic/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/backgroundMusic/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/profilePicture/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Formatting

* Formatting

* Formatting

* Formatting

* Formatting

* Prettier ignore

* Formatting

* format

* Update src/plugins/built-in/timetableEdit/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Update src/plugins/built-in/timetableEdit/index.ts

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>

* Surely thats it

* Finally

* Add back stuff

* fix: fix formatting

---------

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Co-authored-by: Aden Lindsay <lindsaya542@gmail.com>
2026-03-24 12:22:53 +10:30
Aden Lindsay aa6b15aa1b Merge pull request #410 from Jaxx7594/qr
Fix: QR code gen issues on Firefox
2026-03-21 09:10:44 +10:30
Jaxon Lewis-Wilson 08342c3873 Fix: QR code in embedded settings (firefox)
Firefox has an issue where a backdrop filter within a shadow dom breaks the drawing of the popup. This disables the filter only in the embedded menu to avoid this issue altogether.
2026-03-20 19:43:40 +08:00
Aden Lindsay 6527a33e38 Merge pull request #407 from Jaxx7594/background
Fix: Animated background race condition
2026-03-20 08:05:58 +10:30
Jaxon Lewis-Wilson 43f125e45d Fix: QR code gen in extension settings (firefox) 2026-03-19 23:24:51 +08:00
Jaxon Lewis-Wilson 49cc1e26c0 Fix: Animated background race condition 2026-03-19 23:09:53 +08:00
SethBurkart123 809a82f31d fix: update publish-extension 2026-03-18 13:01:55 +11:00
SethBurkart123 3c8c68ce2f fix: re-enable clicks on menu items during sidebar editing 2026-03-18 11:54:19 +11:00
SethBurkart123 18603026a3 fix: update aspect ratio for WhatsNew image 2026-03-18 11:52:27 +11:00
SethBurkart123 52d13cbdc2 fix: dropdowns on windows hopefully 2026-03-18 11:47:46 +11:00
SethBurkart123 26c04f1c24 feat: update changelog 2026-03-18 11:43:42 +11:00
SethBurkart123 e11e402c80 fix: improve dropdown styling on windows 2026-03-18 11:35:23 +11:00
SethBurkart123 bcc7d58ddd fix: keep compact sidebar items icon-only on focus 2026-03-18 11:31:50 +11:00
SethBurkart123 685c6ad771 fix: sidebar breaking on tab 2026-03-18 11:19:54 +11:00
SethBurkart123 4b0372aa56 fix: hide empty titlebar metadata 2026-03-18 10:45:12 +11:00
SethBurkart123 27aa28740e fix: reduce unnecessary notice modal scrolling 2026-03-18 10:45:12 +11:00
AdenMGB 6291b7d0a7 feat: add the RIGHT blurred update video 2026-03-18 10:07:14 +10:30
SethBurkart123 d9f0d89450 fix: align news loader animation 2026-03-18 10:29:50 +11:00
SethBurkart123 6ad221fcb5 fix: use iconfamily eye for timetable toggle 2026-03-18 10:26:56 +11:00
SethBurkart123 f1c55e127c fix: adaptive theme colour looking all wonky 2026-03-18 10:21:37 +11:00
SethBurkart123 2f6e551e22 fix: align home empty state sizing 2026-03-18 10:06:17 +11:00
SethBurkart123 6edffd0306 fix: icons not loading on seqta pages 2026-03-18 09:52:43 +11:00
SethBurkart123 50de668d01 style: add iconfamily icon to cloud signin 2026-03-18 09:52:30 +11:00
SethBurkart123 8a05d85344 fix: news not loading 2026-03-18 09:43:32 +11:00
SethBurkart123 915ce6f5f1 chore: clean up debug logging 2026-03-18 09:27:08 +11:00
SethBurkart123 67f98b13ad feat: add empty state to notices 2026-03-18 09:26:11 +11:00
SethBurkart123 2a147c1d3a fix: auto apply icon only sidebar on load 2026-03-18 09:17:53 +11:00
Aden Lindsay aae9aa6073 Merge pull request #403 from StroepWafel/main
actually do merge well
2026-03-18 08:46:00 +10:30
StroepWafel 9a9885066f actually do WISP correctly 2026-03-18 08:44:49 +10:30
StroepWafel 760d3349c2 Merge branch 'BetterSEQTA:main' into main 2026-03-18 08:43:55 +10:30
StroepWafel ec0dd70a4b ok ONL:Y wisp content this time 2026-03-18 08:42:02 +10:30
Aden Lindsay 4b2184955a Merge pull request #401 from BetterSEQTA/revert-400-main
Revert "automatic WISP content support"
2026-03-18 08:41:26 +10:30
StroepWafel 8d214ff6a3 Revert "Update monofile.ts"
This reverts commit 725d2b2987.
2026-03-18 08:41:17 +10:30
Aden Lindsay 441df9cdf2 Revert "automatic WISP content support" 2026-03-18 08:41:09 +10:30
SethBurkart123 e6e2789a82 fix: transparent background on rich text composer controls 2026-03-18 09:11:05 +11:00
Seth Burkart 70ceb50acd Merge pull request #400 from StroepWafel/main
automatic WISP content support
2026-03-18 09:08:02 +11:00
SethBurkart123 46d5c2e9fc fix: inter font overriden by seqta 2026-03-18 09:07:01 +11:00
StroepWafel 725d2b2987 Update monofile.ts 2026-03-18 08:36:09 +10:30
SethBurkart123 9581b793b5 feat: render floating popup at extension root 2026-03-18 08:44:20 +11:00
Seth Burkart 3fc3f1191c Merge pull request #399 from StroepWafel/hide-more-sensitive-info
Update hideSensitiveContent.ts
2026-03-18 08:19:07 +11:00
StroepWafel 098c79bc99 Update hideSensitiveContent.ts
hides more info
2026-03-18 01:56:42 +10:30
AdenMGB 45b558373b feat: bump to 3.5.0 and minor fixes 2026-03-17 21:38:10 +10:30
AdenMGB 3a2c438223 feat: adaptive themeing 2026-03-16 15:40:16 +10:30
AdenMGB 577287b8a8 feat: timetable editor plugin 2026-03-16 15:16:02 +10:30
Aden Lindsay 1d13b054ee Merge pull request #396 from AdenMGB/assement-overview
Assement overview improvements, notices fix and icon only sidebar
2026-03-15 10:59:35 +10:30
AdenMGB dc3423df13 feat: icon only sidebar 2026-03-15 10:58:48 +10:30
AdenMGB 9791454d62 fix: fix colouring on assement details result bars 2026-03-15 10:39:43 +10:30
AdenMGB 17f648f3ce fix: fix notices on the homepage not being scrollable 2026-03-15 10:30:22 +10:30
AdenMGB 7d89733f96 feat: add more sorting options to kaban view 2026-03-15 10:28:55 +10:30
Aden Lindsay a0e6bdfb20 Merge pull request #391 from BetterSEQTA/revert-390-fix-389
Revert "Fix notice modal scrolling on Home tab (#389)"
2026-03-10 08:26:13 +10:30
Aden Lindsay ac76ce3f03 Revert "Fix notice modal scrolling on Home tab (#389)" 2026-03-10 08:25:51 +10:30
Aden Lindsay 4bef51a3be Merge pull request #390 from jsodf78h823f/fix-389 2026-03-10 08:20:25 +10:30
FemtoClaw Bot 781171d60a Fix notice modal scrolling on home tab 2026-03-09 20:44:01 +00:00
AdenMGB c01342a86c feat: add desqta qr code instant sign in for schools without normal qr code 2026-03-08 09:18:04 +10:30
Aden Lindsay d73a9b2acf Merge pull request #384 from AdenMGB/theme-store
Online Enhanced Theme store
2026-02-25 12:59:59 +10:30
AdenMGB 1d3643a1fc chore: remove unused code 2026-02-25 12:46:36 +10:30
AdenMGB e50de00d08 feat: move bs cloud to a more out of the way location and some theme store tweaks 2026-02-25 10:55:36 +10:30
Alphons Joseph 8c87278850 Merge pull request #385 from AdenMGB/reload-fix
fix: fix bug in Vanilla SEQTA Causing unesasary reloads
2026-02-22 18:49:21 +08:00
AdenMGB 520da46daf fix: fix bug in Vanilla SEQTA Causing unesasary reloads 2026-02-22 19:23:48 +10:30
AdenMGB 01f5e8f61d fix: cf is very annoying 2026-02-20 19:13:06 +10:30
AdenMGB 2faef2ae8d fix: fix cf like too many times 2026-02-20 19:08:36 +10:30
AdenMGB 9d24d07c12 chore: appease codefactor AGAIN 2026-02-20 18:43:02 +10:30
AdenMGB d21ce90a5c feat: download & like count + UI tweaks and cleanup 2026-02-20 18:29:11 +10:30
AdenMGB 889175f3de chore: appease codefactor 2026-02-20 18:06:43 +10:30
AdenMGB 7a70b008c8 feat: betterseqta cloud for favouriting items and future stuff 2026-02-20 10:49:38 +10:30
AdenMGB 4b251e0ea4 feat: add github fallback 2026-02-20 10:28:13 +10:30
AdenMGB f242928682 feat: query the download api for download counts 2026-02-20 10:27:53 +10:30
AdenMGB d64962147a feat: implement cloud store 2026-02-20 10:27:17 +10:30
SethBurkart123 c2cd034556 feat: update to 3.4.16 2026-02-20 07:36:01 +11:00
SethBurkart123 1039ea8137 feat: update publish-browser-extension 2026-02-20 07:31:01 +11:00
Seth Burkart ead8cf80f3 Merge pull request #383 from Jaxx7594/fix_pdfjs_api_mismatch
fix: PDF.js api mismatch
2026-02-20 07:30:15 +11:00
Jaxon Lewis-Wilson 1f0b2d6627 fix: dynamically get the correct pdfjs worker version based on pdfjs package version 2026-02-19 18:07:56 +08:00
SethBurkart123 40d7ece12b fix: patch new SEQTA UI compatibility in 3.4.15 2026-02-19 20:32:14 +11:00
Seth Burkart 098ab27a01 Merge pull request #382 from AdenMGB/font-fix
fix: fix the dodge stying by seqta
2026-02-19 17:56:49 +11:00
SethBurkart123 170b1cf5c3 fix: patch sidebar item hover and font family 2026-02-19 17:56:34 +11:00
AdenMGB 004c3cc61d fix: fix the dodge stying by seqta 2026-02-19 17:01:19 +10:30
SethBurkart123 1b938e2748 feat: 3.4.14 update details 2026-02-16 13:44:15 +11:00
Seth Burkart 652e84783b Merge pull request #369 from StroepWafel/globalSearch-improvements
Global search improvements
2026-02-16 12:30:22 +11:00
StroepWafel 17c2685cae replaced loading font 2026-02-03 15:07:58 +10:30
Seth Burkart 5e89507276 Merge pull request #378 from Jones8683/main
Fix tutorial room not showing in the upcoming lessons block on homepage
2026-02-01 15:00:54 +11:00
Jones8683 0204b97de8 Merge branch 'main' of https://github.com/Jones8683/BetterSEQTA-Plus 2026-02-01 11:37:11 +10:30
Jones8683 d849951a66 fix: tutorial room doesn't show on upcoming lessons 2026-02-01 11:37:05 +10:30
Seth Burkart 1531afd046 Merge pull request #375 from Jones8683/main
Add tutorial room to home page and begin cleaning up some stuff
2026-02-01 10:45:58 +11:00
Jones Jankovic add8a90c77 Merge branch 'BetterSEQTA:main' into main 2026-02-01 09:51:38 +10:30
Jones8683 b9c3c2b5c5 Reset LoadHomePage.ts to upstream version 2026-02-01 09:51:11 +10:30
Seth Burkart 1cc34c38a8 Merge pull request #377 from StroepWafel/theme-docs
Update THEME_CREATION.md
2026-02-01 10:02:21 +11:00
Seth Burkart e5859f419a Merge pull request #374 from Jaxx7594/weightings
feat: Assessments Average weightings
2026-02-01 10:00:25 +11:00
Seth Burkart aadc295bdb Merge branch 'main' into weightings 2026-02-01 09:59:48 +11:00
Seth Burkart af2ef23078 Merge pull request #373 from Jaxx7594/icon-race
fix: Favicon race condition
2026-02-01 09:53:14 +11:00
StroepWafel 7150f03d77 Update THEME_CREATION.md 2026-01-31 21:46:36 +10:30
Jones8683 a381de7c9b final shorthand fix 2026-01-30 20:27:59 +10:30
Jones8683 ce6a5cfdc4 fix: background shorthand warning 2026-01-30 20:11:09 +10:30
Jones8683 de75468f2b most of the rest of the dups 2026-01-30 20:00:30 +10:30
Jones8683 011c1eddb4 remove more duplicates and slight css touch ups 2026-01-30 19:45:44 +10:30
Jones8683 c9443bad27 git please work 2026-01-30 19:29:14 +10:30
Jones8683 445aa9d071 Revert "resolve codefactor warnings about animation shorthand"
This reverts commit e0009ad8dc.
2026-01-30 19:26:38 +10:30
Jones8683 14a2e93b3a styff 2026-01-30 19:25:47 +10:30
Jones8683 3746b05af2 Reapply "Add back GPL license header to iframe.scss"
This reverts commit 1d215d8c75.
2026-01-30 19:19:07 +10:30
Jones8683 1d215d8c75 Revert "Add back GPL license header to iframe.scss"
This reverts commit 401947031b.
2026-01-30 19:17:50 +10:30
Jones8683 b4b1fed576 Merge branch 'main' of https://github.com/Jones8683/BetterSEQTA-Plus 2026-01-30 19:16:14 +10:30
Jones8683 e0009ad8dc resolve codefactor warnings about animation shorthand 2026-01-30 19:16:01 +10:30
Jones Jankovic 401947031b Add back GPL license header to iframe.scss 2026-01-30 16:41:39 +10:30
Jones Jankovic 9da8e104a8 Update src/seqta/utils/Loaders/LoadHomePage.ts
Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
2026-01-30 16:37:52 +10:30
SethBurkart123 3aef2312d0 feat: cleanup and comment removal 2026-01-30 16:07:12 +11:00
Jones8683 b402221477 fix build 2026-01-30 14:58:24 +10:30
Jones8683 8b6bda6dff more cleanup 2026-01-30 08:37:38 +10:30
Jones8683 eed8bac45a fix: clean up more duplicate selectors 2026-01-29 20:53:12 +10:30
Jones Jankovic 3b7bbc9bc6 Merge Codefactor fixes
Apply fixes from CodeFactor
2026-01-29 20:29:52 +10:30
codefactor-io 9820595a70 [CodeFactor] Apply fixes 2026-01-29 09:56:34 +00:00
Jones8683 0a33ca7f6e trigger codefactor for fork 2026-01-29 20:25:22 +10:30
Jones8683 bba96d5159 format: remove some duplicate selectors 2026-01-29 20:21:30 +10:30
Jones8683 d9fe70f442 fix: remove duplicate css block 2026-01-29 20:07:13 +10:30
Jones8683 32c5c8392b fix: tutorials show room as N/A even if room is specified on timetable 2026-01-29 19:40:28 +10:30
Jaxon Lewis-Wilson cc3f06b383 Small refactor (hopefully appease CodeFactor?) 2026-01-29 14:50:48 +08:00
codefactor-io 9ad90e9416 [CodeFactor] Apply fixes to commit 87fdda4 2026-01-28 16:08:12 +00:00
Jaxon Lewis-Wilson 87fdda459a Small refactor 2026-01-29 00:07:36 +08:00
Jaxx7594 6c11bb8143 Merge pull request #2 from StroepWafel/weightings
fix try catch error
2026-01-28 23:16:42 +08:00
Jaxon Lewis-Wilson 391fcfb9dd Various changes/fixes/polish
- Adds weight label below each assessment
 - Correlates assessment name with its ID, so each dom element can be identified: {trimmed_title: assessmentID}
 - Slightly changed regex to be a little more permissive
 - Changed pdfjs src/type due to CORS issues on firefox
 - Permitted weightings to be zero, but not less than zero
 - Modified how assessment items are iterated through, as the previous approach assumed they're in the same order as they are in react
 - Changed parseAssessments() to immediately dispatch parsing for all pdfs asynchronously, as doing it serially is painstakingly slow
 - Discard useless decimals when displaying weight (.0)
2026-01-28 23:06:02 +08:00
StroepWafel 355c5f2d46 fix try catch error 2026-01-28 19:38:20 +10:30
Jaxx7594 afdb4336f8 Merge pull request #1 from StroepWafel/weightings
Weightings
2026-01-28 16:01:51 +08:00
StroepWafel 7a04b22b22 Update index.ts 2026-01-28 17:21:32 +10:30
StroepWafel f594ed4902 i think i fixed it? 2026-01-28 17:19:08 +10:30
StroepWafel f1afa74ee6 Add average grade display and Fix CORS violation
Add average grade display and also fix the CORS violation caused by pdfjs trying to load PDFs from URLs that Firefox extensions can't access.

fixed by instead:
- Fetching the PDF as an ArrayBuffer directly from the URL
- Passing the ArrayBuffer to pdfjs using { data: arrayBuffer } instead of passing a URL
2026-01-28 16:36:16 +10:30
StroepWafel db3f0e0d81 Merge pull request #2 from Jaxx7594/weightings
feat: Assessments Average weightings parsing
2026-01-28 16:12:16 +10:30
Jaxon Lewis-Wilson aceefa16c0 feat: Assessments Average weightings parsing
ONLY PARSING SIDE IS COMPLETE. Does not factor into the average yet.
2026-01-28 13:25:58 +08:00
StroepWafel 89589fe3dc Merge branch 'BetterSEQTA:main' into globalSearch-improvements 2026-01-27 21:25:07 +10:30
Jaxon Lewis-Wilson 2afe2364e4 Narrowed trigger criteria
Instead of running every time document.head mutates, only run when mutation is an attribute change, target is a link, rel includes icon, and attribute is href.
2026-01-27 16:09:15 +08:00
Jaxon Lewis-Wilson 2c0f48877f Appease codefactor 2026-01-27 14:40:02 +08:00
Jaxon Lewis-Wilson 8791038bcf fix: Favicon race condition
Fixes a race condition due to the way the Tes logo has been implemented.
2026-01-27 14:31:33 +08:00
Alphons Joseph aba2ba5bfa Merge pull request #371 from BetterSEQTA/tutor-fix
fix [BUG] Seqta Tutor Lessons Color Not In Home Page And Assesments/Courses showing when not meant to
2026-01-24 18:02:42 +08:00
Alphons Joseph 2b01834765 Merge pull request #372 from BetterSEQTA/copilot/sub-pr-371
[WIP] Fix Seqta Tutor lessons color and course visibility
2026-01-24 12:27:58 +08:00
copilot-swe-agent[bot] 79c4fb511b Remove console.info(lesson) to prevent logging sensitive data
Co-authored-by: Crazypersonalph <93847055+Crazypersonalph@users.noreply.github.com>
2026-01-24 04:26:14 +00:00
Alphons Joseph 14823dcc91 Update src/seqta/utils/Loaders/LoadHomePage.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-24 12:24:14 +08:00
copilot-swe-agent[bot] 9d3494eb56 Initial plan 2026-01-24 04:23:49 +00:00
Alphons Joseph 6af7c32c88 fix [BUG] Seqta Tutor Lessons Color Not In Home Page And Assesments/Courses showing when not meant to
Fixes #343
2026-01-24 12:14:39 +08:00
StroepWafel 1f49fa4bae Update src/plugins/built-in/globalSearch/src/indexing/actions.ts
Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
2026-01-22 19:35:26 +10:30
StroepWafel 86d9cfe50c Update src/plugins/built-in/globalSearch/src/core/index.ts
Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
2026-01-22 19:34:55 +10:30
StroepWafel ae59640162 Update src/plugins/built-in/globalSearch/src/indexing/actions.ts
Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
2026-01-22 19:34:30 +10:30
StroepWafel 5f935cd819 Update src/plugins/built-in/globalSearch/src/indexing/actions.ts
Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
2026-01-22 19:34:18 +10:30
codefactor-io 90e3a946bf [CodeFactor] Apply fixes 2026-01-22 08:42:29 +00:00
StroepWafel 51c940cdd9 Fixed CSS Preload Error 2026-01-22 19:07:48 +10:30
StroepWafel 07ff6d25ca indexing progressbar fixes 2026-01-22 19:05:39 +10:30
StroepWafel 89fd9bbd89 styles 2026-01-22 18:58:33 +10:30
StroepWafel c7033e61fb show indexing progress 2026-01-22 18:58:20 +10:30
StroepWafel 39f8cb1634 include all past assessments 2026-01-22 18:58:03 +10:30
StroepWafel 705c106da8 updates reset cache (clears dead cache from old updates causing issues)
YESS I FINALLY GOT DATABASE INDEXING WORKING TOO
2026-01-22 18:42:45 +10:30
StroepWafel 9b52bae404 update search to hopefully index correctly this time 2026-01-22 18:42:23 +10:30
StroepWafel 9a002d18b0 audit command 2026-01-22 18:42:13 +10:30
StroepWafel 3847ef4269 hopefully fix the issues 2026-01-22 18:41:42 +10:30
StroepWafel f0d0068a2e Add command for compile 2026-01-22 18:40:57 +10:30
StroepWafel b89a6c634c fix vector initialisation? 2026-01-22 11:37:09 +10:30
StroepWafel 29cfb4c792 improve global search 2026-01-22 11:28:43 +10:30
126 changed files with 11656 additions and 1885 deletions
+8 -6
View File
@@ -4,12 +4,11 @@ package-lock.json
bun.lockb
pnpm-lock.yaml
yarn.lock
bun.lock
.parcel-cache
.env
.env.submit
dependency-graph.svg
# PDF.js extension assets (copied by postinstall from pdfjs-dist)
src/public/resources/pdfjs/pdf.worker.min.mjs
src/public/resources/pdfjs/pdf.legacy.min.mjs
# Build
extension.zip
@@ -19,5 +18,8 @@ betterseqtaplus-safari/
.million/
.vscode/
**/.DS_Store
.parcel-cache
.env
.env.submit
dependency-graph.svg
+2
View File
@@ -0,0 +1,2 @@
legacy-peer-deps=true
+5 -5
View File
@@ -6,9 +6,9 @@ Hey there! 👋 Thanks for your interest in contributing to BetterSEQTA+! We're
**Never contributed to an open source project before?** No worries! We've made it super easy to get started:
- **📖 Read our [Getting Started Guide](./docs/GETTING_STARTED_CONTRIBUTING.md)** - This walks you through everything step-by-step, from setting up your development environment to making your first pull request.
- **🏗️ Understand the codebase** with our [Architecture Guide](./docs/ARCHITECTURE.md)
- **🔧 Having issues?** Check our [Troubleshooting Guide](./docs/TROUBLESHOOTING.md)
- **📖 Read our [contributing guide](https://docs.betterseqta.org/contributing/)** - This walks you through everything step-by-step, from setting up your development environment to making your first pull request.
- **🏗️ Understand the codebase** with the [architecture guide](https://docs.betterseqta.org/architecture/)
- **🔧 Having issues?** Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
We have lots of [`good first issue`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/good%20first%20issue) labels that are perfect for beginners!
@@ -33,8 +33,8 @@ Join our community channels to discuss the project, get help, and connect with o
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Creating Your First Plugin](./docs/plugins/creating-plugins.md)
- [Plugin API Reference](./docs/advanced/plugin-api.md)
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
## Pull Request Process
+25 -56
View File
@@ -16,17 +16,15 @@
<img src="https://img.shields.io/chrome-web-store/rating/afdgaoaclhkhemfkkkonemoapeinchel" />
</div>
## Table of contents
## 📚 Documentation
All documentation has been moved to the [official docs site](https://docs.betterseqta.org):
- [Features](#features)
- [Creating Custom Themes](#creating-custom-themes)
- [Getting Started](#getting-started)
- [Running Development](#running-development)
- [Building for production](#building-for-production)
- [Folder Structure](#folder-structure)
- [Contributors](#contributors)
- [Credits](#credits)
- [Star History](#star-history)
Includes:
- Getting started
- Development setup
- Architecture
- Plugin system
- Theme creation
## Features
@@ -50,64 +48,32 @@
## Creating Custom Themes
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://betterseqta.gitbook.io/betterseqta-docs). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://docs.betterseqta.org/theme-creation/). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
Don't worry- if you get stuck feel free to ask around in the [discord](https://discord.gg/YzmbnCDkat). We're open and happy to help out! Happy creating :)
## 🚀 Want to Contribute?
## 🚀 Contributing
**New contributors welcome!**
- 📖 Start here: https://docs.betterseqta.org/install/
- 🧠 Architecture: https://docs.betterseqta.org/architecture/
- 🧩 Plugins: https://docs.betterseqta.org/plugins/
- 💬 Discord: https://discord.gg/YzmbnCDkat
**New contributors welcome!** 🎉 We've made it easy to get started:
- **👋 New to the project?** Start with our [Getting Started Guide](./docs/GETTING_STARTED_CONTRIBUTING.md)
- **🏗️ Want to understand the code?** Check out our [Architecture Guide](./docs/ARCHITECTURE.md)
- **🧩 Interested in plugins?** Read our [Plugin Development Guide](./docs/plugins/README.md)
- **🐛 Found a bug?** Open an [issue](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) or fix it yourself!
- **💬 Need help?** Join our [Discord community](https://discord.gg/YzmbnCDkat)
## ⚡ Quick Start
We have lots of https://github.com/BetterSEQTA/BetterSEQTA-Plus/labels/good%20first%20issue labels perfect for beginners!
## Quick Development Setup
&nbsp;&nbsp;&nbsp; **1. Fork & Clone**
```bash
git clone https://github.com/YOUR_USERNAME/BetterSEQTA-Plus
git clone https://github.com/YOUR_USERNAME_FORKED_WITH/BetterSEQTA-Plus
cd BetterSEQTA-Plus
```
&nbsp;&nbsp;&nbsp; **2. Install & Run**
```bash
npm install --legacy-peer-deps
npm run dev
```
````
&nbsp;&nbsp;&nbsp; **3. Load in Browser**
1. Go to `chrome://extensions`
2. Enable "Developer mode"
3. Click "Load unpacked" → Select `dist` folder
4. Visit a SEQTA page to see it work! 🎉
> [!WARNING]
> Whenever you update the extension while not in dev mode, you will need to use the reload button on the extension page.
Then load `dist` in `chrome://extensions` (Developer Mode → Load unpacked).
📚 **Need more details?** Check our [detailed setup guide](./docs/GETTING_STARTED_CONTRIBUTING.md#your-first-30-minutes)
### Building for Production
```bash
npm run build # Build for all browsers
npm run zip # Package for distribution (requires 7-Zip)
```
## Folder Structure
The folder structure is as follows:
- The `src` folder contains source files that are compiled to the build directory.
- The `src/plugins` folder contains vital loaders required for BetterSEQTA+ functionality.
- The `src/interface` folder contains source React & Svelte files that are required for the Settings page.
- The `dist` folder is where the compiled code ends up, this is the folder what you need to load into chrome as an unpacked extension for development.
Full setup guide:
[https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment](https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment)
## Contributors
@@ -115,11 +81,14 @@ The folder structure is as follows:
<img src="https://contrib.rocks/image?repo=betterseqta/betterseqta-plus" />
</a>
Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plus/blob/main/CONTRIBUTING.md)
Want to contribute? [Click Here!](https://docs.betterseqta.org/contributing/)
## Credits
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers.
Originally developed by [Nulkem](https://github.com/Nulkem/betterseqta)
Ported to Manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68)
Maintained by [SethBurkart123](https://github.com/SethBurkart123), [Crazypersonalph](https://github.com/Crazypersonalph) & the rest of the BetterSEQTA team!
## Star History
+136 -22
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "betterseqtaplus",
@@ -19,6 +20,7 @@
"@types/color": "^4.2.0",
"@types/lodash": "^4.17.16",
"@types/node": "^24.3.0",
"@types/qrcode": "^1.5.6",
"@types/sortablejs": "^1.15.8",
"@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.3",
@@ -42,7 +44,9 @@
"mathjs": "^14.4.0",
"million": "^3.1.11",
"motion": "^12.4.12",
"pdfjs-dist": "^5.4.530",
"postcss": "^8.5.3",
"qrcode": "^1.5.4",
"react": "17",
"react-best-gradient-color-picker": "3.0.11",
"react-dom": "17",
@@ -69,7 +73,7 @@
"mime-types": "^3.0.1",
"prettier": "^3.5.3",
"process": "^0.11.10",
"publish-browser-extension": "^4.0.0",
"publish-browser-extension": "^4.0.4",
"sass": "^1.85.1",
"sass-loader": "^16.0.5",
"semver": "^7.7.1",
@@ -265,6 +269,30 @@
"@msgpack/msgpack": ["@msgpack/msgpack@3.1.2", "", {}, "sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ=="],
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.89", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.89", "@napi-rs/canvas-darwin-arm64": "0.1.89", "@napi-rs/canvas-darwin-x64": "0.1.89", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.89", "@napi-rs/canvas-linux-arm64-gnu": "0.1.89", "@napi-rs/canvas-linux-arm64-musl": "0.1.89", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-musl": "0.1.89", "@napi-rs/canvas-win32-arm64-msvc": "0.1.89", "@napi-rs/canvas-win32-x64-msvc": "0.1.89" } }, "sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.89", "", { "os": "android", "cpu": "arm64" }, "sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ=="],
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.89", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k29cR/Zl20WLYM7M8YePevRu2VQRaKcRedYr1V/8FFHkyIQ8kShEV+MPoPGi+znvmd17Eqjy2Pk2F2kpM2umVg=="],
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.89", "", { "os": "darwin", "cpu": "x64" }, "sha512-iUragqhBrA5FqU13pkhYBDbUD1WEAIlT8R2+fj6xHICY2nemzwMUI8OENDhRh7zuL06YDcRwENbjAVxOmaX9jg=="],
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.89", "", { "os": "linux", "cpu": "arm" }, "sha512-y3SM9sfDWasY58ftoaI09YBFm35Ig8tosZqgahLJ2WGqawCusGNPV9P0/4PsrLOCZqGg629WxexQMY25n7zcvA=="],
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.89", "", { "os": "linux", "cpu": "arm64" }, "sha512-NEoF9y8xq5fX8HG8aZunBom1ILdTwt7ayBzSBIwrmitk7snj4W6Fz/yN/ZOmlM1iyzHDNX5Xn0n+VgWCF8BEdA=="],
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.89", "", { "os": "linux", "cpu": "arm64" }, "sha512-UQQkIEzV12/l60j1ziMjZ+mtodICNUbrd205uAhbyTw0t60CrC/EsKb5/aJWGq1wM0agvcgZV72JJCKfLS6+4w=="],
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.89", "", { "os": "linux", "cpu": "none" }, "sha512-1/VmEoFaIO6ONeeEMGoWF17wOYZOl5hxDC1ios2Bkz/oQjbJJ8DY/X22vWTmvuUKWWhBVlo63pxLGZbjJU/heA=="],
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.89", "", { "os": "linux", "cpu": "x64" }, "sha512-ebLuqkCuaPIkKgKH9q4+pqWi1tkPOfiTk5PM1LKR1tB9iO9sFNVSIgwEp+SJreTSbA2DK5rW8lQXiN78SjtcvA=="],
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.89", "", { "os": "linux", "cpu": "x64" }, "sha512-w+5qxHzplvA4BkHhCaizNMLLXiI+CfP84YhpHm/PqMub4u8J0uOAv+aaGv40rYEYra5hHRWr9LUd6cfW32o9/A=="],
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.89", "", { "os": "win32", "cpu": "arm64" }, "sha512-DmyXa5lJHcjOsDC78BM3bnEECqbK3xASVMrKfvtT/7S7Z8NGQOugvu+L7b41V6cexCd34mBWgMOsjoEBceeB1Q=="],
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -423,6 +451,8 @@
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "3.1.3" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
"@types/react-dom": ["@types/react-dom@19.1.8", "", { "peerDependencies": { "@types/react": "19.1.12" } }, "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ=="],
@@ -481,7 +511,7 @@
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-uri": "3.1.0", "json-schema-traverse": "1.0.0", "require-from-string": "2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "1.1.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="],
@@ -543,6 +573,8 @@
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "1.5.1", "ieee754": "1.2.1" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "1.3.0", "function-bind": "1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@@ -551,6 +583,8 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001737", "", {}, "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw=="],
@@ -573,9 +607,9 @@
"cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="],
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "5.0.0", "string-width": "7.2.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
"cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "4.2.3", "strip-ansi": "6.0.1", "wrap-ansi": "7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -639,6 +673,8 @@
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
@@ -661,6 +697,8 @@
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "2.3.0", "domhandler": "5.0.3", "entities": "4.5.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
@@ -673,12 +711,14 @@
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "2.0.0", "domelementtype": "2.3.0", "domhandler": "5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-errors": "1.3.0", "gopd": "1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.211", "", {}, "sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw=="],
"elegant-spinner": ["elegant-spinner@1.0.1", "", {}, "sha512-B+ZM+RXvRqQaAmkMlO/oSe5nMUOaUnyfGYCEHoR8wrXsZR2mA0XVibsxV1bvTwxdRWah1PkQqso2EzhILGHtEQ=="],
@@ -813,7 +853,7 @@
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
"get-func-name": ["get-func-name@2.0.2", "", {}, "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ=="],
@@ -939,6 +979,12 @@
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "2.0.1" }, "optionalDependencies": { "graceful-fs": "4.2.11" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
@@ -961,7 +1007,7 @@
"listr-verbose-renderer": ["listr-verbose-renderer@0.5.0", "", { "dependencies": { "chalk": "2.4.2", "cli-cursor": "2.1.0", "date-fns": "1.30.1", "figures": "2.0.0" } }, "sha512-04PDPqSlsqIOaaaGZ+41vq5FejI9auqTInicFRndCBgE3bXG8D6W1I+mWhk+1nqbHmyhla/6BUrd5OSiHwKRXw=="],
"listr2": ["listr2@8.3.3", "", { "dependencies": { "cli-truncate": "4.0.0", "colorette": "2.0.20", "eventemitter3": "5.0.1", "log-update": "6.1.0", "rfdc": "1.4.1", "wrap-ansi": "9.0.0" } }, "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ=="],
"listr2": ["listr2@10.1.0", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^7.0.2", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-/6t2KgDYIcCjhELwvrRxi1gaJ4xCGLTjNvh6mSjYenBkrZxggek8EwCbwBU33GMUCpyyrOzz2TzylrO5mTiI1w=="],
"local-pkg": ["local-pkg@0.4.3", "", {}, "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g=="],
@@ -977,15 +1023,29 @@
"lodash.get": ["lodash.get@4.4.2", "", {}, "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
"lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="],
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
"lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
"lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="],
"log-symbols": ["log-symbols@1.0.2", "", { "dependencies": { "chalk": "1.1.3" } }, "sha512-mmPrW0Fh2fxOzdBbFv4g1m6pR72haFLPJ2G5SJEELf1y+iaQrDG6cWCPjy54RHYbZAt7X+ls690Kw62AdWXBzQ=="],
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "7.0.0", "cli-cursor": "5.0.0", "slice-ansi": "7.1.0", "strip-ansi": "7.1.0", "wrap-ansi": "9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"log-update": ["log-update@7.1.0", "", { "dependencies": { "ansi-escapes": "^7.1.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.2", "strip-ansi": "^7.1.2", "wrap-ansi": "^9.0.2" } }, "sha512-y9pi/ZOQQVvTgfRDEHV1Cj4zQUkJZPipEUNOxhn1R6KgmdMs7LKvXWCd9eMVPGJgvYzFLCenecWr0Ps8ChVv2A=="],
"long": ["long@4.0.0", "", {}, "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="],
@@ -1083,7 +1143,7 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"ofetch": ["ofetch@1.4.1", "", { "dependencies": { "destr": "2.0.5", "node-fetch-native": "1.6.7", "ufo": "1.6.1" } }, "sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw=="],
"ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
@@ -1107,6 +1167,8 @@
"p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "3.1.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
@@ -1133,6 +1195,8 @@
"pathval": ["pathval@1.1.1", "", {}, "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ=="],
"pdfjs-dist": ["pdfjs-dist@5.4.530", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.84" } }, "sha512-r1hWsSIGGmyYUAHR26zSXkxYWLXLMd6AwqcaFYG9YUZ0GBf5GvcjJSeo512tabM4GYFhxhl5pMCmPr7Q72Rq2Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -1147,6 +1211,8 @@
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "4.2.0", "read-cache": "1.0.0", "resolve": "1.22.10" }, "peerDependencies": { "postcss": "8.5.6" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
@@ -1177,12 +1243,14 @@
"protobufjs": ["protobufjs@6.11.4", "", { "dependencies": { "@protobufjs/aspromise": "1.1.2", "@protobufjs/base64": "1.1.2", "@protobufjs/codegen": "2.0.4", "@protobufjs/eventemitter": "1.1.0", "@protobufjs/fetch": "1.1.0", "@protobufjs/float": "1.0.2", "@protobufjs/inquire": "1.1.0", "@protobufjs/path": "1.1.2", "@protobufjs/pool": "1.1.0", "@protobufjs/utf8": "1.1.0", "@types/long": "4.0.2", "@types/node": "24.3.0", "long": "4.0.0" }, "bin": { "pbjs": "bin/pbjs", "pbts": "bin/pbts" } }, "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw=="],
"publish-browser-extension": ["publish-browser-extension@4.0.0", "", { "dependencies": { "cac": "^6.7.14", "consola": "^3.4.2", "dotenv": "^17.2.3", "form-data-encoder": "^4.1.0", "formdata-node": "^6.0.3", "listr2": "^8.3.3", "ofetch": "^1.4.1", "zod": "^3.25.76 || ^4.0.0" }, "bin": { "publish-extension": "bin/publish-extension.mjs" } }, "sha512-2bsHf2m+ivNyDa67YdVhcxpF3wjWzVTqG5JWQYwi6by47XyA7jf43A9S7+UBULM/d2f82fPUGnRuecMK1CgEPA=="],
"publish-browser-extension": ["publish-browser-extension@4.0.4", "", { "dependencies": { "cac": "^6.7.14", "consola": "^3.4.2", "dotenv": "^17.2.4", "form-data-encoder": "^4.1.0", "formdata-node": "^6.0.3", "jsonwebtoken": "^9.0.3", "listr2": "^10.1.0", "ofetch": "^1.5.1", "zod": "3.25.76 || ^4.3.6" }, "bin": { "publish-extension": "bin/publish-extension.mjs" } }, "sha512-QMQbWL0FWgBfnkJ6w8HOJoIPaWLE7vTpewM4ae2vLs7SrD4eKdAk+SxOzqAICwbhEPuaLAOA+XkT9sZS5R0PmA=="],
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "1.4.5", "once": "1.4.0" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@@ -1213,6 +1281,8 @@
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "2.16.1", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@@ -1249,6 +1319,8 @@
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "4.2.3", "detect-libc": "2.0.4", "node-addon-api": "6.1.0", "prebuild-install": "7.1.3", "semver": "7.7.2", "simple-get": "4.0.1", "tar-fs": "3.1.0", "tunnel-agent": "0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -1277,7 +1349,7 @@
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "6.2.1", "is-fullwidth-code-point": "4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
"slice-ansi": ["slice-ansi@7.1.0", "", { "dependencies": { "ansi-styles": "6.2.1", "is-fullwidth-code-point": "5.0.0" } }, "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg=="],
"sortablejs": ["sortablejs@1.15.6", "", {}, "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A=="],
@@ -1437,6 +1509,8 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
@@ -1451,15 +1525,15 @@
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="],
@@ -1537,14 +1611,16 @@
"cheerio/undici": ["undici@7.15.0", "", {}, "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ=="],
"cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "10.4.0", "get-east-asian-width": "1.3.0", "strip-ansi": "7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"cli-truncate/string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"concurrently/rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"eslint/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
@@ -1579,9 +1655,11 @@
"log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "5.1.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"log-update/slice-ansi": ["slice-ansi@7.1.0", "", { "dependencies": { "ansi-styles": "6.2.1", "is-fullwidth-code-point": "5.0.0" } }, "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"log-update/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "6.2.0" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"log-update/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"mathjs/fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
@@ -1623,7 +1701,7 @@
"slice-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.0.0", "", { "dependencies": { "get-east-asian-width": "1.3.0" } }, "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA=="],
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -1659,6 +1737,8 @@
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"z-schema/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -1687,12 +1767,16 @@
"any-observable/rxjs/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"cli-truncate/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "6.2.0" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"concurrently/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "4.2.3", "strip-ansi": "6.0.1", "wrap-ansi": "7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"concurrently/yargs/y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"concurrently/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"listr-update-renderer/chalk/ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="],
@@ -1735,6 +1819,10 @@
"log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
"log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "10.4.0", "get-east-asian-width": "1.3.0", "strip-ansi": "7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"million/@rollup/pluginutils/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -1755,6 +1843,8 @@
"sharp/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "1.1.4", "simple-swizzle": "0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -1783,14 +1873,22 @@
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"wrap-ansi/string-width/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@microsoft/api-extractor/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"@rushstack/node-core-library/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
"concurrently/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"concurrently/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"listr-update-renderer/cli-truncate/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@1.0.0", "", { "dependencies": { "number-is-nan": "1.0.1" } }, "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw=="],
"listr-update-renderer/log-update/wrap-ansi/string-width": ["string-width@2.1.1", "", { "dependencies": { "is-fullwidth-code-point": "2.0.0", "strip-ansi": "4.0.0" } }, "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw=="],
@@ -1803,6 +1901,14 @@
"log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "5.0.1" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"log-update/slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"log-update/wrap-ansi/string-width/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"log-update/wrap-ansi/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "6.2.0" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"pkg-install/execa/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="],
"pkg-install/execa/cross-spawn/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
@@ -1913,12 +2019,20 @@
"vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"concurrently/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"listr-update-renderer/log-update/wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
"listr-update-renderer/log-update/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@3.0.1", "", {}, "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw=="],
"listr-verbose-renderer/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"log-update/wrap-ansi/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
"pkg-install/execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="],
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
}
}
+3 -1
View File
@@ -1,5 +1,7 @@
# BetterSEQTA+ Architecture
**Published version:** [docs.betterseqta.org/architecture/](https://docs.betterseqta.org/architecture/)
Hey there! 👋 New to the codebase and feeling a bit lost? Don't worry - this guide will help you understand how everything fits together!
## Table of Contents
@@ -221,7 +223,7 @@ console.log(settingsState.[the setting name])
Ready to contribute? Here's what to do next:
1. **Read the code**: Start with `src/SEQTA.ts` and follow the flow
2. **Try creating a simple plugin**: Follow our [plugin guide](./plugins/README.md)
2. **Try creating a simple plugin**: Follow the [plugin documentation](https://docs.betterseqta.org/plugins/)
3. **Look at existing issues**: Check our [GitHub issues](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) for "good first issue" labels
4. **Join our Discord**: Get help from the community!
+133
View File
@@ -0,0 +1,133 @@
# BetterSEQTA Cloud — settings sync (server specification)
This document describes the HTTP API the BetterSEQTA+ extension expects for **cloud backup of extension settings**. The client is implemented in the extension repo; the accounts service (`accounts.betterseqta.org`) must implement these endpoints.
## Purpose
- Store **one JSON document per authenticated BetterSEQTA Cloud user** representing a snapshot of the extensions `chrome.storage.local` data (theme, layout, plugin settings, `plugin.*` keys, etc.).
- The extension **does not upload OAuth tokens** (`bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`). Those remain only on the client.
- **Download** replaces local storage with the stored snapshot, then the client reapplies the current devices session tokens so the user stays signed in.
## Base URL
All routes below are relative to:
`https://accounts.betterseqta.org`
## Authentication
Every request must include:
```http
Authorization: Bearer <access_token>
```
Use the same **access tokens** issued by the existing BetterSEQTA+ OAuth flows (`/api/bsplus/login`, `/api/bsplus/refresh`). Resolve the user from the token; the document is scoped to that user.
## Endpoints
### `PUT /api/bsplus/settings/sync`
Upserts the callers settings backup.
**Request body (JSON):**
```json
{
"schemaVersion": 1,
"data": {
"...": "flat key-value map mirroring extension storage (see Payload shape)"
}
}
```
- **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations.
- **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`).
**Success response:** HTTP `200` (or `201` if you prefer create semantics). Example:
```json
{
"updated_at": "2026-04-07T12:00:00.000Z"
}
```
`updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields.
**Error responses:** Standard JSON error body if applicable, e.g. `{ "error": "message" }`, with appropriate HTTP status (`401`, `413`, `422`, etc.).
---
### `GET /api/bsplus/settings/sync`
Returns the callers latest settings backup.
**Success response:** HTTP `200` with body:
```json
{
"schemaVersion": 1,
"data": { },
"updated_at": "2026-04-07T12:00:00.000Z"
}
```
- **`data`**: required for restore; must be the same shape as accepted in `PUT` (flat map of storage keys).
- **`schemaVersion`**: optional but recommended; should match what was stored.
- **`updated_at`**: optional; included for UX if the client shows “last backup” time.
**No backup yet:** HTTP **`404`**. The extension treats this as “nothing in the cloud” and shows an error to the user.
**Error responses:** `401` if the token is invalid, etc.
---
## Suggested database shape
Example relational layout:
| Column | Type | Notes |
|---------------|-------------|--------|
| `user_id` | FK → users | Unique per backup row (one row per user). |
| `payload` | JSON / JSONB| Store `{ "schemaVersion", "data" }` or only `data` + separate `schema_version` column. |
| `updated_at` | timestamptz | Set on each successful `PUT`. |
Unique constraint on `user_id`.
## Semantics
- **Last write wins:** each `PUT` replaces the stored backup for that user.
- **Optional later:** `If-Unmodified-Since` or a `revision` field for conflict detection (not required for v1).
## Security and privacy
- **Encryption at rest** for `payload` is recommended.
- Payload may contain **school-related UI preferences** and plugin data; treat as **user data** under your privacy policy.
- **Do not require or store** refresh/access tokens in the payload; the extension already strips them on upload.
### Never included in the sync payload (`chrome.storage.local` only)
The backup is a flat JSON map of **`chrome.storage.local`** keys. It does **not** include:
- **IndexedDB** — e.g. the Global Search index (`betterseqta-index` and related DBs) lives outside extension storage and is never serialized here.
- **OAuth / session keys** — `bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`, plus legacy `cloudAccessToken` / `cloudUsername`.
- **Assessment Averages caches** — `plugin.assessments-average.storage.assessments`, `plugin.assessments-average.storage.weightings` (school assessment data).
- **Keys under** `plugin.global-search.storage.*` — reserved so any future plugin storage cache there is not synced.
- **`bsplus_cloud_settings_known_remote_updated_at`** — client-only watermark for auto-sync (not part of the cloud backup blob).
On restore, those keys are **not** taken from the server; the device keeps its current local values.
## Related endpoint: `GET /api/user/cloud-summary`
The extension may call **`GET /api/user/cloud-summary`** (same host, `Authorization: Bearer`) for a **small** JSON summary (e.g. whether DesQTA / BetterSEQTA+ cloud settings exist and **`bsplus.updated_at`** / **`schemaVersion`**). It does **not** return the large settings `data` blob.
- **Auto-sync flow:** compare `bsplus.updated_at` to a **client-only** watermark stored in extension storage as **`bsplus_cloud_settings_known_remote_updated_at`** (never uploaded, never applied from the server payload; preserved on restore).
- If the server timestamp is newer (and `schemaVersion` is not ahead of the client), the client then calls **`GET /api/bsplus/settings/sync`** and applies the full envelope as usual.
This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages, `storage`) and works on **Chromium and Firefox** builds (see `webextension-polyfill`).
## Client reference (extension)
- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, and **`bsplus_cloud_settings_known_remote_updated_at`**).
- Download: `applyDownloadedEnvelope` after `GET`; local auth keys, sensitive device keys, and the client-only watermark key are merged back after `chrome.storage.local.clear()`.
- Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`.
+3 -1
View File
@@ -1,5 +1,7 @@
# Getting Started as a Contributor
**Published version:** [docs.betterseqta.org/contributing/](https://docs.betterseqta.org/contributing/)
Welcome to BetterSEQTA+! 🎉 This guide will walk you through making your first contribution, even if you're completely new to the project.
## Table of Contents
@@ -222,7 +224,7 @@ git push origin your-branch-name
### Stuck? Here's How to Get Unstuck
1. **Check the docs** - [Architecture guide](./ARCHITECTURE.md) explains everything
1. **Check the docs** - The [architecture guide](https://docs.betterseqta.org/architecture/) explains everything
2. **Search existing issues** - Someone might have had the same problem
3. **Ask in Discord** - Our community is super helpful
4. **Create an issue** - If you found a bug or need help
+21 -21
View File
@@ -1,30 +1,36 @@
# BetterSEQTA+ Documentation
🚧 DOCS UNDER CONSTRUCTION! 🚧
Welcome to the BetterSEQTA+ documentation! This documentation will help you understand how BetterSEQTA+ works and how to extend it with plugins and new features.
**Canonical documentation lives at [docs.betterseqta.org](https://docs.betterseqta.org/).** The pages below are the same topics; use the site for search, navigation, and the latest updates.
## Table of Contents
### Getting Started
- [Project Overview](./README.md) - This file
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+
- [Getting Started Contributing](./GETTING_STARTED_CONTRIBUTING.md) - **Start here!** Complete beginner-friendly guide
- [Architecture Guide](./ARCHITECTURE.md) - How BetterSEQTA+ works under the hood
- [Contributing Guide](../CONTRIBUTING.md) - Official contribution guidelines
- [Troubleshooting](./TROUBLESHOOTING.md) - Common issues and solutions
- [Documentation home](https://docs.betterseqta.org/)
- [Installation](https://docs.betterseqta.org/install/)
- [Contributing](https://docs.betterseqta.org/contributing/)
- [Architecture](https://docs.betterseqta.org/architecture/)
- [Contribution guidelines (repository)](../CONTRIBUTING.md)
- [Troubleshooting](https://docs.betterseqta.org/troubleshooting/)
### Plugin System
### Features & customization
- [Creating Your First Plugin](./plugins/README.md) - A comprehensive, beginner-friendly guide to creating plugins
- [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs
- [Features](https://docs.betterseqta.org/features/)
- [Themes & customization](https://docs.betterseqta.org/customization/)
- [Theme creation](https://docs.betterseqta.org/theme-creation/)
### Plugin system
- [Plugins overview](https://docs.betterseqta.org/plugins/)
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
- [Example plugin](https://docs.betterseqta.org/example-plugin/)
## Core Concepts
BetterSEQTA+ is built around several core concepts:
1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. Check out our [plugin guide](./plugins/README.md) to learn how to create your own!
1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. See the [plugins documentation](https://docs.betterseqta.org/plugins/).
2. **Type-Safe Settings**: Each plugin can define settings that are type-safe and automatically rendered in the settings UI. The settings system uses TypeScript decorators to make it easy to define settings with proper typing.
@@ -36,19 +42,13 @@ BetterSEQTA+ is built around several core concepts:
If you need help with BetterSEQTA+, you can:
- [Open an Issue](https://github.com/SeqtaLearning/betterseqta-plus/issues) - Report bugs or request features
- [Open an Issue](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) - Report bugs or request features
- [Join the Discord](https://discord.gg/YzmbnCDkat) - Chat with the community
- [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly
## Contributing to the Documentation
We welcome contributions to the documentation! If you find something unclear or missing, please open an issue or submit a pull request.
To contribute to the documentation:
1. Fork the repository
2. Make your changes to the documentation files
3. Submit a pull request with a clear description of your changes
We welcome contributions to the documentation. The published site is built from the documentation repository; see [Contributing](https://docs.betterseqta.org/contributing/) for how to help.
## License
+587
View File
@@ -0,0 +1,587 @@
# Theme Creation Guide
**Published version:** [docs.betterseqta.org/theme-creation/](https://docs.betterseqta.org/theme-creation/)
This guide covers everything you need to know about creating custom themes for BetterSEQTA+.
## Table of Contents
1. [Overview](#overview)
2. [Theme Structure](#theme-structure)
3. [CSS Variables](#css-variables)
4. [CSS Selectors & Classes](#css-selectors--classes)
5. [Custom Images](#custom-images)
6. [Theme Settings](#theme-settings)
7. [Best Practices](#best-practices)
8. [Examples](#examples)
## Overview
Themes in BetterSEQTA+ allow you to completely customize the appearance of SEQTA Learn. A theme consists of:
- **Custom CSS**: CSS rules that override default styles
- **Custom Images**: Images that can be referenced via CSS variables
- **Theme Metadata**: Name, description, default color, etc.
- **Theme Settings**: Options like forcing dark/light mode
Themes are applied by injecting CSS into the SEQTA page and setting CSS custom properties (variables) on the document root.
## CSS Variables
BetterSEQTA+ provides a comprehensive set of CSS variables that you can use in your themes. These variables automatically adapt to light/dark mode and user preferences.
### Core Background Variables
| Variable | Light Mode | Dark Mode | Description |
|----------|------------|-----------|-------------|
| `--background-primary` | `#ffffff` | `#232323` | Main background color |
| `--background-secondary` | `#e5e7eb` | `#1a1a1a` | Secondary background color |
| `--theme-primary` | `#ffffff` | `#232323` | Primary theme color (same as background-primary) |
| `--theme-secondary` | `#e5e7eb` | `#1a1a1a` | Secondary theme color (same as background-secondary) |
| `--text-primary` | `black` | `white` | Primary text color |
| `--text-color` | `black` | `white` | Text color (alias for text-primary) |
### BetterSEQTA+ Specific Variables
| Variable | Description | Notes |
|----------|-------------|-------|
| `--better-main` | User's selected accent color | Dynamically set based on color picker |
| `--better-sub` | Dark navy color | Always `#161616` |
| `--better-pale` | Lightened version of accent color | Only available in light mode |
| `--better-light` | Lighter version of accent color | Calculated based on brightness |
| `--better-alert-highlight` | Alert/highlight color | `#c61851` |
| `--betterseqta-logo` | Logo URL | Changes based on dark/light mode |
| `--auto-background` | Auto background color | Falls back to `--better-pale` or `--background-secondary` |
| `--navy` | Navy color | `#1a1a1a` |
| `--theme-fg-parts` | Theme foreground parts | `white` |
### Subject/Item Color Variables
| Variable | Description |
|----------|-------------|
| `--item-colour` | Subject/item color | Set dynamically per subject/item |
| `--colour` | Generic color variable | Used in various contexts |
| `--person-colour` | Person/avatar color | `var(--better-light)` for staff |
### Transparency Effects
When transparency effects are enabled, background variables become semi-transparent:
| Variable | Light Mode (Transparent) | Dark Mode (Transparent) |
|----------|--------------------------|-------------------------|
| `--background-primary` | `rgba(255, 255, 255, 0.6)` | `rgba(35, 35, 35, 0.6)` |
| `--background-secondary` | `rgba(229, 231, 235, 0.6)` | `rgba(26, 26, 26, 0.6)` |
### Using CSS Variables
You can use these variables in your custom CSS:
```css
/* Example: Style a custom element */
.my-custom-element {
background: var(--background-primary);
color: var(--text-primary);
border: 1px solid var(--better-main);
}
/* Example: Create a gradient */
.gradient-box {
background: linear-gradient(
to bottom,
var(--better-main),
var(--background-secondary)
);
}
```
## CSS Selectors & Classes
BetterSEQTA+ uses specific CSS selectors and classes that you can target in your themes. Here are the most important ones:
### Main Layout Elements
| Selector | Description |
|----------|-------------|
| `#container` | Main container element |
| `#content` | Content area |
| `#main` | Main content wrapper |
| `#title` | Top title bar |
| `#menu` | Sidebar menu |
### Dark Mode
The `dark` class is added to `html` when dark mode is active:
```css
/* Target dark mode specifically */
html.dark #main {
background: var(--background-primary);
}
/* Target light mode */
html:not(.dark) #main {
background: var(--background-primary);
}
```
### Transparency Effects
When transparency effects are enabled, the `transparencyEffects` class is added to `html`:
```css
html.transparencyEffects .notice {
backdrop-filter: blur(80px);
}
```
### Common SEQTA Classes
| Class/Selector | Description |
|----------------|-------------|
| `.notice` | Notice cards |
| `.day` | Day containers in timetable |
| `.dashboard` | Dashboard sections |
| `.dashlet` | Dashboard widgets |
| `.document` | Document elements |
| `.quickbar` | Quick action bar |
| `.calendar` | Calendar elements |
| `.message` | Message elements |
| `.thread` | Forum threads |
| `.shortcut` | Shortcut buttons |
| `.upcoming-assessment` | Upcoming assessments |
| `.entry.class` | Timetable entries |
### BetterSEQTA+ Specific Classes
| Class | Description |
|-------|-------------|
| `.addedButton` | BetterSEQTA+ added buttons |
| `.tooltip` | Tooltip elements |
| `.notice-unified-content` | Unified notice content |
| `.home-container` | Home page container |
| `.timetable-container` | Timetable container |
| `.notices-container` | Notices container |
### Attribute Selectors
SEQTA uses data attributes that you can target:
```css
/* Target specific data types */
[data-type="student"] .header {
color: var(--text-primary);
}
/* Target specific labels */
[data-label="inbox"] {
/* Styles */
}
```
### CSS Modules
SEQTA uses CSS modules with hashed class names. You can target them using attribute selectors:
```css
/* Target CSS module classes */
[class*="MessageList__MessageList___"] {
background: var(--background-primary);
}
[class*="BasicPanel__BasicPanel___"] {
border-radius: 16px;
}
```
## Custom Images
Themes can include custom images that are made available as CSS variables.
### Adding Images
1. Upload an image in the theme creator
2. Set a CSS variable name (e.g., `custom-background`)
3. The image will be available as `var(--custom-background)`
### Using Image Variables
```css
/* Use as background */
.my-element {
background-image: var(--custom-background);
background-size: cover;
background-position: center;
}
/* Use in content */
.my-icon::before {
content: '';
background-image: var(--custom-icon);
width: 24px;
height: 24px;
}
```
### Image Variable Format
Images are stored as `url()` values:
```css
/* The variable contains: url(blob:...) */
--custom-background: url(blob:chrome-extension://...);
```
## Theme Settings
### Force Dark/Light Mode
You can force a theme to always use dark or light mode:
```typescript
forceDark: true // Force dark mode
forceDark: false // Force light mode
forceDark: undefined // Use user's preference (default)
```
When `forceDark` is set, users cannot toggle dark/light mode while the theme is active.
### Default Color
Set a default accent color for your theme:
```typescript
defaultColour: "rgba(0, 123, 255, 1)" // Blue
defaultColour: "#ff6b6b" // Red (hex format)
```
### Allow Color Changes
Control whether users can change the accent color:
```typescript
CanChangeColour: true // Users can change color
CanChangeColour: false // Color is locked
```
## Best Practices
### 1. Use CSS Variables
Always use CSS variables instead of hardcoded colors:
```css
/* Good */
.my-element {
background: var(--background-primary);
color: var(--text-primary);
}
/* Bad */
.my-element {
background: #ffffff;
color: #000000;
}
```
### 2. Support Both Light and Dark Modes
Unless your theme forces a specific mode, ensure it works in both:
```css
/* Use variables that adapt automatically */
.my-element {
background: var(--background-primary);
color: var(--text-primary);
}
/* Or explicitly handle both modes */
html.dark .my-element {
background: #1a1a1a;
}
html:not(.dark) .my-element {
background: #ffffff;
}
```
### 3. Use !important Sparingly
Only use `!important` when necessary to override SEQTA's default styles:
```css
/* Good - necessary override */
#title {
background: var(--background-primary) !important;
}
/* Bad - unnecessary */
.my-element {
color: var(--text-primary) !important;
}
```
### 4. Test Responsive Design
SEQTA is responsive. Test your theme at different screen sizes:
```css
/* Example: Mobile-specific styles */
@media (max-width: 900px) {
#menu {
transform: translate(-270px);
}
}
```
### 5. Use Semantic Selectors
Prefer semantic selectors over fragile ones:
```css
/* Good - stable selector */
#main > .dashboard > section {
border-radius: 16px;
}
/* Caution - CSS module classes may change */
[class*="Dashboard__Dashboard___"] {
border-radius: 16px;
}
```
### 6. Optimize Images
Keep image file sizes reasonable:
- Use appropriate formats (PNG for transparency, JPG for photos)
- Compress images before uploading
- Consider using CSS for simple graphics instead of images
### 7. Document Your Theme
Include comments in your CSS explaining complex styles:
```css
/*
* Custom gradient background for dashboard
* Uses the user's accent color for a cohesive look
*/
#main > .dashboard {
background: linear-gradient(
135deg,
var(--better-main),
var(--background-secondary)
);
}
```
## Examples
### Example 1: Simple Color Theme
```css
/* Change accent color throughout */
:root {
--better-main: #ff6b6b;
}
/* Style the menu */
#menu {
background: var(--background-primary);
border-right: 3px solid var(--better-main);
}
/* Style buttons */
.uiButton {
background: var(--better-main);
color: var(--text-color);
border-radius: 8px;
}
```
### Example 2: Custom Background Image
```css
/* Use a custom background image */
body {
background-image: var(--custom-background);
background-size: cover;
background-attachment: fixed;
}
/* Add overlay for readability */
#main::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: -1;
}
```
### Example 3: Rounded Corners Theme
```css
/* Make everything more rounded */
#main > .dashboard > section,
.dashlet,
.notice,
.document {
border-radius: 20px !important;
}
/* Round buttons */
.uiButton {
border-radius: 25px !important;
}
```
### Example 4: Minimal Theme
```css
/* Remove shadows and borders */
#main > .dashboard > section,
.dashlet,
.notice {
box-shadow: none !important;
border: 1px solid var(--background-secondary) !important;
}
/* Simplify colors */
#menu {
background: var(--background-primary) !important;
}
/* Remove gradients */
.day {
background: var(--background-primary) !important;
}
```
### Example 5: High Contrast Theme
```css
/* Increase contrast */
:root {
--background-primary: #000000;
--background-secondary: #1a1a1a;
--text-primary: #ffffff;
}
html:not(.dark) {
--background-primary: #ffffff;
--background-secondary: #f0f0f0;
--text-primary: #000000;
}
/* Add borders for clarity */
.dashlet,
.notice,
.document {
border: 2px solid var(--better-main) !important;
}
```
## Advanced Techniques
### CSS Custom Properties Override
You can override CSS variables in your theme:
```css
/* Override a variable */
:root {
--better-main: #your-color;
}
/* Override conditionally */
html.dark {
--background-primary: #your-dark-color;
}
```
### Animations
Add smooth transitions:
```css
/* Smooth color transitions */
#menu li {
transition: background-color 0.3s ease;
}
/* Hover effects */
.dashlet:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
}
```
### Pseudo-elements
Use pseudo-elements for decorative elements:
```css
/* Add decorative border */
.notice::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--better-main);
}
```
## Troubleshooting
### Theme Not Applying
1. Check browser console for CSS errors
2. Verify CSS syntax is correct
3. Ensure selectors are specific enough
4. Check if `!important` is needed
### Colors Not Changing
1. Verify you're using CSS variables
2. Check if `forceDark` is overriding your styles
3. Ensure variables are set on `:root` or `html`
### Images Not Showing
1. Verify image variable name matches CSS
2. Check image format is supported
3. Ensure image size is reasonable
4. Verify `url()` wrapper in CSS
### Dark Mode Issues
1. Test with `forceDark: true` and `forceDark: false`
2. Check if transparency effects are interfering
3. Verify `html.dark` selector is correct
## Resources
- **Theme Creator**: Access via BetterSEQTA+ settings
- **CSS Variables Reference**: See [CSS Variables](#css-variables) section above
- **SEQTA DOM Structure**: Inspect SEQTA pages in browser DevTools
- **BetterSEQTA+ Source**: Check `src/css/injected.scss` for default styles
## Contributing Themes
If you create a great theme, consider sharing it:
1. Export your theme (Share button in theme creator)
2. Submit to the BetterSEQTA+ theme store
3. Or share on GitHub/Discord
---
**Note**: This documentation is based on BetterSEQTA+ v3.4.13. Some details may change in future versions.
+2
View File
@@ -1,5 +1,7 @@
# Troubleshooting Guide
**Published version:** [docs.betterseqta.org/troubleshooting/](https://docs.betterseqta.org/troubleshooting/)
Having issues with BetterSEQTA+ development? This guide covers the most common problems and their solutions.
## Table of Contents
+5 -3
View File
@@ -1,5 +1,7 @@
# Contributing to BetterSEQTA+
**Published version:** [docs.betterseqta.org/contributing/](https://docs.betterseqta.org/contributing/)
Thank you for your interest in contributing to BetterSEQTA+! This document provides guidelines and instructions for contributing to the project.
## Table of Contents
@@ -57,7 +59,7 @@ Key points:
5. **Install in Chrome/Firefox**
Follow the [installation instructions](./installation.md#development-installation) to load the development version into your browser.
Follow the [installation instructions](https://docs.betterseqta.org/install/) to load the development version into your browser.
### Project Structure
@@ -246,8 +248,8 @@ Join our community channels to discuss the project, get help, and connect with o
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Creating Your First Plugin](./plugins/creating-plugins.md)
- [Plugin API Reference](./advanced/plugin-api.md)
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
## Recognition
+4 -2
View File
@@ -1,5 +1,7 @@
# Installing BetterSEQTA+
**Published version:** [docs.betterseqta.org/install/](https://docs.betterseqta.org/install/)
This guide will walk you through the process of installing and setting up BetterSEQTA+ for development or usage.
## Prerequisites
@@ -178,5 +180,5 @@ bun run dev
Now that you have BetterSEQTA+ installed, you can:
- [Getting Started with Plugins](./plugins/getting-started.md)
- [Contribute to the project](../CONTRIBUTING.md)
- [Plugins](https://docs.betterseqta.org/plugins/)
- [Contribute to the project](https://docs.betterseqta.org/contributing/) · [Repository CONTRIBUTING.md](../CONTRIBUTING.md)
+4 -2
View File
@@ -1,5 +1,7 @@
# Example Plugin Template
**Published version:** [docs.betterseqta.org/example-plugin/](https://docs.betterseqta.org/example-plugin/)
This is a complete, working example of a simple BetterSEQTA+ plugin. You can copy this code and modify it to create your own plugin!
## What This Example Does
@@ -328,8 +330,8 @@ Once you've got this working:
## Need Help?
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
- 📚 Read our [Plugin Development Guide](./README.md)
- 🐛 Check the [Troubleshooting Guide](../TROUBLESHOOTING.md)
- 📚 Read the [plugin documentation](https://docs.betterseqta.org/plugins/)
- 🐛 Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
- 📝 Open an issue on GitHub
Happy coding! 🎉
+3 -1
View File
@@ -1,5 +1,7 @@
# Creating Plugins for BetterSEQTA+
**Published version:** [docs.betterseqta.org/plugins/](https://docs.betterseqta.org/plugins/) · [Plugin development](https://docs.betterseqta.org/plugin-development/) · [Plugin API](https://docs.betterseqta.org/plugin-api/)
Hey there! 👋 So you want to create a plugin for BetterSEQTA+? That's awesome! This guide will walk you through everything you need to know, from the very basics to more advanced features. Don't worry if you're new to this - we'll explain everything step by step.
## What is a Plugin?
@@ -294,4 +296,4 @@ Got stuck? No worries! Here's where you can get help:
- Check out the built-in plugins in the `src/plugins/built-in` folder
- Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues)
Happy coding and feel free to checkout the api reference [here](./api-reference.md)
Happy coding and feel free to check out the [plugin API](https://docs.betterseqta.org/plugin-api/) on the documentation site.
+3 -1
View File
@@ -1,6 +1,8 @@
# Plugin API Reference
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see [Creating Your First Plugin](./README.md).
**Published version:** [docs.betterseqta.org/plugin-api/](https://docs.betterseqta.org/plugin-api/)
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see the [plugins section](https://docs.betterseqta.org/plugins/) at [docs.betterseqta.org](https://docs.betterseqta.org/).
## Plugin Structure
+43
View File
@@ -0,0 +1,43 @@
import type { Plugin } from "vite";
/**
* Firefox extension pages forbid eval / `Function` constructor. Some deps still emit:
* - `Function(\`return this\`)()` (lodash-style global)
* - `try { return Function(\`\`) / new Function("") … }` (feature probes, e.g. PDF.js / ORT)
*/
export function firefoxStripFunctionProbe(): Plugin {
return {
name: "firefox-strip-function-probe",
apply: "build",
enforce: "post",
generateBundle(_options, bundle) {
if ((process.env.MODE || "chrome").toLowerCase() !== "firefox") return;
const literalReplacements: [string, string][] = [
['try{return new Function(""),!0}catch{return!1}', "return!1"],
["try{return new Function(''),!0}catch{return!1}", "return!1"],
['try{return new Function(""),true}catch{return false}', "return false"],
["try{return new Function(''),true}catch{return false}", "return false"],
// Empty template literal probe (minifier output)
["try{return Function(``),!0}catch{return!1}", "return!1"],
];
for (const chunk of Object.values(bundle)) {
if (chunk.type !== "chunk" || typeof chunk.code !== "string") continue;
let { code } = chunk;
code = code.replace(/Function\(`return this`\)\(\)/g, "(globalThis)");
code = code.replace(/Function\("return this"\)\(\)/g, "(globalThis)");
code = code.replace(/Function\('return this'\)\(\)/g, "(globalThis)");
for (const [from, to] of literalReplacements) {
if (code.includes(from)) {
code = code.split(from).join(to);
}
}
chunk.code = code;
}
},
};
}
+16 -10
View File
@@ -1,12 +1,15 @@
{
"name": "betterseqtaplus",
"version": "3.4.13",
"version": "3.6.2",
"type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
"browserslist": "> 0.5%, last 2 versions, not dead",
"scripts": {
"postinstall": "node scripts/copy-pdfjs-assets.mjs",
"autoaudit": "npm audit && npm audit fix && npm run build",
"dev": "cross-env MODE=chrome vite dev",
"dev:firefox": "cross-env MODE=firefox vite build --watch",
"compile": "npm i && npm run build",
"build": "cross-env MODE=chrome vite build && cross-env MODE=firefox vite build",
"build:chrome": "cross-env MODE=chrome vite build",
"build:firefox": "cross-env MODE=firefox vite build",
@@ -29,14 +32,14 @@
"author": {
"name": "SethBurkart123",
"email": "betterseqta.plus@gmail.com",
"url": "https://github.com/BetterSEQTA/BetterSEQTA-plus"
"url": "https://github.com/BetterSEQTA/BetterSEQTA-Plus"
},
"license": "MIT",
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.95",
"@crxjs/vite-plugin": "^2.2.0",
"@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.4.0",
"@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
@@ -47,7 +50,7 @@
"mime-types": "^3.0.1",
"prettier": "^3.5.3",
"process": "^0.11.10",
"publish-browser-extension": "^4.0.0",
"publish-browser-extension": "^4.0.4",
"sass": "^1.85.1",
"sass-loader": "^16.0.5",
"semver": "^7.7.1",
@@ -55,7 +58,7 @@
"url": "^0.11.4"
},
"dependencies": {
"@bedframe/core": "^0.0.46",
"@bedframe/core": "^0.1.0",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1",
@@ -63,13 +66,14 @@
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.1.4",
"@types/color": "^4.2.0",
"@types/lodash": "^4.17.16",
"@types/node": "^24.3.0",
"@types/qrcode": "^1.5.6",
"@types/sortablejs": "^1.15.8",
"@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.3",
@@ -93,16 +97,18 @@
"mathjs": "^14.4.0",
"million": "^3.1.11",
"motion": "^12.4.12",
"pdfjs-dist": "^5.4.530",
"postcss": "^8.5.3",
"qrcode": "^1.5.4",
"react": "17",
"react-best-gradient-color-picker": "3.0.11",
"react-dom": "17",
"rss-parser": "^3.13.0",
"sortablejs": "^1.15.6",
"svelte": "^5.22.6",
"svelte": "^5.46.4",
"typescript": "^5.8.2",
"uuid": "^11.1.0",
"vite": "^6.2.1",
"vite": "^8.0.5",
"webextension-polyfill": "^0.12.0"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { copyFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const pdfjsRoot = join(root, "node_modules", "pdfjs-dist");
const outDir = join(root, "src", "public", "resources", "pdfjs");
mkdirSync(outDir, { recursive: true });
copyFileSync(
join(pdfjsRoot, "build", "pdf.worker.min.mjs"),
join(outDir, "pdf.worker.min.mjs"),
);
copyFileSync(
join(pdfjsRoot, "legacy", "build", "pdf.min.mjs"),
join(outDir, "pdf.legacy.min.mjs"),
);
+69 -22
View File
@@ -11,6 +11,30 @@ import { main } from "@/seqta/main";
import { delay } from "./seqta/utils/delay";
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
function registerFetchSeqtaAppLinkListener() {
browser.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (request?.type !== "fetchSeqtaAppLink") return false;
void (async () => {
try {
const res = await fetch(`${location.origin}/seqta/student/load/profile`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({}),
});
const data = await res.json();
const statusOk = data?.status === "200" || data?.status === 200;
const raw = data?.payload?.app_link;
const appLink = typeof raw === "string" && raw.length > 0 ? raw : null;
sendResponse({ appLink: statusOk ? appLink : null });
} catch {
sendResponse({ appLink: null });
}
})();
return true;
});
}
export let MenuOptionsOpen = false;
var IsSEQTAPage = false;
@@ -25,38 +49,51 @@ if (document.childNodes[1]) {
init();
}
/**
* Initializes BetterSEQTA+ on a SEQTA page.
*
* This function performs the following steps:
* 1. Verifies that the current page is a SEQTA page.
* 2. Injects CSS styles for document loading.
* 3. Changes the page's favicon.
* 4. Initializes the extension's settings state.
* 5. Sets default storage if settings are not already defined.
* 6. Calls the main function to apply core BetterSEQTA+ modifications.
* 7. Initializes legacy and new plugins if the extension is enabled.
* 8. Logs success or error messages during initialization.
*/
async function init() {
const hasSEQTATitle = document.title.includes("SEQTA Learn");
if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) {
// Verify we are on a SEQTA page
if (
hasSEQTAText &&
(document.title.includes("SEQTA Learn") ||
document.title.includes("SEQTA Engage")) &&
!IsSEQTAPage
) {
IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page");
if (typeof window !== "undefined" && window === window.top) {
void browser.runtime.sendMessage({ type: "cloudSettingsPoll" }).catch(() => {});
}
registerFetchSeqtaAppLinkListener();
const documentLoadStyle = document.createElement("style");
documentLoadStyle.textContent = documentLoadCSS;
document.head.appendChild(documentLoadStyle);
const icons =
document.querySelectorAll<HTMLLinkElement>('link[rel*="icon"]');
replaceIcons();
icons.forEach((link) => {
link.href = icon48;
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (
mutation.type === "attributes" &&
mutation.target instanceof HTMLLinkElement &&
mutation.target.rel.includes("icon") &&
mutation.attributeName === "href"
) {
replaceIcons();
return;
}
}
});
observer.observe(document.head, {
subtree: true,
attributes: true,
attributeFilter: ["href"],
});
try {
await initializeSettingsState();
@@ -80,8 +117,18 @@ async function init() {
console.info(
"[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.",
);
} catch (error: any) {
} catch (error) {
console.error(error);
}
}
}
function replaceIcons() {
document
.querySelectorAll<HTMLLinkElement>('link[rel*="icon"]')
.forEach((link) => {
if (link.href !== icon48) {
link.href = icon48;
}
});
}
+378 -38
View File
@@ -1,12 +1,21 @@
import browser from "webextension-polyfill";
import type { SettingsState } from "@/types/storage";
import { fetchNews } from "./background/news";
import {
initCloudSettingsAutoSync,
performCloudSettingsDownloadWithRetry,
performCloudSettingsUploadWithRetry,
runCloudSettingsPoll,
} from "./background/cloudSettingsAutoSync";
function reloadSeqtaPages() {
const result = browser.tabs.query({});
function open(tabs: any) {
for (let tab of tabs) {
if (tab.title.includes("SEQTA Learn")) {
if (
tab.title?.includes("SEQTA Learn") ||
tab.title?.includes("SEQTA Engage")
) {
browser.tabs.reload(tab.id);
}
}
@@ -14,52 +23,376 @@ function reloadSeqtaPages() {
result.then(open, console.error);
}
// @ts-ignore
browser.runtime.onMessage.addListener(
(request: any, _: any, sendResponse: (response?: any) => void) => {
switch (request.type) {
case "reloadTabs":
reloadSeqtaPages();
break;
/** Callback for sending a response back to the message sender */
type MessageSender = { (response?: unknown): void };
case "extensionPages":
browser.tabs.query({}).then(function (tabs) {
for (let tab of tabs) {
if (tab.url?.includes("chrome-extension://")) {
browser.tabs.sendMessage(tab.id!, request);
}
}
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
const { token } = request;
const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(apiUrl, { cache: "no-store", headers })
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
fetch(githubUrl, { cache: "no-store" })
.then((r) => r.json())
.then((data) => sendResponse({ success: true, data: { themes: data.themes ?? [] } }))
.catch((fallbackErr) => {
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
sendResponse({ success: false, error: fallbackErr?.message });
});
break;
});
return true;
}
case "currentTab":
browser.tabs
.query({ active: true, currentWindow: true })
.then(function (tabs) {
browser.tabs
.sendMessage(tabs[0].id!, request)
.then(function (response) {
sendResponse(response);
function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boolean {
const { themeId, token } = request;
if (!themeId || typeof themeId !== "string") {
sendResponse({ success: false, error: "Missing themeId" });
return false;
}
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers })
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.error("[Background] fetchThemeDetails error:", err);
sendResponse({ success: false, error: err?.message });
});
return true;
}
function handleFetchFromUrl(request: any, sendResponse: MessageSender): boolean {
const { url } = request;
if (!url || typeof url !== "string") {
sendResponse({ error: "Missing url" });
return false;
}
fetch(url, { cache: "no-store" })
.then((r) => r.json())
.then((data) => sendResponse({ data }))
.catch((err) => {
console.error("[Background] fetchFromUrl error:", err);
sendResponse({ error: err?.message });
});
return true;
}
async function parseJsonResponse(r: Response): Promise<any> {
const text = await r.text();
try {
return text ? JSON.parse(text) : {};
} catch {
return {};
}
}
function handleCloudReserveClient(request: any, sendResponse: MessageSender): boolean {
const redirect_uri = request.redirect_uri ?? "https://accounts.betterseqta.org/auth/bsplus/callback";
fetch("https://accounts.betterseqta.org/api/bsplus/client/reserve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ redirect_uri }),
})
.then(async (r) => {
const data = await parseJsonResponse(r);
if (!r.ok) sendResponse({ error: data?.error ?? `Reserve failed (${r.status})` });
else sendResponse(data);
})
.catch((err) => {
console.error("[Background] cloudReserveClient error:", err);
sendResponse({ error: err?.message ?? "Network error" });
});
return true;
}
function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
const { client_id, redirect_uri, login, password } = request;
if (!client_id || !redirect_uri || !login || !password) {
sendResponse({ error: "Missing client_id, redirect_uri, login, or password" });
return false;
}
fetch("https://accounts.betterseqta.org/api/bsplus/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id, redirect_uri, login, password }),
})
.then(async (r) => {
const data = await parseJsonResponse(r);
if (!r.ok) sendResponse({ error: data?.error ?? "Login failed" });
else sendResponse(data);
})
.catch((err) => {
console.error("[Background] cloudLogin error:", err);
sendResponse({ error: err?.message ?? "Network error" });
});
return true;
}
function handleCloudStartLogin(request: any, sendResponse: MessageSender): boolean {
const { client_id, redirect_uri } = request;
if (!client_id || !redirect_uri) {
sendResponse({ error: "Missing client_id or redirect_uri" });
return true;
}
const authorizeUrl = `https://accounts.betterseqta.org/login?redirect=${encodeURIComponent(`/oauth/authorize?client_id=${client_id}&redirect_uri=${encodeURIComponent(redirect_uri)}`)}`;
browser.tabs.create({ url: authorizeUrl }).then(() => {
sendResponse({ success: true });
}).catch((err) => {
console.error("[Background] cloudStartLogin error:", err);
sendResponse({ error: err?.message ?? "Failed to open login page" });
});
return true;
}
const CALLBACK_URL_PREFIX = "https://accounts.betterseqta.org/auth/bsplus/callback";
function initCloudLoginCallbackListener() {
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.url && changeInfo.url.startsWith(CALLBACK_URL_PREFIX)) {
try {
const url = new URL(changeInfo.url);
const token = url.searchParams.get("token");
const refreshToken = url.searchParams.get("refresh_token");
const userId = url.searchParams.get("user_id");
if (token && refreshToken) {
// Store tokens
void (async () => {
try {
await browser.storage.local.set({
bsplus_token: token,
bsplus_refresh_token: refreshToken,
});
});
return true;
case "githubTab":
browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
break;
// Fetch full user info
const userRes = await fetch("https://accounts.betterseqta.org/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});
if (userRes.ok) {
const user = await userRes.json();
await browser.storage.local.set({ bsplus_user: user });
} else if (userId) {
await browser.storage.local.set({ bsplus_user: { id: userId } });
}
case "setDefaultStorage":
SetStorageValue(getDefaultValues());
break;
// Trigger cloud settings download
void performCloudSettingsDownloadWithRetry(token).catch((err) => {
console.warn("[Background] Cloud settings download after login:", err);
});
} catch (err) {
console.error("[Background] Failed to process login callback:", err);
}
})();
case "sendNews":
fetchNews(request.source ?? "australia", sendResponse);
return true;
default:
console.log("Unknown request type");
// Close the callback tab
void browser.tabs.remove(tabId);
}
} catch (err) {
console.error("[Background] Error parsing callback URL:", err);
}
}
});
}
initCloudLoginCallbackListener();
function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
const { refresh_token, client_id } = request;
if (!refresh_token || !client_id) {
sendResponse({ error: "Missing refresh_token or client_id" });
return false;
}
fetch("https://accounts.betterseqta.org/api/bsplus/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token, client_id }),
})
.then(async (r) => {
const data = await parseJsonResponse(r);
if (!r.ok) sendResponse({ error: data?.error ?? "Refresh failed" });
else sendResponse(data);
})
.catch((err) => {
console.error("[Background] cloudRefresh error:", err);
sendResponse({ error: err?.message ?? "Network error" });
});
return true;
}
function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const res = await performCloudSettingsUploadWithRetry(token);
sendResponse({
success: res.success,
error: res.error,
updated_at: res.updated_at,
});
} catch (err) {
console.error("[Background] cloudSettingsUpload error:", err);
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Upload failed",
});
}
})();
return true;
}
function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const res = await performCloudSettingsDownloadWithRetry(token);
sendResponse({
success: res.success,
notFound: res.notFound,
error: res.error,
updated_at: res.updated_at,
});
} catch (err) {
console.error("[Background] cloudSettingsDownload error:", err);
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Download failed",
});
}
})();
return true;
}
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
const { themeId, token, action } = request;
if (!themeId || !token) {
sendResponse({ success: false, error: "Theme ID and token required" });
return false;
}
const isFavorite = action === "favorite";
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, {
method: isFavorite ? "POST" : "DELETE",
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.error("[Background] cloudFavorite error:", err);
sendResponse({ success: false, error: err?.message });
});
return true;
}
/** Handler for a message type; receives request, sendResponse, and optional sender (for tab routing) */
type MessageHandler = {
(request: any, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender): boolean | void;
};
function isSeqtaOrigin(origin: string): boolean {
try {
const u = new URL(origin);
return u.hostname.includes("seqta") || u.hostname.endsWith(".edu.au");
} catch {
return false;
}
}
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
reloadTabs: () => reloadSeqtaPages(),
extensionPages: (req) => {
browser.tabs.query({}).then((tabs) => {
for (const tab of tabs) {
if (tab.url?.includes("chrome-extension://")) browser.tabs.sendMessage(tab.id!, req);
}
});
},
currentTab: (req, sendResponse) => {
browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => {
browser.tabs.sendMessage(tabs[0].id!, req).then(sendResponse);
});
return true;
},
githubTab: () => {
void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
},
setDefaultStorage: () => SetStorageValue(getDefaultValues()),
sendNews: (req, sendResponse) => {
fetchNews(req.source ?? "australia", sendResponse);
return true;
},
fetchThemes: handleFetchThemes,
fetchThemeDetails: handleFetchThemeDetails,
fetchFromUrl: handleFetchFromUrl,
cloudReserveClient: handleCloudReserveClient,
cloudLogin: handleCloudLogin,
cloudStartLogin: handleCloudStartLogin,
cloudRefresh: handleCloudRefresh,
cloudFavorite: handleCloudFavorite,
cloudSettingsUpload: handleCloudSettingsUpload,
cloudSettingsDownload: handleCloudSettingsDownload,
cloudSettingsPoll: () => {
void runCloudSettingsPoll();
return false;
},
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
(async () => {
try {
let tabId = sender?.tab?.id;
let originForCheck: string | undefined = req.baseUrl;
if (tabId == null) {
const tabs = await browser.tabs.query({ active: true, lastFocusedWindow: true });
const tab = tabs[0];
if (!tab?.id || !tab.url) {
sendResponse({ appLink: null });
return;
}
tabId = tab.id;
if (!originForCheck) originForCheck = new URL(tab.url).origin;
} else if (!originForCheck && sender?.tab?.url) {
originForCheck = new URL(sender.tab.url).origin;
}
if (!originForCheck || !isSeqtaOrigin(originForCheck)) {
sendResponse({ appLink: null });
return;
}
const reply = (await browser.tabs.sendMessage(tabId, { type: "fetchSeqtaAppLink" })) as
| { appLink?: string | null }
| undefined;
const appLink = typeof reply?.appLink === "string" && reply.appLink.length > 0 ? reply.appLink : null;
sendResponse({ appLink });
} catch (err) {
console.error("[Background] getSeqtaSession error:", err);
sendResponse({ appLink: null });
}
})();
return true;
},
};
browser.runtime.onMessage.addListener(
// @ts-ignore - OnMessageListener expects literal true for async, we return boolean
(request: any, sender: browser.Runtime.MessageSender, sendResponse: MessageSender) => {
const handler = MESSAGE_HANDLERS[request.type];
if (handler) {
const result = handler(request, sendResponse, sender);
return result === true;
}
console.log("Unknown request type");
return false;
},
);
@@ -127,6 +460,11 @@ function getDefaultValues(): SettingsState {
customshortcuts: [],
lettergrade: false,
newsSource: "australia",
iconOnlySidebar: false,
adaptiveThemeColour: false,
adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true,
autoCloudSettingsSync: true,
};
}
@@ -144,3 +482,5 @@ browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.set({ justupdated: true });
}
});
initCloudSettingsAutoSync({ reloadSeqtaPages });
+399
View File
@@ -0,0 +1,399 @@
import browser from "webextension-polyfill";
import {
applyDownloadedEnvelope,
buildUploadPayload,
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
isKeyIncludedInCloudUploadPayload,
setKnownRemoteUpdatedAt,
} from "@/seqta/utils/cloudSettingsSync";
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
export const CLOUD_SUMMARY_URL = `${ACCOUNTS_BASE}/api/user/cloud-summary`;
const CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`;
const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`;
const UPLOAD_DEBOUNCE_MS = 2000;
const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000;
const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll";
type CloudSummaryResponse = {
desqta?: unknown;
bsplus?: { updated_at: string; schemaVersion: number } | null;
};
let reloadSeqtaPagesFn: (() => void) | null = null;
let suppressAutoUploadDuringRestore = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let pollInFlight: Promise<void> | null = null;
function isAutoCloudSyncEnabled(all: Record<string, unknown>): boolean {
return all.autoCloudSettingsSync !== false;
}
async function parseJsonResponse(r: Response): Promise<any> {
const text = await r.text();
try {
return text ? JSON.parse(text) : {};
} catch {
return {};
}
}
async function getAccessToken(): Promise<string | null> {
const { bsplus_token } = await browser.storage.local.get("bsplus_token");
return typeof bsplus_token === "string" && bsplus_token.length > 0 ? bsplus_token : null;
}
async function tryRefreshTokens(): Promise<boolean> {
const result = await browser.storage.local.get([
"bsplus_refresh_token",
"bsplus_client_id",
"bsplus_user",
]);
const refresh_token = result.bsplus_refresh_token as string | undefined;
const client_id = result.bsplus_client_id as string | undefined;
if (!refresh_token || !client_id) return false;
try {
const r = await fetch(REFRESH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token, client_id }),
});
const data = await parseJsonResponse(r);
if (!r.ok || !data.access_token || !data.refresh_token) return false;
await browser.storage.local.set({
bsplus_token: data.access_token,
bsplus_refresh_token: data.refresh_token,
bsplus_user: data.user ?? result.bsplus_user,
});
return true;
} catch {
return false;
}
}
function isServerTimestampNewer(serverIso: string, localIso: string | undefined): boolean {
const a = Date.parse(serverIso);
if (Number.isNaN(a)) return false;
if (localIso === undefined || localIso === "") return true;
const b = Date.parse(localIso);
if (Number.isNaN(b)) return true;
return a > b;
}
async function fetchCloudSummaryOnce(
token: string,
): Promise<
| { ok: true; data: CloudSummaryResponse }
| { ok: false; unauthorized: boolean; error?: string }
> {
try {
const r = await fetch(CLOUD_SUMMARY_URL, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = (await parseJsonResponse(r)) as CloudSummaryResponse;
if (r.status === 401) return { ok: false, unauthorized: true };
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: (data as { error?: string })?.error ?? `Summary failed (${r.status})`,
};
}
return { ok: true, data };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Network error",
};
}
}
async function fetchCloudSummaryWithAuthRetry(
token: string,
): Promise<CloudSummaryResponse | null> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await fetchCloudSummaryOnce(t);
if (res.ok) return res.data;
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) break;
const next = await getAccessToken();
if (!next) break;
t = next;
continue;
}
if (res.error) console.warn("[BS+ cloud sync] cloud-summary:", res.error);
break;
}
return null;
}
type PutResult =
| { ok: true; updated_at?: string }
| { ok: false; unauthorized: boolean; error?: string };
async function putSettingsOnce(token: string): Promise<PutResult> {
try {
const all = await browser.storage.local.get();
const payload = buildUploadPayload(all as Record<string, unknown>);
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await parseJsonResponse(r);
if (r.status === 401) return { ok: false, unauthorized: true };
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: data?.error ?? `Upload failed (${r.status})`,
};
}
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Upload failed",
};
}
}
export async function performCloudSettingsUploadWithRetry(
token: string,
): Promise<{ success: boolean; error?: string; updated_at?: string }> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await putSettingsOnce(t);
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
const next = await getAccessToken();
if (!next) return { success: false, error: "Not authenticated" };
t = next;
continue;
}
return { success: false, error: res.error ?? "Upload failed" };
}
return { success: false, error: "Upload failed" };
}
type GetResult =
| { ok: true; updated_at?: string }
| { ok: false; notFound?: boolean; unauthorized: boolean; error?: string };
async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
try {
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = await parseJsonResponse(r);
if (r.status === 401) return { ok: false, unauthorized: true };
if (r.status === 404) {
return {
ok: false,
notFound: true,
unauthorized: false,
error: "No settings backup found in the cloud",
};
}
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: data?.error ?? `Download failed (${r.status})`,
};
}
await applyDownloadedEnvelope(data);
reloadSeqtaPagesFn?.();
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Download failed",
};
}
}
export async function performCloudSettingsDownloadWithRetry(
token: string,
): Promise<{ success: boolean; notFound?: boolean; error?: string; updated_at?: string }> {
suppressAutoUploadDuringRestore = true;
try {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await getSettingsAndApplyOnce(t);
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
const next = await getAccessToken();
if (!next) return { success: false, error: "Not authenticated" };
t = next;
continue;
}
return {
success: false,
notFound: res.notFound,
error: res.error ?? "Download failed",
};
}
return { success: false, error: "Download failed" };
} finally {
suppressAutoUploadDuringRestore = false;
}
}
async function maybeUploadBaseline(token: string): Promise<void> {
const res = await performCloudSettingsUploadWithRetry(token);
if (!res.success) {
console.warn("[BS+ cloud sync] Baseline upload failed:", res.error);
}
}
async function downloadIfNeeded(token: string): Promise<void> {
const res = await performCloudSettingsDownloadWithRetry(token);
if (!res.success && !res.notFound) {
console.warn("[BS+ cloud sync] Auto-download failed:", res.error);
}
}
async function runCloudSettingsPollInner(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
let token = await getAccessToken();
if (!token) return;
const summary = await fetchCloudSummaryWithAuthRetry(token);
if (!summary) return;
const bsplus = summary.bsplus;
const watermark = all[BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as string | undefined;
if (
bsplus &&
typeof bsplus.schemaVersion === "number" &&
bsplus.schemaVersion > CLOUD_SETTINGS_SYNC_SCHEMA_VERSION
) {
console.warn(
"[BS+ cloud sync] Server schemaVersion newer than client; skip auto-download",
);
return;
}
token = (await getAccessToken()) ?? token;
if (!watermark) {
if (!bsplus?.updated_at) {
await maybeUploadBaseline(token);
return;
}
await downloadIfNeeded(token);
return;
}
if (!bsplus?.updated_at) return;
if (isServerTimestampNewer(bsplus.updated_at, watermark)) {
await downloadIfNeeded(token);
}
}
export function runCloudSettingsPoll(): Promise<void> {
if (pollInFlight) return pollInFlight;
pollInFlight = (async () => {
try {
const { [POLL_THROTTLE_KEY]: last } = await browser.storage.local.get(POLL_THROTTLE_KEY);
if (Date.now() - (Number(last) || 0) < POLL_THROTTLE_MS) return;
await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() });
await runCloudSettingsPollInner();
} catch (e) {
console.error("[BS+ cloud sync] Poll error:", e);
} finally {
pollInFlight = null;
}
})();
return pollInFlight;
}
function clearUploadDebounce(): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
}
function scheduleDebouncedUpload(): void {
if (suppressAutoUploadDuringRestore) return;
clearUploadDebounce();
debounceTimer = setTimeout(() => {
debounceTimer = null;
void runDebouncedUploadJob();
}, UPLOAD_DEBOUNCE_MS);
}
async function runDebouncedUploadJob(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
const token = await getAccessToken();
if (!token) return;
const res = await performCloudSettingsUploadWithRetry(token);
if (!res.success) {
console.warn("[BS+ cloud sync] Auto-upload failed:", res.error);
}
}
async function syncAutoUploadWithStorage(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) {
clearUploadDebounce();
}
}
function onStorageChanged(
changes: Record<string, browser.storage.StorageChange>,
area: string,
): void {
if (area !== "local") return;
if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) {
void syncAutoUploadWithStorage();
}
const keys = Object.keys(changes);
if (!keys.some((k) => isKeyIncludedInCloudUploadPayload(k))) return;
void (async () => {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
if (suppressAutoUploadDuringRestore) return;
if (!(await getAccessToken())) return;
scheduleDebouncedUpload();
})();
}
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
browser.storage.onChanged.addListener(onStorageChanged);
}
+13 -12
View File
@@ -92,8 +92,12 @@ const rssFeedsByCountry: Record<string, string[]> = {
* used to send the fetched news data back to the caller.
* It's called with an object like `{ news: { articles: [...] } }`.
*/
export async function fetchNews(source: string, sendResponse: any) {
if (source === "australia") {
export async function fetchNews(source: string | undefined, sendResponse: any) {
const normalizedSource = typeof source === "string" && source.trim()
? source.trim()
: "australia";
if (normalizedSource === "australia") {
const date = new Date();
const from =
@@ -111,18 +115,15 @@ export async function fetchNews(source: string, sendResponse: any) {
const parser = new Parser();
let feeds: string[];
console.log("fetchNews", source);
console.log("fetchNews", normalizedSource);
if (rssFeedsByCountry[source.toLowerCase()]) {
// If the source is a country, fetch from predefined feeds
feeds = rssFeedsByCountry[source.toLowerCase()];
} else if (source.startsWith("http")) {
// If the source is a URL, use it directly
feeds = [source];
if (rssFeedsByCountry[normalizedSource.toLowerCase()]) {
feeds = rssFeedsByCountry[normalizedSource.toLowerCase()];
} else if (normalizedSource.startsWith("http")) {
feeds = [normalizedSource];
} else {
throw new Error(
"Invalid source. Provide a country code or a valid RSS feed URL.",
);
console.warn("[BetterSEQTA+] Invalid news source, falling back to Australia", normalizedSource);
return fetchNews("australia", sendResponse);
}
const articlesPromises = feeds.map(async (feedUrl) => {
+33 -1
View File
@@ -17,10 +17,42 @@
@use "injected/popup.scss";
@font-face {
font-family: "Roboto";
src: url("https://fonts.gstatic.com/s/roboto/v50/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2")
format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IconFamily";
src: url("@/resources/fonts/IconFamily.woff") format("woff");
font-weight: normal;
font-style: normal;
font-display: block;
}
@layer base, override;
@layer override {
* {
font-family: Rubik, sans-serif !important;
}
.iconFamily,
.iconFamily *,
[class~="iconFamily"],
[class~="iconFamily"] * {
font-family: "IconFamily" !important;
}
}
html {
background: #161616 !important;
background-color: #161616;
font-family: Rubik, Roboto !important;
font-family: Roboto, system-ui, -apple-system, sans-serif !important;
}
.tooltip svg {
-1
View File
@@ -116,7 +116,6 @@ body {
}
.cke_panel_listItem > a {
&:hover {
background: #3d3d3e !important;
}
+597 -277
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -35,9 +35,19 @@
}
#menu .sub {
transition: transform 0.3s ease;
transition: transform 0.3s ease, left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
#menu > ul:has(li.hasChildren.active) > li.active {
background: transparent !important;
}
/* Icon-only collapsed: submenu slides over narrow icons */
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li::before,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li::before,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li > label,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li > svg,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > label,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > svg {
transform: translateX(-70px);
}
+15 -2
View File
@@ -1,7 +1,20 @@
<script lang="ts">
let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>();
let {
onClick,
text,
disabled = false,
} = $props<{
onClick: () => void;
text: string;
disabled?: boolean;
}>();
</script>
<button onclick={onClick} class='px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg'>
<button
type="button"
onclick={onClick}
disabled={disabled}
class="px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
>
{text}
</button>
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from "svelte";
import { animate } from "motion";
import { delay } from "@/seqta/utils/delay.ts";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
const { hidePanel } = $props<{
hidePanel: () => void;
}>();
let cloudState = $state(cloudAuth.state);
let background = $state<HTMLDivElement | null>(null);
let content = $state<HTMLDivElement | null>(null);
let loginError = $state<string | null>(null);
onMount(() => {
const unsub = cloudAuth.subscribe((s) => {
cloudState = s;
});
if (background && content) {
animate(
background,
{ opacity: [0, 1] },
{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }
);
animate(
content,
{ scale: [0.4, 1], opacity: [0, 1] },
{ type: "spring", stiffness: 400, damping: 30 }
);
}
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closePanel();
};
document.addEventListener("keydown", handleEscapeKey);
return () => {
unsub();
document.removeEventListener("keydown", handleEscapeKey);
};
});
async function closePanel() {
if (!background || !content) return;
animate(
content,
{ scale: [1, 0.4], opacity: [1, 0] },
{ type: "spring", stiffness: 400, damping: 30 }
);
animate(
background,
{ opacity: [1, 0] },
{ ease: [0.4, 0, 0.2, 1] }
);
await delay(400);
hidePanel();
}
function handleBackgroundClick(event: MouseEvent) {
if (event.target === background) closePanel();
}
async function handleSignIn() {
loginError = null;
const result = await cloudAuth.startLogin();
if (result.success) {
closePanel();
} else {
loginError = result.error ?? "Failed to open login page";
}
}
async function handleLogout() {
await cloudAuth.logout();
}
function getInitials(): string {
const u = cloudState.user;
if (!u) return "?";
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
if (u.username) return u.username.slice(0, 2).toUpperCase();
if (u.email) return u.email.slice(0, 2).toUpperCase();
return "?";
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={background}
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50"
onclick={handleBackgroundClick}
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }}
>
<div
bind:this={content}
class="p-5 w-[320px] bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
>
<h3 class="text-lg font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
<p class="mt-0.5 text-sm text-zinc-500 dark:text-zinc-400">Account & sync</p>
<div class="mt-4">
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()}
</div>
{/if}
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-zinc-900 dark:text-white truncate">
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</p>
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
<p class="text-xs text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
{/if}
</div>
</div>
<button
type="button"
onclick={handleLogout}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Sign out
</button>
</div>
{:else}
<div class="flex flex-col gap-3">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Sign in to sync settings across devices, use your cloud profile picture, and more.
</p>
<button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
{#if loginError}
<p class="text-xs text-red-600 dark:text-red-400">{loginError}</p>
{/if}
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,120 @@
<script lang="ts">
import browser from "webextension-polyfill";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import Button from "./Button.svelte";
import Switch from "./Switch.svelte";
let { showDisclaimer } = $props<{
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
}>();
let cloudState = $state(cloudAuth.state);
let busy = $state(false);
let statusMessage = $state<string | null>(null);
let statusError = $state<string | null>(null);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => {
cloudState = s;
});
return unsub;
});
async function upload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsUpload",
token,
})) as { success?: boolean; error?: string };
if (res?.success) {
statusMessage = "Settings uploaded.";
} else {
statusError = res?.error ?? "Upload failed";
}
} catch (e) {
statusError = e instanceof Error ? e.message : "Upload failed";
} finally {
busy = false;
}
}
function promptDownload() {
showDisclaimer(confirmDownload, () => {});
}
async function confirmDownload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsDownload",
token,
})) as { success?: boolean; error?: string; notFound?: boolean };
if (res?.success) {
statusMessage = "Settings restored.";
} else {
statusError = res?.error ?? "Download failed";
}
} catch (e) {
statusError = e instanceof Error ? e.message : "Download failed";
} finally {
busy = false;
}
}
</script>
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-2.5">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-[11px] font-semibold text-zinc-800 dark:text-zinc-100">Automatic sync</p>
<p class="text-[10px] text-zinc-500 dark:text-zinc-400">Syncs settings when SEQTA loads and when you make changes</p>
</div>
<div class="shrink-0">
<Switch
state={$settingsState.autoCloudSettingsSync !== false}
onChange={(isOn: boolean) => (settingsState.autoCloudSettingsSync = isOn)}
/>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button
text={busy ? "Please wait\u2026" : "Upload"}
onClick={upload}
disabled={busy}
/>
<Button
text={busy ? "Please wait\u2026" : "Download"}
onClick={promptDownload}
disabled={busy}
/>
</div>
{#if statusMessage}
<p class="text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
{/if}
{#if statusError}
<p class="text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
{/if}
<p class="text-[10px] text-zinc-400 dark:text-zinc-500">
Passwords and tokens are never synced.
<a
href="https://betterseqta.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 rounded-sm"
>Privacy policy</a>
</p>
</div>
{/if}
@@ -0,0 +1,172 @@
<script lang="ts">
import { fade } from "svelte/transition";
import browser from "webextension-polyfill";
import QRCode from "qrcode";
import { portal } from "../utils/portal";
let showQrModal = $state(false);
let qrDataUrl = $state<string | null>(null);
let appLink = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let isLoading = $state(false);
let isStandalone = $state(false);
function isExtensionPage(): boolean {
return (
window.location.protocol === "chrome-extension:" ||
window.location.protocol === "moz-extension:"
);
}
function isSeqtaUrl(url: string): boolean {
try {
const u = new URL(url);
return u.hostname.includes("seqta") || u.hostname.endsWith(".edu.au");
} catch {
return false;
}
}
function normalizeBaseUrl(url: string): string {
try {
const u = new URL(url);
return u.origin;
} catch {
return url;
}
}
async function getAppLink(): Promise<string | null> {
let baseUrl: string | undefined;
if (isExtensionPage()) {
baseUrl = undefined;
} else {
baseUrl = normalizeBaseUrl(window.location.href);
if (!isSeqtaUrl(baseUrl)) return null;
}
const { appLink: link } = (await browser.runtime.sendMessage({
type: "getSeqtaSession",
baseUrl,
})) as { appLink: string | null };
return link ?? null;
}
async function generateQrCode() {
errorMessage = null;
qrDataUrl = null;
isLoading = true;
try {
isStandalone = isExtensionPage();
const link = await getAppLink();
if (!link) {
if (isStandalone) {
errorMessage =
"Open SEQTA Learn in a tab and log in, then open settings from that tab to generate a QR code.";
} else {
errorMessage = "Please log in to SEQTA Learn first.";
}
return;
}
const dataUrl = await QRCode.toDataURL(link, { width: 256, margin: 2 });
appLink = link;
qrDataUrl = dataUrl;
showQrModal = true;
} catch (err) {
console.error("[ConnectMobileApp] Failed to generate QR:", err);
errorMessage = "Failed to generate QR code. Please try again.";
} finally {
isLoading = false;
}
}
function closeModal() {
showQrModal = false;
qrDataUrl = null;
appLink = null;
errorMessage = null;
}
function openAppLink() {
if (appLink) window.location.href = appLink;
}
function downloadQrImage() {
if (!qrDataUrl) return;
const link = document.createElement("a");
link.href = qrDataUrl;
link.download = "desqta-login-qr.png";
link.click();
}
</script>
<div class="flex flex-col gap-1 items-end">
<button
type="button"
onclick={generateQrCode}
disabled={isLoading}
class="px-5 py-1.5 text-[0.75rem] text-nowrap shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-opacity">
{isLoading ? "Generating..." : "Generate QR"}
</button>
{#if errorMessage}
<p class="text-xs text-right text-amber-600 dark:text-amber-400">{errorMessage}</p>
{/if}
</div>
{#if showQrModal && qrDataUrl}
<div
use:portal
class="fixed cursor-auto inset-0 z-[10000] flex justify-center items-center bg-black/50 {isStandalone ? 'backdrop-blur-sm' : ''}"
role="button"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget) closeModal();
}}
onkeydown={(e) => {
if (e.key === "Escape") closeModal();
}}
transition:fade={{ duration: 150 }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="p-6 mx-4 w-full max-w-sm bg-white rounded-2xl shadow-2xl dark:bg-zinc-800"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}>
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Scan with DesQTA</h2>
<button
type="button"
onclick={closeModal}
class="p-2 rounded-lg transition-colors text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 dark:hover:text-zinc-400 dark:hover:bg-zinc-700"
aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex justify-center p-4 bg-white rounded-xl dark:bg-zinc-900">
<img src={qrDataUrl} alt="SEQTA Learn app link QR code" class="w-64 h-64" />
</div>
<div class="flex flex-col gap-2 mt-4">
<button
type="button"
onclick={openAppLink}
class="px-4 py-2.5 w-full text-sm font-medium text-white bg-indigo-600 rounded-lg transition-colors dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
Sign into DesQTA Desktop
</button>
<button
type="button"
onclick={downloadQrImage}
class="px-4 py-2 w-full text-xs font-medium rounded-lg border transition-colors text-zinc-500 dark:text-zinc-400 border-zinc-200 dark:border-zinc-600 hover:bg-zinc-50 dark:hover:bg-zinc-800/50">
Download QR as image
</button>
</div>
<p class="mt-2 text-sm text-center text-zinc-600 dark:text-zinc-400">
Or scan this QR code with DesQTA on your phone.
</p>
</div>
</div>
{/if}
+54 -12
View File
@@ -8,12 +8,12 @@
let select: HTMLSelectElement;
</script>
<div class="border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 shadow-2xl rounded-xl w-full overflow-clip">
<div class="select-wrapper relative w-full overflow-hidden rounded-2xl border shadow-2xl">
<select
bind:this={select}
value={state}
onchange={() => onChange(select.value)}
class="px-4 py-2 pr-9 text-[0.875rem] font-medium text-black dark:text-white w-full border-none bg-white/80 dark:bg-zinc-800/70 hover:bg-white/90 dark:hover:bg-zinc-800/80 focus:bg-white/90 dark:focus:bg-zinc-800/80 focus:ring-0 rounded-md appearance-none transition-colors"
class="select-input w-full appearance-none border-none bg-transparent px-4 py-2.5 pr-10 text-[0.875rem] font-medium transition-colors"
>
{#each options as option}
<option value={option.value}>
@@ -21,20 +21,62 @@
</option>
{/each}
</select>
<span class="select-icon pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd"></path>
</svg>
</span>
</div>
<style>
/* Make native dropdown list readable on Windows */
select option {
background-color: #ffffff;
color: #111827; /* zinc-900 */
}
:global(.dark) select option {
background-color: #1f2937; /* zinc-800 */
color: #ffffff;
.select-wrapper {
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 72%, transparent);
border-radius: 18px;
color: var(--text-primary);
transition:
background-color 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease,
transform 180ms ease;
}
:global(.dark) div::after {
color: rgba(255, 255, 255, 0.6);
.select-wrapper:hover {
background: color-mix(in srgb, var(--background-primary) 94%, var(--background-secondary) 6%);
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 88%, transparent);
}
.select-wrapper:focus-within {
background: color-mix(in srgb, var(--background-primary) 96%, var(--background-secondary) 4%);
border-color: color-mix(in srgb, var(--text-primary) 22%, var(--theme-offset-bg, var(--background-secondary)) 78%);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent);
}
.select-input {
color: var(--text-primary);
outline: none;
text-overflow: ellipsis;
}
.select-input:hover,
.select-input:focus {
background: transparent;
}
.select-input option {
background: var(--background-primary);
color: var(--text-primary);
}
.select-icon {
color: color-mix(in srgb, var(--text-primary) 60%, transparent);
}
.select-input {
color-scheme: light;
}
:global(.dark) .select-input {
color-scheme: dark;
}
</style>
@@ -0,0 +1,79 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { animate } from "motion";
import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let { onClose } = $props<{ onClose: () => void }>();
let modalElement: HTMLElement;
onMount(() => {
return cloudAuth.subscribe((s) => {
if (s.isLoggedIn) onClose();
});
});
$effect(() => {
if (modalElement) {
animate(
modalElement,
{ scale: [0.9, 1], opacity: [0, 1] },
{ type: "spring", stiffness: 300, damping: 25 },
);
}
});
async function handleSignIn() {
await cloudAuth.startLogin();
}
</script>
<div
class="flex fixed inset-0 z-[99999] justify-center items-center bg-black/50"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onkeydown={(e) => {
if (e.key === "Escape") onClose();
}}
role="button"
tabindex="-1"
transition:fade={{ duration: 150 }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={modalElement}
class="p-4 mx-4 w-full max-w-md max-h-[90vh] overflow-y-auto bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2 class="mb-3 text-xl font-bold text-zinc-900 dark:text-white">
Sign in to favorite themes
</h2>
<p class="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
Sign in to the Theme Store to save favorites across devices, or create an account to get started.
</p>
<button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
<p class="mt-2 text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
<div class="flex justify-end mt-4">
<button
type="button"
onclick={onClose}
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Close
</button>
</div>
</div>
</div>
+1 -1
View File
@@ -9,7 +9,7 @@
let percentage = $derived(((state - min) / (max - min)) * 100);
</script>
<div class="relative mx-auto w-full max-w-lg">
<div class="relative w-full min-w-0">
<input
type="range"
min={min}
@@ -3,8 +3,7 @@
import './TabbedContainer.css';
import { onMount } from 'svelte';
let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>();
let activeTab = $state(0);
let { tabs, activeTab = $bindable(0) } = $props<{ tabs: { title: string, Content: any, props?: any }[]; activeTab?: number }>();
let containerRef: HTMLElement | null = null;
let tabWidth = $state(0);
@@ -0,0 +1,161 @@
<script lang="ts">
import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let { alwaysShowUserName = false, onClick = undefined } = $props<{
alwaysShowUserName?: boolean;
onClick?: () => void;
}>();
let cloudState = $state(cloudAuth.state);
let open = $state(false);
let dropdownEl: HTMLElement;
onMount(() => {
const unsubscribe = cloudAuth.subscribe((state) => {
cloudState = state;
});
return unsubscribe;
});
function handleClickOutside(e: MouseEvent) {
if (dropdownEl && !dropdownEl.contains(e.target as Node)) {
open = false;
}
}
$effect(() => {
if (open) {
const timer = setTimeout(() => {
document.addEventListener("click", handleClickOutside);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener("click", handleClickOutside);
};
}
});
async function handleLogout() {
await cloudAuth.logout();
open = false;
}
async function handleSignIn() {
await cloudAuth.startLogin();
open = false;
}
function handleButtonClick() {
if (onClick) {
onClick();
} else {
open = !open;
}
}
function getInitials(): string {
const u = cloudState.user;
if (!u) return "?";
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
if (u.username) return u.username.slice(0, 2).toUpperCase();
if (u.email) return u.email.slice(0, 2).toUpperCase();
return "?";
}
</script>
<div class="relative flex items-center" bind:this={dropdownEl}>
<button
type="button"
onclick={handleButtonClick}
class="flex items-center gap-2 px-3 py-1.5 text-[0.75rem] rounded-lg shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white transition-colors duration-200"
>
{#if cloudState.isLoggedIn}
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
{getInitials()}
</div>
{/if}
<span
class={alwaysShowUserName
? "inline max-w-[10rem] truncate text-[0.75rem]"
: "hidden max-w-24 truncate sm:inline text-[0.75rem]"}
>
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</span>
{:else}
<span class="text-sm font-IconFamily" aria-hidden="true">{'\ued53'}</span>
<span class="text-[0.75rem]">Sign in</span>
{/if}
</button>
{#if !onClick && open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute right-0 top-full mt-2 w-80 rounded-xl border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-xl z-[100] overflow-hidden"
onclick={(e) => e.stopPropagation()}
>
<div class="p-4 border-b border-zinc-200 dark:border-zinc-600">
<h3 class="text-xl font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
<p class="text-base text-zinc-500 dark:text-zinc-400">Sync favorites across devices</p>
</div>
<div class="p-4">
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()}
</div>
{/if}
<div class="min-w-0 flex-1">
<p class="text-base font-medium text-zinc-900 dark:text-white truncate">
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</p>
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
<p class="text-base text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
{/if}
</div>
</div>
<button
type="button"
onclick={handleLogout}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Sign out
</button>
</div>
{:else}
<div class="flex flex-col gap-3">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Sign in to sync favorites across devices.
</p>
<button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
</div>
{/if}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,111 @@
<script lang="ts">
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let {
introText,
onSuccess,
compact = false,
} = $props<{
introText?: string;
onSuccess?: () => void;
/** Smaller padding/text for overlays (e.g. SignInToFavoriteModal) */
compact?: boolean;
}>();
let username = $state("");
let password = $state("");
let loading = $state(false);
let error = $state<string | null>(null);
const inputClass = $derived(
compact
? "w-full px-4 py-2 text-sm rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
: "w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200",
);
const btnClass = $derived(
compact
? "w-full px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200"
: "w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200",
);
const linkClass = $derived(
compact
? "inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
: "inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200",
);
async function handleLogin() {
if (loading) return;
error = null;
if (!username.trim() || !password) {
error = "Please enter username and password";
return;
}
loading = true;
try {
const result = await cloudAuth.login(username.trim(), password);
if (result.success) {
password = "";
onSuccess?.();
} else {
error = result.error ?? "Login failed";
}
} finally {
loading = false;
}
}
</script>
{#if introText}
<p
class="mb-4 text-zinc-600 dark:text-zinc-400 {compact ? 'text-sm' : 'text-base'}"
>
{introText}
</p>
{/if}
<form
class="flex flex-col gap-3"
autocomplete="off"
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
<input
type="text"
name="betterseqta-cloud-username"
autocomplete="off"
placeholder="Email or username"
bind:value={username}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute("readonly")}
class={inputClass}
/>
<input
type="password"
name="betterseqta-cloud-password"
autocomplete="new-password"
placeholder="Password"
bind:value={password}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute("readonly")}
class={inputClass}
/>
{#if error}
<p class="text-red-600 dark:text-red-400 {compact ? 'text-sm' : 'text-base'}">{error}</p>
{/if}
<button type="submit" disabled={loading} class={btnClass}>
{loading ? "Signing in..." : "Sign in"}
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class={linkClass}
>
Create account
</a>
</form>
@@ -29,7 +29,7 @@
{#if coverThemes.length > 0}
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
<div
class="w-full aspect-8/3"
class="w-full aspect-[5/1] max-h-[500px]"
use:emblaCarouselSvelte={{ options, plugins }}
onemblaInit={onInit}
>
@@ -42,9 +42,25 @@
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
onclick={() => setDisplayTheme(theme)}
>
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-full" />
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-full" />
{#if theme.featured === true}
<div class="absolute top-4 left-4 z-[2] pointer-events-none">
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm"
aria-label="Featured theme"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
</svg>
Featured
</span>
</div>
{/if}
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
{#if theme.author}
<p class="text-sm text-white/90 mt-1 mb-1 line-clamp-1">By {theme.author}</p>
{/if}
<p class='text-lg text-white'>{theme.description}</p>
</div>
<div class='absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80'></div>
@@ -3,6 +3,7 @@
import logoDark from '@/resources/icons/betterseqta-light-full.png';
import { closeStore } from '@/seqta/ui/renderStore'
import browser from 'webextension-polyfill';
import CloudHeader from './CloudHeader.svelte';
// Props
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
@@ -39,6 +40,8 @@
>
Backgrounds
</button>
<CloudHeader />
</div>
<div class="flex relative gap-2">
+118 -8
View File
@@ -1,19 +1,129 @@
<script lang="ts">
import type { Theme } from '@/interface/types/Theme'
let { theme, onClick } = $props<{ theme: Theme; onClick: () => void }>();
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{
theme: Theme;
onClick: () => void;
toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean;
onRequestSignIn?: () => void;
}>();
let menuOpen = $state(false);
let menuRef: HTMLDivElement;
onMount(() => {
const closeMenu = (e: MouseEvent) => {
if (menuOpen && menuRef && !menuRef.contains(e.target as Node)) {
menuOpen = false;
}
};
document.addEventListener('click', closeMenu);
return () => document.removeEventListener('click', closeMenu);
});
function handleCardClick(e: MouseEvent) {
if ((e.target as HTMLElement).closest('[data-theme-menu]')) return;
onClick();
}
function handleFavoriteClick(e: MouseEvent) {
e.stopPropagation();
if (isLoggedIn) {
toggleFavorite(theme);
} else {
onRequestSignIn?.();
}
menuOpen = false;
}
</script>
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={onClick}>
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white">
{theme.name}
<div
class="relative z-0 hover:z-20 w-full cursor-pointer"
role="button"
tabindex="-1"
onkeydown={onClick}
onclick={handleCardClick}
>
<div
class="bg-gray-50 w-full transition-all duration-500 ease-out relative group flex flex-col rounded-xl overflow-clip border hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto"
transition:fade
>
{#if theme.featured === true}
<div class="absolute top-2 left-2 z-20 pointer-events-none">
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm"
aria-label="Featured theme"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
</svg>
Featured
</span>
</div>
{/if}
<!-- Menu dropdown -->
<div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}>
<button
type="button"
class="flex justify-center items-center w-8 h-8 rounded-lg bg-black/40 hover:bg-black/60 text-white transition-all"
onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}
aria-label="Theme options"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
</button>
{#if menuOpen}
<div
class="absolute right-0 top-full mt-1 py-1 min-w-[140px] rounded-lg bg-white dark:bg-zinc-800 shadow-lg border border-zinc-200 dark:border-zinc-700"
role="menu"
>
<button
type="button"
class="flex gap-2 items-center w-full px-3 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700"
role="menuitem"
onclick={handleFavoriteClick}
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill={theme.is_favorited ? 'currentColor' : 'none'}
stroke="currentColor"
stroke-width="2"
class="w-5 h-5 {theme.is_favorited ? 'text-red-500' : ''}"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{theme.is_favorited ? 'Favorited' : 'Favorite'}
</button>
</div>
{/if}
</div>
<div class="absolute bottom-1 left-3 right-3 z-10 mb-1 flex flex-col gap-0.5">
<span class="text-xl font-bold text-white drop-shadow-md">{theme.name}</span>
{#if theme.author}
<span class="text-xs text-white/85 drop-shadow-md line-clamp-1">By {theme.author}</span>
{/if}
<div class="flex gap-3 text-xs font-medium text-white/90 drop-shadow-sm">
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{(theme.download_count ?? 0).toLocaleString()}
</span>
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{(theme.favorite_count ?? 0).toLocaleString()}
</span>
</div>
</div>
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
<div class='w-full'>
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
</div>
</div>
</div>
@@ -2,7 +2,14 @@
import type { Theme } from '@/interface/types/Theme'
import ThemeCard from './ThemeCard.svelte';
let { themes, searchTerm, setDisplayTheme } = $props<{ themes: Theme[]; searchTerm: string, setDisplayTheme: (theme: Theme) => void }>();
let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{
themes: Theme[];
searchTerm: string;
setDisplayTheme: (theme: Theme) => void;
toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean;
onRequestSignIn?: () => void;
}>();
let filteredThemes = $derived(themes.filter((theme: Theme) =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
@@ -12,12 +19,18 @@
<div class="relative" >
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
{#each filteredThemes as theme (theme.id)}
<ThemeCard theme={theme} onClick={() => setDisplayTheme(theme)} />
<ThemeCard
{theme}
onClick={() => setDisplayTheme(theme)}
{toggleFavorite}
{isLoggedIn}
{onRequestSignIn}
/>
{/each}
{#if filteredThemes.length !== 0}
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='w-full cursor-pointer'>
<div class="bg-zinc-50 h-48 w-full transition-all hover:scale-105 duration-500 relative justify-center items-center group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] rounded-xl overflow-clip border">
<a href="https://docs.betterseqta.org/theme-creation/" class="block relative z-0 hover:z-20 w-full cursor-pointer">
<div class="bg-zinc-50 h-48 w-full transition-all duration-500 ease-out relative overflow-clip rounded-xl border group group/card flex flex-col justify-center items-center hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1]">
<div class="text-2xl font-IconFamily">{'\uecb3'}</div>
<div class="text-xl font-bold text-center transition-all duration-500 dark:text-white">
Got a Theme Idea?
@@ -32,7 +45,7 @@
<div class="absolute top-0 flex flex-col items-center justify-center w-full text-center h-96">
<h1 class="mt-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl">That doesn't exist! 😭😭😭</h1>
<p class="mt-6 text-lg leading-7 text-zinc-600 dark:text-zinc-300">Sorry, we couldn't find the theme you're looking for. Maybe... you could create it?</p>
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
<a href="https://docs.betterseqta.org/theme-creation/" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
Show me how!
</a>
</div>
+133 -50
View File
@@ -2,8 +2,7 @@
import type { Theme } from '@/interface/types/Theme'
import { fade } from 'svelte/transition';
import { animate } from 'motion';
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme } = $props<{
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{
theme: Theme | null;
currentThemes: string[];
setDisplayTheme: (theme: Theme | null) => void;
@@ -11,18 +10,42 @@
onRemove: (themeId: string) => void;
allThemes: Theme[];
displayTheme: Theme | null;
toggleFavorite?: (theme: Theme) => void;
isLoggedIn?: boolean;
onRequestSignIn?: () => void;
}>();
let installing = $state(false);
let modalElement: HTMLElement;
// Function to get related themes
function getRelatedThemes() {
return allThemes
.filter((t: Theme) => t.id !== theme.id)
.sort((a: Theme, b: Theme) => a.name.localeCompare(theme.name) - b.name.localeCompare(theme.name))
.slice(0, 4);
function handleFavoriteClick() {
if (isLoggedIn && toggleFavorite && theme) {
toggleFavorite(theme);
} else {
onRequestSignIn?.();
}
}
function tagsOverlap(a: string[] | undefined, b: string[] | undefined): boolean {
const lowerB = new Set((b ?? []).map((t) => t.toLowerCase()));
return (a ?? []).some((t) => lowerB.has(t.toLowerCase()));
}
const relatedThemes = $derived.by(() => {
const t = theme;
if (!t) return [] as Theme[];
if ((t.tags ?? []).length === 0) return [];
return allThemes
.filter((x: Theme) => !!x && x.id !== t.id && tagsOverlap(t.tags, x.tags))
.sort((a: Theme, b: Theme) => {
const diff = (b.download_count ?? 0) - (a.download_count ?? 0);
if (diff !== 0) return diff;
const byName = a.name.localeCompare(b.name);
if (byName !== 0) return byName;
return a.id.localeCompare(b.id);
})
.slice(0, 4);
});
$effect(() => {
if (displayTheme) {
animate(
@@ -72,55 +95,115 @@
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
{#if theme}
<div class="relative h-auto">
<button class="absolute top-0 right-0 p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
{'\ued8a'}
</button>
<h2 class="mb-4 text-2xl font-bold">
{theme.name}
</h2>
<img src={theme.marqueeImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" />
<div class="absolute top-0 right-0 flex gap-1 items-center">
<button class="p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
{'\ued8a'}
</button>
</div>
<div class="flex flex-wrap items-center gap-2 pr-12 mb-2">
<h2 class="text-2xl font-bold">
{theme.name}
</h2>
{#if theme.featured === true}
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100"
aria-label="Featured theme"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
</svg>
Featured
</span>
{/if}
</div>
{#if theme.author}
<p class="mb-2 text-sm text-zinc-600 dark:text-zinc-400">
By {theme.author}
</p>
{/if}
<div class="flex gap-4 mb-4 text-sm text-zinc-600 dark:text-zinc-400">
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{(theme.download_count ?? 0).toLocaleString()} downloads
</span>
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{(theme.favorite_count ?? 0).toLocaleString()} favorites
</span>
</div>
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" />
<p class="mb-4 text-gray-700 dark:text-gray-300">
{theme.description}
</p>
{#if currentThemes.includes(theme.id)}
<button onclick={async () => {installing = true; await onRemove(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 mt-4 ml-auto w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200">
{#if installing}
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
<div class="flex flex-wrap gap-2 mt-4 justify-end items-center">
{#if toggleFavorite && theme}
<button
type="button"
class="flex items-center gap-2 px-4 py-2 rounded-full transition-all duration-200 hover:scale-105 active:scale-95 {theme.is_favorited ? 'text-red-500 bg-red-500/10 dark:bg-red-500/20' : 'bg-zinc-200 dark:bg-zinc-700 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600'}"
onclick={handleFavoriteClick}
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
aria-label={theme.is_favorited ? 'Unfavorite' : 'Favorite'}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{/if}
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Remove</span>
</button>
{:else}
<button onclick={async () => {installing = true; await onInstall(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 mt-4 ml-auto w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200">
{#if installing}
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
</svg>
{/if}
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Install</span>
</button>
{/if}
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
<h3 class="mb-4 text-lg font-bold">
Similar Themes
</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{#each getRelatedThemes() as relatedTheme (relatedTheme.id)}
<button onclick={() => { hideModal(relatedTheme) }} class="w-full cursor-pointer">
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border">
<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}
</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" />
</div>
{theme.is_favorited ? 'Favorited' : 'Favorite'}
</button>
{/each}
{/if}
{#if currentThemes.includes(theme.id)}
<button onclick={async () => {installing = true; await onRemove(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95">
{#if installing}
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
</svg>
{/if}
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Remove</span>
</button>
{:else}
<button onclick={async () => {installing = true; await onInstall(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95">
{#if installing}
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
</svg>
{/if}
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Install</span>
</button>
{/if}
</div>
{#if relatedThemes.length > 0}
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
<h3 class="mb-4 text-lg font-bold">
Related themes
</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{#each relatedThemes as relatedTheme (relatedTheme.id)}
<button onclick={() => { hideModal(relatedTheme) }} class="relative z-0 hover:z-20 w-full cursor-pointer">
<div class="bg-gray-50 w-full transition-all duration-500 ease-out relative group group/card flex flex-col hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border">
<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}
</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 || relatedTheme.coverImage} alt="Theme Preview" class="object-cover w-full h-48" />
</div>
</button>
{/each}
</div>
{/if}
</div>
{:else}
<div class="flex justify-center items-center h-full text-zinc-600 dark:text-zinc-300">
<button class="px-4 py-2 rounded-lg bg-zinc-200 dark:bg-zinc-700 transition-all duration-200 hover:scale-105 active:scale-95" onclick={() => hideModal()}>
Close
</button>
</div>
{/if}
</div>
</div>
@@ -1,11 +1,14 @@
<script lang="ts">
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
import { onDestroy, onMount } from 'svelte'
import browser from 'webextension-polyfill'
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { OpenStorePage } from '@/seqta/ui/renderStore'
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte'
const themeManager = ThemeManager.getInstance();
@@ -13,6 +16,17 @@
let { isEditMode } = $props<{ isEditMode: boolean }>();
let isDragging = $state(false);
let tempTheme = $state(null);
let favoriteStatus = $state<Record<string, boolean>>({});
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
let prevLoggedIn = $state(false);
let showSignInModal = $state(false);
cloudAuth.subscribe((s) => {
const now = s.isLoggedIn;
if (now && !prevLoggedIn && themes) void fetchThemes();
prevLoggedIn = now;
cloudLoggedIn = now;
});
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
if (isEditMode) return;
@@ -71,7 +85,7 @@
try {
const result = JSON.parse(event.target?.result as string);
tempTheme = result;
await themeManager.installTheme(result);
await themeManager.installTheme(result, { fromStore: false });
await fetchThemes();
} catch (error) {
console.error('Error parsing file:', error);
@@ -87,11 +101,55 @@
themes: await themeManager.getAvailableThemes(),
selectedTheme: themeManager.getSelectedThemeId() || '',
}
if (themes && cloudLoggedIn) {
const token = await cloudAuth.getStoredToken();
if (token) {
const status: Record<string, boolean> = {};
await Promise.all(
themes.themes.map(async (t) => {
try {
const res = (await browser.runtime.sendMessage({
type: 'fetchThemeDetails',
themeId: t.id,
token,
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
if (res?.success && res?.data?.theme) {
status[t.id] = !!res.data.theme.is_favorited;
}
} catch {
// Theme may not exist on store (e.g. locally created)
}
})
);
favoriteStatus = status;
}
} else {
favoriteStatus = {};
}
}
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
e.stopPropagation();
if (!cloudLoggedIn) {
showSignInModal = true;
return;
}
const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !favoriteStatus[theme.id];
const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite',
themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean };
if (result?.success) {
favoriteStatus = { ...favoriteStatus, [theme.id]: isFavorite };
}
}
onMount(async () => {
await fetchThemes();
themeUpdates.addListener(fetchThemes);
})
@@ -144,6 +202,18 @@
{/if}
{#if !isEditMode}
<div
class="flex absolute right-24 top-1/4 z-20 place-items-center p-2 w-8 h-8 text-center rounded-full opacity-0 transition-all -translate-y-1/2 group-hover:opacity-100 group-hover:top-1/2 {(favoriteStatus[theme.id] ?? false) ? 'text-red-400' : 'text-white/80'} bg-black/50"
onclick={(event) => handleToggleFavorite(theme, event)}
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') handleToggleFavorite(theme, event as any) }}
role="button"
tabindex="-1"
title={cloudLoggedIn ? ((favoriteStatus[theme.id] ?? false) ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={(favoriteStatus[theme.id] ?? false) ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</div>
<div
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
@@ -211,3 +281,7 @@
</button>
</div>
</div>
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if}
+29 -14
View File
@@ -15,12 +15,16 @@
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
import ColourPicker from "../components/ColourPicker.svelte";
import CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "../hooks/SettingsPopup";
let devModeSequence = "";
let settingsActiveTab = $state(0);
let showDisclaimerModal = $state(false);
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
let disclaimerTitle = $state("Confirm");
let disclaimerMessage = $state("");
const handleDevModeToggle = () => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -65,19 +69,28 @@
let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state<boolean>(false);
let showCloudPanel = $state<boolean>(false);
const showDisclaimer = (onConfirm: () => void, onCancel: () => void) => {
const openCloudPanel = () => {
showCloudPanel = true;
};
const showDisclaimer = (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => {
disclaimerCallbacks = { onConfirm, onCancel };
disclaimerTitle = title ?? "Confirm";
disclaimerMessage = message ?? "";
showDisclaimerModal = true;
};
onMount(async () => {
onMount(() => {
settingsPopup.addListener(() => {
showColourPicker = false;
showCloudPanel = false;
});
if (!standalone) return;
StandaloneStore.setStandalone(true);
if (standalone) {
StandaloneStore.setStandalone(true);
}
});
</script>
@@ -275,11 +288,12 @@
</div>
<TabbedContainer
bind:activeTab={settingsActiveTab}
tabs={[
{
title: "Settings",
Content: Settings,
props: { showColourPicker: openColourPicker, showDisclaimer },
props: { showColourPicker: openColourPicker, showDisclaimer, showCloudPanel: openCloudPanel },
},
{ title: "Shortcuts", Content: Shortcuts },
{ title: "Themes", Content: Theme },
@@ -294,19 +308,20 @@
}}
/>
{/if}
{#if showCloudPanel}
<CloudPanel
hidePanel={() => {
showCloudPanel = false;
}}
/>
{/if}
</div>
{#if showDisclaimerModal && disclaimerCallbacks}
<DisclaimerModal
title="Assessment Averages Disclaimer"
message="This feature calculates a simple average of your assessment grades. It does not take into account:
• Assessment weightings
• Different grading scales
• Other factors used in official reports
The displayed average may be inaccurate compared to your actual marks found in reports.
Do you want to enable this feature?"
title={disclaimerTitle}
message={disclaimerMessage}
onConfirm={() => {
disclaimerCallbacks?.onConfirm();
showDisclaimerModal = false;
+164 -49
View File
@@ -10,8 +10,13 @@
import type { SettingsList } from "@/interface/types/SettingsProps"
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
import CloudSettingsSync from "@/interface/components/CloudSettingsSync.svelte"
import CloudHeader from "@/interface/components/store/CloudHeader.svelte"
import { cloudAuth } from "@/seqta/utils/CloudAuth"
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
import { getAllPluginSettings } from "@/plugins"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
@@ -51,6 +56,12 @@
const pluginSettings = getAllPluginSettings() as Plugin[];
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({});
let cloudState = $state(cloudAuth.state);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => { cloudState = s; });
return unsub;
});
async function loadPluginSettings() {
for (const plugin of pluginSettings) {
if (Object.keys(plugin.settings).length === 0) continue;
@@ -92,10 +103,24 @@
loadPluginSettings();
})
const { showColourPicker, showDisclaimer } = $props<{
const { showColourPicker, showDisclaimer, showCloudPanel } = $props<{
showColourPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void;
showCloudPanel: () => void;
}>();
async function exportCloudSettingsJsonToFile() {
const payload = await getSnapshotForUpload();
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `betterseqta-plus-settings-export-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
a.click();
URL.revokeObjectURL(url);
}
</script>
{#snippet Setting({ title, description, Component, props }: SettingsList) }
@@ -113,27 +138,15 @@
<div class="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700">
{#each [
{
title: "Transparency Effects",
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)",
id: 1,
Component: Switch,
props: {
state: $settingsState.transparencyEffects,
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
}
},
{
title: "Custom Theme Colour",
description: "Customise the overall theme colour of SEQTA Learn.",
id: 4,
Component: PickerSwatch,
props: {
onClick: showColourPicker
}
title: "Connect Mobile App",
description: "Link your SEQTA session to DesQTA — the modern desktop and mobile app for SEQTA Learn",
id: 0,
Component: ConnectMobileApp,
props: {}
},
{
title: "Edit Sidebar Layout",
description: "Customise the sidebar layout.",
description: "Reorder pages on the sidebar",
id: 5,
Component: Button,
props: {
@@ -141,9 +154,28 @@
text: "Edit"
}
},
{
title: "Custom Theme Colour",
description: "Customise the overall theme colour of SEQTA Learn",
id: 4,
Component: PickerSwatch,
props: {
onClick: showColourPicker
}
},
{
title: "Icon Only Sidebar",
description: "Show only icons in the sidebar for a compact layout",
id: 14,
Component: Switch,
props: {
state: $settingsState.iconOnlySidebar ?? false,
onChange: (isOn: boolean) => settingsState.iconOnlySidebar = isOn
}
},
{
title: "Animations",
description: "Enables animations on certain pages.",
description: "Enable animations on certain pages",
id: 6,
Component: Switch,
props: {
@@ -162,27 +194,37 @@
}
},
{
title: "Default Page",
description: "The page to load when SEQTA Learn is opened.",
id: 10,
Component: Select,
title: "Transparency Effects",
description: "Enable transparency effects on certain elements, such as blur (May impact battery life)",
id: 1,
Component: Switch,
props: {
state: $settingsState.defaultPage,
onChange: (value: string) => settingsState.defaultPage = value,
options: [
{ value: 'home', label: 'Home' },
{ value: 'dashboard', label: 'Dashboard' },
{ value: 'timetable', label: 'Timetable' },
{ value: 'welcome', label: 'Welcome' },
{ value: 'messages', label: 'Messages' },
{ value: 'documents', label: 'Documents' },
{ value: 'reports', label: 'Reports' },
]
state: $settingsState.transparencyEffects,
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
}
},
{
title: "Default Page",
description: "Choose which page loads first when you open SEQTA",
id: 10,
Component: Select,
props: {
state: $settingsState.defaultPage ?? "home",
onChange: (value: string) => (settingsState.defaultPage = value),
options: [
{ value: "home", label: "Home" },
{ value: "dashboard", label: "Dashboard" },
{ value: "timetable", label: "Timetable" },
{ value: "welcome", label: "Welcome" },
{ value: "messages", label: "Messages" },
{ value: "documents", label: "Documents" },
{ value: "reports", label: "Reports" },
],
},
},
{
title: "News Feed Source",
description: "Choose sources of your news feed.",
description: "Choose the sources for your news feed",
id: 11,
Component: Select,
props: {
@@ -207,6 +249,49 @@
{@render Setting(option)}
{/each}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Adaptive Theme Colour</h2>
<p class="text-xs">Change the theme colour based on the current class (e.g. when viewing a course or assessments page)</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeColour ?? false}
onChange={(isOn: boolean) => settingsState.adaptiveThemeColour = isOn}
/>
</div>
</div>
{#if $settingsState.adaptiveThemeColour}
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
<div class="pr-4">
<h2 class="text-sm font-bold">Soft Gradient</h2>
<p class="text-xs">Use a soft gradient instead of a solid colour when viewing a class</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeGradient ?? false}
onChange={(isOn: boolean) => settingsState.adaptiveThemeGradient = isOn}
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
<div class="pr-4">
<h2 class="text-sm font-bold">Smooth colour transition</h2>
<p class="text-xs">Ease between class/subject colours when navigating instead of switching instantly</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeColourTransition ?? true}
onChange={(isOn: boolean) => settingsState.adaptiveThemeColourTransition = isOn}
/>
</div>
</div>
{/if}
</div>
</div>
{#each pluginSettings as plugin}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40 {!(plugin as any).disableToggle && Object.keys(plugin.settings).length === 0 ? 'hidden' : ''}">
@@ -233,9 +318,9 @@
async () => {
await updatePluginSetting(plugin.pluginId, 'enabled', true);
},
() => {
// Do nothing on cancel
}
() => {},
"Assessment Averages Disclaimer",
"This feature calculates a simple average of your assessment grades. It does not take into account:\n• Assessment weightings\n• Different grading scales\n• Other factors used in official reports\n\nThe displayed average may be inaccurate compared to your actual marks found in reports.\n\nDo you want to enable this feature?"
);
return;
}
@@ -248,8 +333,8 @@
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]}
<!-- Skip the 'enabled' setting if it's part of the settings object -->
{#if key !== 'enabled'}
<!-- Skip the 'enabled' setting and hide cloud-only settings when not signed in -->
{#if key !== 'enabled' && !(key === 'useCloudPfp' && !cloudState.isLoggedIn)}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">{setting.title || key}</h2>
@@ -262,13 +347,15 @@
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}
/>
<div class="w-28 shrink-0">
<Slider
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
min={setting.min}
max={setting.max}
step={setting.step}
/>
</div>
{:else if setting.type === 'string'}
<input
type="text"
@@ -310,6 +397,25 @@
</div>
{/each}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">BetterSEQTA Cloud</h2>
<p class="text-xs">Account & sync</p>
</div>
<div>
<CloudHeader alwaysShowUserName onClick={showCloudPanel} />
</div>
</div>
{#if cloudState.isLoggedIn}
<div class="px-3 pb-3">
<CloudSettingsSync showDisclaimer={(onConfirm, onCancel) => showDisclaimer(onConfirm, onCancel, "Restore from cloud?", "This will replace your local settings with the cloud backup. Continue?")} />
</div>
{/if}
</div>
</div>
<div class="p-1 border-none"></div>
{@render Setting({
@@ -377,6 +483,15 @@
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
<p class="text-xs">Download the same payload as cloud sync (OAuth tokens stripped). For debugging and server testing.</p>
</div>
<div>
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
</div>
</div>
</div>
{/if}
</div>
+95 -16
View File
@@ -15,8 +15,13 @@
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
import Backgrounds from '../components/store/Backgrounds.svelte'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
// State variables
let searchTerm = $state('');
@@ -30,6 +35,7 @@
let error = $state<string | null>(null);
let selectedBackground = $state<string | null>(null);
let showSignInOverlay = $state(false);
const fetchCurrentThemes = async () => {
const themes = await themeManager.getAvailableThemes();
@@ -48,20 +54,65 @@
activeTab = tab;
};
// Fetch themes and initialize app
/** Featured themes first; within each group, newest by `created_at` (API: Unix seconds). */
function compareStoreThemes(a: Theme, b: Theme): number {
const fa = a.featured === true ? 1 : 0;
const fb = b.featured === true ? 1 : 0;
if (fa !== fb) return fb - fa;
const ca = a.created_at ?? 0;
const cb = b.created_at ?? 0;
if (ca !== cb) return cb - ca;
return a.name.localeCompare(b.name);
}
const toggleFavorite = async (theme: Theme) => {
const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !theme.is_favorited;
const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite',
themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean };
if (result?.success) {
const delta = isFavorite ? 1 : -1;
themes = themes.map((t) =>
t.id === theme.id
? { ...t, is_favorited: isFavorite, favorite_count: Math.max(0, (t.favorite_count ?? 0) + delta) }
: t
);
if (displayTheme?.id === theme.id) {
displayTheme = {
...displayTheme,
is_favorited: isFavorite,
favorite_count: Math.max(0, (displayTheme.favorite_count ?? 0) + delta),
};
}
}
};
// Fetch themes via background script (avoids CORS when store runs inside SEQTA page)
const fetchThemes = async () => {
try {
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${(new Date()).getTime()}`, { cache: 'no-store' });
const data = await response.json();
themes = data.themes;
// Shuffle for cover themes
const shuffled = [...themes].sort(() => 0.5 - Math.random());
coverThemes = shuffled.slice(0, 3);
const token = await cloudAuth.getStoredToken();
const data = (await browser.runtime.sendMessage({
type: 'fetchThemes',
token: token ?? undefined,
})) as {
success?: boolean;
data?: { themes: Theme[] };
error?: string;
};
if (!data?.success || !data?.data?.themes) {
throw new Error(data?.error || 'Failed to fetch themes');
}
themes = [...data.data.themes].sort(compareStoreThemes);
coverThemes = themes.slice(0, 3);
loading = false;
} catch (error) {
console.error('Failed to fetch themes', error);
} catch (err) {
console.error('Failed to fetch themes', err);
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs
}
};
@@ -75,11 +126,14 @@
darkMode = $settingsState.DarkMode;
});
// Filter themes based on search term
let filteredThemes = $derived(themes.filter(theme =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
theme.description.toLowerCase().includes(searchTerm.toLowerCase())
));
// Filter themes (list is already featured-first, then newest; filter preserves order)
let filteredThemes = $derived(
themes.filter(
(theme) =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
theme.description.toLowerCase().includes(searchTerm.toLowerCase()),
),
);
$effect(() => {
loadBackground();
@@ -91,6 +145,17 @@
console.error(error);
}
});
// Refetch themes when user logs in (from another tab) to get is_favorited
let lastLoggedIn = $state(false);
$effect(() => {
if (cloudLoggedIn && !lastLoggedIn) {
lastLoggedIn = true;
fetchThemes();
} else if (!cloudLoggedIn) {
lastLoggedIn = false;
}
});
</script>
<div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}">
@@ -111,7 +176,14 @@
{/if}
<!-- ThemeGrid to display filtered themes -->
<ThemeGrid themes={filteredThemes} {searchTerm} {setDisplayTheme} />
<ThemeGrid
themes={filteredThemes}
{searchTerm}
{setDisplayTheme}
{toggleFavorite}
isLoggedIn={cloudLoggedIn}
onRequestSignIn={() => (showSignInOverlay = true)}
/>
{#if displayTheme}
<ThemeModal
@@ -120,6 +192,9 @@
theme={displayTheme}
{displayTheme}
{setDisplayTheme}
{toggleFavorite}
isLoggedIn={cloudLoggedIn}
onRequestSignIn={() => (showSignInOverlay = true)}
onInstall={async () => {
if (displayTheme) {
await themeManager.downloadTheme(displayTheme);
@@ -144,4 +219,8 @@
{/if}
</div>
</div>
{#if showSignInOverlay}
<SignInToFavoriteModal onClose={() => (showSignInOverlay = false)} />
{/if}
</div>
+57 -10
View File
@@ -4,7 +4,10 @@
import { slide } from 'svelte/transition';
import { fade } from 'svelte/transition';
import { type LoadedCustomTheme } from '@/types/CustomThemes'
import {
type LoadedCustomTheme,
shouldForceThemeAppearance,
} from '@/types/CustomThemes'
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
@@ -21,9 +24,9 @@
handleImageVariableChange,
handleCoverImageUpload
} from '../utils/themeImageHandlers';
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
const { themeID } = $props<{ themeID: string }>()
const themeManager = ThemeManager.getInstance();
@@ -40,7 +43,9 @@
coverImage: null,
isEditable: true,
hideThemeName: false,
forceDark: undefined
forceTheme: undefined,
forceDark: undefined,
adaptiveCssVariables: [],
})
let closedAccordions = $state<string[]>([])
let themeLoaded = $state(false);
@@ -80,7 +85,13 @@
}))
}
theme = loadedTheme
theme = {
...loadedTheme,
adaptiveCssVariables: loadedTheme.adaptiveCssVariables ?? [],
forceTheme:
loadedTheme.forceTheme ??
(loadedTheme.forceDark !== undefined ? true : undefined),
}
themeLoaded = true
} else {
themeLoaded = true
@@ -114,6 +125,14 @@
blob: image.blob
}))
themeClone.coverImage = theme.coverImage
themeClone.userEdited = true
if (shouldForceThemeAppearance(themeClone)) {
themeClone.forceTheme = true;
} else {
themeClone.forceTheme = false;
themeClone.forceDark = undefined;
}
themeManager.clearPreview();
await themeManager.saveTheme(themeClone);
@@ -270,7 +289,7 @@
<h1 class='text-xl font-semibold'>Theme Creator</h1>
<a href='https://betterseqta.gitbook.io/betterseqta-docs' target='_blank' class='text-sm font-light text-zinc-500 dark:text-zinc-400'>
<a href='https://docs.betterseqta.org/theme-creation/' target='_blank' rel='noopener noreferrer' class='text-sm font-light text-zinc-500 dark:text-zinc-400'>
<span class='pr-0.5 no-underline font-IconFamily'>{'\ueb44'}</span>
<span class='underline'>
Need help? Check out the docs!
@@ -317,6 +336,27 @@
<Divider />
<div class="py-3">
<h2 class="text-sm font-bold">Adaptive CSS variables</h2>
<p class="text-xs text-zinc-600 dark:text-zinc-400">
One per line, each must start with <code class="text-xs">--</code>. These receive the same colour as the adaptive accent when &quot;Adaptive theme colour&quot; is enabled in general settings. Use them in Custom CSS, e.g. <code class="text-xs">border-color: var(--my-accent);</code>
</p>
<textarea
placeholder="--my-accent&#10;--class-banner"
value={theme.adaptiveCssVariables?.join('\n') ?? ''}
oninput={(e) => {
const lines = e.currentTarget.value
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
theme = { ...theme, adaptiveCssVariables: lines };
}}
class="p-2 mt-2 w-full min-h-[5rem] font-mono text-sm rounded-lg border-0 transition dark:placeholder-zinc-400 bg-zinc-200 dark:bg-zinc-700 focus:outline-none focus:ring-1 focus:ring-zinc-100 dark:focus:ring-zinc-700 focus:bg-zinc-300/50 dark:focus:bg-zinc-600"
></textarea>
</div>
<Divider />
{#each [
{
type: 'switch',
@@ -332,21 +372,28 @@
title: 'Force Theme',
description: 'Force users to use either dark or light mode',
props: {
state: theme.forceDark !== undefined,
onChange: (value: boolean) => theme = { ...theme, forceDark: value ? false : undefined }
state: shouldForceThemeAppearance(theme),
onChange: (value: boolean) => {
if (value) {
theme = { ...theme, forceTheme: true, forceDark: false };
} else {
theme = { ...theme, forceTheme: false, forceDark: undefined };
}
}
}
},
{
type: 'conditional',
props: {
condition: theme.forceDark !== undefined,
condition: shouldForceThemeAppearance(theme),
children: {
type: 'lightDarkToggle',
title: 'Mode',
description: 'Choose whether to force light or dark mode',
props: {
state: theme.forceDark === true,
onChange: (value: boolean) => theme = { ...theme, forceDark: value }
onChange: (value: boolean) =>
(theme = { ...theme, forceDark: value, forceTheme: true })
}
}
}
+13 -2
View File
@@ -1,7 +1,18 @@
export type Theme = {
id: string;
name: string;
description: string;
coverImage: string;
marqueeImage: string;
id: string;
marqueeImage?: string;
theme_json_url?: string;
is_favorited?: boolean;
favorite_count?: number;
download_count?: number;
author?: string;
featured?: boolean;
tags?: string[];
/** Unix time in seconds (API list/detail). */
created_at?: number;
/** Unix seconds — last server update (GET /api/themes). */
updated_at?: number;
};
+22
View File
@@ -0,0 +1,22 @@
import type { Action } from "svelte/action";
/**
* Svelte action that moves the element to a different DOM target.
* Defaults to the nearest ShadowRoot so styles remain intact when the app
* is rendered inside a shadow DOM. Falls back to document.body otherwise.
* Keeps all Svelte reactivity/events intact while escaping ancestor stacking contexts.
*/
export const portal: Action<HTMLElement, HTMLElement | ShadowRoot | undefined> = (node, target) => {
const root = node.getRootNode();
const dest = target ?? (root instanceof ShadowRoot ? root : document.body);
dest.appendChild(node);
return {
update(newTarget) {
(newTarget ?? dest).appendChild(node);
},
destroy() {
node.remove();
},
};
};
+27
View File
@@ -0,0 +1,27 @@
import * as pdfjs from "pdfjs-dist";
import browser from "webextension-polyfill";
/** Static copies in `src/public` (see `scripts/copy-pdfjs-assets.mjs`, manifest web_accessible_resources). */
const PDF_WORKER_RESOURCE = "resources/pdfjs/pdf.worker.min.mjs";
const PDF_LEGACY_RESOURCE = "resources/pdfjs/pdf.legacy.min.mjs";
function extensionAssetUrl(relativePath: string): string {
return browser.runtime.getURL(relativePath.replace(/^\/+/, ""));
}
let workerConfigured = false;
/** Required before pdfjs spawns a worker (content-script / extension isolate). */
export function ensurePdfjsWorker(): void {
if (workerConfigured) return;
pdfjs.GlobalWorkerOptions.workerSrc = extensionAssetUrl(PDF_WORKER_RESOURCE);
workerConfigured = true;
}
/** Page-context script on Firefox must load these chrome-extension:// URLs (see web_accessible_resources). */
export function getPdfjsPageContextUrls(): { lib: string; worker: string } {
return {
lib: extensionAssetUrl(PDF_LEGACY_RESOURCE),
worker: extensionAssetUrl(PDF_WORKER_RESOURCE),
};
}
+8 -3
View File
@@ -16,12 +16,12 @@
}
},
"permissions": ["tabs", "notifications", "storage"],
"host_permissions": ["https://newsapi.org/", "*://*/*"],
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
"background": {
"service_worker": "background.ts"
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org"
},
"content_scripts": [
{
@@ -32,7 +32,12 @@
],
"web_accessible_resources": [
{
"resources": ["resources/icons/*", "resources/update-image.webp"],
"resources": [
"resources/icons/*",
"resources/update-image.webp",
"resources/pdfjs/pdf.worker.min.mjs",
"resources/pdfjs/pdf.legacy.min.mjs"
],
"matches": ["*://*/*"]
}
]
@@ -6,6 +6,7 @@ import {
Setting,
} from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline";
import { waitForElm } from "@/seqta/utils/waitForElm";
const settings = defineSettings({
speed: numberSetting({
@@ -35,13 +36,10 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
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 [container, menu] = await Promise.all([
waitForElm("#container", true),
waitForElm("#menu", true),
]);
const backgrounds = [
{ classes: ["bg"] },
@@ -7,6 +7,20 @@ import {
import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import {
clearStuck,
getClassByPattern,
initStorage,
letterToNumber,
parseAssessments,
processAssessments,
} from "./utils.ts";
interface weightingsStorage {
weightings: Record<string, string>;
assessments: Record<string, string>;
}
const settings = defineSettings({
lettergrade: booleanSetting({
@@ -23,7 +37,7 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
const instance = new AssessmentsAveragePluginClass();
const assessmentsAveragePlugin: Plugin<typeof settings> = {
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
id: "assessments-average",
name: "Assessment Averages",
description: "Adds an average grade to the Assessments page",
@@ -32,8 +46,10 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
settings: instance.settings,
run: async (api) => {
await initStorage(api);
clearStuck(api);
api.seqta.onMount(".assessmentsWrapper", async () => {
// Wait for any assessment item to load first
await waitForElm(
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
true,
@@ -41,26 +57,13 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
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));
await parseAssessments(api);
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___"),
@@ -83,7 +86,6 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
"AssessmentItem__title___",
);
// Get Thermoscore classes
const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
@@ -102,62 +104,34 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
"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___']",
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
if (!marks || !marks.length) return;
const assessmentItems = Array.from(
assessmentsList.querySelectorAll(
`[class*='AssessmentItem__AssessmentItem___']`,
),
).filter(
(item) =>
!item
.querySelector(`[class*='AssessmentItem__title___']`)
?.textContent?.includes("Subject Average"),
);
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,
};
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
await processAssessments(api, assessmentItems);
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;
}
if (!count || totalWeight === 0) return;
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 avg = weightedTotal / totalWeight;
const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce(
(acc, [k, v]) => {
@@ -172,33 +146,86 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
? 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 */ `
let warningHTML = "";
if (hasInaccurateWeighting) {
warningHTML = /* html */ `
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
Some weightings unavailable
</div>
`;
}
assessmentsList.insertBefore(
stringToHTML(/* html */ `
<div class="${assessmentItemClass}">
<div class="${metaContainerClass}">
<div class="${metaClass}">
<div class="${simpleResultClass}">
<div class="${titleClass}">Subject Average</div>
${warningHTML}
</div>
</div>
</div>
<div class="${thermoscoreClass}">
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
<div class="${textClass}" title="${display}">${display}</div>
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
</div>
</div>
</div>
`).firstChild;
`).firstChild!,
assessmentsList.firstChild,
);
assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild);
applySubjectColourToOverallResult();
const observer = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
}
});
},
};
function applySubjectColourToOverallResult() {
const selectedAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
) || document.querySelector(
"[class*='Collapsible__content___'] [class*='AssessmentItem__AssessmentItem___']",
);
const assessmentThermoscore = selectedAssessmentItem?.querySelector(
"[class*='Thermoscore__Thermoscore___']",
) as HTMLElement | null;
const overallResult = document.querySelector(
"[class*='OverallResult__OverallResult___']",
) as HTMLElement | null;
const assessableCriterionHeaders = document.querySelectorAll(
"[class*='AssessableCriterion__header___']",
);
if (assessmentThermoscore && (overallResult || assessableCriterionHeaders.length > 0)) {
const accentColour =
getComputedStyle(assessmentThermoscore).getPropertyValue("--assessment-accent-colour").trim() ||
getComputedStyle(assessmentThermoscore).getPropertyValue("--fill-colour").trim() ||
getComputedStyle(assessmentThermoscore.closest("[class*='Collapsible__Collapsible___']") || assessmentThermoscore).getPropertyValue("--assessment-accent-colour").trim() ||
getComputedStyle(assessmentThermoscore.closest("[class*='Collapsible__Collapsible___']") || assessmentThermoscore).getPropertyValue("--item-colour").trim();
if (accentColour) {
overallResult?.style.setProperty("--assessment-accent-colour", accentColour);
overallResult?.style.setProperty("--fill-colour", accentColour);
assessableCriterionHeaders.forEach((el) => {
(el as HTMLElement).style.setProperty("--assessment-accent-colour", accentColour);
(el as HTMLElement).style.setProperty("--fill-colour", accentColour);
});
}
}
}
export default assessmentsAveragePlugin;
@@ -0,0 +1,589 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import {
ensurePdfjsWorker,
getPdfjsPageContextUrls,
} from "@/lib/pdfjsExtension.ts";
import * as pdfjs from "pdfjs-dist";
ensurePdfjsWorker();
export async function initStorage(api: any) {
await api.storage.loaded;
if (!api.storage.weightings) {
api.storage.weightings = {};
}
if (!api.storage.assessments) {
api.storage.assessments = {};
}
}
export function clearStuck(api: any) {
let hasStuckProcessing = false;
for (const key in api.storage.weightings) {
if (api.storage.weightings[key] === "processing") {
delete api.storage.weightings[key];
hasStuckProcessing = true;
}
}
if (hasStuckProcessing) {
api.storage.weightings = { ...api.storage.weightings };
}
}
// Helper function to find actual class names by their base pattern
export const getClassByPattern = (
element: Element | Document,
basePattern: string,
): string => {
const classes = Array.from(element.querySelectorAll("*"))
.flatMap((el) => Array.from(el.classList))
.filter((className) => className.startsWith(basePattern));
return classes.length ? classes[0] : "";
};
export const letterToNumber: Record<string, number> = {
"A+": 100,
A: 95,
"A-": 90,
"B+": 85,
B: 80,
"B-": 75,
"C+": 70,
C: 65,
"C-": 60,
"D+": 55,
D: 50,
"D-": 45,
"E+": 40,
E: 35,
"E-": 30,
F: 0,
};
function parseGrade(text: string): number {
const str = text.trim().toUpperCase();
if (str.includes("/")) {
const [raw, max] = str.split("/").map((n) => parseFloat(n));
return (raw / max) * 100;
}
if (str.includes("%")) {
return parseFloat(str.replace("%", "")) || 0;
}
return letterToNumber[str] ?? 0;
}
function createWeightLabel(
assessmentItem: Element,
weighting: string | undefined,
) {
const statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___']`,
) as HTMLElement;
if (
!statsContainer ||
statsContainer.querySelector(".betterseqta-weight-label")
)
return;
const label = statsContainer.querySelector(
`[class*='Label__Label___']`,
) as HTMLElement;
if (!label) return;
const weightLabel = label.cloneNode(true) as HTMLElement;
weightLabel.classList.add("betterseqta-weight-label");
const innerTextDiv = weightLabel.querySelector(
`[class*='Label__innerText___']`,
);
if (innerTextDiv) innerTextDiv.textContent = "Weight";
const textNodes = Array.from(weightLabel.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE,
);
if (textNodes.length) {
textNodes[0].textContent =
weighting && weighting !== "processing"
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
: "N/A";
}
// Stack weight under Max/native stats — absolute right:0 overlapped the max column (#414).
statsContainer.style.display = "flex";
statsContainer.style.flexDirection = "column";
statsContainer.style.alignItems = "flex-end";
statsContainer.style.gap = "2px";
statsContainer.style.justifyContent = "center";
weightLabel.style.position = "relative";
weightLabel.style.inset = "unset";
weightLabel.style.transform = "none";
statsContainer.appendChild(weightLabel);
}
export const isFirefox =
navigator.userAgent.toLowerCase().indexOf("firefox") > -1 &&
!navigator.userAgent.toLowerCase().includes("seamonkey") &&
!navigator.userAgent.toLowerCase().includes("waterfox");
async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
const isBlobUrl = url.startsWith("blob:");
if (isBlobUrl || isFirefox) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`;
const escapedUrl = url.replace(/'/g, "\\'");
script.textContent = `
(function() {
fetch('${escapedUrl}')
.then(response => {
if (!response.ok) {
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
}
return response.arrayBuffer();
})
.then(arrayBuffer => {
window.postMessage({
type: '${requestId}',
success: true,
data: Array.from(new Uint8Array(arrayBuffer))
}, '*');
})
.catch(error => {
window.postMessage({
type: '${requestId}',
success: false,
error: error.message || String(error)
}, '*');
});
})();
`;
const messageHandler = (event: MessageEvent) => {
if (event.data?.type === requestId) {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
if (event.data.success) {
resolve(new Uint8Array(event.data.data).buffer);
} else {
reject(new Error(event.data.error || "Failed to fetch PDF"));
}
}
};
window.addEventListener("message", messageHandler);
(document.head || document.documentElement).appendChild(script);
setTimeout(() => {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
reject(new Error("Timeout fetching PDF"));
}, 30000);
});
}
try {
const response = await fetch(url, {
credentials: "include",
redirect: "follow",
});
if (response.url && response.url.startsWith("blob:")) {
return await fetchPDFAsArrayBuffer(response.url);
}
if (!response.ok) {
throw new Error(
`Failed to fetch PDF: ${response.status} ${response.statusText}`,
);
}
return await response.arrayBuffer();
} catch (error: any) {
if (
error?.message?.includes("blob") ||
error?.message?.includes("Security") ||
error?.message?.includes("CSP")
) {
return await fetchPDFAsArrayBuffer(url);
}
throw error;
}
}
export async function extractPDFText(url: string): Promise<string> {
try {
if (isFirefox) {
const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls();
const escJsSingleQuoted = (s: string) =>
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
const escapedUrl = url
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/"/g, '\\"');
script.textContent = `
(function() {
const requestId = '${requestId}';
const url = '${escapedUrl}';
const pdfLibSrc = '${pdfLibInj}';
const pdfWorkerSrc = '${pdfWorkerInj}';
if (window.pdfjsLib) {
extractPDF();
} else {
const pdfjsScript = document.createElement('script');
pdfjsScript.src = pdfLibSrc;
pdfjsScript.type = 'module';
pdfjsScript.onload = function() {
extractPDF();
};
pdfjsScript.onerror = function() {
window.postMessage({
type: requestId,
success: false,
error: 'Failed to load pdfjs library'
}, '*');
};
document.head.appendChild(pdfjsScript);
}
function extractPDF() {
try {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'arraybuffer';
xhr.withCredentials = true;
xhr.onload = function() {
if (xhr.status !== 200) {
window.postMessage({
type: requestId,
success: false,
error: 'HTTP ' + xhr.status + ': ' + xhr.statusText
}, '*');
return;
}
try {
const arrayBuffer = xhr.response;
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
throw new Error('PDF response is empty');
}
window.pdfjsLib.getDocument({
data: arrayBuffer,
useSystemFonts: true,
verbosity: 0,
useWorkerFetch: false,
isEvalSupported: false
}).promise
.then(pdf => {
const pagePromises = [];
for (let i = 1; i <= pdf.numPages; i++) {
pagePromises.push(
pdf.getPage(i).then(page => {
return page.getTextContent().then(content => {
return content.items.map(item => item.str).join(' ');
});
})
);
}
return Promise.all(pagePromises);
})
.then(pages => {
const text = pages.join('\\n');
window.postMessage({
type: requestId,
success: true,
text: text
}, '*');
})
.catch(error => {
window.postMessage({
type: requestId,
success: false,
error: 'PDF parsing error: ' + (error.message || String(error))
}, '*');
});
} catch (error) {
window.postMessage({
type: requestId,
success: false,
error: 'ArrayBuffer error: ' + (error.message || String(error))
}, '*');
}
};
xhr.onerror = function() {
window.postMessage({
type: requestId,
success: false,
error: 'Network error fetching PDF'
}, '*');
};
xhr.ontimeout = function() {
window.postMessage({
type: requestId,
success: false,
error: 'Timeout fetching PDF'
}, '*');
};
xhr.timeout = 30000;
xhr.send();
} catch (error) {
window.postMessage({
type: requestId,
success: false,
error: 'Setup error: ' + (error.message || String(error))
}, '*');
}
}
})();
`;
const messageHandler = (event: MessageEvent) => {
if (event.data?.type === requestId) {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
if (event.data.success) {
resolve(event.data.text);
} else {
reject(
new Error(event.data.error || "Failed to extract PDF text"),
);
}
}
};
window.addEventListener("message", messageHandler);
(document.head || document.documentElement).appendChild(script);
setTimeout(() => {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
reject(new Error("Timeout extracting PDF text"));
}, 60000);
});
}
const arrayBuffer = await fetchPDFAsArrayBuffer(url);
if (arrayBuffer.byteLength === 0) {
throw new Error("PDF response is empty");
}
const pdf = await pdfjs.getDocument({
data: arrayBuffer,
useSystemFonts: true,
}).promise;
let text = "";
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
text += content.items.map((item: any) => item.str).join(" ") + "\n";
}
return text;
} catch (error) {
console.error("[BetterSEQTA+] Failed to extract PDF text:", error);
throw error;
}
}
async function handleWeightings(mark: any, api: any) {
const assessmentID = mark.id;
const metaclassID = mark.metaclassID;
const userInfo = await getUserInfo();
const userID = userInfo.id;
const title = mark.title;
if (
api.storage.weightings[assessmentID] != undefined &&
api.storage.weightings[assessmentID] !== "processing"
) {
return;
}
api.storage.weightings = {
...api.storage.weightings,
[assessmentID]: "processing",
};
api.storage.assessments = {
...api.storage.assessments,
[title.trim()]: assessmentID,
};
try {
const filename =
"BetterSEQTA-" +
String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
const printResponse = await fetch(
`${location.origin}/seqta/student/print/assessment`,
{
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({
fileName: filename,
id: assessmentID,
metaclass: metaclassID,
student: userID,
}),
},
);
if (!printResponse.ok) {
throw new Error(
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
);
}
await new Promise((resolve) => setTimeout(resolve, 1000));
const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
if (pdfUrl.startsWith("blob:")) {
throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`);
}
let text: string;
try {
text = await extractPDFText(pdfUrl);
} catch (error: any) {
if (
isFirefox &&
(error?.message?.includes("blob") ||
error?.message?.includes("Security") ||
error?.message?.includes("CSP"))
) {
await new Promise((resolve) => setTimeout(resolve, 2000));
text = await extractPDFText(pdfUrl);
} else {
throw new Error(`PDF extraction failed: ${error.message}`);
}
}
const match = text.match(/weight:\s*(\d+\.?\d*)/i);
api.storage.weightings = {
...api.storage.weightings,
[assessmentID]: match ? match[1] : "N/A",
};
} catch (error: any) {
api.storage.weightings = {
...api.storage.weightings,
[assessmentID]: "N/A",
};
}
}
export async function parseAssessments(api: any) {
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
if (!marks) return;
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
}
export async function processAssessments(api: any, assessmentItems: Element[]) {
let weightedTotal = 0;
let totalWeight = 0;
let hasInaccurateWeighting = false;
let count = 0;
for (const assessmentItem of assessmentItems) {
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
const titleEl = assessmentItem.querySelector(
`[class*='AssessmentItem__title___']`,
);
if (!titleEl) continue;
const title = titleEl.textContent?.trim();
if (!title) continue;
const assessmentID = api.storage.assessments?.[title];
const weighting = assessmentID
? api.storage.weightings?.[assessmentID]
: undefined;
createWeightLabel(assessmentItem, weighting);
if (
weighting === null ||
weighting === undefined ||
weighting === "N/A" ||
weighting === "processing"
) {
hasInaccurateWeighting = true;
weightedTotal += grade;
totalWeight += 1;
} else {
const weight = parseFloat(weighting);
if (!isNaN(weight) && weight >= 0) {
weightedTotal += grade * weight;
totalWeight += weight;
} else {
weightedTotal += grade;
totalWeight += 1;
hasInaccurateWeighting = true;
}
}
count++;
}
return {
weightedTotal,
totalWeight,
hasInaccurateWeighting,
count,
};
}
@@ -7,9 +7,11 @@
interface FilterOptions {
subject: string;
sortBy: "due" | "grade" | "subject" | "title";
sortBy: "due" | "grade" | "subject" | "title" | "year";
}
const HIDDEN_ASSESSMENTS_KEY = "betterseqta-hidden-assessments";
function percentageToLetter(percentage: number): string {
const letterMap: Record<number, string> = {
100: "A+",
@@ -41,48 +43,108 @@
let filteredAssessments: any[] = [];
let statusGroups: Record<string, any[]> = {};
let columns: { key: string; title: string; className: string; icon: string }[] = [];
function getAssessmentYear(a: any): number {
const dateStr = a.due || a.date || a.dueDate || a.created;
return dateStr ? new Date(dateStr).getFullYear() : 0;
}
function getAssessmentType(a: any): string {
return (a.type || a.assessmentType || a.taskType || "Other").toString();
}
function getAssessmentGrade(a: any): string {
const val = getGradeValue(a);
if (val === null) return "No grade";
return percentageToLetter(val);
}
function getGroupKey(assessment: any): string {
switch (currentFilters.sortBy) {
case "due":
return determineStatus(assessment);
case "year":
return String(getAssessmentYear(assessment) || "Unknown");
case "subject":
return assessment.code || "Unknown";
case "grade":
return getAssessmentGrade(assessment);
case "title":
const first = (assessment.title || "?")[0].toUpperCase();
return /[A-Z0-9]/.test(first) ? first : "#";
default:
return determineStatus(assessment);
}
}
function sortCompare(a: any, b: any): number {
return new Date(a.due || a.date || 0).getTime() - new Date(b.due || b.date || 0).getTime();
}
const STATUS_COLUMNS = [
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "📅" },
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "⏰" },
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "🚨" },
{ key: "SUBMITTED", title: "Submitted", className: "column-submitted", icon: "📝" },
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "✅" },
];
function buildGroupsAndColumns() {
if (!data?.assessments) return { filteredAssessments: [], statusGroups: {}, columns: [] };
const subjectFilters = settingsState.subjectfilters || {};
const hiddenAssessmentIds = new Set(
(JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]")).map(String)
);
const filtered = data.assessments.filter((a: any) => {
if (hiddenAssessmentIds.has(String(a.id))) return false;
if (subjectFilters[a.code] === false) return false;
return currentFilters.subject === "all" || a.code === currentFilters.subject;
});
const groups: Record<string, any[]> = {};
filtered.forEach((assessment) => {
const key = getGroupKey(assessment);
if (!groups[key]) groups[key] = [];
groups[key].push(assessment);
});
Object.keys(groups).forEach((key) => {
groups[key].sort(sortCompare);
});
let cols: { key: string; title: string; className: string; icon: string }[];
if (currentFilters.sortBy === "due") {
cols = STATUS_COLUMNS;
} else {
const keys = Object.keys(groups).filter((k) => groups[k]?.length > 0);
if (currentFilters.sortBy === "year") {
cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: "📆" }));
} else if (currentFilters.sortBy === "subject") {
const subjectTitles = new Map(data?.subjects?.map((s: any) => [s.code, `${s.code} - ${s.title}`]) || []);
cols = keys.sort().map((k) => ({ key: k, title: subjectTitles.get(k) || k, className: "column-custom", icon: "📚" }));
} else {
cols = keys.sort().map((k) => ({ key: k, title: k, className: "column-custom", icon: "📋" }));
}
}
return { filteredAssessments: filtered, statusGroups: groups, columns: cols };
}
$: if (data) {
const _ = currentFilters.sortBy && currentFilters.subject;
const result = buildGroupsAndColumns();
filteredAssessments = result.filteredAssessments;
statusGroups = result.statusGroups;
columns = result.columns;
}
function updateAssessments() {
filteredAssessments = data.assessments.filter((a: any) => {
const subjectMatch =
currentFilters.subject === "all" || a.code === currentFilters.subject;
return subjectMatch;
});
filteredAssessments.sort((a: any, b: any) => {
switch (currentFilters.sortBy) {
case "due":
return new Date(a.due).getTime() - new Date(b.due).getTime();
case "grade":
const gradeA = getGradeValue(a);
const gradeB = getGradeValue(b);
if (gradeA === null && gradeB === null) return 0;
if (gradeA === null) return 1;
if (gradeB === null) return -1;
return gradeB - gradeA;
case "subject":
return a.code.localeCompare(b.code);
case "title":
return a.title.localeCompare(b.title);
default:
return 0;
}
});
statusGroups = {
UPCOMING: [],
DUE_SOON: [],
OVERDUE: [],
SUBMITTED: [],
MARKS_RELEASED: [],
};
filteredAssessments.forEach((assessment) => {
const status = determineStatus(assessment);
if (statusGroups[status]) {
statusGroups[status].push(assessment);
}
});
const result = buildGroupsAndColumns();
filteredAssessments = result.filteredAssessments;
statusGroups = result.statusGroups;
columns = result.columns;
}
function getDueDateClass(assessment: any): string {
@@ -123,6 +185,56 @@
}
}
function hideAssessment(assessment: any) {
const hidden = JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]");
const id = String(assessment.id);
if (!hidden.includes(id)) {
hidden.push(id);
localStorage.setItem(HIDDEN_ASSESSMENTS_KEY, JSON.stringify(hidden));
visibilityRefresh++;
closeAllMenus();
updateAssessments();
}
}
function hideSubject(subjectCode: string) {
const filters = { ...(settingsState.subjectfilters || {}) };
filters[subjectCode] = false;
settingsState.subjectfilters = filters;
closeAllMenus();
updateAssessments();
}
function unhideSubject(subjectCode: string) {
const filters = { ...(settingsState.subjectfilters || {}) };
filters[subjectCode] = true;
settingsState.subjectfilters = filters;
updateAssessments();
}
function unhideAssessment(assessmentId: string) {
const hidden = JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]");
const idStr = String(assessmentId);
const filtered = hidden.filter((id: string) => id !== idStr);
localStorage.setItem(HIDDEN_ASSESSMENTS_KEY, JSON.stringify(filtered));
visibilityRefresh++;
updateAssessments();
}
function initSubjectFilters() {
const filters = settingsState.subjectfilters || {};
let updated = false;
data.subjects.forEach((s: any) => {
if (!Object.prototype.hasOwnProperty.call(filters, s.code)) {
filters[s.code] = true;
updated = true;
}
});
if (updated) {
settingsState.subjectfilters = filters;
}
}
function checkForCelebration() {
const overdueCount = statusGroups.OVERDUE?.length || 0;
const dueSoonCount = statusGroups.DUE_SOON?.length || 0;
@@ -201,6 +313,20 @@
}
let openMenuId: string | null = null;
let showVisibilityPanel = false;
let visibilityRefresh = 0;
$: hiddenSubjects = data?.subjects?.filter(
(s: any) => (settingsState.subjectfilters || {})[s.code] === false
) || [];
$: hiddenAssessmentIds = (() => {
visibilityRefresh; // Dependency for reactivity
return new Set((JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]")).map(String));
})();
$: hiddenAssessmentsWithInfo = data?.assessments?.filter(
(a: any) => hiddenAssessmentIds.has(String(a.id))
) || [];
$: hasHiddenItems = hiddenSubjects.length > 0 || hiddenAssessmentsWithInfo.length > 0;
function toggleMenu(assessmentId: string, event: Event) {
event.stopPropagation();
@@ -211,44 +337,13 @@
openMenuId = null;
}
$: {
if (data) {
updateAssessments();
}
$: if (data) {
initSubjectFilters();
updateAssessments();
void currentFilters.sortBy;
void currentFilters.subject;
}
const columns = [
{
key: "UPCOMING",
title: "Upcoming",
className: "column-upcoming",
icon: "📅",
},
{
key: "DUE_SOON",
title: "Due Soon",
className: "column-due-soon",
icon: "⏰",
},
{
key: "OVERDUE",
title: "Overdue",
className: "column-overdue",
icon: "🚨",
},
{
key: "SUBMITTED",
title: "Submitted",
className: "column-submitted",
icon: "📝",
},
{
key: "MARKS_RELEASED",
title: "Marked",
className: "column-marked",
icon: "✅",
},
];
</script>
<svelte:window on:click={closeAllMenus} />
@@ -263,15 +358,58 @@
<option value={subject.code}>{subject.code} - {subject.title}</option>
{/each}
</select>
<select class="filter-select" bind:value={currentFilters.sortBy}>
<option value="due">Sort by Due Date</option>
<option value="grade">Sort by Grade</option>
<option value="subject">Sort by Subject</option>
<option value="title">Sort by Title</option>
<select class="filter-select" bind:value={currentFilters.sortBy} title="Group by - columns change based on this">
<option value="due">Group: Status</option>
<option value="year">Group: Year</option>
<option value="subject">Group: Subject</option>
<option value="grade">Group: Grade</option>
<option value="title">Group: Title (A-Z)</option>
</select>
{#if hasHiddenItems}
<button
class="visibility-toggle"
class:active={showVisibilityPanel}
on:click={() => (showVisibilityPanel = !showVisibilityPanel)}
title="Manage hidden subjects and assessments"
>
👁 Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})
</button>
{/if}
</div>
</div>
{#if showVisibilityPanel && hasHiddenItems}
<div class="visibility-panel">
<h4 class="visibility-panel-title">Hidden items</h4>
{#if hiddenSubjects.length > 0}
<div class="visibility-section">
<span class="visibility-label">Subjects:</span>
<div class="visibility-chips">
{#each hiddenSubjects as subject}
<span class="visibility-chip">
{subject.code}
<button class="visibility-unhide" on:click={() => unhideSubject(subject.code)}>Show</button>
</span>
{/each}
</div>
</div>
{/if}
{#if hiddenAssessmentsWithInfo.length > 0}
<div class="visibility-section">
<span class="visibility-label">Assessments:</span>
<div class="visibility-chips">
{#each hiddenAssessmentsWithInfo as assessment}
<span class="visibility-chip">
{assessment.title}
<button class="visibility-unhide" on:click={() => unhideAssessment(assessment.id)}>Show</button>
</span>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<div id="main-grid-content">
{#if filteredAssessments.length === 0}
<div class="empty-state">
@@ -340,6 +478,12 @@
Mark as Not Complete
</button>
{/if}
<button class="menu-item menu-item-hide" on:click={() => hideAssessment(assessment)}>
Hide assessment
</button>
<button class="menu-item menu-item-hide" on:click={() => hideSubject(assessment.code)}>
Hide subject ({assessment.code})
</button>
</div>
</div>
{/if}
@@ -349,7 +493,7 @@
{#if !assessment.results && !isCompleted}
<div class="assessment-meta">
<div class="due-date {dueDateClass}">
📅 {formatDate(assessment.due, assessment.submitted)}
📅 {formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)}
</div>
</div>
{/if}
@@ -56,6 +56,18 @@ async function loadUpcoming(student: number) {
return res.payload;
}
function normalizeAssessmentDates(t: any, subject: Subject): any {
const normalized = { ...t };
// Past API may use different date fields - ensure we have 'due' for year filter & display
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
}
if (!normalized.programmeID) normalized.programmeID = subject.programme;
if (!normalized.metaclassID) normalized.metaclassID = subject.metaclass;
if (!normalized.code && t.subject) normalized.code = t.subject;
return normalized;
}
async function loadPast(student: number, subjects: Subject[]) {
const map: Record<number, any> = {};
await Promise.all(
@@ -65,10 +77,22 @@ async function loadPast(student: number, subjects: Subject[]) {
metaclass: s.metaclass,
student,
});
if (res.payload.tasks) {
res.payload.tasks.forEach((t: any) => {
map[t.id] = t;
});
const processAssessment = (t: any) => {
if (t && t.id) {
const merged = {
...t,
programmeID: t.programmeID || t.programme || s.programme,
metaclassID: t.metaclassID || t.metaclass || s.metaclass,
code: t.code || t.subject || s.code,
};
map[t.id] = normalizeAssessmentDates(merged, s);
}
};
if (res.payload?.pending && Array.isArray(res.payload.pending)) {
res.payload.pending.forEach(processAssessment);
}
if (res.payload?.tasks && Array.isArray(res.payload.tasks)) {
res.payload.tasks.forEach(processAssessment);
}
}),
);
@@ -1,9 +1,10 @@
import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api";
import { renderSkeletonLoader, renderErrorState } from "./ui";
import { renderErrorState, renderSkeletonLoader } from "./ui";
import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
const assessmentsOverviewPlugin: Plugin<{}> = {
id: "assessments-overview",
@@ -16,6 +17,8 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
styles,
run: async () => {
if (isSeqtaEngageExperience()) return;
const menu = (await waitForElm(
'[data-key="assessments"] > .sub > ul',
true,
@@ -34,19 +34,38 @@
}
.filter-select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background: #ffffff !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2364748b'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
background-position: right 0.9rem center !important;
background-repeat: no-repeat !important;
background-size: 1rem !important;
border: 2px solid #e2e8f0;
border-radius: 8px;
border-radius: 10px;
color: #1a1a1a;
padding: 0.75rem 1rem;
color-scheme: light;
padding: 0.75rem 2.5rem 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 500;
font-family: Rubik, sans-serif;
line-height: 1.2;
transition: all 0.2s ease;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
min-width: 180px;
}
.filter-select::-ms-expand {
display: none;
}
.filter-select option {
background: #ffffff;
color: #1a1a1a;
}
.filter-select:focus {
outline: none;
border-color: #d41e3a;
@@ -61,8 +80,10 @@
/* Dark mode dropdowns */
.dark .filter-select {
background: var(--background-primary) !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='rgba(255,255,255,0.72)'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
border-color: var(--background-secondary);
color: var(--text-primary);
color-scheme: dark;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
@@ -73,7 +94,8 @@
.dark .filter-select:hover {
border-color: var(--background-secondary);
background: var(--background-secondary);
background: var(--background-secondary) !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='rgba(255,255,255,0.72)'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
}
.dark .filter-select option {
@@ -106,7 +128,6 @@
max-height: 100%;
background: #f8fafc;
border-radius: 12px;
box-shadow: 0 0 0 2px #e2e8f0;
display: flex;
flex-direction: column;
min-height: 0;
@@ -336,11 +357,146 @@
color: #ef4444;
}
.menu-item.menu-item-hide {
color: #64748b;
}
.dark .menu-item.menu-item-hide {
color: var(--text-primary);
opacity: 0.8;
}
.visibility-toggle {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 8px;
border: 2px solid #e2e8f0;
background: #ffffff;
color: #64748b;
cursor: pointer;
transition: all 0.2s ease;
}
.visibility-toggle:hover {
border-color: #cbd5e1;
color: #1a1a1a;
}
.visibility-toggle.active {
border-color: #d41e3a;
background: rgba(212, 30, 58, 0.08);
color: #d41e3a;
}
.dark .visibility-toggle {
background: var(--background-primary);
border-color: var(--background-secondary);
color: var(--text-primary);
}
.dark .visibility-toggle:hover {
border-color: rgba(255, 255, 255, 0.2);
}
.dark .visibility-toggle.active {
border-color: #d41e3a;
background: rgba(212, 30, 58, 0.15);
color: #d41e3a;
}
.visibility-panel {
padding: 1rem 1.25rem;
margin: 0 1rem 1rem;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.dark .visibility-panel {
background: var(--background-secondary);
border-color: rgba(255, 255, 255, 0.1);
}
.visibility-panel-title {
font-size: 0.875rem;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 0.75rem;
}
.dark .visibility-panel-title {
color: var(--text-primary);
}
.visibility-section {
margin-bottom: 0.5rem;
}
.visibility-section:last-child {
margin-bottom: 0;
}
.visibility-label {
font-size: 0.75rem;
font-weight: 500;
color: #64748b;
display: block;
margin-bottom: 0.25rem;
}
.dark .visibility-label {
color: var(--text-primary);
opacity: 0.7;
}
.visibility-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.visibility-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: #e2e8f0;
border-radius: 6px;
font-size: 0.8125rem;
color: #1a1a1a;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.dark .visibility-chip {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
.visibility-unhide {
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 4px;
border: none;
background: #d41e3a;
color: white;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.visibility-unhide:hover {
background: #b91c33;
}
.assessment-title {
font-size: 0.875rem;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 0.75rem 0;
margin: 0 0 0.75rem;
line-height: 1.4;
padding-right: 2rem; /* Make room for menu button */
}
@@ -456,6 +612,10 @@
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
}
.column-custom .column-header {
background: linear-gradient(135deg, #ffffff 0%, #f1f5f9 100%);
}
/* Dark mode column headers */
.dark .column-upcoming .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%);
@@ -477,6 +637,10 @@
background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%);
}
.dark .column-custom .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a5f 100%);
}
/* Subject filter view */
.subject-section {
margin-bottom: 2rem;
@@ -1,5 +1,5 @@
import type { Plugin } from "@/plugins/core/types";
import { componentSetting, defineSettings, numberSetting, booleanSetting } from "@/plugins/core/settingsHelpers";
import { booleanSetting, componentSetting, defineSettings, numberSetting } from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline";
import BackgroundMusicSetting from "./BackgroundMusicSetting.svelte";
import localforage from "localforage";
@@ -7,7 +7,7 @@ import localforage from "localforage";
const settings = defineSettings({
uploader: componentSetting({
title: "Background Music",
description: "Upload a .wav or .mp3 audio file to play in the background.",
description: "Upload a .wav or .mp3 audio file to play in the background",
component: BackgroundMusicSetting,
}),
volume: numberSetting({
@@ -99,7 +99,7 @@ async function startPlayback(volume: number): Promise<void> {
const backgroundMusicPlugin: Plugin<typeof settings> = {
id: "background-music",
name: "Background Music",
description: "Play your own music in the background while SEQTA is open.",
description: "Play your own music in the background while SEQTA is open",
version: "1.0.0",
settings,
styles,
@@ -142,7 +142,6 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
if (!currentAudio) return;
const pauseOnHidden = (api.settings as any).pauseOnHidden ?? true;
if (!pauseOnHidden) return;
if (document.visibilityState === "hidden") {
if (visibilityResumeTimeout !== null) {
clearTimeout(visibilityResumeTimeout);
@@ -183,5 +182,3 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
};
export default backgroundMusicPlugin;
+60 -24
View File
@@ -42,32 +42,69 @@ const settings = defineSettings({
if (confirmed) {
try {
// Dynamically import the worker manager to avoid loading heavy dependencies
// Dynamically import modules to avoid loading heavy dependencies
const { VectorWorkerManager } = await import("./src/indexing/worker/vectorWorkerManager");
const workerManager = VectorWorkerManager.getInstance();
await workerManager.resetWorker();
console.log("Vector worker reset successfully");
} catch (e) {
console.warn("Failed to reset vector worker:", e);
}
const { resetDatabase } = await import("./src/indexing/db");
// Delete both 'embeddiaDB' and 'betterseqta-index' using native IndexedDB APIs
const deleteDb = (dbName: string) => {
return new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
req.onblocked = () => {
reject(new Error(`One database is open, failed to remove: ${dbName}`));
};
});
};
try {
await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index");
alert("Search index and storage have been reset.");
// Reset vector worker first
try {
const workerManager = VectorWorkerManager.getInstance();
await workerManager.resetWorker();
console.log("Vector worker reset successfully");
} catch (e) {
console.warn("Failed to reset vector worker:", e);
}
// Close all database connections properly before deletion
try {
await resetDatabase();
console.log("betterseqta-index database closed and reset");
} catch (e) {
console.warn("Failed to reset betterseqta-index database:", e);
}
// Wait a bit for connections to fully close
await new Promise(resolve => setTimeout(resolve, 100));
// Delete embeddiaDB (vector search database)
const deleteDb = (dbName: string) => {
return new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => {
console.log(`Successfully deleted database: ${dbName}`);
resolve();
};
req.onerror = () => {
console.error(`Error deleting database ${dbName}:`, req.error);
reject(req.error);
};
req.onblocked = () => {
console.warn(`Database ${dbName} deletion blocked - connections still open`);
// Wait and retry once
setTimeout(() => {
const retryReq = indexedDB.deleteDatabase(dbName);
retryReq.onsuccess = () => {
console.log(`Successfully deleted database on retry: ${dbName}`);
resolve();
};
retryReq.onerror = () => reject(retryReq.error);
retryReq.onblocked = () => {
reject(new Error(`One database is open, failed to remove: ${dbName}. Please close other tabs and try again.`));
};
}, 500);
};
});
};
try {
await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index");
alert("Search index and storage have been reset successfully.");
} catch (e) {
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
}
} catch (e) {
alert("Failed to reset one or more databases: " + String(e));
alert("Failed to reset index: " + String(e));
}
}
},
@@ -83,7 +120,6 @@ export default defineLazyPlugin({
settings,
disableToggle: true,
defaultEnabled: false,
beta: true,
styles: styles,
// Lazy loader - only imports the heavy plugin when actually needed
@@ -35,6 +35,8 @@
let isIndexing = $state(false);
let completedJobs = $state(0);
let totalJobs = $state(0);
let indexingStatus = $state<string | null>(null);
let indexingDetail = $state<string | null>(null);
let commandPalleteOpen = $state(false);
let searchTerm = $state('');
@@ -110,10 +112,12 @@
onMount(() => {
const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing } = event.detail;
const { completed, total, indexing, status, detail } = event.detail;
completedJobs = completed;
totalJobs = total;
isIndexing = indexing;
indexingStatus = status || null;
indexingDetail = detail || null;
};
window.addEventListener('indexing-progress', progressHandler as EventListener);
@@ -168,6 +172,9 @@
term,
commandsFuse,
commandIdToItemMap,
dynamicContentFuse,
dynamicIdToItemMap,
true, // sortByRecent
);
} else {
combinedResults = [];
@@ -176,13 +183,19 @@
isLoading = false;
};
const debouncedPerformSearch = debounce(performSearch, 20);
// Optimized debounce: shorter delay for better responsiveness
const debouncedPerformSearch = debounce(performSearch, 50);
$effect(() => {
if (commandPalleteOpen) {
if (searchTerm === '') {
// Immediate search for empty query (shows recent items)
performSearch();
} else if (searchTerm.length <= 2) {
// Immediate search for very short queries
performSearch();
} else {
// Debounced search for longer queries
debouncedPerformSearch();
}
tick().then(() => searchbar?.focus());
@@ -389,19 +402,6 @@
{@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>
{/if}
</div>
@@ -4,8 +4,8 @@ import {
booleanSetting,
buttonSetting,
defineSettings,
Setting,
hotkeySetting,
Setting,
} from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline";
import { waitForElm } from "@/seqta/utils/waitForElm";
@@ -14,6 +14,7 @@ import { initVectorSearch } from "../search/vector/vectorSearch";
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
import { IndexedDbManager } from "embeddia";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { checkAndHandleUpdate } from "../utils/versionCheck";
// Platform-aware default hotkey
const getDefaultHotkey = () => {
@@ -50,31 +51,67 @@ const settings = defineSettings({
if (confirmed) {
try {
// Reset the vector worker first
const workerManager = VectorWorkerManager.getInstance();
await workerManager.resetWorker();
console.log("Vector worker reset successfully");
} catch (e) {
console.warn("Failed to reset vector worker:", e);
}
// Import resetDatabase function to properly close connections
const { resetDatabase } = await import("../indexing/db");
// Delete both 'embeddiaDB' and 'betterseqta-index' using native IndexedDB APIs
const deleteDb = (dbName: string) => {
return new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
req.onblocked = () => {
reject(new Error(`One database is open, failed to remove: ${dbName}`));
};
});
};
try {
await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index");
alert("Search index and storage have been reset.");
// Reset the vector worker first
try {
const workerManager = VectorWorkerManager.getInstance();
await workerManager.resetWorker();
console.log("Vector worker reset successfully");
} catch (e) {
console.warn("Failed to reset vector worker:", e);
}
// Close all database connections properly before deletion
try {
await resetDatabase();
} catch (e) {
console.warn("Failed to reset betterseqta-index database:", e);
}
// Wait a bit for connections to fully close
await new Promise(resolve => setTimeout(resolve, 100));
// Delete embeddiaDB (vector search database)
const deleteDb = (dbName: string) => {
return new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => {
console.log(`Successfully deleted database: ${dbName}`);
resolve();
};
req.onerror = () => {
console.error(`Error deleting database ${dbName}:`, req.error);
reject(req.error);
};
req.onblocked = () => {
console.warn(`Database ${dbName} deletion blocked - connections still open`);
// Wait and retry once
setTimeout(() => {
const retryReq = indexedDB.deleteDatabase(dbName);
retryReq.onsuccess = () => {
console.log(`Successfully deleted database on retry: ${dbName}`);
resolve();
};
retryReq.onerror = () => reject(retryReq.error);
retryReq.onblocked = () => {
reject(new Error(`One database is open, failed to remove: ${dbName}. Please close other tabs and try again.`));
};
}, 500);
};
});
};
try {
await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index");
alert("Search index and storage have been reset successfully.");
} catch (e) {
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
}
} catch (e) {
alert("Failed to reset one or more databases: " + String(e));
alert("Failed to reset index: " + String(e));
}
}
},
@@ -108,12 +145,32 @@ const globalSearchPlugin: Plugin<typeof settings> = {
settings: settingsInstance.settings,
disableToggle: true,
defaultEnabled: false,
beta: true,
styles: styles,
run: async (api) => {
const appRef = { current: null };
// Check for extension updates and clear caches if needed
// Use a timeout to avoid blocking initialization
setTimeout(async () => {
try {
const wasUpdated = await checkAndHandleUpdate();
if (wasUpdated) {
console.log("[Global Search] Extension updated - caches cleared");
}
} catch (error: any) {
// Handle CSS preload errors and other failures gracefully
// These can happen in Firefox or when assets aren't available
if (error?.message?.includes("preload CSS") ||
error?.message?.includes("MIME type") ||
error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")) {
console.debug("[Global Search] Version check skipped due to asset loading restrictions:", error.message);
} else {
console.warn("[Global Search] Failed to check for updates:", error);
}
}
}, 100);
try {
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
primaryKey: "id",
@@ -126,10 +183,16 @@ const globalSearchPlugin: Plugin<typeof settings> = {
initVectorSearch();
// Warm up vector worker in background to improve initial response time
// Warm up vector worker in background to improve initial response time (skip in Firefox)
setTimeout(async () => {
try {
VectorWorkerManager.getInstance();
// Only initialize worker if vector search is supported
const { isVectorSearchSupported } = await import("../utils/browserDetection");
if (isVectorSearchSupported()) {
VectorWorkerManager.getInstance();
} else {
console.debug("[Global Search] Skipping vector worker warm-up (Firefox detected - using text search only)");
}
} catch (error) {
console.warn("[Global Search] Vector worker warm-up failed:", error);
}
@@ -8,7 +8,7 @@ import browser from "webextension-polyfill";
export function mountSearchBar(
titleElement: Element,
api: any,
appRef: { current: any; storageChangeHandler?: any },
appRef: { current: any; storageChangeHandler?: any; progressHandler?: any },
) {
if (titleElement.querySelector(".search-trigger")) {
return;
@@ -21,6 +21,72 @@ export function mountSearchBar(
const searchButton = document.createElement("div");
searchButton.className = "search-trigger";
// Create progress indicator container
const progressContainer = document.createElement("div");
progressContainer.className = "search-progress-container";
progressContainer.style.cssText = "display: flex; align-items: center; gap: 8px; margin-left: 8px; min-width: 120px;";
// Create progress bar
const progressBarWrapper = document.createElement("div");
progressBarWrapper.className = "search-progress-bar-wrapper";
progressBarWrapper.style.cssText = "flex: 1; height: 4px; background: rgba(0, 0, 0, 0.1); border-radius: 2px; overflow: hidden; display: none;";
const progressBar = document.createElement("div");
progressBar.className = "search-progress-bar";
progressBar.style.cssText = "height: 100%; background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6); transition: width 0.3s ease-out; width: 0%; position: relative;";
// Add shimmer effect
const shimmer = document.createElement("div");
shimmer.style.cssText = "position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); animation: shimmer 2s infinite;";
progressBar.appendChild(shimmer);
progressBarWrapper.appendChild(progressBar);
// Create progress text
const progressText = document.createElement("span");
progressText.className = "search-progress-text";
progressText.style.cssText = "font-size: 11px; color: #666; white-space: nowrap; display: none;";
progressContainer.appendChild(progressBarWrapper);
progressContainer.appendChild(progressText);
// Indexing state
let isIndexing = false;
let completedJobs = 0;
let totalJobs = 0;
let indexingStatus: string | null = null;
const updateProgressDisplay = () => {
if (isIndexing && totalJobs > 0) {
const percentage = Math.round((completedJobs / totalJobs) * 100);
progressBar.style.width = `${Math.max(2, percentage)}%`;
progressBarWrapper.style.display = "block";
if (indexingStatus) {
progressText.textContent = indexingStatus.length > 20 ? indexingStatus.substring(0, 20) + "..." : indexingStatus;
progressText.style.display = "block";
} else {
progressText.textContent = `${completedJobs}/${totalJobs} (${percentage}%)`;
progressText.style.display = "block";
}
} else {
progressBarWrapper.style.display = "none";
progressText.style.display = "none";
}
};
// Listen for indexing progress events
const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing, status } = event.detail;
completedJobs = completed || 0;
totalJobs = total || 0;
isIndexing = indexing || false;
indexingStatus = status || null;
updateProgressDisplay();
};
window.addEventListener('indexing-progress', progressHandler as EventListener);
appRef.progressHandler = progressHandler;
const updateSearchButtonDisplay = () => {
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">
@@ -34,6 +100,7 @@ export function mountSearchBar(
updateSearchButtonDisplay();
titleElement.appendChild(searchButton);
titleElement.appendChild(progressContainer);
// Listen for hotkey setting changes
const handleStorageChange = (changes: any, area: string) => {
@@ -72,7 +139,7 @@ export function mountSearchBar(
}
}
export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any }) {
export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any; progressHandler?: any }) {
if (appRef.current) {
try {
unmount(appRef.current);
@@ -82,12 +149,24 @@ export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?:
}
}
// Remove progress event listener
if (appRef.progressHandler) {
window.removeEventListener('indexing-progress', appRef.progressHandler as EventListener);
appRef.progressHandler = null;
}
// Remove search trigger button
const searchTrigger = document.querySelector(".search-trigger");
if (searchTrigger) {
searchTrigger.remove();
}
// Remove progress container
const progressContainer = document.querySelector(".search-progress-container");
if (progressContainer) {
progressContainer.remove();
}
// Remove search root
const searchRoot = document.querySelector("div[data-search-root]");
if (searchRoot) {
@@ -69,3 +69,71 @@
.dark .highlight {
background-color: rgba(255, 230, 100, 0.4);
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
/* Progress indicator next to search trigger */
.search-progress-container {
display: flex;
align-items: center;
gap: 8px;
margin-left: 8px;
min-width: 120px;
max-width: 200px;
height: 32px;
}
.search-progress-bar-wrapper {
flex: 1;
height: 4px;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
overflow: hidden;
display: none;
min-width: 60px;
}
.dark .search-progress-bar-wrapper {
background: rgba(255, 255, 255, 0.1);
}
.search-progress-bar {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6);
transition: width 0.3s ease-out;
width: 0%;
position: relative;
border-radius: 2px;
}
.search-progress-bar::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
animation: shimmer 2s infinite;
border-radius: 2px;
}
.search-progress-text {
font-size: 11px;
color: #666;
white-space: nowrap;
display: none;
font-weight: 500;
}
.dark .search-progress-text {
color: #999;
}
@@ -59,17 +59,132 @@ export const actionMap: Record<string, ActionHandler<any>> = {
}) as ActionHandler<any>,
assessment: (async (item: IndexItem & { metadata: AssessmentMetadata }) => {
if (item.metadata.isMessageBased) {
// Deep clone the entire item to avoid Firefox XrayWrapper issues
// Firefox XrayWrapper prevents direct access to nested properties
let itemClone: IndexItem & { metadata: AssessmentMetadata };
let metadata: AssessmentMetadata;
try {
// First try to clone the entire item
itemClone = JSON.parse(JSON.stringify(item));
metadata = itemClone.metadata || {};
} catch (e) {
console.warn("[Assessment Action] Failed to clone item, trying to clone metadata separately:", e);
try {
// If full clone fails, try cloning just metadata
metadata = JSON.parse(JSON.stringify(item.metadata || {}));
itemClone = { ...item, metadata };
} catch (e2) {
console.warn("[Assessment Action] Failed to clone metadata, using direct access:", e2);
itemClone = item;
metadata = item.metadata || {} as AssessmentMetadata;
}
}
// Try to extract metadata values using multiple methods to handle XrayWrapper
const getMetadataValue = (key: string, altKey?: string): any => {
try {
// Try direct access first
const value = metadata[key];
if (value !== undefined && value !== null) {
return value;
}
if (altKey) {
const altValue = metadata[altKey];
if (altValue !== undefined && altValue !== null) {
return altValue;
}
}
// Try accessing via Object.keys iteration (works around XrayWrapper)
try {
const keys = Object.keys(metadata);
for (const k of keys) {
if (k === key || k === altKey) {
const val = metadata[k];
if (val !== undefined && val !== null) {
return val;
}
}
}
} catch (e) {
// Object.keys might fail on XrayWrapper, that's okay
}
return undefined;
} catch (e) {
console.warn(`[Assessment Action] Failed to access metadata.${key}:`, e);
return undefined;
}
};
if (getMetadataValue('isMessageBased')) {
window.location.hash = `#?page=/messages`;
await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20);
// Select the specific direct message
ReactFiber.find('[class*="Viewer__Viewer___"] > div').setState({
selected: new Set([item.metadata.messageId]),
selected: new Set([getMetadataValue('messageId')]),
});
} else {
window.location.hash = `#?page=/assessments&id=${item.metadata.assessmentId}`;
// Extract values - check both camelCase and PascalCase, and try multiple access methods
let programmeId = getMetadataValue('programmeId', 'programmeID');
let metaclassId = getMetadataValue('metaclassId', 'metaclassID');
let assessmentId = getMetadataValue('assessmentId', 'assessmentID');
// Fallback: try to extract assessmentId from item ID if metadata is missing
if ((assessmentId === undefined || assessmentId === null) && itemClone.id && itemClone.id.startsWith('assignment-')) {
const extractedId = itemClone.id.replace('assignment-', '');
assessmentId = Number(extractedId) || extractedId;
console.log("[Assessment Action] Extracted assessmentId from item ID:", assessmentId);
}
// Convert to numbers, but preserve 0 as valid
if (programmeId !== undefined && programmeId !== null && programmeId !== '') {
const num = Number(programmeId);
programmeId = isNaN(num) ? programmeId : num;
}
if (metaclassId !== undefined && metaclassId !== null && metaclassId !== '') {
const num = Number(metaclassId);
metaclassId = isNaN(num) ? metaclassId : num;
}
if (assessmentId !== undefined && assessmentId !== null && assessmentId !== '') {
const num = Number(assessmentId);
assessmentId = isNaN(num) ? assessmentId : num;
}
// Check if values exist (including 0, which is a valid ID)
// Use typeof check to properly handle 0
const hasProgrammeId = programmeId !== undefined && programmeId !== null && programmeId !== '' && typeof programmeId === 'number';
const hasMetaclassId = metaclassId !== undefined && metaclassId !== null && metaclassId !== '' && typeof metaclassId === 'number';
const hasAssessmentId = assessmentId !== undefined && assessmentId !== null && assessmentId !== '' && typeof assessmentId === 'number';
if (hasProgrammeId && hasMetaclassId && hasAssessmentId) {
const url = `#?page=/assessments/${programmeId}:${metaclassId}&item=${assessmentId}`;
console.log("[Assessment Action] ✅ Navigating to:", url);
window.location.hash = url;
} else {
// Fallback: try to navigate to assessments page if metadata is incomplete
console.error("[Assessment Action] ❌ Missing required metadata:", {
programmeId,
metaclassId,
assessmentId,
hasProgrammeId,
hasMetaclassId,
hasAssessmentId,
metadataKeys: Object.keys(metadata),
metadataString: JSON.stringify(metadata),
itemId: itemClone.id,
});
// If we at least have an assessmentId, try to navigate to the general assessments page
if (hasAssessmentId) {
window.location.hash = `#?page=/assessments/upcoming&item=${assessmentId}`;
} else {
console.warn("[Assessment Action] No valid assessment ID, redirecting to upcoming");
window.location.hash = `#?page=/assessments/upcoming`;
}
}
}
}) as ActionHandler<any>,
@@ -213,25 +213,54 @@ export async function clear(store: string): Promise<void> {
}
export async function resetDatabase(): Promise<void> {
// Close cached database connection
if (cachedDb) {
cachedDb.close();
try {
cachedDb.close();
} catch (e) {
console.warn("[DB] Error closing cached database:", e);
}
cachedDb = null;
}
// Close pending database promise
if (dbPromise) {
try {
const db = await dbPromise;
db.close();
} catch (e) {}
} catch (e) {
// Database might not be open yet, that's okay
}
dbPromise = null;
}
// Wait a bit for connections to fully close
await new Promise(resolve => setTimeout(resolve, 100));
return new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(DB_NAME);
req.onsuccess = () => {
localStorage.removeItem(VERSION_KEY);
resolve();
};
req.onerror = () => reject(req.error);
req.onerror = () => {
console.error("[DB] Error deleting database:", req.error);
reject(req.error);
};
req.onblocked = () => {
console.warn("[DB] Database deletion blocked - waiting for connections to close");
// Wait a bit longer and try again
setTimeout(() => {
const retryReq = indexedDB.deleteDatabase(DB_NAME);
retryReq.onsuccess = () => {
localStorage.removeItem(VERSION_KEY);
resolve();
};
retryReq.onerror = () => reject(retryReq.error);
retryReq.onblocked = () => {
reject(new Error(`Database is still open. Please close other tabs/windows and try again.`));
};
}, 500);
};
});
}
@@ -1,4 +1,4 @@
import { clear, getAll, get, put, remove } from "./db";
import { clear, get, getAll, put, remove } from "./db";
import { jobs } from "./jobs";
import { renderComponentMap } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types";
@@ -396,18 +396,34 @@ export async function runIndexing(): Promise<void> {
stopHeartbeat();
allItemsInPrimaryStores = await loadAllStoredItems();
allItemsInPrimaryStores.forEach(item => {
const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId];
if (jobDef) {
const renderComponent = renderComponentMap[jobDef.renderComponentId];
if (renderComponent) {
item.renderComponent = renderComponent;
}
} else if (renderComponentMap[item.renderComponentId]) {
item.renderComponent = renderComponentMap[item.renderComponentId];
// Create new objects to avoid XrayWrapper issues in Firefox
const itemsWithComponents = allItemsInPrimaryStores.map(item => {
try {
const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) {
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
} else if (renderComponentMap[item.renderComponentId]) {
renderComponent = renderComponentMap[item.renderComponentId];
}
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
// Use JSON serialization to ensure all nested properties are accessible
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch (e) {
// Fallback to shallow copy if deep clone fails
console.warn("[Indexer] Failed to deep clone item, using shallow copy:", e);
return { ...item, renderComponent };
}
} catch (error) {
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
console.warn("[Indexer] Failed to add render component to item (Firefox XrayWrapper):", error);
return item;
}
});
loadDynamicItems(allItemsInPrimaryStores);
loadDynamicItems(itemsWithComponents);
window.dispatchEvent(new Event("dynamic-items-updated"));
}
@@ -3,10 +3,12 @@ import { messagesJob } from "./jobs/messages";
import { notificationsJob } from "./jobs/notifications";
import { forumsJob } from "./jobs/forums";
import { subjectsJob } from "./jobs/subjects";
import { assignmentsJob } from "./jobs/assignments";
export const jobs: Record<string, Job> = {
messages: messagesJob,
notifications: notificationsJob,
forums: forumsJob,
subjects: subjectsJob,
assignments: assignmentsJob,
};
@@ -0,0 +1,369 @@
import type { IndexItem, Job } from "../types";
const fetchJSON = async (url: string, body: any) => {
const res = await fetch(`${location.origin}${url}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
});
return res.json();
};
const fetchUpcomingAssessments = async (student: number = 69) => {
try {
const res = await fetchJSON("/seqta/student/assessment/list/upcoming?", {
student,
});
// Match analytics.rs: payload is an array, return empty array if not found
return Array.isArray(res.payload) ? res.payload : [];
} catch (e) {
console.error("[Assignments job] Failed to fetch upcoming assessments:", e);
return [];
}
};
const fetchSubjects = async () => {
try {
const res = await fetchJSON("/seqta/student/load/subjects?", {});
return res.payload
?.filter((s: any) => s.active === 1)
?.flatMap((s: any) => s.subjects) || [];
} catch (e) {
console.error("[Assignments job] Failed to fetch subjects:", e);
return [];
}
};
const fetchPastAssessments = async (student: number = 69, subjects: any[]) => {
const map: Record<number, any> = {};
// Fetch past assessments for all subjects in parallel (like assessmentsOverview does)
// This is much faster than sequential fetching
await Promise.all(
subjects.map(async (subject) => {
try {
// Match analytics.rs exactly: parameter order is programme, metaclass, student
const res = await fetchJSON("/seqta/student/assessment/list/past?", {
programme: subject.programme,
metaclass: subject.metaclass,
student,
});
// Past assessments API can return data in payload.tasks OR payload.pending (or both)
// Based on analytics.rs fetch_past_assessments, we need to check both arrays
const processAssessment = (assessment: any) => {
if (assessment && assessment.id) {
// Ensure programme and metaclass are included from the subject
// Use the assessment's IDs if available, otherwise fall back to subject's
map[assessment.id] = {
...assessment,
programme: assessment.programme || assessment.programmeID || subject.programme,
programmeID: assessment.programmeID || assessment.programme || subject.programme,
metaclass: assessment.metaclass || assessment.metaclassID || subject.metaclass,
metaclassID: assessment.metaclassID || assessment.metaclass || subject.metaclass,
};
}
};
// Match analytics.rs: Check both pending and tasks arrays
// Check for pending array first (matching Rust code order)
if (res.payload?.pending && Array.isArray(res.payload.pending)) {
res.payload.pending.forEach(processAssessment);
}
// Check for tasks array
if (res.payload?.tasks && Array.isArray(res.payload.tasks)) {
res.payload.tasks.forEach(processAssessment);
}
} catch (e) {
console.warn(`[Assignments job] Failed to fetch past assessments for subject ${subject.code || subject.subject || 'unknown'}:`, e);
}
})
);
return Object.values(map);
};
export const assignmentsJob: Job = {
id: "assignments",
label: "Assignments",
renderComponentId: "assessment",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 }, // Daily
boostCriteria: (item, searchTerm) => {
if (searchTerm === "") {
return -100;
}
let score = 0;
// Boost upcoming assignments
if (item.metadata.dueDate) {
const dueDate = new Date(item.metadata.dueDate).getTime();
const now = Date.now();
const daysUntilDue = (dueDate - now) / (1000 * 60 * 60 * 24);
if (daysUntilDue >= 0 && daysUntilDue <= 7) {
score += 0.05; // Boost assignments due within a week
}
if (daysUntilDue < 0) {
score -= 0.1; // Penalty for overdue assignments
}
}
// Boost if submitted
if (item.metadata.submitted) {
score += 0.02;
}
return score;
},
run: async (ctx) => {
// Don't filter by existing IDs - we want to process ALL assessments (both new and old)
// to ensure metadata is up-to-date and all past assignments are indexed
const existingItems = await ctx.getStoredItems("assignments");
const existingIds = new Set(existingItems.map((i) => i.id));
const student = 69; // TODO: Get from context if available
console.debug("[Assignments job] Starting indexing - fetching all assessments (upcoming and past)...");
// Fetch data in parallel
const [upcoming, subjects] = await Promise.all([
fetchUpcomingAssessments(student),
fetchSubjects(),
]);
console.debug(`[Assignments job] Fetched ${upcoming.length} upcoming assessments and ${subjects.length} subjects`);
// Fetch past assessments for ALL subjects to ensure we get all historical assignments
const past = await fetchPastAssessments(student, subjects);
console.debug(`[Assignments job] Fetched ${past.length} past assessments`);
// Create a lookup map from subject code to programme/metaclass
const subjectLookup = new Map<string, { programme: number; metaclass: number }>();
subjects.forEach((s: any) => {
if (s.code && s.programme && s.metaclass) {
subjectLookup.set(s.code, { programme: s.programme, metaclass: s.metaclass });
}
});
// Combine and deduplicate
const allAssessments = new Map<number, any>();
upcoming.forEach((a: any) => {
if (a && a.id) {
// Prioritize capital ID fields (programmeID, metaclassID) as that's what the API returns
let programme = a.programmeID || a.programme;
let metaclass = a.metaclassID || a.metaclass;
// If missing, try to get from subject lookup
if ((!programme || !metaclass) && a.code) {
const subjectInfo = subjectLookup.get(a.code);
if (subjectInfo) {
programme = programme || subjectInfo.programme;
metaclass = metaclass || subjectInfo.metaclass;
}
}
allAssessments.set(a.id, {
...a,
programme,
metaclass,
programmeID: programme, // Ensure both formats are available
metaclassID: metaclass,
isUpcoming: true,
});
}
});
past.forEach((a: any) => {
if (a && a.id) {
// Prioritize capital ID fields (programmeID, metaclassID) as that's what the API returns
let programme = a.programmeID || a.programme;
let metaclass = a.metaclassID || a.metaclass;
const existing = allAssessments.get(a.id);
if (existing) {
// Merge past assessment data, ensuring programme/metaclass are preserved
// Use existing values if new ones are missing
programme = programme || existing.programme || existing.programmeID;
metaclass = metaclass || existing.metaclass || existing.metaclassID;
Object.assign(existing, {
...a,
programme,
metaclass,
programmeID: programme,
metaclassID: metaclass,
});
} else {
allAssessments.set(a.id, {
...a,
programme,
metaclass,
programmeID: programme,
metaclassID: metaclass,
isUpcoming: false
});
}
}
});
const items: IndexItem[] = [];
const processedIds = new Set<string>();
// Process assessments in batches to avoid overwhelming the API
const assessmentArray = Array.from(allAssessments.values());
const pastCount = assessmentArray.filter(a => !a.isUpcoming).length;
const upcomingCount = assessmentArray.filter(a => a.isUpcoming).length;
console.debug(`[Assignments job] Processing ${assessmentArray.length} total assessments (${upcomingCount} upcoming, ${pastCount} past)`);
const batchSize = 15; // Increased batch size for better performance
// Skip fetching assessment details - the API endpoint doesn't exist or returns 404
// Details are optional and not critical for search functionality
// Process ALL assessments (both upcoming and past) to ensure everything is indexed
for (let i = 0; i < assessmentArray.length; i += batchSize) {
const batch = assessmentArray.slice(i, i + batchSize);
const batchItems = await Promise.all(
batch.map(async (assessment) => {
const id = `assignment-${assessment.id}`;
// Skip if already processed in this batch
if (processedIds.has(id)) {
return null;
}
processedIds.add(id);
// Process ALL assessments (both new and existing, upcoming and past)
// This ensures all historical assignments are indexed and metadata is up-to-date
// Skip fetching details - API endpoint doesn't exist
const description = "";
const subjectName = assessment.subject || assessment.code || "Unknown Subject";
const dueDate = assessment.due ? new Date(assessment.due).getTime() : null;
// Prioritize capital ID fields (programmeID, metaclassID) as that's what the API returns
const programmeId = assessment.programmeID || assessment.programme;
const metaclassId = assessment.metaclassID || assessment.metaclass;
// Validate that we have the required IDs for navigation
if (!programmeId || !metaclassId || !assessment.id) {
console.warn(`[Assignments job] Skipping assignment ${assessment.id} - missing required IDs:`, {
programmeId,
metaclassId,
assessmentId: assessment.id,
programmeID: assessment.programmeID,
metaclassID: assessment.metaclassID,
programme: assessment.programme,
metaclass: assessment.metaclass,
assessment,
});
return null;
}
// Convert to numbers, preserving 0 as valid
let finalProgrammeId: number | undefined;
let finalMetaclassId: number | undefined;
if (programmeId !== undefined && programmeId !== null && programmeId !== '') {
const num = Number(programmeId);
finalProgrammeId = isNaN(num) ? undefined : num;
}
if (metaclassId !== undefined && metaclassId !== null && metaclassId !== '') {
const num = Number(metaclassId);
finalMetaclassId = isNaN(num) ? undefined : num;
}
// Final validation - check for actual numbers (including 0)
if (finalProgrammeId === undefined || finalMetaclassId === undefined || !assessment.id) {
console.error(`[Assignments job] ❌ Skipping assignment ${assessment.id} - invalid IDs after conversion:`, {
programmeId: finalProgrammeId,
metaclassId: finalMetaclassId,
assessmentId: assessment.id,
rawProgrammeId: programmeId,
rawMetaclassId: metaclassId,
assessment,
});
return null;
}
const item: IndexItem = {
id,
text: assessment.title || assessment.name || "Untitled Assignment",
category: "assignments",
content: `${description}\nSubject: ${subjectName}\nDue: ${assessment.due || "No due date"}`.trim(),
dateAdded: dueDate || Date.now(),
metadata: {
assessmentId: assessment.id,
assessmentID: assessment.id, // Store both variants for compatibility
subject: subjectName,
subjectCode: assessment.code,
dueDate: assessment.due,
programmeId: finalProgrammeId,
programmeID: finalProgrammeId, // Store both variants for compatibility
metaclassId: finalMetaclassId,
metaclassID: finalMetaclassId, // Store both variants for compatibility
submitted: assessment.submitted || false,
isUpcoming: assessment.isUpcoming || false,
term: assessment.term,
timestamp: assessment.due || new Date().toISOString(), // Required by AssessmentMetadata interface
},
actionId: "assessment",
renderComponentId: "assessment",
};
console.debug(`[Assignments job] ✅ Created item for assignment ${assessment.id}:`, {
id: item.id,
programmeId: item.metadata.programmeId,
programmeID: item.metadata.programmeID,
metaclassId: item.metadata.metaclassId,
metaclassID: item.metadata.metaclassID,
assessmentId: item.metadata.assessmentId,
assessmentID: item.metadata.assessmentID,
});
return item;
})
);
// Filter out nulls and add to items
batchItems.forEach(item => {
if (item) {
items.push(item);
}
});
// Small delay between batches to avoid rate limiting
if (i + batchSize < assessmentArray.length) {
await new Promise(resolve => setTimeout(resolve, 50)); // Reduced delay
}
}
const newItemsCount = items.filter(item => !existingIds.has(item.id)).length;
const updatedItemsCount = items.length - newItemsCount;
console.debug(`[Assignments job] Indexed ${items.length} assignment items (${newItemsCount} new, ${updatedItemsCount} updated)`);
return items;
},
purge: (items) => {
// Keep ALL assignments - don't purge old ones as users may want to search for them
// Only remove items that are truly invalid (missing required metadata)
return items.filter((i) => {
// Keep all items that have valid metadata
return i.metadata &&
i.metadata.assessmentId &&
i.metadata.programmeId !== undefined &&
i.metadata.metaclassId !== undefined;
});
},
};
@@ -1,4 +1,4 @@
import type { Job, IndexItem } from "../types";
import type { IndexItem, Job } from "../types";
const fetchForums = async () => {
const res = await fetch(`${location.origin}/seqta/student/load/forums`, {
@@ -1,4 +1,4 @@
import type { Job, IndexItem } from "../types";
import type { IndexItem, Job } from "../types";
import { htmlToPlainText } from "../utils";
import { delay } from "@/seqta/utils/delay";
import { VectorWorkerManager } from "../worker/vectorWorkerManager";
@@ -604,22 +604,34 @@ export const messagesJob: Job = {
if (processedItems.length > 0) {
try {
const currentItems = await loadAllStoredItems();
currentItems.forEach((item) => {
const jobDef =
jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId];
if (jobDef) {
const renderComponent =
renderComponentMap[jobDef.renderComponentId];
if (renderComponent) {
item.renderComponent = renderComponent;
// Create new objects to avoid XrayWrapper issues in Firefox
const itemsWithComponents = currentItems.map((item) => {
try {
const jobDef =
jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) {
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
} else if (renderComponentMap[item.renderComponentId]) {
renderComponent = renderComponentMap[item.renderComponentId];
}
} else if (renderComponentMap[item.renderComponentId]) {
item.renderComponent = renderComponentMap[item.renderComponentId];
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch (e) {
// Fallback to shallow copy if deep clone fails
return { ...item, renderComponent };
}
} catch (error) {
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
return item;
}
});
loadDynamicItems(currentItems);
loadDynamicItems(itemsWithComponents);
window.dispatchEvent(
new CustomEvent("dynamic-items-updated", {
detail: {
@@ -1,4 +1,4 @@
import type { Job, IndexItem } from "../types";
import type { IndexItem, Job } from "../types";
import { htmlToPlainText } from "../utils";
import { fetchMessageContent } from "./messages";
import { delay } from "@/seqta/utils/delay";
@@ -372,23 +372,34 @@ export const notificationsJob: Job = {
if (items.length > 0) {
try {
const currentItems = await loadAllStoredItems();
currentItems.forEach((item) => {
const jobDef =
jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId];
if (jobDef) {
const renderComponent =
renderComponentMap[jobDef.renderComponentId];
if (renderComponent) {
item.renderComponent = renderComponent;
// Create new objects to avoid XrayWrapper issues in Firefox
const itemsWithComponents = currentItems.map((item) => {
try {
const jobDef =
jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) {
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
} else if (renderComponentMap[item.renderComponentId]) {
renderComponent = renderComponentMap[item.renderComponentId];
}
} else if (renderComponentMap[item.renderComponentId]) {
item.renderComponent =
renderComponentMap[item.renderComponentId];
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch (e) {
// Fallback to shallow copy if deep clone fails
return { ...item, renderComponent };
}
} catch (error) {
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
return item;
}
});
loadDynamicItems(currentItems);
loadDynamicItems(itemsWithComponents);
window.dispatchEvent(
new CustomEvent("dynamic-items-updated", {
detail: {
@@ -3,9 +3,24 @@ import type { IndexItem } from "../types";
let vectorIndex: EmbeddingIndex | null = null;
let isInitialized = false;
let initializationFailed = false;
let currentAbortController: AbortController | null = null;
let loadedItemIds = new Set<string>();
// Detect Firefox in worker context
function isFirefoxWorker(): boolean {
try {
// Check for Firefox-specific APIs or user agent
if (typeof navigator !== "undefined") {
return navigator.userAgent.toLowerCase().includes("firefox");
}
// In worker context, check for Firefox-specific behavior
return false;
} catch {
return false;
}
}
let streamingSession: {
isActive: boolean;
totalExpected: number;
@@ -21,6 +36,16 @@ async function initWorker() {
console.debug("Vector worker already initialized.");
return;
}
// Skip initialization in Firefox
if (isFirefoxWorker()) {
console.debug("[Vector Worker] Vector search not supported in Firefox - skipping initialization");
isInitialized = true;
initializationFailed = true;
vectorIndex = null;
return;
}
console.debug("Initializing vector worker...");
try {
await initializeModel();
@@ -48,8 +73,9 @@ async function initWorker() {
isInitialized = true;
console.debug("Vector worker initialized successfully.");
} catch (e) {
console.error("Failed to initialize vector worker:", e);
console.warn("[Vector Worker] Failed to initialize vector worker (will use text search only):", e);
isInitialized = true;
initializationFailed = true;
vectorIndex = null;
}
}
@@ -80,18 +106,29 @@ async function startStreamingSession(
totalExpected: number,
batchSize: number = 5,
) {
if (initializationFailed || isFirefoxWorker()) {
self.postMessage({
type: "progress",
data: {
status: "complete",
message: "Vector search not available in Firefox - using text search only",
},
});
return;
}
if (!vectorIndex) {
console.warn(
"Streaming requested but vector index not ready. Attempting init.",
);
await initWorker();
if (!vectorIndex) {
if (!vectorIndex || initializationFailed) {
self.postMessage({
type: "progress",
data: {
status: "error",
status: "complete",
message:
"Vector index not available for streaming after init attempt.",
"Vector index not available - using text search only",
},
});
return;
@@ -306,18 +343,29 @@ async function endStreamingSession() {
async function processItems(items: IndexItem[], signal: AbortSignal) {
console.debug("Worker received process request.");
if (initializationFailed || isFirefoxWorker()) {
self.postMessage({
type: "progress",
data: {
status: "complete",
message: "Vector search not available - using text search only",
},
});
return;
}
if (!vectorIndex) {
console.warn(
"Processing requested but vector index not ready. Attempting init.",
);
await initWorker();
if (!vectorIndex) {
if (!vectorIndex || initializationFailed) {
self.postMessage({
type: "progress",
data: {
status: "error",
status: "complete",
message:
"Vector index not available for processing after init attempt.",
"Vector index not available - using text search only",
},
});
return;
@@ -1,5 +1,6 @@
import { refreshVectorCache } from "../../search/vector/vectorSearch";
import type { IndexItem } from "../types";
import { isVectorSearchSupported } from "../../utils/browserDetection";
import vectorWorker from "./vectorWorker.ts?inlineWorker";
export type ProgressCallback = (data: {
@@ -42,6 +43,13 @@ export class VectorWorkerManager {
}
private async initWorker(): Promise<void> {
// Skip initialization if vector search is not supported (e.g., Firefox)
if (!isVectorSearchSupported()) {
console.debug("[VectorWorkerManager] Vector search not supported - skipping worker initialization");
this.isInitialized = false;
return Promise.resolve();
}
if (this.isInitialized) return Promise.resolve();
if (this.readyPromise) return this.readyPromise;
@@ -234,6 +242,17 @@ export class VectorWorkerManager {
}
async processItems(items: IndexItem[], onProgress?: ProgressCallback) {
// Skip if vector search is not supported
if (!isVectorSearchSupported()) {
if (onProgress) {
onProgress({
status: "complete",
message: "Vector search not available - using text search only"
});
}
return;
}
// Only initialize worker if we actually have items to process
if (items.length === 0) {
if (onProgress) {
@@ -298,6 +317,18 @@ export class VectorWorkerManager {
batchSize: number = 10,
jobId?: string,
): Promise<void> {
// Skip if vector search is not supported
if (!isVectorSearchSupported()) {
console.debug("[VectorWorker] Vector search not supported - skipping streaming session");
if (onProgress) {
onProgress({
status: "complete",
message: "Vector search not available - using text search only",
});
}
return;
}
// Only initialize if we expect items to process
if (totalExpectedItems === 0) {
console.debug("[VectorWorker] No items expected, not starting streaming session");
@@ -0,0 +1,280 @@
import type { IndexItem } from "../indexing/types";
import type { CombinedResult } from "../core/types";
import { searchVectors, type VectorSearchResult } from "./vector/vectorSearch";
import { jobs } from "../indexing/jobs";
/**
* Hybrid Search Implementation
*
* Flow:
* 1. BM25 (Fuse.js) gets top N results fast
* 2. Vector search reranks by semantic similarity
* 3. Apply optional boosting (recency, popularity, tags)
*/
export interface HybridSearchOptions {
/** Maximum number of BM25 results to retrieve before reranking */
bm25TopK?: number;
/** Maximum number of final results to return */
finalLimit?: number;
/** Whether to apply recency boost */
recencyBoost?: boolean;
/** Weight for BM25 scores (0-1) */
bm25Weight?: number;
/** Weight for vector similarity scores (0-1) */
vectorWeight?: number;
/** Weight for recency boost */
recencyWeight?: number;
}
const DEFAULT_OPTIONS: Required<HybridSearchOptions> = {
bm25TopK: 50, // Get top 50 from BM25, then rerank
finalLimit: 10,
recencyBoost: true,
bm25Weight: 0.4, // 40% BM25, 60% vector
vectorWeight: 0.6,
recencyWeight: 0.1,
};
/**
* Normalizes a score to 0-1 range
*/
function normalizeScore(score: number, min: number, max: number): number {
if (max === min) return 0.5;
return Math.max(0, Math.min(1, (score - min) / (max - min)));
}
/**
* Calculates recency boost based on item age
*/
function calculateRecencyBoost(item: IndexItem, now: number): number {
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
// Exponential decay: newer items get higher boost
// Items from today get boost of 1, items from 30 days ago get ~0.03
return 1 / (1 + ageInDays / 7); // Half-life of 7 days
}
/**
* Calculates popularity boost (can be extended with click tracking, etc.)
*/
function calculatePopularityBoost(item: IndexItem): number {
// For now, boost based on category and metadata
let boost = 0;
// Boost assignments/assessments
if (item.category === "assignments") {
boost += 0.1;
}
// Boost upcoming items
if (item.metadata?.isUpcoming) {
boost += 0.15;
}
// Boost items with subject codes (more structured)
if (item.metadata?.subjectCode) {
boost += 0.05;
}
return Math.min(boost, 0.3); // Cap at 0.3
}
/**
* Reranks BM25 results using vector search
*/
export async function hybridSearch(
bm25Results: CombinedResult[],
query: string,
options: HybridSearchOptions = {},
): Promise<CombinedResult[]> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const trimmedQuery = query.trim().toLowerCase();
// If no BM25 results, return empty
if (bm25Results.length === 0) {
return [];
}
// Limit BM25 results to top K
const topBm25Results = bm25Results.slice(0, opts.bm25TopK);
// Get vector search results for reranking
// We'll search the full index and then filter to our BM25 results
let vectorResults: VectorSearchResult[] = [];
if (trimmedQuery.length > 2) {
try {
// Get more vector results than BM25 results to ensure coverage
// This allows us to find semantic matches that BM25 might have missed
const vectorSearchResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
// Create a map of item ID to vector similarity
const vectorMap = new Map<string, number>();
vectorSearchResults.forEach(v => {
// Use the highest similarity if item appears multiple times
const existing = vectorMap.get(v.object.id);
if (!existing || v.similarity > existing) {
vectorMap.set(v.object.id, v.similarity);
}
});
// Now rerank BM25 results with vector scores
const now = Date.now();
const rerankedResults = topBm25Results.map(result => {
const item = result.item;
// Normalize BM25 score to 0-1
// Fuse.js scores: lower is better (0 = perfect match)
// We need to invert: higher score = better match
// Result.score is typically 0-100, where higher = better
// So we normalize it to 0-1
const normalizedBm25Score = Math.max(0, Math.min(1, result.score / 100));
// Get vector similarity (0-1, already normalized)
// If item wasn't in vector results, use a default low score
const vectorSimilarity = vectorMap.get(item.id) || 0.3; // Default to 0.3 if not found
// Calculate recency boost (0-1 range)
const recencyBoost = opts.recencyBoost
? calculateRecencyBoost(item, now) * opts.recencyWeight
: 0;
// Calculate popularity boost (0-1 range)
const popularityBoost = calculatePopularityBoost(item);
// Apply job-specific boost if available
const job = jobs[item.category];
let jobBoost = 0;
if (job && typeof job.boostCriteria === 'function') {
const boost = job.boostCriteria(item, trimmedQuery);
if (boost) {
jobBoost = boost / 100; // Normalize boost to 0-1
}
}
// Combine scores using weighted average
// BM25 and vector are weighted, boosts are additive
const hybridScore =
(normalizedBm25Score * opts.bm25Weight) +
(vectorSimilarity * opts.vectorWeight) +
recencyBoost +
popularityBoost +
jobBoost;
return {
...result,
score: hybridScore * 100, // Scale back to 0-100 for consistency
// Store component scores for debugging (optional, can be removed in production)
_hybridScores: {
bm25: normalizedBm25Score,
vector: vectorSimilarity,
recency: recencyBoost,
popularity: popularityBoost,
jobBoost: jobBoost,
final: hybridScore,
},
};
});
// Sort by hybrid score descending
rerankedResults.sort((a, b) => b.score - a.score);
// Return top results
return rerankedResults.slice(0, opts.finalLimit);
} catch (e) {
console.warn("[Hybrid Search] Vector reranking failed, using BM25 only:", e);
// Fallback to BM25 only
return topBm25Results.slice(0, opts.finalLimit);
}
}
// If query is too short for vector search, just return BM25 results
return topBm25Results.slice(0, opts.finalLimit);
}
/**
* Enhanced hybrid search that also includes vector-only results not found by BM25
*/
export async function hybridSearchWithExpansion(
bm25Results: CombinedResult[],
query: string,
allItems: IndexItem[],
options: HybridSearchOptions = {},
): Promise<CombinedResult[]> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const trimmedQuery = query.trim().toLowerCase();
// First, rerank BM25 results
const rerankedBm25 = await hybridSearch(bm25Results, query, options);
// If query is too short, skip vector expansion
if (trimmedQuery.length <= 2) {
return rerankedBm25;
}
// Get vector search results
let vectorResults: VectorSearchResult[] = [];
try {
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK);
} catch (e) {
console.warn("[Hybrid Search] Vector search failed:", e);
return rerankedBm25;
}
// Find vector results that weren't in BM25 results
const bm25Ids = new Set(bm25Results.map(r => r.item.id));
const vectorOnlyResults: CombinedResult[] = [];
const now = Date.now();
vectorResults.forEach(v => {
if (!bm25Ids.has(v.object.id)) {
// This is a semantic match that BM25 missed
const item = v.object;
// Calculate boosts
const recencyBoost = opts.recencyBoost
? calculateRecencyBoost(item, now) * opts.recencyWeight
: 0;
const popularityBoost = calculatePopularityBoost(item);
// Vector-only results get lower base score but high vector similarity
const vectorScore = v.similarity * opts.vectorWeight + recencyBoost + popularityBoost;
// Apply job-specific boost if available
const job = jobs[item.category];
let jobBoost = 0;
if (job && typeof job.boostCriteria === 'function') {
const boost = job.boostCriteria(item, trimmedQuery);
if (boost) {
jobBoost = boost / 100; // Normalize boost
}
}
vectorOnlyResults.push({
id: item.id,
type: "dynamic" as const,
score: (vectorScore + jobBoost) * 100,
item,
_hybridScores: {
bm25: 0,
vector: v.similarity,
recency: recencyBoost,
popularity: popularityBoost,
final: vectorScore + jobBoost,
},
});
}
});
// Combine reranked BM25 results with vector-only results
const allResults = [...rerankedBm25, ...vectorOnlyResults];
// Sort by score and return top results
allResults.sort((a, b) => b.score - a.score);
return allResults.slice(0, opts.finalLimit);
}
@@ -6,32 +6,79 @@ import type { IndexItem } from "../indexing/types";
import { searchVectors } from "./vector/vectorSearch";
import type { VectorSearchResult } from "./vector/vectorTypes";
import { jobs } from "../indexing/jobs";
import { hybridSearchWithExpansion } from "./hybridSearch";
// Search result cache for better performance
const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>();
const CACHE_TTL = 1000 * 60 * 5; // 5 minutes
const MAX_CACHE_SIZE = 100;
function getCachedResults(query: string): CombinedResult[] | null {
const cached = searchCache.get(query);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.results;
}
return null;
}
function setCachedResults(query: string, results: CombinedResult[]) {
// Limit cache size
if (searchCache.size >= MAX_CACHE_SIZE) {
const firstKey = searchCache.keys().next().value;
searchCache.delete(firstKey);
}
searchCache.set(query, { results, timestamp: Date.now() });
}
/**
* Clears the search result cache
*/
export function clearSearchCache(): void {
searchCache.clear();
console.debug("[Search] Search result cache cleared");
}
// Listen for cache clear events (e.g., on extension update)
if (typeof window !== 'undefined') {
window.addEventListener('betterseqta-clear-search-cache', () => {
clearSearchCache();
});
}
export function createSearchIndexes() {
const commands = getStaticCommands();
const dynamicItems = getDynamicItems();
// Optimized command search options
const commandOptions = {
keys: ["text", "category", "keywords"],
includeScore: true,
includeMatches: true,
threshold: 0.4,
threshold: 0.35, // Slightly more permissive for better recall
minMatchCharLength: 2,
useExtendedSearch: false,
ignoreLocation: false,
findAllMatches: false, // Performance optimization
};
// Optimized dynamic content search options
const dynamicOptions = {
keys: [
{ name: "text", weight: 2 },
{ name: "text", weight: 3 }, // Increased weight for title matches
{ name: "content", weight: 1 },
{ name: "category", weight: 1 },
{ name: "category", weight: 0.5 }, // Lower weight for category
{ name: "metadata.subjectName", weight: 1.5 }, // Boost subject name matches
{ name: "metadata.subjectCode", weight: 1.5 }, // Boost subject code matches
],
includeScore: true,
includeMatches: true,
threshold: 0.4,
minMatchCharLength: 2,
distance: 100,
threshold: 0.5, // More permissive for better partial word matching (increased from 0.4)
minMatchCharLength: 2, // Minimum 2 characters for Fuse.js matches (substring fallback handles shorter queries)
distance: 100, // Increased to allow matches across longer strings
useExtendedSearch: true,
ignoreLocation: true, // Allow matches anywhere in the string for better partial word matching
findAllMatches: true, // Enable to find all matches for better partial word support
shouldSort: true,
};
return {
@@ -105,18 +152,64 @@ export function searchDynamicItems(
}
const now = Date.now();
const searchResults = dynamicContentFuse.search(query, { limit });
const queryLower = query.toLowerCase();
const queryTrimmed = query.trim();
return searchResults.map((result: FuseResult<IndexItem>) => {
// For short queries (3 chars or less), use a more permissive approach
const isShortQuery = queryTrimmed.length <= 3;
const searchLimit = Math.min(limit * 3, 50);
// First, try Fuse.js search
const searchResults = dynamicContentFuse.search(query, { limit: searchLimit });
// For short queries, always do a simple substring match to supplement Fuse.js results
// This ensures we catch partial word matches like "SAT" in "SAT 1: Differential Calculus"
let additionalMatches: IndexItem[] = [];
if (isShortQuery) {
// Always do substring search for short queries to catch partial word matches
for (const item of dynamicIdToItemMap.values()) {
const textLower = item.text.toLowerCase();
const contentLower = (item.content || '').toLowerCase();
const subjectNameLower = (item.metadata?.subjectName || '').toLowerCase();
const subjectCodeLower = (item.metadata?.subjectCode || '').toLowerCase();
// Check if query appears anywhere in the text, content, or metadata
if (textLower.includes(queryLower) ||
contentLower.includes(queryLower) ||
subjectNameLower.includes(queryLower) ||
subjectCodeLower.includes(queryLower)) {
// Only add if not already in Fuse.js results
if (!searchResults.find(r => r.item.id === item.id)) {
additionalMatches.push(item);
}
}
}
}
const results = searchResults.map((result: FuseResult<IndexItem>) => {
const item = result.item;
const fuseScore = 10 * (1 - (result.score || 0.5));
let score = fuseScore;
// Recency boost
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
score += recencyBoost;
// Boost for exact text matches (especially at the start)
const textLower = item.text.toLowerCase();
if (textLower.startsWith(queryLower)) {
score += 5; // Strong boost for prefix matches
} else if (textLower.includes(queryLower)) {
score += 2; // Boost for substring matches
}
// Boost for category matches
if (item.category.toLowerCase().includes(queryLower)) {
score += 1;
}
return {
id: item.id,
type: "dynamic" as const,
@@ -125,60 +218,124 @@ export function searchDynamicItems(
matches: result.matches,
};
});
// Add additional matches from simple substring search
additionalMatches.forEach((item) => {
// Check if already in results
if (!results.find(r => r.id === item.id)) {
const textLower = item.text.toLowerCase();
let score = 5; // Base score for substring matches
// Boost for prefix matches
if (textLower.startsWith(queryLower)) {
score += 5;
}
// Recency boost
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
score += recencyBoost;
results.push({
id: item.id,
type: "dynamic" as const,
score,
item,
});
}
});
// Sort by score and return top results
return results.sort((a, b) => b.score - a.score).slice(0, limit);
}
export async function performSearch(
query: string,
commandsFuse: Fuse<StaticCommandItem>,
commandIdToItemMap: Map<string, StaticCommandItem>,
dynamicContentFuse?: Fuse<IndexItem>,
dynamicIdToItemMap?: Map<string, IndexItem>,
sortByRecent: boolean = true,
): Promise<CombinedResult[]> {
// Get all results first
const trimmedQuery = query.trim().toLowerCase();
// Check cache first
if (trimmedQuery.length > 2) {
const cached = getCachedResults(trimmedQuery);
if (cached) {
return cached;
}
}
// Step 1: Get command results (these don't need hybrid search)
const commandResults = searchCommands(
commandsFuse,
query,
trimmedQuery,
commandIdToItemMap,
);
// Get vector results in parallel
let vectorResults: VectorSearchResult[] = [];
try {
vectorResults = await searchVectors(query);
} catch (e) {}
// Step 2: Get BM25 results for dynamic items
let dynamicResults: CombinedResult[] = [];
if (dynamicContentFuse && dynamicIdToItemMap) {
// Get BM25 results first (fast text-based search)
const bm25Results = searchDynamicItems(
dynamicContentFuse,
trimmedQuery,
dynamicIdToItemMap,
50, // Get top 50 for reranking
sortByRecent,
);
// Create a map to store our final results, using ID as key to avoid duplicates
const resultMap = new Map<string, CombinedResult>();
// Step 3: Apply hybrid search (BM25 + Vector reranking + boosting)
if (trimmedQuery.length > 2 && bm25Results.length > 0) {
try {
// Get all items for expansion
const allItems = Array.from(dynamicIdToItemMap.values());
// 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>();
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
let score = v.similarity * 0.5; // High base score for semantic matches
const job = jobs[v.object.category];
if (job && typeof job.boostCriteria === 'function') {
const boost = job.boostCriteria(v.object, query);
if (boost) {
score += boost;
}
// Apply hybrid search with expansion
dynamicResults = await hybridSearchWithExpansion(
bm25Results,
trimmedQuery,
allItems,
{
bm25TopK: 50,
finalLimit: 20, // Return top 20 after reranking
recencyBoost: sortByRecent,
bm25Weight: 0.4, // 40% BM25, 60% vector
vectorWeight: 0.6,
recencyWeight: 0.1,
},
);
} catch (e) {
console.warn("[Search] Hybrid search failed, using BM25 only:", e);
// Fallback to BM25 only
dynamicResults = bm25Results.slice(0, 20);
}
resultMap.set(id, {
id,
type: "dynamic" as const,
score,
item: v.object,
});
} else {
// For very short queries or no BM25 results, use BM25 only
dynamicResults = bm25Results.slice(0, 20);
}
}
// Step 4: Combine command and dynamic results
const allResults = [...commandResults, ...dynamicResults];
// Sort by score (commands typically have higher priority)
allResults.sort((a, b) => {
// Commands always come first if scores are similar
if (a.type === "command" && b.type === "dynamic") {
return b.score - a.score - 10; // Commands get +10 boost
}
if (a.type === "dynamic" && b.type === "command") {
return b.score - a.score + 10; // Commands get +10 boost
}
return b.score - a.score;
});
// Convert to array and sort by score
const results = Array.from(resultMap.values());
results.sort((a, b) => b.score - a.score);
// Cache results for queries longer than 2 chars
if (trimmedQuery.length > 2) {
setCachedResults(trimmedQuery, allResults);
}
return results;
return allResults;
}
@@ -1,16 +1,36 @@
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
import type { IndexItem } from "../../indexing/types";
import type { SearchResult } from "embeddia";
import { isVectorSearchSupported } from "../../utils/browserDetection";
let vectorIndex: EmbeddingIndex | null = null;
let initializationAttempted = false;
let initializationFailed = false;
export async function initVectorSearch() {
// Skip initialization if already attempted and failed, or if not supported
if (initializationFailed || !isVectorSearchSupported()) {
if (!isVectorSearchSupported()) {
console.debug("[Vector Search] Vector search not supported in Firefox - using text search only");
}
return;
}
if (initializationAttempted) {
return;
}
initializationAttempted = true;
try {
await initializeModel();
vectorIndex = new EmbeddingIndex([]);
vectorIndex.preloadIndexedDB();
console.debug("[Vector Search] Initialized successfully");
} catch (e) {
console.error("Error initializing vector search", e);
console.warn("[Vector Search] Failed to initialize vector search (will use text search only):", e);
initializationFailed = true;
vectorIndex = null;
}
}
@@ -18,28 +38,111 @@ export interface VectorSearchResult extends SearchResult {
object: IndexItem & { embedding: number[] };
}
// Cache for query embeddings to avoid recomputing
const embeddingCache = new Map<string, number[]>();
const EMBEDDING_CACHE_TTL = 1000 * 60 * 30; // 30 minutes
const MAX_EMBEDDING_CACHE_SIZE = 50;
function getCachedEmbedding(query: string): number[] | null {
const cached = embeddingCache.get(query);
if (cached) {
return cached;
}
return null;
}
function setCachedEmbedding(query: string, embedding: number[]) {
// Limit cache size
if (embeddingCache.size >= MAX_EMBEDDING_CACHE_SIZE) {
const firstKey = embeddingCache.keys().next().value;
embeddingCache.delete(firstKey);
}
embeddingCache.set(query, embedding);
}
/**
* Clears the embedding cache
*/
export function clearEmbeddingCache(): void {
embeddingCache.clear();
console.debug("[Vector Search] Embedding cache cleared");
}
// Listen for cache clear events (e.g., on extension update)
if (typeof window !== 'undefined') {
window.addEventListener('betterseqta-clear-embedding-cache', () => {
clearEmbeddingCache();
});
}
export async function searchVectors(
query: string,
topK: number = 20,
): Promise<VectorSearchResult[]> {
if (!vectorIndex) await initVectorSearch();
// Return empty array if vector search is not supported or failed to initialize
if (!isVectorSearchSupported() || initializationFailed) {
return [];
}
const queryEmbedding = await getEmbedding(query.slice(0, 100));
if (!vectorIndex) {
await initVectorSearch();
if (!vectorIndex) {
return [];
}
}
const results = await vectorIndex!.search(queryEmbedding, {
topK,
useStorage: "indexedDB",
dedupeEntries: true,
});
// Normalize query for caching
const normalizedQuery = query.trim().toLowerCase().slice(0, 100);
// filter results with a similarity below 0.81
const filteredResults = results.filter((r) => r.similarity > 0.81);
// Check cache first
let queryEmbedding = getCachedEmbedding(normalizedQuery);
return filteredResults as VectorSearchResult[];
if (!queryEmbedding) {
try {
queryEmbedding = await getEmbedding(normalizedQuery);
setCachedEmbedding(normalizedQuery, queryEmbedding);
} catch (e) {
console.warn("[Vector Search] Failed to get embedding:", e);
return [];
}
}
try {
const results = await vectorIndex!.search(queryEmbedding, {
topK: Math.min(topK * 2, 30), // Get more results, filter later
useStorage: "indexedDB",
dedupeEntries: true,
});
// Filter results with a similarity below 0.80 (slightly more permissive)
// and sort by similarity descending
const filteredResults = results
.filter((r) => r.similarity > 0.80)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, topK);
return filteredResults as VectorSearchResult[];
} catch (e) {
console.warn("[Vector Search] Search failed:", e);
return [];
}
}
export async function refreshVectorCache() {
if (!vectorIndex) await initVectorSearch();
vectorIndex!.clearIndexedDBCache();
vectorIndex!.preloadIndexedDB();
if (!isVectorSearchSupported() || initializationFailed) {
return;
}
if (!vectorIndex) {
await initVectorSearch();
}
if (vectorIndex) {
try {
vectorIndex.clearIndexedDBCache();
vectorIndex.preloadIndexedDB();
} catch (e) {
console.warn("[Vector Search] Failed to refresh cache:", e);
}
}
}
@@ -0,0 +1,30 @@
import browser from "webextension-polyfill";
/**
* Detects if the current browser is Firefox
*/
export function isFirefox(): boolean {
try {
// Firefox-specific API
if (typeof (browser.runtime as any).getBrowserInfo === "function") {
return true;
}
// Fallback: check user agent
if (typeof navigator !== "undefined") {
return navigator.userAgent.toLowerCase().includes("firefox");
}
return false;
} catch {
// If we can't detect, assume not Firefox (safer for Chrome/Edge)
return false;
}
}
/**
* Checks if vector search is supported in the current browser
* Currently disabled for Firefox due to security restrictions
*/
export function isVectorSearchSupported(): boolean {
return !isFirefox();
}
@@ -0,0 +1,115 @@
import browser from "webextension-polyfill";
const VERSION_STORAGE_KEY = "betterseqta-global-search-version";
const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version";
/**
* Gets the current extension version from the manifest
*/
export function getCurrentVersion(): string {
try {
return browser.runtime.getManifest().version;
} catch (e) {
console.warn("[Version Check] Failed to get manifest version:", e);
return "0.0.0";
}
}
/**
* Gets the last stored version from localStorage
*/
export function getStoredVersion(): string | null {
try {
return localStorage.getItem(VERSION_STORAGE_KEY);
} catch (e) {
console.warn("[Version Check] Failed to get stored version:", e);
return null;
}
}
/**
* Stores the current version in localStorage
*/
export function storeVersion(version: string): void {
try {
localStorage.setItem(VERSION_STORAGE_KEY, version);
localStorage.setItem(VERSION_CACHE_KEY, version);
} catch (e) {
console.warn("[Version Check] Failed to store version:", e);
}
}
/**
* Checks if the extension has been updated and clears caches if needed
* Returns true if an update was detected
*/
export async function checkAndHandleUpdate(): Promise<boolean> {
const currentVersion = getCurrentVersion();
const storedVersion = getStoredVersion();
// If no stored version, this is first run - store current version
if (!storedVersion) {
console.debug(`[Version Check] First run detected, storing version ${currentVersion}`);
storeVersion(currentVersion);
return false;
}
// If versions match, no update
if (storedVersion === currentVersion) {
return false;
}
// Version mismatch detected - extension was updated
console.log(`[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, clearing caches...`);
// Clear all caches
await clearAllCaches();
// Store new version
storeVersion(currentVersion);
return true;
}
/**
* Clears all search-related caches
*/
export async function clearAllCaches(): Promise<void> {
try {
// Clear search result cache (in-memory Map)
if (typeof window !== 'undefined') {
// Dispatch event to clear caches in other modules
window.dispatchEvent(new CustomEvent('betterseqta-clear-search-cache'));
window.dispatchEvent(new CustomEvent('betterseqta-clear-embedding-cache'));
}
// Also try to directly clear caches if modules are already loaded
// Use setTimeout to avoid blocking and handle CSS preload errors
setTimeout(async () => {
try {
const { clearSearchCache } = await import("../search/searchUtils");
clearSearchCache();
} catch (e: any) {
// Module might not be loaded yet, or CSS preload error - that's okay
if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) {
console.debug("[Version Check] Could not clear search cache:", e);
}
}
try {
const { clearEmbeddingCache } = await import("../search/vector/vectorSearch");
clearEmbeddingCache();
} catch (e: any) {
// Module might not be loaded yet, or CSS preload error - that's okay
if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) {
console.debug("[Version Check] Could not clear embedding cache:", e);
}
}
}, 50);
console.debug("[Version Check] All caches cleared");
} catch (e) {
console.error("[Version Check] Error clearing caches:", e);
}
}
@@ -1,4 +1,5 @@
import type { Plugin } from "../../core/types";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
interface NotificationCollectorStorage {
lastNotificationCount: number;
@@ -15,6 +16,10 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
disableToggle: true,
run: async (api) => {
if (isSeqtaEngageExperience()) {
return () => {};
}
let pollInterval: number | null = null;
let isVisible = !document.hidden;
let baseInterval = 30000; // 30 seconds
+42 -13
View File
@@ -1,11 +1,21 @@
import type { Plugin } from "@/plugins/core/types";
import { defineSettings, componentSetting } from "@/plugins/core/settingsHelpers";
import {
booleanSetting,
componentSetting,
defineSettings,
} from "@/plugins/core/settingsHelpers";
import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import styles from "./styles.css?inline";
import localforage from "localforage";
const settings = defineSettings({
useCloudPfp: booleanSetting({
default: false,
title: "Use BetterSEQTA Cloud profile picture",
description: "Use your cloud account avatar instead of the uploaded image below",
}),
picture: componentSetting({
title: "Profile Picture",
description: "Upload or remove your custom profile image",
@@ -13,12 +23,11 @@ const settings = defineSettings({
}),
});
const profilePicturePlugin: Plugin<typeof settings> = {
id: "profile-picture",
name: "Custom Profile Picture",
description: "Use your own image in place of the profile icon",
version: "1.1.0",
version: "1.2.0",
settings: settings,
disableToggle: true,
defaultEnabled: false,
@@ -37,14 +46,12 @@ const profilePicturePlugin: Plugin<typeof settings> = {
let img: HTMLImageElement | null = null;
let currentBlobUrl: string | undefined;
// Setup localforage instance
const store = localforage.createInstance({
name: "profile-picture-store",
storeName: "profilePicture",
});
async function updateImageFromStore() {
// Remove old image if present
async function applyProfileImage() {
if (img) {
img.remove();
img = null;
@@ -53,6 +60,19 @@ const profilePicturePlugin: Plugin<typeof settings> = {
URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = undefined;
}
const useCloud = api.settings.useCloudPfp;
const pfpUrl = cloudAuth.state.user?.pfpUrl;
if (useCloud && pfpUrl) {
img = document.createElement("img");
img.className = "userInfoImg";
img.src = pfpUrl;
if (svg) svg.style.display = "none";
container.appendChild(img);
return;
}
const blob = await store.getItem<Blob>("profile-picture");
if (blob && blob instanceof Blob) {
currentBlobUrl = URL.createObjectURL(blob);
@@ -66,15 +86,25 @@ const profilePicturePlugin: Plugin<typeof settings> = {
}
}
// Initial load
await updateImageFromStore();
await applyProfileImage();
// Listen for profile picture updates
const handler = () => { updateImageFromStore(); };
window.addEventListener('profile-picture-updated', handler);
const onLocalPictureUpdated = () => {
void applyProfileImage();
};
window.addEventListener("profile-picture-updated", onLocalPictureUpdated);
const cloudUnsub = cloudAuth.subscribe(() => {
void applyProfileImage();
});
const useCloudUnreg = api.settings.onChange("useCloudPfp", () => {
void applyProfileImage();
});
return () => {
window.removeEventListener('profile-picture-updated', handler);
useCloudUnreg.unregister();
cloudUnsub();
window.removeEventListener("profile-picture-updated", onLocalPictureUpdated);
if (img) img.remove();
if (svg) svg.style.display = "";
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
@@ -83,4 +113,3 @@ const profilePicturePlugin: Plugin<typeof settings> = {
};
export default profilePicturePlugin;
+3 -4
View File
@@ -1,11 +1,10 @@
import renderSvelte from "@/interface/main";
import themeCreator from "@/interface/pages/themeCreator.svelte";
import { unmount } from "svelte";
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
import { unmount } from "svelte";
import themeCreator from "@/interface/pages/themeCreator.svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
let themeCreatorSvelteApp: any = null;
const themeManager = ThemeManager.getInstance();
/**
* Open the Theme Creator sidebar, it is an embedded page loaded similar to the extension popup
@@ -41,7 +40,7 @@ export function OpenThemeCreator(themeID: string = "") {
closeButton.textContent = "×";
closeButton.addEventListener("click", () => {
CloseThemeCreator();
themeManager.clearPreview();
ThemeManager.getInstance().clearPreview();
});
document.body.appendChild(closeButton);
+265 -30
View File
@@ -1,7 +1,20 @@
import localforage from "localforage";
import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes";
import browser from "webextension-polyfill";
import {
type CustomTheme,
getForcedDarkMode,
type LoadedCustomTheme,
shouldForceThemeAppearance,
} from "@/types/CustomThemes";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import debounce from "@/seqta/utils/debounce";
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { updateAllColors } from "@/seqta/ui/colors/Manager";
import {
clearCustomThemeAdaptiveCssVariables,
setCustomThemeAdaptiveCssVariables,
} from "@/seqta/ui/colors/customThemeAdaptiveBindings";
type ThemeContent = {
id: string;
@@ -13,6 +26,15 @@ type ThemeContent = {
CustomCSS?: string;
hideThemeName?: boolean;
forceDark?: boolean;
images?: { id: string; variableName: string; data: string }[]; // data: base64
};
export type InstallThemeMeta = {
fromStore: boolean;
/** Server list `updated_at` (Unix seconds); set when installing from store. */
serverUpdatedAtSec?: number;
forceTheme?: boolean;
adaptiveCssVariables?: string[];
images: { id: string; variableName: string; data: string }[]; // data: base64
};
@@ -26,6 +48,7 @@ export class ThemeManager {
private originalPreviewTheme: boolean | null = null;
private imageUrlCache: Map<string, string> = new Map();
private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 };
private storeUpdateCheckRunning = false;
private constructor() {
console.debug("[ThemeManager] Initializing...");
@@ -174,6 +197,8 @@ export class ThemeManager {
}
} catch (error) {
console.error("[ThemeManager] Error during initialization:", error);
} finally {
void this.checkStoreThemeUpdates();
}
}
@@ -208,7 +233,7 @@ export class ThemeManager {
console.debug("[ThemeManager] Storing original settings");
settingsState.originalSelectedColor = settingsState.selectedColor;
if (theme.forceDark) {
if (shouldForceThemeAppearance(theme)) {
settingsState.originalDarkMode = settingsState.DarkMode;
}
}
@@ -239,6 +264,7 @@ export class ThemeManager {
this.currentTheme = theme;
settingsState.selectedTheme = themeId;
}
void updateAllColors();
} catch (error) {
console.error("[ThemeManager] Error setting theme:", error);
}
@@ -269,9 +295,10 @@ export class ThemeManager {
}
// Apply theme settings
if (theme.forceDark !== undefined) {
console.debug("[ThemeManager] Setting dark mode:", theme.forceDark);
settingsState.DarkMode = theme.forceDark;
if (shouldForceThemeAppearance(theme)) {
const dark = getForcedDarkMode(theme);
console.debug("[ThemeManager] Setting dark mode:", dark);
settingsState.DarkMode = dark;
}
// Use the stored selected color if available, otherwise use the default
@@ -288,6 +315,8 @@ export class ThemeManager {
);
settingsState.selectedColor = theme.defaultColour;
}
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
} catch (error) {
console.error("[ThemeManager] Error applying theme:", error);
}
@@ -344,9 +373,18 @@ export class ThemeManager {
if (this.currentTheme) {
// Store the current color with the theme before removing it
const selectedColor = settingsState.selectedColor;
const markUserEditedForColor =
this.currentTheme.userEdited !== true &&
this.currentTheme.installedFromStore !== false &&
selectedColor &&
this.currentTheme.defaultColour &&
selectedColor.trim().toLowerCase() !==
this.currentTheme.defaultColour.trim().toLowerCase();
await localforage.setItem(this.currentTheme.id, {
...this.currentTheme,
selectedColor: settingsState.selectedColor,
selectedColor,
...(markUserEditedForColor ? { userEdited: true } : {}),
});
}
@@ -372,6 +410,7 @@ export class ThemeManager {
if (clearSelectedTheme) {
settingsState.selectedTheme = "";
}
clearCustomThemeAdaptiveCssVariables();
} catch (error) {
console.error("[ThemeManager] Error removing theme:", error);
}
@@ -426,18 +465,24 @@ export class ThemeManager {
public async saveTheme(theme: LoadedCustomTheme): Promise<void> {
console.debug("[ThemeManager] Saving theme:", theme.name);
try {
await localforage.setItem(theme.id, theme);
const existing = (await localforage.getItem(theme.id)) as CustomTheme | null;
let toSave = theme;
if (existing?.userEdited === true && theme.userEdited !== false) {
toSave = { ...theme, userEdited: true };
}
await localforage.setItem(toSave.id, toSave);
const themeIds = (await localforage.getItem("customThemes")) as
| string[]
| null;
if (themeIds) {
if (!themeIds.includes(theme.id)) {
themeIds.push(theme.id);
if (!themeIds.includes(toSave.id)) {
themeIds.push(toSave.id);
await localforage.setItem("customThemes", themeIds);
}
} else {
await localforage.setItem("customThemes", [theme.id]);
await localforage.setItem("customThemes", [toSave.id]);
}
} catch (error) {
console.error("[ThemeManager] Error saving theme:", error);
@@ -470,34 +515,87 @@ export class ThemeManager {
}
}
private readonly THEME_API_BASE = 'https://betterseqta.org/api';
private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes';
/**
* Download and install a theme from the store
* Fetch JSON from a URL via background script (avoids CORS when running inside SEQTA page)
*/
private async fetchFromUrl(url: string): Promise<any> {
const result = (await browser.runtime.sendMessage({
type: 'fetchFromUrl',
url,
})) as { data?: unknown; error?: string };
if (result?.error) throw new Error(result.error);
return result?.data;
}
/**
* Download and install a theme from the store.
* Uses API first (increments download_count), falls back to GitHub if unreachable.
*/
public async downloadTheme(themeContent: {
id: string;
name: string;
description: string;
coverImage: string;
description?: string;
coverImage?: string;
theme_json_url?: string;
updated_at?: number;
}): Promise<void> {
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
try {
if (!themeContent.id) return;
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);
await this.downloadAndInstallStoreTheme(themeContent);
} catch (error) {
console.error("[ThemeManager] Error downloading theme:", error);
}
}
/**
* Fetch theme.json from the store and install (throws on failure).
*/
private async downloadAndInstallStoreTheme(themeContent: {
id: string;
name: string;
description?: string;
coverImage?: string;
theme_json_url?: string;
updated_at?: number;
}): Promise<void> {
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
if (!themeContent.id) {
throw new Error("Missing theme id");
}
let themeData: ThemeContent;
try {
const downloadData = (await this.fetchFromUrl(
`${this.THEME_API_BASE}/themes/${themeContent.id}/download`,
)) as { success?: boolean; data?: { theme_json_url: string } };
if (!downloadData?.success || !downloadData?.data?.theme_json_url) {
throw new Error("Failed to get theme download URL");
}
themeData = (await this.fetchFromUrl(
downloadData.data.theme_json_url,
)) as ThemeContent;
} catch (apiError) {
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
const githubUrl = `${this.GITHUB_THEMES_BASE}/${themeContent.id}/theme.json`;
themeData = (await this.fetchFromUrl(githubUrl)) as ThemeContent;
}
await this.installTheme(themeData, {
fromStore: true,
serverUpdatedAtSec: themeContent.updated_at,
});
}
/**
* Install a theme from theme data
*/
public async installTheme(themeData: ThemeContent): Promise<void> {
public async installTheme(
themeData: ThemeContent,
meta?: InstallThemeMeta,
): Promise<void> {
console.debug("[ThemeManager] Installing theme:", themeData.name);
try {
// Validate required fields
@@ -505,6 +603,9 @@ export class ThemeManager {
throw new Error("Theme is missing required fields (id or name)");
}
const fromStore = meta?.fromStore ?? false;
const serverUpdatedAtSec = meta?.serverUpdatedAtSec;
// Handle cover image (optional)
let coverImageBlob = null;
if (themeData.coverImage) {
@@ -539,7 +640,6 @@ export class ThemeManager {
})
.filter((img) => img !== null) ?? [];
// Create theme with defaults for optional fields
const theme: LoadedCustomTheme = {
id: themeData.id,
name: themeData.name,
@@ -554,6 +654,19 @@ export class ThemeManager {
isEditable: false,
hideThemeName: themeData.hideThemeName ?? false,
forceDark: themeData.forceDark,
installedFromStore: fromStore,
userEdited: fromStore ? false : undefined,
storeSyncedAtSec:
fromStore && serverUpdatedAtSec != null
? serverUpdatedAtSec
: undefined,
forceTheme:
themeData.forceTheme !== undefined
? themeData.forceTheme
: themeData.forceDark !== undefined
? true
: undefined,
adaptiveCssVariables: themeData.adaptiveCssVariables,
};
await this.saveTheme(theme);
@@ -563,6 +676,115 @@ export class ThemeManager {
}
}
/**
* Compare installed store themes to GET /api/themes and refresh when the server is newer.
* Skips themes with userEdited: true (theme creator / popup save, or custom accent vs default).
*/
private static STORE_CHECK_KEY = "bsplus_lastStoreThemeCheck";
private static STORE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
public async checkStoreThemeUpdates(): Promise<void> {
if (this.storeUpdateCheckRunning) return;
const lastCheck = Number(localStorage.getItem(ThemeManager.STORE_CHECK_KEY) || 0);
if (Date.now() - lastCheck < ThemeManager.STORE_CHECK_INTERVAL_MS) return;
this.storeUpdateCheckRunning = true;
localStorage.setItem(ThemeManager.STORE_CHECK_KEY, String(Date.now()));
try {
const token = await cloudAuth.getStoredToken();
const res = (await browser.runtime.sendMessage({
type: "fetchThemes",
token: token ?? undefined,
})) as {
success?: boolean;
data?: { themes?: Array<{ id: string; updated_at?: number }> };
};
if (!res?.success || !res.data?.themes?.length) return;
const serverById = new Map<string, number>();
for (const t of res.data.themes) {
if (
typeof t.id === "string" &&
typeof t.updated_at === "number"
) {
serverById.set(t.id, t.updated_at);
}
}
if (serverById.size === 0) return;
const themeIds = (await localforage.getItem("customThemes")) as
| string[]
| null;
if (!themeIds?.length) return;
for (const id of themeIds) {
const theme = (await localforage.getItem(id)) as CustomTheme | null;
if (!theme || theme.userEdited === true) {
continue;
}
// File imports explicitly set installedFromStore: false — never auto-sync.
if (theme.installedFromStore === false) {
continue;
}
const serverUpdated = serverById.get(id);
if (serverUpdated == null) {
continue;
}
if (
theme.installedFromStore === true &&
theme.storeSyncedAtSec != null
) {
if (serverUpdated <= theme.storeSyncedAtSec) {
continue;
}
}
// Else: legacy installs from before store metadata (installedFromStore/storeSyncedAtSec
// unset) or incomplete rows — still listed on the server, so sync to latest once.
const wasSelected = settingsState.selectedTheme === id;
try {
if (wasSelected) {
await this.disableTheme();
}
await this.downloadAndInstallStoreTheme({
id: theme.id,
name: theme.name,
updated_at: serverUpdated,
});
console.log(
"[ThemeManager] Theme auto-updated from store:",
theme.name,
);
if (wasSelected) {
await this.setTheme(id, false);
}
themeUpdates.triggerUpdate();
} catch (err) {
console.error("[ThemeManager] Store theme auto-update failed:", id, err);
if (wasSelected) {
try {
await this.setTheme(id, false);
} catch (restoreErr) {
console.error(
"[ThemeManager] Failed to restore theme after update error:",
restoreErr,
);
}
}
}
}
} catch (e) {
console.error("[ThemeManager] checkStoreThemeUpdates error:", e);
} finally {
this.storeUpdateCheckRunning = false;
}
}
/**
* Share a theme by exporting it
*/
@@ -583,6 +805,9 @@ export class ThemeManager {
isEditable,
selectedColor,
allowBackgrounds,
installedFromStore,
storeSyncedAtSec,
userEdited,
...themeBasics
} = theme;
@@ -620,7 +845,7 @@ export class ThemeManager {
public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
console.debug("[ThemeManager] Previewing theme:", theme.name);
try {
const { CustomCSS, CustomImages, defaultColour, forceDark } = theme;
const { CustomCSS, CustomImages, defaultColour } = theme;
// Store original settings only if this is a new theme
if (!theme.webURL) {
@@ -666,13 +891,16 @@ export class ThemeManager {
this.previousImageVariableNames = newImageVariableNames;
// Apply theme settings
if (forceDark !== undefined) {
settingsState.DarkMode = forceDark;
if (shouldForceThemeAppearance(theme)) {
settingsState.DarkMode = getForcedDarkMode(theme);
}
if (defaultColour) {
settingsState.selectedColor = defaultColour;
}
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
void updateAllColors();
} catch (error) {
console.error("[ThemeManager] Error previewing theme:", error);
}
@@ -738,15 +966,18 @@ export class ThemeManager {
this.previousImageVariableNames = newImageVariableNames;
}
// Always apply dark mode setting
if (theme.forceDark !== undefined) {
settingsState.DarkMode = theme.forceDark;
// Always apply dark mode setting when theme forces appearance
if (shouldForceThemeAppearance(theme as CustomTheme)) {
settingsState.DarkMode = getForcedDarkMode(theme as CustomTheme);
}
// Only apply color if this is a new theme
if (!theme.webURL && theme.defaultColour) {
settingsState.selectedColor = theme.defaultColour;
}
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
void updateAllColors();
} catch (error) {
console.error("[ThemeManager] Error updating theme preview:", error);
}
@@ -784,6 +1015,8 @@ export class ThemeManager {
this.previewStyleElement = null;
}
clearCustomThemeAdaptiveCssVariables();
// Restore original settings
const storedColor = localStorage.getItem("originalPreviewColor");
@@ -813,6 +1046,8 @@ export class ThemeManager {
settingsState.DarkMode = this.originalPreviewTheme;
this.originalPreviewTheme = null;
}
void updateAllColors();
} catch (error) {
console.error("[ThemeManager] Error clearing preview:", error);
}
+7 -2
View File
@@ -63,7 +63,12 @@ function resetTimetableStyles(): void {
}
async function handleTimetable(): Promise<void> {
await waitForElm(".time", true, 10);
// SEQTA uses `.times` blocks on entries, not necessarily `.time`; avoid infinite polling on a missing selector.
try {
await waitForElm(".timetablepage .times, .timetablepage .entry.class", true, 50, 200);
} catch {
/* timetable body may render after the shell */
}
// Convert time format if needed
if (settingsState.timeFormat == "12") {
@@ -130,7 +135,7 @@ function handleTimetableAssessmentHide(): void {
const hideOn = document.createElement("button");
hideOn.className = "uiButton timetable-hide iconFamily";
hideOn.innerHTML = "&#128065;";
hideOn.innerHTML = "&#xeab3;";
hideControls.appendChild(hideOn);
+348
View File
@@ -0,0 +1,348 @@
import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm";
import styles from "./styles.css?inline";
interface TimetableEntryData {
ci: number;
description: string;
room: string;
staff: string;
}
interface TimetableOverrides {
[ci: string]: { room?: string; staff?: string };
}
interface TimetableOverridesBySubject {
[description: string]: { room?: string; staff?: string };
}
interface TimetableStorage {
timetableOverrides?: TimetableOverrides;
timetableOverridesBySubject?: TimetableOverridesBySubject;
}
/** SEQTA timetable entries use .teacher and .room as direct children, and data-instance for ci */
function getRoomAndTeacherElements(entry: HTMLElement): {
roomEl: HTMLElement | null;
teacherEl: HTMLElement | null;
} {
const roomEl = entry.querySelector(".room") as HTMLElement | null;
const teacherEl = entry.querySelector(".teacher") as HTMLElement | null;
return { roomEl, teacherEl };
}
const EDIT_ICON_SVG =
'<svg width="24" height="24" viewBox="0 0 24 24"><g style="fill: currentcolor;"><path d="M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z"/></g></svg>';
function showEditModal(
item: TimetableEntryData,
overrides: TimetableOverrides | undefined,
overridesBySubject: TimetableOverridesBySubject | undefined,
onSave: (
ci: number,
room: string,
staff: string,
applyToFuture: boolean,
) => void,
onClear: (ci: number) => void,
): void {
const overlay = document.createElement("div");
overlay.className = "timetable-edit-modal-overlay";
const modal = document.createElement("div");
modal.className = "timetable-edit-modal";
const override = overrides?.[String(item.ci)] ?? overridesBySubject?.[item.description];
const roomValue = override?.room ?? item.room ?? "";
const staffValue = override?.staff ?? item.staff ?? "";
const escapeHtml = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
const title = escapeHtml(item.description);
modal.innerHTML = `
<h3>Edit ${title}</h3>
<label for="timetable-edit-room">Room</label>
<input type="text" id="timetable-edit-room" value="${roomValue.replace(/"/g, "&quot;")}" placeholder="Room" />
<label for="timetable-edit-staff">Teacher</label>
<input type="text" id="timetable-edit-staff" value="${staffValue.replace(/"/g, "&quot;")}" placeholder="Teacher" />
<div class="timetable-edit-modal-checkbox">
<input type="checkbox" id="timetable-edit-apply-future" />
<label for="timetable-edit-apply-future">Apply to future weeks</label>
</div>
<div class="timetable-edit-modal-actions">
${override ? '<button type="button" class="timetable-edit-btn-clear">Clear</button>' : ""}
<button type="button" class="timetable-edit-btn-cancel">Cancel</button>
<button type="button" class="timetable-edit-btn-save">Save</button>
</div>
`;
overlay.appendChild(modal);
const removeModal = () => {
overlay.remove();
document.removeEventListener("keydown", handleKeydown);
};
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") removeModal();
};
overlay.addEventListener("click", (e) => {
if (e.target === overlay) removeModal();
});
modal.addEventListener("click", (e) => e.stopPropagation());
modal.addEventListener("mousedown", (e) => e.stopPropagation());
modal.addEventListener("mouseup", (e) => e.stopPropagation());
const roomInput = modal.querySelector("#timetable-edit-room") as HTMLInputElement;
const staffInput = modal.querySelector("#timetable-edit-staff") as HTMLInputElement;
const applyFutureCheckbox = modal.querySelector("#timetable-edit-apply-future") as HTMLInputElement;
modal.querySelector(".timetable-edit-btn-save")?.addEventListener("click", () => {
onSave(
item.ci,
roomInput.value.trim(),
staffInput.value.trim(),
applyFutureCheckbox?.checked ?? false,
);
removeModal();
});
modal.querySelector(".timetable-edit-btn-cancel")?.addEventListener("click", removeModal);
const clearBtn = modal.querySelector(".timetable-edit-btn-clear");
if (clearBtn) {
clearBtn.addEventListener("click", () => {
onClear(item.ci);
removeModal();
});
}
document.body.appendChild(overlay);
document.addEventListener("keydown", handleKeydown);
roomInput?.focus();
}
const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
id: "timetableEdit",
name: "Edit Rooms & Teachers",
description: "Edit room and teacher names in timetable classes",
version: "1.0.0",
settings: {},
disableToggle: true,
defaultEnabled: true,
run: async (api) => {
const styleEl = document.createElement("style");
styleEl.textContent = styles;
document.head.appendChild(styleEl);
await api.storage.loaded;
let observer: MutationObserver | null = null;
let quickbarObserver: MutationObserver | null = null;
let lastClickedCi: number | null = null;
let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null;
const getOverrides = (): TimetableOverrides =>
api.storage.timetableOverrides ?? {};
const getOverridesBySubject = (): TimetableOverridesBySubject =>
api.storage.timetableOverridesBySubject ?? {};
const getEffectiveOverride = (
ci: number,
description: string,
): { room?: string; staff?: string } | undefined =>
getOverrides()[String(ci)] ?? getOverridesBySubject()[description];
const processEntry = (entry: HTMLElement): void => {
if (entry.classList.contains("assessment") || entry.hasAttribute("data-timetable-edit-processed")) return;
const ciStr = entry.getAttribute("data-instance");
if (!ciStr) return;
const ci = parseInt(ciStr, 10);
if (isNaN(ci)) return;
const { roomEl, teacherEl } = getRoomAndTeacherElements(entry);
if (!roomEl && !teacherEl) return;
const titleEl = entry.querySelector(".title");
const description = titleEl?.textContent?.trim() ?? "";
const room = roomEl?.textContent?.trim() ?? "";
const staff = teacherEl?.textContent?.trim() ?? "";
const item: TimetableEntryData = { ci, description, room, staff };
entry.setAttribute("data-timetable-edit-processed", "true");
const override = getEffectiveOverride(ci, description);
if (override) {
if (override.room !== undefined && roomEl) roomEl.textContent = override.room;
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
}
const captureClick = (e: MouseEvent) => {
lastClickedCi = ci;
lastClickedEntry = { roomEl, teacherEl, item };
};
entry.addEventListener("click", captureClick, true);
};
const processAllEntries = () => {
document.querySelectorAll(".timetablepage .entry.class").forEach((entry) => {
processEntry(entry as HTMLElement);
});
};
const addEditButtonToQuickbar = (quickbar: HTMLElement) => {
if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return;
const actions = quickbar.querySelector(".actions");
if (!actions) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "uiButton timetable-edit-quickbar-btn";
btn.title = "Edit room and teacher";
btn.innerHTML = EDIT_ICON_SVG;
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const ci = lastClickedCi;
const entryData = lastClickedEntry;
if (!ci || !entryData) return;
const qb = (e.currentTarget as HTMLElement).closest(".quickbar");
if (!qb) return;
const quickbarRoom = qb.querySelector(".meta .room")?.textContent?.trim() ?? "";
const quickbarTeacher = qb.querySelector(".meta .teacher")?.textContent?.trim() ?? "";
const quickbarTitle = qb.querySelector(".title")?.textContent?.trim() ?? "";
const item: TimetableEntryData = {
ci,
description: quickbarTitle || entryData.item.description,
room: quickbarRoom || entryData.item.room,
staff: quickbarTeacher || entryData.item.staff,
};
showEditModal(
item,
getOverrides(),
getOverridesBySubject(),
(ci, room, staff, applyToFuture) => {
if (applyToFuture) {
const bySubject = { ...getOverridesBySubject() };
bySubject[item.description] = {
room: room || undefined,
staff: staff || undefined,
};
api.storage.timetableOverridesBySubject = bySubject;
} else {
const current = getOverrides();
api.storage.timetableOverrides = {
...current,
[String(ci)]: { room: room || undefined, staff: staff || undefined },
};
}
if (entryData.roomEl) entryData.roomEl.textContent = room;
if (entryData.teacherEl) entryData.teacherEl.textContent = staff;
processAllEntries();
},
(ci) => {
const current = getOverrides();
delete current[String(ci)];
api.storage.timetableOverrides = current;
const bySubject = getOverridesBySubject();
delete bySubject[item.description];
api.storage.timetableOverridesBySubject = bySubject;
if (entryData.roomEl) entryData.roomEl.textContent = item.room;
if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff;
processAllEntries();
},
);
});
actions.insertBefore(btn, actions.firstChild);
};
const syncQuickbarFromDOM = () => {
const quickbar = document.querySelector(
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
);
if (quickbar && quickbar.getAttribute("data-type") === "class") {
const titleEl = quickbar.querySelector(".title");
const roomEl = quickbar.querySelector(".meta .room");
const teacherEl = quickbar.querySelector(".meta .teacher");
if (titleEl && roomEl && teacherEl && lastClickedCi !== null && lastClickedEntry) {
addEditButtonToQuickbar(quickbar as HTMLElement);
}
}
};
const setupQuickbarObserver = () => {
const timetablePage = document.querySelector(".timetablepage");
if (!timetablePage || quickbarObserver) return;
quickbarObserver = new MutationObserver(() => {
const quickbar = document.querySelector(
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
);
if (quickbar?.getAttribute("data-type") === "class") {
addEditButtonToQuickbar(quickbar as HTMLElement);
}
});
quickbarObserver.observe(timetablePage, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class"],
});
};
const handleTimetable = async () => {
// Class entries (`div.entry.class`) load after the page shell; don't fail the whole
// setup if they are slow or briefly absent (e.g. navigation). Observers still catch them.
try {
await waitForElm(".timetablepage .entry.class", true, 50, 300);
} catch {
/* entries may appear later */
}
processAllEntries();
setupQuickbarObserver();
syncQuickbarFromDOM();
const timetablePage = document.querySelector(".timetablepage");
if (timetablePage && !observer) {
observer = new MutationObserver(() => {
document.querySelectorAll(".timetablepage .entry.class").forEach((entry) => {
if (!entry.hasAttribute("data-timetable-edit-processed")) {
processEntry(entry as HTMLElement);
}
});
});
observer.observe(timetablePage, { childList: true, subtree: true });
}
};
const { unregister } = api.seqta.onMount(".timetablepage", handleTimetable);
return () => {
unregister();
observer?.disconnect();
quickbarObserver?.disconnect();
styleEl.remove();
document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => {
el.removeAttribute("data-timetable-edit-processed");
});
document.querySelectorAll(".timetable-edit-quickbar-btn").forEach((el) => el.remove());
};
},
};
export default timetableEditPlugin;
@@ -0,0 +1,188 @@
/* Timetable Edit Plugin - BetterSEQTA Plus style */
/* Edit button in quickbar */
.timetable-edit-quickbar-btn {
padding: 0;
margin: 0;
background: transparent !important;
border: none !important;
cursor: pointer;
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
}
.timetable-edit-quickbar-btn:hover {
transform: scale(1.05);
}
.timetable-edit-quickbar-btn:active {
transform: scale(0.95);
}
.timetable-edit-quickbar-btn svg {
fill: currentColor;
width: 24px;
height: 24px;
}
/* Edit modal animations */
@keyframes timetable-edit-overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes timetable-edit-modal-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Edit modal overlay - fix click-through with proper stacking */
.timetable-edit-modal-overlay {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
pointer-events: auto;
animation: timetable-edit-overlay-in 0.2s ease-out forwards;
}
.timetable-edit-modal {
padding: 1rem 1.5rem;
margin: 0 1rem;
min-width: 18rem;
max-width: 24rem;
width: 100%;
box-sizing: border-box;
background: var(--background-primary);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
pointer-events: auto;
border: 1px solid var(--background-secondary);
animation: timetable-edit-modal-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.timetable-edit-modal h3 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.timetable-edit-modal label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
opacity: 0.8;
}
.timetable-edit-modal input[type="text"] {
width: 100%;
min-width: 0;
padding: 0.5rem 1rem 0.5rem 0.75rem;
margin-bottom: 1rem;
font-size: 0.875rem;
border: 1px solid var(--background-secondary);
border-radius: 0.5rem;
background: var(--background-secondary);
color: var(--text-primary);
box-sizing: border-box;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
user-select: text;
-webkit-user-select: text;
}
.timetable-edit-modal input[type="text"]:focus {
outline: none;
border-color: var(--better-main, #007bff);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.timetable-edit-modal-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.timetable-edit-modal-checkbox input {
width: auto;
margin: 0;
}
.timetable-edit-modal-checkbox label {
margin: 0;
cursor: pointer;
}
.timetable-edit-modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1rem;
flex-wrap: wrap;
}
.timetable-edit-modal-actions button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.timetable-edit-modal-actions .timetable-edit-btn-clear {
background: transparent;
border: 1px solid var(--background-secondary);
color: var(--text-primary);
}
.timetable-edit-modal-actions .timetable-edit-btn-clear:hover {
background: var(--background-secondary);
transform: translateY(-1px);
}
.timetable-edit-modal-actions .timetable-edit-btn-cancel {
background: transparent;
border: 1px solid var(--background-secondary);
color: var(--text-primary);
}
.timetable-edit-modal-actions .timetable-edit-btn-cancel:hover {
background: var(--background-secondary);
transform: translateY(-1px);
}
.timetable-edit-modal-actions .timetable-edit-btn-save {
background: var(--better-main, #007bff);
border: none;
color: var(--text-color, white);
}
.timetable-edit-modal-actions .timetable-edit-btn-save:hover {
transform: scale(1.03) translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.35);
}
.timetable-edit-modal-actions .timetable-edit-btn-save:active {
transform: scale(0.98) translateY(0);
box-shadow: none;
}
+11 -1
View File
@@ -47,7 +47,17 @@ export function createLazyPlugin<T extends PluginSettings = PluginSettings, S =
// Execute the actual plugin's run function
return await actualPlugin.run(api);
} catch (error) {
} catch (error: any) {
// Handle Firefox MIME type errors gracefully
if (error?.message?.includes("MIME type") || error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")) {
console.error(
`[BetterSEQTA+] Failed to load plugin "${lazyPlugin.id}" due to Firefox module loading restrictions. ` +
`This may be a build configuration issue. Error:`,
error
);
// Don't throw - allow the extension to continue functioning without this plugin
return;
}
console.error(`[BetterSEQTA+] Failed to dynamically load plugin "${lazyPlugin.id}":`, error);
throw error;
}
+3 -3
View File
@@ -1,13 +1,13 @@
import type {
BooleanSetting,
ButtonSetting,
ComponentSetting,
HotkeySetting,
NumberSetting,
Plugin,
PluginSettings,
SelectSetting,
StringSetting,
ButtonSetting,
HotkeySetting,
ComponentSetting,
} from "./types";
import { createPluginAPI } from "./createAPI";
import browser from "webextension-polyfill";
+3 -3
View File
@@ -1,12 +1,12 @@
import type {
BooleanSetting,
ButtonSetting,
ComponentSetting,
HotkeySetting,
NumberSetting,
PluginSettings,
SelectSetting,
StringSetting,
HotkeySetting,
PluginSettings,
ComponentSetting,
} from "./types";
/**
+2
View File
@@ -2,6 +2,7 @@ import { PluginManager } from "./core/manager";
// Lightweight plugins (load immediately)
import timetablePlugin from "./built-in/timetable";
import timetableEditPlugin from "./built-in/timetableEdit";
import notificationCollectorPlugin from "./built-in/notificationCollector";
import themesPlugin from "./built-in/themes";
import animatedBackgroundPlugin from "./built-in/animatedBackground";
@@ -23,6 +24,7 @@ pluginManager.registerPlugin(animatedBackgroundPlugin);
pluginManager.registerPlugin(assessmentsAveragePlugin);
pluginManager.registerPlugin(notificationCollectorPlugin);
pluginManager.registerPlugin(timetablePlugin);
pluginManager.registerPlugin(timetableEditPlugin);
pluginManager.registerPlugin(profilePicturePlugin);
pluginManager.registerPlugin(assessmentsOverviewPlugin);
pluginManager.registerPlugin(backgroundMusicPlugin);
+269 -52
View File
@@ -17,14 +17,19 @@ import { StorageChangeHandler } from "@/seqta/utils/listeners/StorageChanges";
import { eventManager } from "@/seqta/utils/listeners/EventManager";
// UI and theme management
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
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 { getEngageRoutePage } from "@/seqta/utils/engageRoute";
import {
loadEngageHomePage,
updateEngageHomeMenuActive,
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification";
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
@@ -82,7 +87,13 @@ export function hideSideBar() {
}
}
let betterSeqtaFinishLoadDone = false;
let engageHashListenerAttached = false;
export async function finishLoad() {
if (betterSeqtaFinishLoadDone) return;
betterSeqtaFinishLoadDone = true;
try {
document.querySelector(".legacy-root")?.classList.remove("hidden");
@@ -94,14 +105,7 @@ export async function finishLoad() {
console.error("Error during loading cleanup:", err);
}
// Check and show privacy statement notification (before what's new)
if (!document.getElementById("privacy-notification")) {
await showPrivacyNotification();
}
if (settingsState.justupdated && !document.getElementById("whatsnewbk") && !document.getElementById("privacy-notification")) {
OpenWhatsNewPopup();
}
runStartupPopupQueue();
}
export function GetCSSElement(file: string) {
@@ -115,19 +119,19 @@ export function GetCSSElement(file: string) {
}
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 iframe = item as HTMLIFrameElement;
try {
const doc = iframe.contentDocument;
if (!doc?.body) continue;
const body = doc.body;
const bodyText = body.innerHTML;
body.innerHTML = bodyText
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " ");
} catch {
// Cross-origin or otherwise inaccessible iframe (common during Engage load / filter frames)
}
}
}
@@ -202,7 +206,20 @@ function SortMessagePageItems(messagesParentElement: any) {
async function LoadPageElements(): Promise<void> {
await AddBetterSEQTAElements();
const sublink: string | undefined = window.location.href.split("/")[4];
const sublink: string | undefined = isSeqtaEngageExperience()
? getEngageRoutePage()
: window.location.href.split("/")[4];
if (isSeqtaEngageExperience() && !engageHashListenerAttached) {
engageHashListenerAttached = true;
window.addEventListener("hashchange", () => {
if (getEngageRoutePage() === "home") {
void loadEngageHomePage();
} else {
updateEngageHomeMenuActive(false);
}
});
}
eventManager.register(
"messagesAdded",
@@ -296,6 +313,28 @@ async function handleNotices(node: Element): Promise<void> {
}
async function handleSublink(sublink: string | undefined): Promise<void> {
if (isSeqtaEngageExperience()) {
switch (sublink) {
case undefined:
window.location.replace(
`${location.origin}/#?page=/${settingsState.defaultPage}`,
);
if (settingsState.defaultPage === "home") void loadEngageHomePage();
finishLoad();
break;
case "home":
window.location.replace(`${location.origin}/#?page=/home`);
console.info("[BetterSEQTA+] Started Init (SEQTA Engage home)");
if (settingsState.onoff) void loadEngageHomePage();
finishLoad();
break;
default:
finishLoad();
break;
}
return;
}
switch (sublink) {
case "news":
await handleNewsPage();
@@ -382,15 +421,22 @@ async function handleDashboard(node: Element): Promise<void> {
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],
},
);
try {
const children = document.querySelectorAll(".dashboard > *");
if (children.length) {
animate(
children,
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.1),
duration: 0.5,
ease: [0.22, 0.03, 0.26, 1],
},
);
}
} catch {
// Avoid uncaught errors if motion hits an unexpected DOM state during load.
}
document.head.querySelector("style.dashboardHider")?.remove();
}
@@ -400,15 +446,22 @@ async function handleDocuments(node: Element): Promise<void> {
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],
},
);
try {
const rows = document.querySelectorAll(".documents tbody tr.document");
if (rows.length) {
animate(
rows,
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.05),
duration: 0.5,
ease: [0.22, 0.03, 0.26, 1],
},
);
}
} catch {
// ignore
}
}
async function handleReports(node: Element): Promise<void> {
@@ -416,15 +469,22 @@ async function handleReports(node: Element): Promise<void> {
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],
},
);
try {
const items = document.querySelectorAll(".reports .item");
if (items.length) {
animate(
items,
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.05, { startDelay: 0.2 }),
duration: 0.5,
ease: [0.22, 0.03, 0.26, 1],
},
);
}
} catch {
// ignore
}
}
function CheckNoticeTextColour(notice: any) {
@@ -448,7 +508,86 @@ function CheckNoticeTextColour(notice: any) {
);
}
function watchForEngageLogin() {
if (!document.querySelector(".login")) {
return;
}
const observer = new MutationObserver(() => {
if (!document.querySelector(".login")) {
observer.disconnect();
location.reload();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
/** Wait until Engage shows either the login shell or the main app (`#content`), so we never call `LoadPageElements` while still on login (which would hang on `waitForElm("#content")`). */
function waitForEngageLoginOrContent(): Promise<"login" | "app" | "timeout"> {
if (document.querySelector(".login")) {
return Promise.resolve("login");
}
if (document.getElementById("content")) {
return Promise.resolve("app");
}
return new Promise((resolve) => {
let settled = false;
const finish = (mode: "login" | "app") => {
if (settled) return;
settled = true;
mo.disconnect();
window.clearTimeout(tid);
resolve(mode);
};
const check = () => {
if (document.querySelector(".login")) finish("login");
else if (document.getElementById("content")) finish("app");
};
const mo = new MutationObserver(check);
mo.observe(document.documentElement, { subtree: true, childList: true });
const tid = window.setTimeout(() => {
if (settled) return;
mo.disconnect();
settled = true;
if (document.querySelector(".login")) resolve("login");
else if (document.getElementById("content")) resolve("app");
else {
console.warn(
"[BetterSEQTA+] Engage: timed out waiting for .login or #content; unblocking load UI.",
);
resolve("timeout");
}
}, 120_000);
});
}
export function tryLoad() {
if (isSeqtaEngageExperience()) {
updateIframesWithDarkMode();
window.addEventListener("load", () => removeThemeTagsFromNotices(), { once: true });
const runEngageLoad = async () => {
const mode = await waitForEngageLoginOrContent();
if (mode === "login") {
finishLoad();
watchForEngageLogin();
return;
}
if (mode === "timeout") {
finishLoad();
void waitForElm("#content").then(() => void LoadPageElements());
return;
}
await LoadPageElements();
};
if (document.readyState === "complete") {
void runEngageLoad();
} else {
window.addEventListener("load", () => void runEngageLoad(), { once: true });
}
return;
}
waitForElm(".login").then(() => {
finishLoad();
});
@@ -466,13 +605,10 @@ export function tryLoad() {
});
updateIframesWithDarkMode();
// Waits for page to call on load, run scripts
document.addEventListener(
window.addEventListener(
"load",
function () {
removeThemeTagsFromNotices();
},
true,
() => removeThemeTagsFromNotices(),
{ once: true },
);
}
@@ -489,6 +625,7 @@ function ReplaceMenuSVG(element: HTMLElement, svg: string) {
const processedSymbol = Symbol("processed");
export async function ObserveMenuItemPosition() {
if (isSeqtaEngageExperience()) return;
await waitForElm("#menu > ul > li");
eventManager.register(
@@ -612,6 +749,15 @@ export function init() {
if (settingsState.onoff) {
console.info("[BetterSEQTA+] Enabled");
if (settingsState.DarkMode) document.documentElement.classList.add("dark");
if (settingsState.iconOnlySidebar) {
if (document.body) {
document.body.classList.add("icon-only-sidebar");
} else {
document.addEventListener("DOMContentLoaded", () => {
document.body?.classList.add("icon-only-sidebar");
});
}
}
document.querySelector(".legacy-root")?.classList.add("hidden");
ObserveMenuItemPosition();
@@ -619,12 +765,83 @@ export function init() {
new StorageChangeHandler();
new MessageHandler();
updateAllColors();
void updateAllColors();
window.addEventListener("hashchange", () => {
if (settingsState.adaptiveThemeColour) void updateAllColors();
});
loading();
InjectCustomIcons();
HideMenuItems();
tryLoad();
// Auto-focus WISP direct online submission editor when pane opens
eventManager.register(
"wispassessmentAdded",
{
customCheck: (el) =>
el.classList.contains("wispassessment") ||
el.querySelector(".wispassessment") !== null,
},
(element) => {
const wispassessment = element.classList.contains("wispassessment")
? (element as Element)
: element.querySelector(".wispassessment");
if (!wispassessment) return;
const focusEditableBody = (iframe: HTMLIFrameElement) => {
try {
const doc = iframe.contentDocument;
const win = iframe.contentWindow;
if (doc?.body && win) {
const editable =
doc.body.querySelector(".cke_editable") || doc.body;
const el = editable as HTMLElement;
el.focus();
const range = doc.createRange();
range.selectNodeContents(el);
range.collapse(true);
const sel = win.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
return true;
}
} catch (_) {}
return false;
};
const focusEditor = () => {
const iframe = wispassessment.querySelector(".cke_wysiwyg_frame");
if (iframe instanceof HTMLIFrameElement) {
if (focusEditableBody(iframe)) return;
iframe.focus();
return;
}
const ckeditor = (window as any).CKEDITOR;
if (ckeditor?.instances?.editor1) {
try {
ckeditor.instances.editor1.focus();
} catch (_) {}
}
};
const iframe = wispassessment.querySelector(".cke_wysiwyg_frame");
if (iframe instanceof HTMLIFrameElement) {
iframe.addEventListener(
"load",
() => focusEditableBody(iframe),
{ once: true },
);
}
[1000, 1200, 1500].forEach((delay) =>
setTimeout(focusEditor, delay),
);
},
);
setTimeout(() => {
const legacyElement = document.querySelector(
".outside-container .bottom-container",
Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+488 -123
View File
@@ -1,7 +1,11 @@
import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
import { attachNotificationsPanelAnimation } from "@/seqta/utils/attachNotificationsPanelAnimation";
import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { appendBackgroundToUI } from "./ImageBackgrounds";
@@ -13,8 +17,11 @@ import { delay } from "@/seqta/utils/delay";
let cachedUserInfo: any = null;
let LightDarkModeSnakeEggButton = 0;
let sidebarAccessibilityObserver: MutationObserver | null = null;
let sidebarTabOrderAnimationFrame: number | null = null;
let sidebarAccessibilityListenersAttached = false;
async function getUserInfo() {
export async function getUserInfo() {
if (cachedUserInfo) return cachedUserInfo;
try {
@@ -30,16 +37,26 @@ async function getUserInfo() {
}),
});
const responseData = await response.json();
cachedUserInfo = responseData.payload;
cachedUserInfo = (await response.json()).payload;
return cachedUserInfo;
} catch (error) {
console.error("Error fetching user info:", error);
console.error("[BetterSEQTA+] Failed to get user info:", error);
throw error;
}
}
export async function AddBetterSEQTAElements() {
if (isSeqtaEngageExperience()) {
await waitForElm("#content");
addExtensionSettings();
if (settingsState.onoff) {
await injectEngageHomeButton();
}
void setupEngageSettingsButton();
void addEngageUserInfo();
return;
}
if (settingsState.onoff) {
if (settingsState.DarkMode) {
document.documentElement.classList.add("dark");
@@ -61,17 +78,19 @@ export async function AddBetterSEQTAElements() {
handleStudentData(),
]);
} catch (error) {
console.error("Error initializing UI elements:", error);
console.error("[BetterSEQTA+] Failed to initialize UI elements:", error);
}
setupEventListeners();
await addDarkLightToggle();
customizeMenuToggle();
setupSidebarAccessibility();
}
addExtensionSettings();
await createSettingsButton();
setupSettingsButton();
attachNotificationsPanelAnimation();
}
function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
@@ -80,20 +99,18 @@ function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
div.classList.add("titlebar");
container.append(div);
const NewButton = stringToHTML(
/* html */`<li class="item" data-key="home" id="homebutton" data-path="/home" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" /></svg><span>Home</span></label></li>`
fragment.appendChild(
stringToHTML(
/* html */ `<li class="item" data-key="home" id="homebutton" data-path="/home" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" /></svg><span>Home</span></label></li>`,
).firstChild!,
);
if (NewButton.firstChild) {
fragment.appendChild(NewButton.firstChild);
}
}
async function handleUserInfo() {
try {
const info = await getUserInfo();
updateUserInfo(info);
updateUserInfo(await getUserInfo());
} catch (error) {
console.error("Error fetching and processing student data:", error);
console.error("[BetterSEQTA+] Failed to handle user info:", error);
}
}
@@ -116,31 +133,37 @@ function updateUserInfo(info: {
userName: string | null;
}) {
const titlebar = document.getElementsByClassName("titlebar")[0];
const metadata = [info.meta.code, info.meta.governmentID]
.filter((value): value is string => Boolean(value))
.join(" // ");
const displayName = info.userDesc || info.userName || "";
const userInfo = stringToHTML(/* html */ `
<div class="userInfosvgdiv tooltip">
<svg class="userInfosvg" viewBox="0 0 24 24"><path fill="var(--text-primary)" d="M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"></path></svg>
<div class="tooltiptext topmenutooltip" id="logouttooltip"></div>
</div>
`).firstChild;
titlebar.append(userInfo!);
const userinfo = stringToHTML(/* html */ `
<div class="userInfo">
<div class="userInfoText">
<div style="display: flex; align-items: center;">
<p class="userInfohouse userInfoCode"></p>
<p class="userInfoName">${info.userDesc}</p>
</div>
<p class="userInfoCode">${info.meta.code} // ${info.meta.governmentID}</p>
titlebar.append(
stringToHTML(/* html */ `
<div class="userInfosvgdiv tooltip">
<svg class="userInfosvg" viewBox="0 0 24 24"><path fill="var(--text-primary)" d="M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"></path></svg>
<div class="tooltiptext topmenutooltip" id="logouttooltip"></div>
</div>
</div>
`).firstChild;
titlebar.append(userinfo!);
`).firstChild!,
);
var logoutbutton = document.getElementsByClassName("logout")[0];
var userInfosvgdiv = document.getElementById("logouttooltip")!;
userInfosvgdiv.appendChild(logoutbutton);
titlebar.append(
stringToHTML(/* html */ `
<div class="userInfo">
<div class="userInfoText">
<div style="display: flex; align-items: center;">
<p class="userInfohouse userInfoCode" style="display: none;"></p>
${displayName ? `<p class="userInfoName">${displayName}</p>` : ""}
</div>
${metadata ? `<p class="userInfoCode">${metadata}</p>` : ""}
</div>
</div>
`).firstChild!,
);
document
.getElementById("logouttooltip")!
.appendChild(document.getElementsByClassName("logout")[0]);
}
async function handleStudentData() {
@@ -156,64 +179,58 @@ async function handleStudentData() {
},
);
const responseData = await response.json();
let students = responseData.payload;
await updateStudentInfo(students);
await updateStudentInfo((await response.json()).payload);
} catch (error) {
console.error("Error fetching and processing student data:", error);
console.error("[BetterSEQTA+] Failed to handle student data:", error);
}
}
async function updateStudentInfo(students: any) {
const info = await getUserInfo();
var index = students.findIndex(function (person: any) {
return (
const index = students.findIndex(
(person: any) =>
person.firstname == info.userDesc.split(" ")[0] &&
person.surname == info.userDesc.split(" ")[1]
);
});
person.surname == info.userDesc.split(" ")[1],
);
const houseelement = document.getElementsByClassName("userInfohouse")[0] as HTMLElement;
const houseelement = document.getElementsByClassName(
"userInfohouse",
)[0] as HTMLElement | undefined;
if (!houseelement) return;
// Fallback to N/A
let text = 'N/A';
const student = students[index] ?? {};
let text = "";
// If student has a house, prefer to show year + house. If no year, only show house.
if (student.house) {
text = `${student.year ?? ""}${student.house}`;
// If house_colour exists, compute colour
if (student.house_colour) {
houseelement.style.background = student.house_colour;
try {
const colorresult = GetThresholdOfColor(student.house_colour);
houseelement.style.color =
colorresult && colorresult > 300 ? "black" : "white";
} catch (err) {
// Colour calculation failed, no text colour set
} catch {
// Invalid color format, leave text color as default
}
}
} else if (student.year) {
// No house, only year will be shown
text = student.year;
}
houseelement.innerText = text;
houseelement.style.display = text ? "block" : "none";
}
function createNewsButton(fragment: DocumentFragment, menu: HTMLElement) {
const NewsButtonStr =
'<li class="item" data-key="news" id="newsbutton" data-path="/news" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M20 3H4C2.89 3 2 3.89 2 5V19C2 20.11 2.89 21 4 21H20C21.11 21 22 20.11 22 19V5C22 3.89 21.11 3 20 3M5 7H10V13H5V7M19 17H5V15H19V17M19 13H12V11H19V13M19 9H12V7H19V9Z" /></svg><span>News</span></label></li>';
const NewsButton = stringToHTML(NewsButtonStr);
fragment.appendChild(
stringToHTML(
'<li class="item" data-key="news" id="newsbutton" data-path="/news" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M20 3H4C2.89 3 2 3.89 2 5V19C2 20.11 2.89 21 4 21H20C21.11 21 22 20.11 22 19V5C22 3.89 21.11 3 20 3M5 7H10V13H5V7M19 17H5V15H19V17M19 13H12V11H19V13M19 9H12V7H19V9Z" /></svg><span>News</span></label></li>',
).firstChild!,
);
if (NewsButton.firstChild) {
fragment.appendChild(NewsButton.firstChild);
}
let iconCover = document.createElement("div");
const iconCover = document.createElement("div");
iconCover.classList.add("icon-cover");
iconCover.id = "icon-cover";
menu.appendChild(iconCover);
@@ -224,22 +241,27 @@ function setupEventListeners() {
const homebutton = document.getElementById("homebutton");
const newsbutton = document.getElementById("newsbutton");
homebutton?.addEventListener("click", function () {
const activateMenuAction = (button: HTMLElement, action: () => void) => {
if (
!homebutton.classList.contains("draggable") &&
!homebutton.classList.contains("active")
button.classList.contains("draggable") ||
button.classList.contains("active")
) {
loadHomePage();
return;
}
action();
};
homebutton?.addEventListener("click", function () {
activateMenuAction(homebutton, () => {
loadHomePage();
});
});
newsbutton?.addEventListener("click", function () {
if (
!newsbutton.classList.contains("draggable") &&
!newsbutton.classList.contains("active")
) {
activateMenuAction(newsbutton, () => {
SendNewsPage();
}
});
});
menuCover?.addEventListener("click", function () {
@@ -251,47 +273,192 @@ function setupEventListeners() {
});
}
async function createSettingsButton() {
let SettingsButton = stringToHTML(/* html */ `
<button class="addedButton tooltip" id="AddedSettings">
<svg width="24" height="24" viewBox="0 0 24 24">
<g><g><path d="M23.182,6.923c-.29,0-3.662,2.122-4.142,2.4l-2.8-1.555V4.511l4.257-2.456a.518.518,0,0,0,.233-.408.479.479,0,0,0-.233-.407,6.511,6.511,0,1,0-3.327,12.107,6.582,6.582,0,0,0,6.148-4.374,5.228,5.228,0,0,0,.333-1.542A.461.461,0,0,0,23.182,6.923Z"></path><path d="M9.73,10.418,7.376,12.883c-.01.01-.021.016-.03.025L1.158,19.1a2.682,2.682,0,1,0,3.793,3.793l4.583-4.582,0,0,4.1-4.005-.037-.037A9.094,9.094,0,0,1,9.73,10.418ZM3.053,21.888A.894.894,0,1,1,3.946,21,.893.893,0,0,1,3.053,21.888Z"></path></g></g>
</svg>
${settingsState.onoff ? '<div class="tooltiptext topmenutooltip">BetterSEQTA+ Settings</div>' : ""}
</button>
`);
let ContentDiv = document.getElementById("content");
ContentDiv!.append(SettingsButton.firstChild!);
async function createSettingsButton(parent?: Element) {
const target = parent ?? document.getElementById("content")!;
target.append(
stringToHTML(/* html */ `
<button class="addedButton tooltip" id="AddedSettings">
<svg width="24" height="24" viewBox="0 0 24 24">
<g><g><path d="M23.182,6.923c-.29,0-3.662,2.122-4.142,2.4l-2.8-1.555V4.511l4.257-2.456a.518.518,0,0,0,.233-.408.479.479,0,0,0-.233-.407,6.511,6.511,0,1,0-3.327,12.107,6.582,6.582,0,0,0,6.148-4.374,5.228,5.228,0,0,0,.333-1.542A.461.461,0,0,0,23.182,6.923Z"></path><path d="M9.73,10.418,7.376,12.883c-.01.01-.021.016-.03.025L1.158,19.1a2.682,2.682,0,1,0,3.793,3.793l4.583-4.582,0,0,4.1-4.005-.037-.037A9.094,9.094,0,0,1,9.73,10.418ZM3.053,21.888A.894.894,0,1,1,3.946,21,.893.893,0,0,1,3.053,21.888Z"></path></g></g>
</svg>
${settingsState.onoff ? '<div class="tooltiptext topmenutooltip">BetterSEQTA+ Settings</div>' : ""}
</button>
`).firstChild!,
);
}
function GetLightDarkModeString() {
if (settingsState.DarkMode) {
return "Switch to light theme";
} else {
return "Switch to dark theme";
/** Engage mounts the sidebar inside batched React trees; EventManager-based waitForElm can miss `#menu`. Polling `waitForElm` matches the real DOM reliably. */
async function waitForEngageMenuList(): Promise<HTMLElement | null> {
const poll = true as const;
const interval = 100;
const trySelectors: { selector: string; maxIterations: number }[] = [
{ selector: "#menu > ul > li", maxIterations: 500 },
{ selector: "#menu ul", maxIterations: 350 },
{ selector: "#menu", maxIterations: 350 },
];
for (const { selector, maxIterations } of trySelectors) {
try {
await waitForElm(selector, poll, interval, maxIterations);
} catch {
continue;
}
if (selector === "#menu > ul > li") {
const ul = document.querySelector("#menu > ul") as HTMLElement | null;
if (ul) return ul;
} else if (selector === "#menu ul") {
const ul = document.querySelector("#menu ul") as HTMLElement | null;
if (ul) return ul;
} else {
const menu = document.getElementById("menu");
const ul =
(menu?.querySelector("ul") as HTMLElement | null) ??
(menu?.firstElementChild as HTMLElement | null);
if (ul) return ul;
}
}
console.warn(
"[BetterSEQTA+] Engage: could not find a menu list to inject the home button",
);
return null;
}
async function injectEngageHomeButton() {
if (document.getElementById("homebutton")) return;
const menuList = await waitForEngageMenuList();
if (!menuList || document.getElementById("homebutton")) return;
const li = stringToHTML(
/* html */ `<li class="item" data-key="home" id="homebutton" data-path="/home" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" /></svg><span>Home</span></label></li>`,
).firstChild as HTMLElement;
menuList.insertBefore(li, menuList.firstElementChild);
document.getElementById("homebutton")?.addEventListener("click", () => {
const btn = document.getElementById("homebutton") as HTMLElement;
if (
btn.classList.contains("draggable") ||
btn.classList.contains("active")
) {
return;
}
window.location.replace(`${location.origin}/#?page=/home`);
void loadEngageHomePage();
});
}
async function getEngageUserInfo() {
const response = await fetch(`${location.origin}/seqta/parent/login`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
mode: "normal",
query: null,
redirect_url: location.origin + "/",
}),
});
return (await response.json()).payload as {
userDesc: string | null;
userName: string | null;
userCode: string | null;
email: string | null;
type: string | null;
};
}
async function addEngageUserInfo() {
const content = await waitForElm("#content") as HTMLElement;
let info: Awaited<ReturnType<typeof getEngageUserInfo>>;
try {
info = await getEngageUserInfo();
} catch (error) {
console.error("[BetterSEQTA+] Failed to get Engage user info:", error);
return;
}
const displayName = info.userDesc || info.userName || "";
const subText = info.userCode || info.email || "";
const titlebar = document.createElement("div");
titlebar.classList.add("titlebar", "engage-titlebar");
titlebar.append(
stringToHTML(/* html */ `
<div class="userInfo">
<div class="userInfoText">
${displayName ? `<p class="userInfoName">${displayName}</p>` : ""}
${subText ? `<p class="userInfoCode">${subText}</p>` : ""}
</div>
</div>
`).firstChild!,
);
const iconNode = stringToHTML(/* html */ `
<div class="userInfosvgdiv tooltip" id="engage-logouttooltip-wrap">
<svg class="userInfosvg" viewBox="0 0 24 24"><path fill="var(--text-primary)" d="M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"></path></svg>
<div class="tooltiptext topmenutooltip" id="engage-logouttooltip">
<button class="logout engage-logout">
<svg style="width:20px;height:20px;vertical-align:middle;" viewBox="0 0 24 24"><path fill="currentColor" d="M17 7L15.59 8.41L18.17 11H8V13H18.17L15.59 15.58L17 17L22 12M4 5H12V3H4C2.9 3 2 3.9 2 5V19C2 20.1 2.9 21 4 21H12V19H4V5Z"/></svg>
</button>
</div>
</div>
`).firstChild!;
titlebar.append(iconNode);
content.appendChild(titlebar);
titlebar.querySelector<HTMLElement>(".engage-logout")?.addEventListener("click", async () => {
await fetch(`${location.origin}/seqta/parent/logout`, { method: "POST" });
location.reload();
});
}
async function setupEngageSettingsButton() {
try {
const notificationsWrapper = await waitForElm(
"#content > div.connectedNotificationsWrapper > div",
);
const parent = notificationsWrapper.parentElement!;
await addDarkLightToggle(parent);
await createSettingsButton(parent);
setupSettingsButton();
attachNotificationsPanelAnimation();
} catch {
await addDarkLightToggle();
await createSettingsButton();
setupSettingsButton();
attachNotificationsPanelAnimation();
}
}
async function addDarkLightToggle() {
const tooltipString = GetLightDarkModeString();
function GetLightDarkModeString() {
return settingsState.DarkMode
? "Switch to light theme"
: "Switch to dark theme";
}
async function addDarkLightToggle(parent?: Element) {
const SUN_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_80"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_80)"><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -4,-2.2100000381469727 -4,0 C-4,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
const MOON_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_263"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_263)"><g style="display: block;" transform="matrix(1.5,0,0,1.5,7,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -1.2920000553131104,-2.2100000381469727 -1.2920000553131104,0 C-1.2920000553131104,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(-1,0,0,-1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
const initialSvgContent = settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG;
const LightDarkModeButton = stringToHTML(/* html */ `
<button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton">
<svg xmlns="http://www.w3.org/2000/svg">${initialSvgContent}</svg>
<div class="tooltiptext topmenutooltip" id="darklighttooliptext">${tooltipString}</div>
</button>
`);
let ContentDiv = document.getElementById("content");
ContentDiv!.append(LightDarkModeButton.firstChild!);
const toggleTarget = parent ?? document.getElementById("content")!;
toggleTarget.append(
stringToHTML(/* html */ `
<button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton">
<svg xmlns="http://www.w3.org/2000/svg">${settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG}</svg>
<div class="tooltiptext topmenutooltip" id="darklighttooliptext">${GetLightDarkModeString()}</div>
</button>
`).firstChild!,
);
updateAllColors();
const lightDarkModeButtonElement = document.getElementById("LightDarkModeButton")!;
const lightDarkModeButtonElement = document.getElementById(
"LightDarkModeButton",
)!;
lightDarkModeButtonElement.addEventListener("click", async () => {
const darklightText = document.getElementById("darklighttooliptext");
@@ -303,7 +470,6 @@ async function addDarkLightToggle() {
LightDarkModeSnakeEggButton = 0;
}
if (
settingsState.originalDarkMode !== undefined &&
settingsState.selectedTheme
@@ -314,38 +480,237 @@ async function addDarkLightToggle() {
return;
}
if (!document.startViewTransition || !settingsState.animations || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
settingsState.DarkMode = !settingsState.DarkMode;
updateAllColors();
const newSvgContent = settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG;
const svgElement = lightDarkModeButtonElement.querySelector("svg");
if (svgElement) svgElement.innerHTML = newSvgContent;
darklightText!.innerText = GetLightDarkModeString();
return;
}
settingsState.DarkMode = !settingsState.DarkMode;
updateAllColors();
const newSvgContent = settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG;
const svgElement = lightDarkModeButtonElement.querySelector("svg");
if (svgElement) svgElement.innerHTML = newSvgContent;
const svgElement = lightDarkModeButtonElement.querySelector("svg")!;
svgElement.innerHTML = settingsState.DarkMode
? SUN_ICON_SVG
: MOON_ICON_SVG;
darklightText!.innerText = GetLightDarkModeString();
});
}
function customizeMenuToggle() {
const menuToggle = document.getElementById("menuToggle");
if (menuToggle) {
menuToggle.innerHTML = "";
}
const menuToggle = document.getElementById("menuToggle")!;
menuToggle.innerHTML = "";
for (let i = 0; i < 3; i++) {
const line = document.createElement("div");
line.className = "hamburger-line";
menuToggle!.appendChild(line);
menuToggle.appendChild(line);
}
}
function setupSidebarAccessibility() {
updateSidebarAccessibility();
const menu = document.getElementById("menu");
if (!menu) return;
sidebarAccessibilityObserver?.disconnect();
sidebarAccessibilityObserver = new MutationObserver(() => {
scheduleSidebarAccessibilityUpdate();
});
sidebarAccessibilityObserver.observe(menu, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["class", "style"],
});
if (!sidebarAccessibilityListenersAttached) {
document.addEventListener("keydown", handleSidebarKeyboardActivation);
sidebarAccessibilityListenersAttached = true;
}
}
function scheduleSidebarAccessibilityUpdate() {
if (sidebarTabOrderAnimationFrame !== null) {
cancelAnimationFrame(sidebarTabOrderAnimationFrame);
}
sidebarTabOrderAnimationFrame = requestAnimationFrame(() => {
sidebarTabOrderAnimationFrame = null;
updateSidebarAccessibility();
});
}
function handleSidebarKeyboardActivation(event: KeyboardEvent) {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const menuItem = target.closest("#menu li, #menu section") as
| HTMLElement
| null;
if (!menuItem || target !== menuItem) return;
if (event.key === "Tab") {
const menu = document.getElementById("menu");
if (!menu) return;
const visibleList = getVisibleSidebarList(menu);
if (!visibleList) return;
const visibleEntries = getDirectSidebarEntries(visibleList);
if (visibleEntries.length === 0) return;
const boundaryEntry = event.shiftKey
? visibleEntries[0]
: visibleEntries[visibleEntries.length - 1];
if (boundaryEntry !== menuItem) return;
const parentEntry = getSidebarListParentEntry(visibleList);
if (!parentEntry) return;
event.preventDefault();
parentEntry.classList.remove("active");
scheduleSidebarAccessibilityUpdate();
requestAnimationFrame(() => {
parentEntry.focus();
});
return;
}
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
const childSubmenu = menuItem.querySelector(":scope > .sub > ul") as
| HTMLElement
| null;
menuItem.click();
scheduleSidebarAccessibilityUpdate();
if (childSubmenu) {
focusFirstSidebarSubmenuEntry(menuItem);
}
}
function updateSidebarAccessibility() {
const menu = document.getElementById("menu");
if (!menu) return;
const visibleEntries = new Set(getVisibleSidebarEntries(menu));
const menuEntries = menu.querySelectorAll("li.item, section.item, li, section");
for (const entry of menuEntries) {
if (!(entry instanceof HTMLElement)) continue;
const label = entry.querySelector(":scope > label") as HTMLLabelElement | null;
if (!label) continue;
const childSubmenu = entry.querySelector(":scope > .sub") as HTMLElement | null;
const isHidden =
entry.offsetParent === null ||
window.getComputedStyle(entry).display === "none" ||
window.getComputedStyle(label).display === "none" ||
!visibleEntries.has(entry);
if (isHidden) {
entry.tabIndex = -1;
label.tabIndex = -1;
entry.setAttribute("aria-hidden", "true");
label.setAttribute("aria-hidden", "true");
if (childSubmenu) {
childSubmenu.setAttribute("aria-hidden", "true");
}
continue;
}
entry.tabIndex = 0;
label.tabIndex = -1;
entry.removeAttribute("aria-hidden");
label.removeAttribute("aria-hidden");
if (!entry.hasAttribute("role")) {
entry.setAttribute("role", "button");
}
const accessibleLabel = label.textContent?.trim();
if (accessibleLabel) {
entry.setAttribute("aria-label", accessibleLabel);
}
if (childSubmenu) {
const isExpanded = entry.classList.contains("active");
entry.setAttribute("aria-expanded", String(isExpanded));
childSubmenu.setAttribute("aria-hidden", String(!isExpanded));
} else {
entry.removeAttribute("aria-expanded");
}
}
}
function getVisibleSidebarEntries(menu = document.getElementById("menu")) {
if (!menu) return [] as HTMLElement[];
const visibleList = getVisibleSidebarList(menu);
if (!visibleList) return [] as HTMLElement[];
return getDirectSidebarEntries(visibleList);
}
function getDirectSidebarEntries(list: HTMLElement) {
return Array.from(list.querySelectorAll(":scope > li, :scope > section")).filter(
(entry): entry is HTMLElement => entry instanceof HTMLElement,
);
}
function getVisibleSidebarList(menu: HTMLElement) {
let currentList = menu.querySelector(":scope > ul") as HTMLElement | null;
while (currentList) {
const activeSubmenuParent = currentList.querySelector(
":scope > li.hasChildren.active, :scope > section.hasChildren.active",
) as HTMLElement | null;
if (!activeSubmenuParent) {
return currentList;
}
const nextList = activeSubmenuParent.querySelector(
":scope > .sub > ul",
) as HTMLElement | null;
if (!nextList) {
return currentList;
}
currentList = nextList;
}
return null;
}
function getSidebarListParentEntry(list: HTMLElement) {
return list.closest(".sub")?.parentElement instanceof HTMLElement
? (list.closest(".sub")!.parentElement as HTMLElement)
: null;
}
function focusFirstSidebarSubmenuEntry(parentEntry: HTMLElement) {
const menu = document.getElementById("menu");
if (!menu) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!parentEntry.classList.contains("active")) return;
const visibleList = getVisibleSidebarList(menu);
if (!visibleList || getSidebarListParentEntry(visibleList) !== parentEntry) {
return;
}
const firstEntry = getDirectSidebarEntries(visibleList).find(
(entry) =>
entry.offsetParent !== null &&
window.getComputedStyle(entry).display !== "none",
);
firstEntry?.focus({ preventScroll: true });
});
});
}
+3 -6
View File
@@ -18,12 +18,9 @@ export class SettingsResizer {
if (!iframePopup) return;
const viewportHeight = window.innerHeight;
const idealHeight = viewportHeight - 80 - 15; // -80px for the top of the popup
const rawIdeal = viewportHeight - 80 - 15; // room below top chrome
const idealHeight = Math.min(Math.max(rawIdeal, 280), 600);
if (idealHeight > 600) {
iframePopup.style.height = "600px";
} else {
iframePopup.style.height = `${idealHeight}px`;
}
iframePopup.style.height = `${idealHeight}px`;
}
}
+160 -7
View File
@@ -1,25 +1,90 @@
import browser from "webextension-polyfill";
import Color from "color";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { lightenAndPaleColor } from "./lightenAndPaleColor";
import ColorLuminance from "./ColorLuminance";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
import { getCustomThemeAdaptiveCssVariables } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
const ADAPTIVE_THEME_TRANSITION_MS = 400;
let colorTransitionRafId: number | null = null;
let lastInterpolatedHex: string | null = null;
// Helper functions
const setCSSVar = (varName: any, value: any) =>
document.documentElement.style.setProperty(varName, value);
const applyProperties = (props: any) =>
Object.entries(props).forEach(([key, value]) => setCSSVar(key, value));
export function updateAllColors() {
// Determine the color to use
const selectedColor =
settingsState.selectedColor !== ""
? settingsState.selectedColor
: "#007bff";
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
/** Best-effort parse of a single sRGB hex from a colour string (hex, rgb, or gradient). */
function parseRepresentativeHex(s: string): string | null {
if (!s || !s.trim()) return null;
const trimmed = s.trim();
try {
return Color(trimmed).hex();
} catch {
// continue
}
if (trimmed.includes("gradient")) {
const regex =
/#[0-9a-fA-F]{6}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)/gi;
const stops = trimmed.match(regex);
if (stops?.length) {
try {
return Color(stops[0]).hex();
} catch {
// continue
}
}
}
const hexMatch = trimmed.match(/#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})\b/);
if (hexMatch) {
try {
return Color(hexMatch[0]).hex();
} catch {
// continue
}
}
const rgbaMatch = trimmed.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
if (rgbaMatch) {
try {
return Color.rgb(
Number(rgbaMatch[1]),
Number(rgbaMatch[2]),
Number(rgbaMatch[3]),
).hex();
} catch {
// continue
}
}
return null;
}
function getFromHex(): string | null {
const fromComputed = parseRepresentativeHex(
getComputedStyle(document.documentElement).getPropertyValue("--better-main").trim(),
);
if (fromComputed) return fromComputed;
return lastInterpolatedHex;
}
function cancelColorTransition() {
if (colorTransitionRafId !== null) {
cancelAnimationFrame(colorTransitionRafId);
colorTransitionRafId = null;
}
}
function applyColorsWith(selectedColor: string) {
if (settingsState.transparencyEffects) {
document.documentElement.classList.add("transparencyEffects");
}
@@ -28,7 +93,7 @@ export function updateAllColors() {
const commonProps = {
"--better-sub": "#161616",
"--better-alert-highlight": "#c61851",
"--better-main": settingsState.selectedColor,
"--better-main": selectedColor,
};
// Mode-based properties, applied if storedSetting is provided
@@ -63,6 +128,12 @@ export function updateAllColors() {
// Apply all the properties
applyProperties({ ...commonProps, ...modeProps, ...dynamicProps });
if (settingsState.selectedTheme) {
for (const name of getCustomThemeAdaptiveCssVariables()) {
setCSSVar(name, selectedColor);
}
}
let alliframes = document.getElementsByTagName("iframe");
for (let i = 0; i < alliframes.length; i++) {
@@ -79,3 +150,85 @@ export function updateAllColors() {
}
}
}
function toSoftGradient(hex: string): string {
const base = Color(hex);
const analogous = base.rotate(30).lighten(0.25).saturate(0.15);
const mid = base.mix(analogous, 0.5).hex();
return `linear-gradient(135deg, ${hex} 0%, ${mid} 50%, ${analogous.hex()} 100%)`;
}
export async function updateAllColors() {
let effectiveColor =
settingsState.selectedColor !== ""
? settingsState.selectedColor
: "#007bff";
let adaptiveHex: string | null = null;
if (settingsState.adaptiveThemeColour) {
const adaptiveColor = await getAdaptiveColour();
if (adaptiveColor) {
adaptiveHex = adaptiveColor;
effectiveColor = settingsState.adaptiveThemeGradient
? toSoftGradient(adaptiveColor)
: adaptiveColor;
}
}
const baseSelected =
settingsState.selectedColor !== "" ? settingsState.selectedColor : "#007bff";
const toHex =
adaptiveHex ?? parseRepresentativeHex(baseSelected);
const shouldAnimate =
settingsState.adaptiveThemeColour &&
(settingsState.adaptiveThemeColourTransition ?? true) &&
!!toHex;
const applyImmediate = () => {
cancelColorTransition();
applyColorsWith(effectiveColor);
if (toHex) lastInterpolatedHex = toHex;
};
if (!shouldAnimate) {
applyImmediate();
return;
}
const fromHex = getFromHex();
if (!fromHex || !toHex || fromHex === toHex) {
applyImmediate();
return;
}
const useSoftGradientOnFrames =
!!adaptiveHex && !!settingsState.adaptiveThemeGradient;
cancelColorTransition();
const start = performance.now();
const step = (now: number) => {
const elapsed = now - start;
const t = Math.min(1, elapsed / ADAPTIVE_THEME_TRANSITION_MS);
const eased = easeInOutCubic(t);
const interpolatedHex = Color(fromHex).mix(Color(toHex), eased).hex();
const display = useSoftGradientOnFrames
? toSoftGradient(interpolatedHex)
: interpolatedHex;
applyColorsWith(display);
if (t < 1) {
colorTransitionRafId = requestAnimationFrame(step);
} else {
colorTransitionRafId = null;
applyColorsWith(effectiveColor);
lastInterpolatedHex = toHex;
}
};
colorTransitionRafId = requestAnimationFrame(step);
}
@@ -0,0 +1,40 @@
/** Tracks which author-declared CSS variables mirror the effective accent; not persisted in settings storage. */
const VALID_CUSTOM_PROP = /^--[a-zA-Z0-9_-]{1,120}$/;
let boundNames: string[] = [];
export function normalizeAdaptiveCssVariableNames(
names: string[] | undefined,
): string[] {
if (!names?.length) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const raw of names) {
const s = raw.trim();
if (!VALID_CUSTOM_PROP.test(s) || seen.has(s)) continue;
seen.add(s);
out.push(s);
}
return out;
}
export function setCustomThemeAdaptiveCssVariables(
names: string[] | undefined,
): void {
for (const n of boundNames) {
document.documentElement.style.removeProperty(n);
}
boundNames = normalizeAdaptiveCssVariableNames(names);
}
export function getCustomThemeAdaptiveCssVariables(): string[] {
return boundNames;
}
export function clearCustomThemeAdaptiveCssVariables(): void {
for (const n of boundNames) {
document.documentElement.style.removeProperty(n);
}
boundNames = [];
}
+169 -31
View File
@@ -1,6 +1,10 @@
interface ElementConfig {
selector: string;
action: (element: Element) => void;
/** When true, element is not added to processedElements so the action runs every time (e.g. overwriting container content) */
alwaysRun?: boolean;
/** When true, never add to processedElements so the action can run again after DOM resets (e.g. home day column) */
neverMarkProcessed?: boolean;
}
interface ContentConfig {
@@ -10,6 +14,12 @@ interface ContentConfig {
// Track processed elements to avoid re-randomizing
const processedElements = new WeakSet<Element>();
/** Marks mock-generated `.day` rows so granular rules do not re-randomize them */
const MOCK_DAY_ATTR = "data-bsp-mock-day";
/** Skip MutationObserver-driven reprocessing while we inject the home mock (avoids feedback loops) */
let suppressMockMutations = false;
function debounce(func: Function, wait: number): Function {
let timeout: NodeJS.Timeout;
return function executedFunction(...args: any[]) {
@@ -42,19 +52,19 @@ function getRandomDate(): Date {
const contentConfig: ContentConfig = {
lessonTitle: {
selector: ".day h2",
selector: `.day:not([${MOCK_DAY_ATTR}]) h2`,
action: (element) => {
element.textContent = getRandomElement(mockData.subjects);
},
},
teacher: {
selector: ".day h3:first-of-type",
selector: `.day:not([${MOCK_DAY_ATTR}]) h3:first-of-type`,
action: (element) => {
element.textContent = getRandomElement(mockData.teachers);
},
},
classroom: {
selector: ".day h3:last-of-type",
selector: `.day:not([${MOCK_DAY_ATTR}]) h3:last-of-type`,
action: (element) => {
element.textContent = getRandomElement(mockData.classrooms);
},
@@ -77,6 +87,18 @@ const contentConfig: ContentConfig = {
element.textContent = getRandomElement(mockData.assessmentTitles);
},
},
assessmentTitleInTooltip: {
selector: ".assessmenttooltip .tooltiptext p",
action: (element) => {
element.textContent = getRandomElement(mockData.assessmentTitles);
},
},
assessmentTitleInDetail: {
selector: "[class*='AssessmentItem__title___'], .assessment-title",
action: (element) => {
element.textContent = getRandomElement(mockData.assessmentTitles);
},
},
assessmentSubject: {
selector: ".upcoming-assessment .upcoming-details h5",
action: (element) => {
@@ -92,7 +114,8 @@ const contentConfig: ContentConfig = {
noticeContent: {
selector: ".notice .contents",
action: (element) => {
element.textContent = "Content has been redacted for privacy.";
element.textContent =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
},
},
upcomingCheckboxes: {
@@ -135,7 +158,7 @@ const contentConfig: ContentConfig = {
selector:
'[class*="MessageList__recipients___"] [class*="MessageList__value___"]',
action: (element) => {
element.textContent = "Recipient(s) Redacted";
element.textContent = getRandomElement(mockData.messages.recipients);
},
},
@@ -175,16 +198,15 @@ const contentConfig: ContentConfig = {
documentNames: {
selector: ".document td.title",
action: (element) => {
element.textContent = "Document Name Redacted";
element.textContent = getRandomElement(mockData.documentTitles);
},
},
forumTopics: {
selector: "#menu .sub ul li:not([data-colour]):not(.hasChildren) label",
action: (element) => {
// Only redact if not in assessments section
const assessmentsSection = element.closest('[data-key="assessments"]');
if (!assessmentsSection) {
element.textContent = "Forum Topic Redacted";
element.textContent = getRandomElement(mockData.forumTopics);
}
},
},
@@ -210,25 +232,27 @@ const contentConfig: ContentConfig = {
courseNames: {
selector: "#menu .sub ul li[data-colour] label",
action: (element) => {
element.textContent = "Course Name Redacted";
element.textContent = getRandomElement(mockData.subjects);
},
},
yearGroups: {
selector: "#menu .sub > ul > li > label",
action: (element) => {
element.textContent = "Year Group Redacted";
const yearGroup = Math.floor(Math.random() * 5) + 8;
element.textContent = `Year ${yearGroup}`;
},
},
newsArticleTitle: {
selector: ".ArticleText a",
action: (element) => {
element.textContent = "News Article Title Redacted";
element.textContent = getRandomElement(mockData.notices);
},
},
newsArticleContent: {
selector: ".ArticleText p",
action: (element) => {
element.textContent = "News Article Content Redacted";
element.textContent =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
},
},
userHouse: {
@@ -237,6 +261,60 @@ const contentConfig: ContentConfig = {
element.textContent = "House";
},
},
// Timetable page: replace class names, teachers, rooms with fake data
timetableEntryTitle: {
selector: ".timetablepage .entry .title",
action: (element) => {
element.textContent = getRandomElement(mockData.subjects);
},
},
timetableEntryTeacher: {
selector: ".timetablepage .entry .teacher, .timetablepage .quickbar .meta .teacher",
action: (element) => {
element.textContent = getRandomElement(mockData.teachers);
},
},
timetableEntryRoom: {
selector: ".timetablepage .entry .room, .timetablepage .quickbar .meta .room",
action: (element) => {
element.textContent = getRandomElement(mockData.classrooms);
},
},
quickbarTitle: {
selector: ".timetablepage .quickbar .title",
action: (element) => {
element.textContent = getRandomElement(mockData.subjects);
},
},
// Home page: replace entire day with mock schedule (care + 7 lessons 8:553:15)
homeDayContainer: {
selector: "#day-container",
neverMarkProcessed: true,
action: (element) => {
const container = element as HTMLElement;
if (!container.closest(".timetable-container")) return; // only on home
if (container.classList.contains("loading") || container.innerHTML.trim() === "") {
delete container.dataset.bspMockSchedule;
return;
}
if (
container.dataset.bspMockSchedule === "1" &&
container.querySelector(`[${MOCK_DAY_ATTR}]`)
) {
return;
}
suppressMockMutations = true;
const schedule = getMockDaySchedule();
container.innerHTML = schedule;
container.classList.remove("loading");
container.dataset.bspMockSchedule = "1";
requestAnimationFrame(() => {
suppressMockMutations = false;
});
},
},
};
const mockData = {
@@ -367,7 +445,26 @@ const mockData = {
"Field Trip",
"Cultural Festival",
],
documentTitles: [
"Course Outline",
"Assignment Brief",
"Study Guide",
"Reference Material",
"Worksheet",
"Reading List",
"Project Guidelines",
],
forumTopics: [
"General Discussion",
"Homework Help",
"Resource Share",
"Class Updates",
"Study Group",
"Q&A",
"Announcements",
],
messages: {
recipients: ["Students", "Class", "Year Group", "Parents", "Guardians"],
subjects: [
"Mid-year Exams",
"Science project due soon",
@@ -573,6 +670,35 @@ Register through the PE department or see your house captains for more informati
]
};
/** Mock day schedule for home timetable: care 8:308:55, then 7 lessons 8:553:15 (45m each), 20m recess, lunch. */
function getMockDaySchedule(): string {
const blocks: { title: string; teacher: string; room: string; from: string; until: string }[] = [
{ title: "Care Group", teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "8:30am", until: "8:55am" },
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "8:55am", until: "9:40am" },
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "9:40am", until: "10:25am" },
{ title: "Recess", teacher: "—", room: "—", from: "10:25am", until: "10:45am" },
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "10:45am", until: "11:30am" },
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "11:30am", until: "12:15pm" },
{ title: "Lunch", teacher: "—", room: "—", from: "12:15pm", until: "1:00pm" },
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "1:00pm", until: "1:45pm" },
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "1:45pm", until: "2:30pm" },
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "2:30pm", until: "3:15pm" },
];
const colours = ["#8e8e8e", "#4FBBFE", "#59F675", "#fa915d", "#9c27b0", "#2196f3", "#4caf50", "#ff9800", "#e91e63", "#673ab7"];
return blocks
.map(
(b, i) =>
`<div class="day" ${MOCK_DAY_ATTR} style="--item-colour: ${colours[i % colours.length]};">
<h2>${b.title}</h2>
<h3>${b.teacher}</h3>
<h3>${b.room}</h3>
<h4>${b.from} ${b.until}</h4>
<h5> </h5>
</div>`,
)
.join("");
}
export function getMockNotices() {
return {
payload: mockData.noticesData
@@ -617,12 +743,15 @@ export function getMockAssessmentsData() {
{ submitted: false, score: null, dayOffset: () => Math.floor(Math.random() * -3) - 1 }, // Recently overdue
];
const assessments = Array.from({ length: 12 }, (_, i) => {
const currentYear = new Date().getFullYear();
const assessments = Array.from({ length: 14 }, (_, i) => {
const subj = subjects[i % subjects.length];
const template = statusTemplates[i % statusTemplates.length];
const due = new Date();
due.setDate(due.getDate() + template.dayOffset());
if (i >= 10) due.setFullYear(currentYear - 1);
const types = ["Assignment", "Test", "Exam", "Project", "Presentation", "Report"];
const assessment: any = {
id: i + 1,
title: mockData.assessmentTitles[i % mockData.assessmentTitles.length],
@@ -631,6 +760,7 @@ export function getMockAssessmentsData() {
metaclassID: subj.metaclass,
due: due.toISOString(),
submitted: template.submitted,
type: types[i % types.length],
};
if (template.score && typeof template.score === 'function') {
@@ -650,20 +780,21 @@ export function getMockAssessmentsData() {
const debouncedProcessElements = debounce(processNewElements, 1);
function processNewElements() {
Object.entries(contentConfig).forEach(([_, { selector, action }]) => {
Object.entries(contentConfig).forEach(([_, config]) => {
const { selector, action, alwaysRun, neverMarkProcessed } = config;
const elements = document.querySelectorAll(selector);
elements.forEach((element: Element) => {
// Only process elements that haven't been processed before
if (!processedElements.has(element)) {
if (alwaysRun || neverMarkProcessed || !processedElements.has(element)) {
action(element);
processedElements.add(element);
if (!alwaysRun && !neverMarkProcessed) {
processedElements.add(element);
}
}
});
});
}
let observer: MutationObserver | null = null;
let intervalId: NodeJS.Timeout | null = null;
export default function hideSensitiveContent() {
// Initial processing of existing elements
@@ -672,6 +803,8 @@ export default function hideSensitiveContent() {
// Set up MutationObserver if not already created
if (!observer) {
observer = new MutationObserver((mutations) => {
if (suppressMockMutations) return;
let shouldProcess = false;
mutations.forEach((mutation) => {
@@ -693,9 +826,25 @@ export default function hideSensitiveContent() {
});
}
// Also trigger on large DOM replacements (like page navigation)
// Large DOM replacements (e.g. page navigation). Skip only when #day-container gains many *mock* rows (our inject).
if (mutation.addedNodes.length > 5 || mutation.removedNodes.length > 5) {
shouldProcess = true;
const target = mutation.target as Element;
if (target.id === "day-container") {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as Element;
if (
el.classList?.contains("day") &&
!el.hasAttribute(MOCK_DAY_ATTR)
) {
shouldProcess = true;
break;
}
}
}
} else {
shouldProcess = true;
}
}
}
@@ -724,13 +873,6 @@ export default function hideSensitiveContent() {
attributeFilter: ['class', 'id'] // Watch for class/id changes that might affect our selectors
});
}
// Fallback: periodic check for new elements (especially useful for SPA navigation)
if (!intervalId) {
intervalId = setInterval(() => {
debouncedProcessElements();
}, 500); // Check every 500ms as a fallback
}
}
// Function to stop observing (useful for cleanup)
@@ -739,8 +881,4 @@ export function stopHidingSensitiveContent() {
observer.disconnect();
observer = null;
}
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
}

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