Compare commits

..

318 Commits

Author SHA1 Message Date
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
Seth Burkart c205a52f03 Merge pull request #370 from Jaxx7594/icon
fix: Favicon not showing
2026-01-23 15:33:17 +11:00
SethBurkart123 a6d95f27ed chore: update publish-browser extension 2026-01-23 14:13:11 +11:00
Jaxon Lewis-Wilson f05cd66e88 fix: Favicon not showing 2026-01-23 09:27:14 +08:00
SethBurkart123 a151e7a07e feat: 3.4.13 2026-01-23 08:10:38 +11: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
Seth Burkart 5b590512ee Merge pull request #365 from Jaxx7594/line
fix: House/year box hard failing when house_colour does not exist
2026-01-21 21:52:43 +11:00
Seth Burkart 3ff8ef144a Merge pull request #366 from Jones8683/main
Fix the message of the day being unreadable in light mode
2026-01-21 21:52:20 +11:00
Seth Burkart d9abed1c5d Merge pull request #367 from Jaxx7594/bar
fix: Incorrect styling due to SEQTA update
2026-01-21 21:51:40 +11:00
Jaxon Lewis-Wilson 82a789bbec fix: Global fonts
Prior commit was not actually functional upon review
2026-01-21 12:22:45 +08:00
Jaxon Lewis-Wilson ce6538f850 fix: Global font
Overrides SEQTA's new important tag for font under *{}
2026-01-21 11:40:16 +08:00
Jaxon Lewis-Wilson 979ae7149f fix: Incorrect styling due to SEQTA update 2026-01-21 11:17:49 +08:00
codefactor-io 6e71437fe8 [CodeFactor] Apply fixes to commit 940ecf8 2026-01-19 01:41:43 +00:00
Jones8683 940ecf8714 fix: message of the day unreadable in light mode 2026-01-19 12:09:57 +10:30
Jaxon Lewis-Wilson e0cc2e0fdf fix: House/year box hard failing when house_colour does not exist 2026-01-18 00:11:09 +08:00
SethBurkart123 5a19ef92e8 feat: v3.4.12 2025-12-19 17:04:08 +11:00
SethBurkart123 0a3781e9c2 fix: video aspect changing on load 2025-12-19 16:30:38 +11:00
SethBurkart123 a2e39c9d84 feat: add DisclaimerModal component to assessment averages switch 2025-12-19 14:50:55 +11:00
SethBurkart123 520abbb5c3 chore: hide the minecraft server icon 2025-12-19 14:30:26 +11:00
SethBurkart123 d0a11da15f feat: updated privacy statement 2025-12-19 14:29:11 +11:00
Seth Burkart fd5802f9a3 Merge pull request #362 from Jones8683/main
Fix some popup stuff
2025-12-12 13:26:52 +11:00
Jones8683 380d829d19 fix: bad spacing and ordering of popup buttons 2025-12-04 11:52:48 +10:30
Alphons Joseph 702528fb0c Merge pull request #361 from StroepWafel/Privacy-statement
re-add Privacy statement stuff
2025-12-03 18:17:28 +08:00
StroepWafel 2c077bc755 Add dynamic privacy policy notification with API fetch
Implements fetching the privacy policy from the BetterSEQTA+ API and displays a notification if the policy has been updated. Adds sanitization for HTML content, updates settings state to track last shown timestamp, and provides a manual trigger in settings. Refactors notification logic for improved security and maintainability.
2025-11-29 19:47:30 +10:30
StroepWafel fd86e57442 re-add privacy statement
Re-Added privacy statement and ported it over to jones' new system
2025-11-29 16:51:59 +10:30
Alphons Joseph 60ce18280e Merge pull request #357 from Jones8683/main 2025-11-29 09:38:26 +08:00
Jones8683 668dbfd78b fix: remove a comment 2025-11-29 11:57:58 +10:30
Jones8683 810aa17f15 Merge branch 'main' of https://github.com/Jones8683/BetterSEQTA-Plus 2025-11-29 11:46:22 +10:30
Jones8683 b64558e50a fix: whatsnew not scrolling 2025-11-29 11:46:14 +10:30
Jones 9b969bd708 fix: add back the contribute link
Added a section inviting contributions to the README.
2025-11-29 11:37:43 +10:30
Jones 1945f7c592 Merge branch 'BetterSEQTA:main' into main 2025-11-29 11:31:13 +10:30
Alphons Joseph 3e26d9af3c Merge pull request #360 from BetterSEQTA/revert-359-Privacy-statement
Revert "add privacy statement popup"
2025-11-29 08:54:03 +08:00
Alphons Joseph 3c8d7e246b Revert "add privacy statement popup" 2025-11-29 08:53:50 +08:00
Alphons Joseph 2e56518330 Merge pull request #359 from StroepWafel/Privacy-statement
add privacy statement popup
2025-11-29 07:42:45 +08:00
Alphons Joseph e67f3110e0 Update monofile.ts 2025-11-28 22:24:37 +08:00
Alphons Joseph a67f4d2e25 Update monofile.ts 2025-11-28 22:22:11 +08:00
StroepWafel d6025140fd add privacy statement popup 2025-11-28 14:03:17 +10:30
Jones8683 88e9ddf29c fix: uneven spacing on popup buttons 2025-11-18 10:59:43 +10:30
Jones8683 11adc4f933 Merge branch 'main' of https://github.com/Jones8683/BetterSEQTA-Plus 2025-11-12 13:53:47 +10:30
Jones8683 15691e8d94 fix: bottom corners of custom timetable events arent roudned 2025-11-12 13:52:28 +10:30
Jones 754b8d0589 fix: indent in readme 2025-11-11 11:30:40 +10:30
Jones8683 1d634d0da1 feat: seperate file to manage the 3 popups 2025-11-10 19:05:08 +10:30
Jones8683 7136de90be fix: crash in notificatio fetcher 2025-11-10 17:10:53 +10:30
Jones8683 466628479e feat: import close popup function from whatsnew instead of having its own 2025-11-10 16:57:53 +10:30
Jones8683 9c08d0bac2 fix: spam clicking outside a popup restarts closing animation, and remove multiple close popup functions 2025-11-10 16:57:14 +10:30
Jones8683 6c5320007f fix: empty scrollbar in about server popup 2025-11-10 16:29:41 +10:30
Jones8683 4734a443b4 fix: border applying to bottom most item 2025-11-10 16:18:55 +10:30
Jones 7c38e1dc29 fix: no border between submissions when transparency effects on 2025-11-10 11:26:53 +10:30
SethBurkart123 f3f90ef2a8 bump(version): 3.4.11 2025-10-13 15:00:08 +11:00
SethBurkart123 9bcc94aa8a feat(homepage): add empty state for assessments 2025-10-13 14:36:23 +11:00
SethBurkart123 ff2431f269 style(homepage): increased max width on days timetable 2025-10-13 14:28:44 +11:00
SethBurkart123 b442194bc5 fix: move custom shortcuts above regular shortcuts 2025-10-13 14:24:27 +11:00
SethBurkart123 b59c0eae25 feat: make edit mode on themes tab more plain #240 2025-10-13 14:11:55 +11:00
SethBurkart123 e895ce9f6b feat: add colorPicker hex/rgba controls back #351 2025-10-13 13:37:26 +11:00
SethBurkart123 7192f41535 feat: improvements to background music plugin 2025-10-13 13:26:15 +11:00
SethBurkart123 f1b707ab25 style: add line clamp 2025-09-15 11:28:18 +10:00
Seth Burkart 7f47cb8183 Merge pull request #348 from BetterSEQTA/goto-fix
Go to popup not scrolling #342
2025-09-15 11:27:09 +10:00
SethBurkart123 7f5d138bc9 fix: Go to popup not scrolling #342 2025-09-15 11:26:31 +10:00
Seth Burkart cef0f29640 Merge pull request #346 from StroepWafel/Fix-dropdowns
fix: Drop down menu styling
2025-09-15 11:20:32 +10:00
SethBurkart123 157343dda9 fix: remove excess arrow 2025-09-15 11:20:22 +10:00
Seth Burkart 7705c0a3cd Merge pull request #347 from StroepWafel/Fix-wrapping
fix: Text now wraps correctly in most divs
2025-09-15 11:11:24 +10:00
SethBurkart123 7def7b190c fix: duplicate #menu selectors 2025-09-15 11:11:05 +10:00
Alphons Joseph c294fb7369 Merge pull request #344 from StroepWafel:main
feat(plugin):Background Music plugin
2025-09-14 09:29:43 +08:00
StroepWafel 0dbbef0eb1 fix: Text now wraps correctly in most divs
Adjusted divs to wrap text, this can cause some issues where substantially long words get chopped up, but scaling down the font size makes it look weird.
2025-09-12 16:17:18 +09:30
StroepWafel c3c747d996 fix: Drop down menu styling
Fix for drop down menu styling so it doesn't look abhorrent
2025-09-12 15:50:24 +09:30
SethBurkart123 cdc8062275 fix: broken shortcut rendering logic #345 2025-09-11 19:14:53 +10:00
codefactor-io 1857b5ff01 [CodeFactor] Apply fixes to commit 700e3eb 2025-09-08 10:21:47 +00:00
StroepWafel 700e3ebb48 feat(plugin):Background Music plugin
Added a plugin so users can upload and play a .wav audio file as background music. added volume setting, made sure the file is stored across version updates/downdates, made the music stop when the tab is unfocussed, and registered the plugin as official.
2025-09-08 19:12:35 +09:30
Seth Burkart 16b9610301 Merge pull request #340 from Jones8683/main
Bump crxjs version
2025-08-28 14:56:35 +10:00
Jones8683 7d11e203a6 bump: more packages 2025-08-27 19:58:54 +09:30
Jones8683 530f07e640 bump(deps): bump more deps 2025-08-23 10:52:42 +09:30
Jones8683 08586781ce feat: proper gramatical naming for news sources 2025-08-19 12:24:03 +09:30
Jones8683 3ca5a49769 bump(deps): updated deps to match 2025-08-19 11:49:19 +09:30
Jones8683 886c79b3ee fix(deps): crxjs beta being used 2025-08-19 11:39:28 +09:30
Jones 30aa39142d fix: icons not loading 2025-08-19 11:26:59 +09:30
Jones8683 4188ef0d67 format: remove extra lines 2025-08-19 08:14:12 +09:30
Jones8683 ad9a013b00 update injected.scss 2025-08-19 08:13:20 +09:30
Jones8683 cd1f954cc7 feat: remove ugly line in transparency effects 2025-08-18 16:24:02 +09:30
Jones8683 6ef6c986dc fix (build): stop duplicate icon bundling warnings temp 2025-08-18 16:04:53 +09:30
Jones8683 f2e28175a0 feat: update crxjs 2025-08-18 15:53:35 +09:30
SethBurkart123 3ddcb204ef bump(version): 3.4.10.2 2025-08-17 21:27:57 +10:00
Alphons Joseph 766f0e6d3f undebump it 2025-08-17 18:48:09 +08:00
Alphons Joseph f1fcba58ef debump vite plugin 2025-08-17 18:30:19 +08:00
SethBurkart123 dba2d13bb3 bump(deps): publish-browser-extension to 3.0.1 2025-08-17 16:17:24 +10:00
SethBurkart123 30bf345b86 feat: add changelog for 3.4.10 2025-08-17 14:29:26 +10:00
SethBurkart123 0e98f52058 fix: UIfile styling applying on documents 2025-08-17 14:15:24 +10:00
SethBurkart123 f89508deb2 bump(version): 3.4.10 2025-08-17 14:14:44 +10:00
SethBurkart123 c7b69ad97b chore: import updates 2025-08-17 11:27:23 +10:00
SethBurkart123 2ef8bb215a perf: improved efficiency of element scanning in eventmanager 2025-08-17 11:02:41 +10:00
SethBurkart123 16273cf012 style: show icon on image files 2025-08-17 09:07:26 +10:00
Seth Burkart 13d3ccd8e4 Merge pull request #334 from Jones8683/main
Fix windows dev script???
2025-08-17 09:05:29 +10:00
SethBurkart123 7ebc4db9db fix: global search missing styles #335 2025-08-17 09:00:37 +10:00
Jones ed9d662ba4 Update README.md 2025-08-16 20:16:45 +09:30
Jones8683 8647e0b272 feat: update crxjs to out of beta 2025-08-16 19:51:38 +09:30
SethBurkart123 d93abec615 docs: update architecture 2025-08-15 17:32:05 +10:00
Seth Burkart 339b409937 Merge pull request #333 from Jones8683/main
Apply rounded corners when dragging event
2025-08-15 17:28:19 +10:00
SethBurkart123 0fb05c7f26 bump(version): 3.4.9 2025-08-15 17:16:32 +10:00
SethBurkart123 b866dde6e2 style: file pills 2025-08-15 17:13:43 +10:00
SethBurkart123 a42d781955 perf: lazy loading improvements 2025-08-15 16:12:27 +10:00
SethBurkart123 b03e99faa2 perf: only load svelte app on click 2025-08-15 11:04:31 +10:00
SethBurkart123 c87cbce218 perf: only enable sensitive hider in devmode 2025-08-15 10:51:44 +10:00
SethBurkart123 0d6aa1e5fd perf: settingsstate storage performance improvements 2025-08-15 10:49:41 +10:00
SethBurkart123 a396aa8a9d perf: settingstate caching improvements 2025-08-15 10:44:14 +10:00
SethBurkart123 f3048d0cae feat: mc popup on update, improved styles 2025-08-15 10:37:33 +10:00
SethBurkart123 adb3beb2b1 perf: limit notice length in preview 2025-08-15 10:22:10 +10:00
Jones8683 860916a5b8 feat: apply rounded corners when dragging event 2025-08-13 10:16:05 +09:30
Seth Burkart 21e0b0a05e Merge pull request #330 from Jones8683/main
Advertisement of MC in extension
2025-08-07 08:56:59 +10:00
Jones8683 f7ca1c7ddd chore: update all code to correct repo 2025-08-05 17:56:20 +09:30
Jones8683 3fb70f280a feat: replace image with mc trailer vid 2025-08-05 17:50:07 +09:30
Jones8683 58b1a70cc9 feat: youtube link in popups 2025-08-05 14:58:48 +09:30
Jones8683 ce2b376469 feat: add version number 2025-08-05 09:32:19 +09:30
Jones8683 2ded9b3f83 fixes 2025-08-04 14:36:26 +09:30
Jones8683 a0e8fc2233 fix: images pull from repo fork 2025-08-04 13:46:50 +09:30
Jones8683 3527817ed1 feat: update list content 2025-08-04 12:47:24 +09:30
Jones8683 5cf0a928c9 fix: apply fix for all popups 2025-08-04 12:45:07 +09:30
Jones8683 ae84a22128 fix: official website icon not displaying properly in light mode 2025-08-04 12:42:44 +09:30
Jones8683 b16a48c26c feat: make new icon work for dark mode 2025-08-04 10:04:41 +09:30
Jones8683 ceb9424ab9 fix: use font instead of image for ip adress 2025-08-04 09:57:51 +09:30
Jones8683 52192002e7 fix: content not showing properly 2025-08-04 08:40:34 +09:30
Jones8683 4160f6ee10 new image 2025-08-03 20:32:44 +09:30
Jones8683 028c011a98 feat: finalise content 2025-08-03 20:31:05 +09:30
Jones8683 bb6bf7bfb2 fix errors 2025-08-03 20:10:35 +09:30
Jones8683 c5cef0c9a7 feat: image 2025-08-03 20:09:19 +09:30
Jones8683 e6d418d569 feat: template prepped for styling 2025-08-03 19:55:09 +09:30
Jones8683 c4ff994e38 feat: finalise icon 2025-08-03 19:41:35 +09:30
Jones8683 da9a1e8c0b feat: placeholder popup & icon 2025-08-03 19:00:50 +09:30
Jones8683 6eebb6911a feat: image for mc popup 2025-08-01 15:12:26 +09:30
Jones8683 c0271968e2 settings 2025-08-01 15:08:35 +09:30
Jones8683 871b893532 fix: popup casuing error, removed until ready 2025-08-01 14:55:23 +09:30
Jones8683 0cad870c28 feat: template for mc server popup 2025-08-01 14:54:00 +09:30
Jones8683 4f38a28d9c feat: prep for mc popup 2025-08-01 14:47:51 +09:30
Jones8683 f3029d6d9a Merge branch 'main' of https://github.com/jones8683/BetterSEQTA-Plus 2025-08-01 14:15:29 +09:30
Jones8683 10f67c8d60 feat: betterseqta.org in whats new and about page 2025-08-01 14:15:12 +09:30
Seth Burkart 9030f20540 Merge pull request #329 from Jones8683/main
Update about page for 14 contributers
2025-07-30 19:34:31 +10:00
Jones e12a724ab8 Update about page for 14 contributers 2025-07-30 13:47:42 +09:30
Seth Burkart b5f418938a Merge pull request #327 from NNIDNHU/main
Make new-contributor.md a yaml file to fit with other files
2025-07-23 09:34:45 +10:00
NNIDNHU 743deb9fe0 Delete .github/ISSUE_TEMPLATE/new-old-contributor.md 2025-07-22 14:01:41 +09:30
NNIDNHU a696f5b333 Update and rename new-contributor.md to new-old-contributor.md 2025-07-22 14:01:12 +09:30
NNIDNHU 397e440b6f Update new_contributor.yml 2025-07-22 14:00:47 +09:30
NNIDNHU a6f0e5bc55 Update new_contributor.yml 2025-07-22 13:59:57 +09:30
NNIDNHU cadb8f6269 Update new_contributor.yml 2025-07-22 13:59:13 +09:30
NNIDNHU 0f3f5fca83 Create new_contributor.yml 2025-07-22 13:57:50 +09:30
Seth Burkart e12fe43ed8 Merge pull request #324 from Jones8683/main
feat: Apply 12-hour time to more elements and restyle motd
2025-07-19 18:26:00 +10:00
Jones 5b94c2c9b5 fix: put back message box on dashboard 2025-07-18 16:11:24 +09:30
Jones f2d748baf9 Merge branch 'main' of https://github.com/jones8683/BetterSEQTA-Plus 2025-07-17 20:17:05 +09:30
Jones 5dfd738848 restyle motd 2025-07-17 20:16:53 +09:30
Jones e88b2e0404 Merge branch 'BetterSEQTA:main' into main 2025-07-16 18:08:41 +09:30
Jones 10f3c1e942 fix: remove ID that wont work 2025-07-16 18:07:57 +09:30
Jones 9911966fe7 feat: apply 12 hour time while event is being made and give rounded corners while creating new event 2025-07-16 17:58:31 +09:30
Seth Burkart d49f4c539c Merge pull request #323 from Jones8683/main
Add rounded corners for custom timetable events while editor is open
2025-07-16 18:03:43 +10:00
Jones 77074f085a Merge branch 'main' of https://github.com/jones8683/BetterSEQTA-Plus 2025-07-16 16:46:29 +09:30
Jones ae8b890282 feat: apply rounded corners to custom timetable events while they are being edited (It has a different ID to once its been added) 2025-07-16 16:45:39 +09:30
Jones 3d5aa7ebd9 Update README.md 2025-07-10 16:44:32 +09:30
SethBurkart123 7251e4eee5 fix: weird colouring on courses cover page button #303 2025-07-04 14:03:08 +10:00
SethBurkart123 ad0a329331 fix: auto read message on notification click 2025-07-02 06:39:40 +10:00
SethBurkart123 43a780de8e fix: retrieval of state 2025-07-02 06:34:45 +10:00
SethBurkart123 de9c6bc481 style: improve transparency effects 2025-07-01 16:09:34 +10:00
Seth Burkart bf1fe51e94 Merge pull request #321 from Jones8683/main
feat: apply 12 hour time to timetable details
2025-07-01 16:03:32 +10:00
SethBurkart123 1f3dea55bb fix: more reliable zoom buttons on timetable page 2025-07-01 13:15:11 +10:00
Jones8683 980432c501 feat: apply 12 hour time to timetable details 2025-06-30 19:42:33 +09:30
Seth Burkart b5c3a0fce8 Merge pull request #319 from Jones8683/patch-1
Fix grammar
2025-06-29 15:45:10 +10:00
SethBurkart123 64bc9e6cad fix: profile picture inverted with neumorphic theme 2025-06-29 15:14:17 +10:00
Jones 839366432e fix: add missing full stop to readme 2025-06-29 11:40:58 +09:30
SethBurkart123 e5a410ff58 docs: update email 2025-06-29 10:46:42 +10:00
SethBurkart123 26613beb02 docs: more comprehensive documentation 2025-06-29 09:44:39 +10:00
Seth Burkart db92af7405 Merge pull request #315 from Jones8683/main
fix: tell people to fork it from 'main' not 'master'
2025-06-25 13:49:58 +10:00
Jones 78909bc242 Merge branch 'BetterSEQTA:main' into main 2025-06-25 12:56:05 +09:30
SethBurkart123 b503363d64 fix: assessment tooltips on homepage 2025-06-25 09:46:35 +10:00
SethBurkart123 b69d5f47fc feat: update changelog 2025-06-25 09:17:23 +10:00
SethBurkart123 404d3c02f3 feat: new update video 2025-06-25 09:13:19 +10:00
Jones e28a3e1bc6 fix: missing word in changelog 2025-06-24 11:09:20 +09:30
Jones f1b7c3475e fix: tell people to fork it from 'main' not 'master'
since when was it called "master"???
2025-06-23 19:42:11 +09:30
SethBurkart123 964a026e7a feat: improved sensitive content hider (dev) 2025-06-23 12:33:37 +10:00
SethBurkart123 c7d9e1d955 feat: remove rounded corners on custom shortcut icons 2025-06-23 11:58:18 +10:00
SethBurkart123 e305b70035 chore: update changelog 2025-06-20 10:01:56 +10:00
133 changed files with 13011 additions and 4489 deletions
+114
View File
@@ -0,0 +1,114 @@
name: 🙋 New Contributor - Need Help Getting Started
description: Perfect for first-time contributors who need guidance
labels: ["help wanted", "documentation"]
title: "[NEW CONTRIBUTOR] "
body:
- type: markdown
attributes:
value: |
## Hi there! 👋
Welcome to BetterSEQTA+! We're excited to have you join our community.
- type: checkboxes
attributes:
label: Tell us about yourself (check all that apply)
options:
- label: "This is my first time contributing to open source"
required: false
- label: "I'm new to browser extensions"
required: false
- label: "I'm new to TypeScript/JavaScript"
required: false
- label: "I have some coding experience but new to this project"
required: false
- type: checkboxes
attributes:
label: What would you like to work on? (check all that apply)
options:
- label: "Fix a bug 🐛"
required: false
- label: "Add a new feature ✨"
required: false
- label: "Improve documentation 📚"
required: false
- label: "Create a plugin 🧩"
required: false
- label: "Improve the UI/design 🎨"
required: false
- label: "Write tests 🧪"
required: false
- label: "Not sure - I want to help but need guidance!"
required: false
- type: checkboxes
attributes:
label: Have you read our guides?
options:
- label: "Getting Started Guide (see docs/GETTING_STARTED_CONTRIBUTING.md)"
required: true
- label: "Architecture Guide (see docs/ARCHITECTURE.md)"
required: true
- label: "Plugin Development Guide (see docs/plugins/README.md)"
required: true
- type: checkboxes
attributes:
label: Have you set up the development environment yet?
options:
- label: Yes, everything works! 🎉
required: false
- label: Partially - I can run `npm run dev` but having some issues
required: false
- label: No, I need help with setup
required: false
- label: I tried but ran into errors (please describe below)
required: false
- type: input
attributes:
label: Errors
description: "Please list any encountered errors here:"
placeholder: "I am encountering issues with..."
validations:
required: false
- type: input
attributes:
label: Questions or Issues
description: "Tell us:
1. What specifically would you like help with?
2. Are you stuck on anything?
3. Do you have any questions about the codebase?
4. Is there anything in our documentation that's unclear?"
placeholder: "I want help with..."
validations:
required: false
- type: input
attributes:
label: Ideas or Suggestions
description: "If you have any ideas for features, improvements, or just want to share your thoughts:"
placeholder: "It would be cool if I could help add..."
validations:
required: false
- type: markdown
attributes:
value: |
## What happens next?
A maintainer will respond within 24-48 hours to:
- Answer your questions
- Suggest some good issues to work on
- Help you with setup if needed
- Point you to relevant documentation
Don't worry if you're new to this - we're here to help! Every expert was once a beginner. 🚀
**Join our [Discord server](https://discord.gg/YzmbnCDkat) for real-time help and community chat!**
+1
View File
@@ -4,6 +4,7 @@ package-lock.json
bun.lockb bun.lockb
pnpm-lock.yaml pnpm-lock.yaml
yarn.lock yarn.lock
bun.lock
.parcel-cache .parcel-cache
.env .env
+2
View File
@@ -0,0 +1,2 @@
legacy-peer-deps=true
+23 -5
View File
@@ -1,13 +1,31 @@
# Contributing # Contributing to BetterSEQTA+
When contributing to this repository, please first discuss the change you wish to make via issue, Hey there! 👋 Thanks for your interest in contributing to BetterSEQTA+! We're excited to have you join our community of contributors.
email, or any other method with the owners of this repository before making a change.
## 🚀 New Contributors Start Here!
**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)
We have lots of [`good first issue`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/good%20first%20issue) labels that are perfect for beginners!
## Discussion Before Contributing
For significant changes, please first discuss what you'd like to change via:
- Opening an issue
- Joining our Discord server
- Emailing the maintainers
This helps ensure your contribution aligns with the project's goals and saves you time!
## Community ## Community
Join our community channels to discuss the project, get help, and connect with other contributors: Join our community channels to discuss the project, get help, and connect with other contributors:
- **Discord Server**: [Join our Discord](https://discord.gg/betterseqta) - **Discord Server**: [Join our Discord](https://discord.gg/YzmbnCDkat)
- **GitHub Discussions**: For longer-form conversations - **GitHub Discussions**: For longer-form conversations
- **GitHub Issues**: For bug reports and feature requests - **GitHub Issues**: For bug reports and feature requests
@@ -21,7 +39,7 @@ If you're interested in creating plugins for BetterSEQTA+, check out our plugin
## Pull Request Process ## Pull Request Process
1. It is recommended to start by opening an issue to discuss the change you wish to make. This will allow us to discuss the change and ensure it is a good fit for the project. 1. It is recommended to start by opening an issue to discuss the change you wish to make. This will allow us to discuss the change and ensure it is a good fit for the project.
2. Fork the repo and create your branch from `master`. 2. Fork the repo and create your branch from `main`.
3. When writing your pull request, make sure to use the pull request template. 3. When writing your pull request, make sure to use the pull request template.
### Pull Request Template ### Pull Request Template
+36 -48
View File
@@ -1,5 +1,3 @@
#
<a href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel"> <a href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel">
<img src="https://socialify.git.ci/betterseqta/betterseqta-plus/image?description=1&font=Inter&forks=1&issues=1&logo=data%3Aimage%2Fsvg%2Bxml%2C%253Csvg%20height%3D%27656pt%27%20fill%3D%27white%27%20preserveAspectRatio%3D%27xMidYMid%20meet%27%20viewBox%3D%270%200%20658%20656%27%20width%3D%27658pt%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%253E%253Cg%20transform%3D%27matrix(.1%200%200%20-.1%200%20656)%27%253E%253Cpath%20d%3D%27m2960%206499c-918-100-1726-561-2278-1299-196-262-374-609-475-925-171-533-203-1109-91-1655%20228-1115%201030-2032%202104-2408%20356-124%20680-177%201080-176%20269%201%20403%2014%20650%2064%20790%20159%201503%20624%201980%201290%20714%20998%20799%202342%20217%203420-488%20902-1361%201515-2382%201671-113%2017-196%2022-430%2024-159%202-328-1-375-6zm566-1443c476-99%20885-385%201134-791%20190-309%20282-696%20250-1045-22-240-73-420-180-635-78-156-159-275-274-401l-77-84h445%20446v-235-236l-1162%204-1163%203-100%2023c-449%20101-812%20337-1071%20697-77%20107-193%20335-233%20459-115%20358-116%20726-1%201078%20209%20644%20766%201101%201446%201187%20128%2016%20405%204%20540-24z%27%2F%253E%253Cpath%20d%3D%27m3065%204604c-250-36-396-89-576-209-280-187-470-478-535-821-25-135-16-395%2019-525%2095-351%20331-644%20651-806%2098-49%20225-93%20331-114%2092-18%20368-18%20460%200%20481%2095%20853%20444%20982%20921%2035%20129%2044%20389%2019%20524-36%20191-121%20387-228%20531-186%20249-476%20428-783%20485-65%2012-291%2021-340%2014z%27%2F%253E%253C%2Fg%253E%253C%2Fsvg%253E&name=1&owner=1&pattern=Signal&stargazers=1&theme=Dark" /> <img src="https://socialify.git.ci/betterseqta/betterseqta-plus/image?description=1&font=Inter&forks=1&issues=1&logo=data%3Aimage%2Fsvg%2Bxml%2C%253Csvg%20height%3D%27656pt%27%20fill%3D%27white%27%20preserveAspectRatio%3D%27xMidYMid%20meet%27%20viewBox%3D%270%200%20658%20656%27%20width%3D%27658pt%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%253E%253Cg%20transform%3D%27matrix(.1%200%200%20-.1%200%20656)%27%253E%253Cpath%20d%3D%27m2960%206499c-918-100-1726-561-2278-1299-196-262-374-609-475-925-171-533-203-1109-91-1655%20228-1115%201030-2032%202104-2408%20356-124%20680-177%201080-176%20269%201%20403%2014%20650%2064%20790%20159%201503%20624%201980%201290%20714%20998%20799%202342%20217%203420-488%20902-1361%201515-2382%201671-113%2017-196%2022-430%2024-159%202-328-1-375-6zm566-1443c476-99%20885-385%201134-791%20190-309%20282-696%20250-1045-22-240-73-420-180-635-78-156-159-275-274-401l-77-84h445%20446v-235-236l-1162%204-1163%203-100%2023c-449%20101-812%20337-1071%20697-77%20107-193%20335-233%20459-115%20358-116%20726-1%201078%20209%20644%20766%201101%201446%201187%20128%2016%20405%204%20540-24z%27%2F%253E%253Cpath%20d%3D%27m3065%204604c-250-36-396-89-576-209-280-187-470-478-535-821-25-135-16-395%2019-525%2095-351%20331-644%20651-806%2098-49%20225-93%20331-114%2092-18%20368-18%20460%200%20481%2095%20853%20444%20982%20921%2035%20129%2044%20389%2019%20524-36%20191-121%20387-228%20531-186%20249-476%20428-783%20485-65%2012-291%2021-340%2014z%27%2F%253E%253C%2Fg%253E%253C%2Fsvg%253E&name=1&owner=1&pattern=Signal&stargazers=1&theme=Dark" />
</a> </a>
@@ -10,7 +8,7 @@
<p align="center"> <p align="center">
<a target="_blank" href="https://chrome.google.com/webstore/detail/betterseqta%20/afdgaoaclhkhemfkkkonemoapeinchel"><img src="https://user-images.githubusercontent.com/95666457/149519713-159d7ef7-2c21-4034-a616-f037ff46d9a4.png" alt="ChromeDownload" width="250"></a> <a target="_blank" href="https://chrome.google.com/webstore/detail/betterseqta%20/afdgaoaclhkhemfkkkonemoapeinchel"><img src="https://user-images.githubusercontent.com/95666457/149519713-159d7ef7-2c21-4034-a616-f037ff46d9a4.png" alt="ChromeDownload" width="250"></a>
<a target="_blank" href="https://discord.gg/YzmbnCDkat"><img src="https://github.com/SethBurkart123/EvenBetterSEQTA/assets/108050083/23055730-b16e-44c0-9bef-221d8545af92" width="240" style="border-radius:10%;" /></a> <a target="_blank" href="https://discord.gg/YzmbnCDkat"><img src="https://github.com/BetterSEQTA/BetterSEQTA-Plus/assets/108050083/23055730-b16e-44c0-9bef-221d8545af92" width="240" style="border-radius:10%;" /></a>
</p> </p>
<div> <div>
@@ -56,58 +54,48 @@ If you are looking to create custom themes, I would recommend you start at the o
Don't worry- if you get stuck feel free to ask around in the [discord](https://discord.gg/YzmbnCDkat). We're open and happy to help out! Happy creating :) Don't worry- if you get stuck feel free to ask around in the [discord](https://discord.gg/YzmbnCDkat). We're open and happy to help out! Happy creating :)
## Getting started ## 🚀 Want to Contribute?
&nbsp;&nbsp;&nbsp; **1. Clone the repository** **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)
git clone https://github.com/BetterSEQTA/BetterSEQTA-Plus - **🏗️ Want to understand the code?** Check out our [Architecture Guide](./docs/ARCHITECTURE.md)
- **🧩 Interested in plugins?** Read our [Plugin Development Guide](./docs/plugins/README.md)
- **🐛 Found a bug?** Open an [issue](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) or fix it yourself!
- **💬 Need help?** Join our [Discord community](https://discord.gg/YzmbnCDkat)
We have lots of https://github.com/BetterSEQTA/BetterSEQTA-Plus/labels/good%20first%20issue labels perfect for beginners!
## Quick Development Setup
&nbsp;&nbsp;&nbsp; **1. Fork & Clone**
```bash
git clone https://github.com/YOUR_USERNAME/BetterSEQTA-Plus
cd BetterSEQTA-Plus
``` ```
&nbsp;&nbsp;&nbsp; **2. Install dependencies** &nbsp;&nbsp;&nbsp; **2. Install & Run**
```bash
You may install the dependencies like below: npm install --legacy-peer-deps
npm run dev
```
npm install # or your preferred package manager like pnpm or yarn
``` ```
But it is recommended to do it like this: &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.
📚 **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)
``` ```
npm install --legacy-peer-deps # Only NPM supported
```
### Running Development
&nbsp;&nbsp;&nbsp; **3. Run the dev script (it updates as you save files)**
```
npm run dev # or use your preferred package manager
```
### Building for production
&nbsp;&nbsp;&nbsp; **4. Run the build script**
```
npm run build # or use your preferred package manager
```
&nbsp;&nbsp;&nbsp; **4.1. Package it up (optional)**
```
npm run zip # This REQUIRES 7-Zip to be installed in order to work. You can also use your preferred package manager
```
&nbsp;&nbsp;&nbsp; **5. Load the extension into chrome**
- Go to `chrome://extensions`
- Enable developer mode
- Click `Load unpacked`
- Select the `dist` folder
Just remember, in order to update changes to the extension if you are running in developer mode, you need to click the refresh button on the extension in `chrome://extensions` whenever anything's changed.
## Folder Structure ## Folder Structure
@@ -131,7 +119,7 @@ Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plu
## Credits ## Credits
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers.
## Star History ## Star History
+1994
View File
File diff suppressed because it is too large Load Diff
+235
View File
@@ -0,0 +1,235 @@
# BetterSEQTA+ 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
- [Overview](#overview)
- [High-Level Architecture](#high-level-architecture)
- [Core Components](#core-components)
- [Plugin System](#plugin-system)
- [File Structure Explained](#file-structure-explained)
- [Data Flow](#data-flow)
- [Browser Extension Basics](#browser-extension-basics)
## Overview
BetterSEQTA+ is a browser extension that enhances SEQTA Learn by:
- Adding new features through a plugin system
- Providing customizable themes and UI improvements
- Offering better navigation and user experience
Think of it like this: **SEQTA Learn + BetterSEQTA+ = Enhanced SEQTA Experience**
## High-Level Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ BROWSER EXTENSION │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Background │ │ Content Script │ │
│ │ Script │ │ (SEQTA.ts) │ │
│ │ │ │ │ │
│ │ - Settings │◄───┤ - Page Detection│ │
│ │ - Storage │ │ - Plugin Loading│ │
│ │ - Updates │ │ - UI Injection │ │
│ └─────────────────┘ └──────────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ Plugin System │ │
│ │ │ │
│ │ ┌─────────────┐ │ │
│ │ │ Built-in │ │ │
│ │ │ Plugins │ │ │
│ │ │ │ │ │
│ │ │ - Themes │ │ │
│ │ │ - Search │ │ │
│ │ │ - Timetable │ │ │
│ │ │ - etc... │ │ │
│ │ └─────────────┘ │ │
│ └───────────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ Settings UI │ │
│ │ (Svelte App) │ │
│ │ │ │
│ │ - Plugin Config │ │
│ │ - Theme Creator │ │
│ │ - General Settings│ │
│ └───────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────▼─────────┐
│ SEQTA Learn │
│ Website │
└───────────────────┘
```
## Core Components
### 1. Entry Point (`src/SEQTA.ts`)
This is where it all begins! When you visit a SEQTA page:
1. Detects if you're on a SEQTA Learn page
2. Injects our CSS styles
3. Changes the favicon to BetterSEQTA+ icon
4. Loads settings from storage
5. Initializes the plugin system
### 2. Plugin System (`src/plugins/`)
The heart of BetterSEQTA+! This is what makes it extensible:
- **Plugin Manager**: Registers and manages all plugins
- **Built-in Plugins**: Pre-made plugins (themes, search, etc.)
- **Plugin API**: Provides plugins with tools to interact with SEQTA
### 3. Settings UI (`src/interface/`)
A Svelte application that lets users:
- Enable/disable plugins
- Configure plugin settings
- Create custom themes
- Browse the theme store
### 4. Background Script (`src/background.ts`)
Runs in the background and handles:
- Extension-wide settings storage
- Communication between different parts
- Update notifications
## Plugin System
Our plugin system is what makes BetterSEQTA+ so powerful. Here's how it works:
### Plugin Lifecycle
```
Plugin Registration → Settings Loading → Plugin Initialization → Running → Cleanup
```
### Built-in Plugins Overview
| Plugin | What it does | Files |
|--------|-------------|-------|
| **Themes** | Custom CSS themes and backgrounds | `src/plugins/built-in/themes/` |
| **Global Search** | Search across all SEQTA content | `src/plugins/built-in/globalSearch/` |
| **Timetable** | Enhanced timetable features | `src/plugins/built-in/timetable/` |
| **Profile Picture** | Custom profile pictures | `src/plugins/built-in/profilePicture/` |
| **Animated Background** | Moving background animations | `src/plugins/built-in/animatedBackground/` |
### Creating a Plugin
Every plugin follows this structure:
```typescript
const myPlugin: Plugin = {
id: "unique-plugin-id",
name: "Human Readable Name",
description: "What does this plugin do?",
version: "1.0.0",
settings: { /* user configurable options */ },
run: async (api) => {
// Your plugin code goes here!
}
};
```
## File Structure Explained
```
src/
├── SEQTA.ts # 🚀 Main entry point - start reading here!
├── background.ts # 🔧 Background script for extension
├── manifests/ # 📦 Browser extension manifests
├── plugins/ # 🧩 Plugin system (the magic happens here!)
│ ├── core/ # 🏗️ Plugin infrastructure
│ ├── built-in/ # 🎁 Pre-made plugins
│ └── index.ts # 📋 Plugin registration
├── interface/ # 🎨 Settings UI (Svelte app)
│ ├── pages/ # 📄 Settings pages
│ ├── components/ # 🧱 Reusable UI components
│ └── main.ts # 🏠 Settings app entry point
├── seqta/ # 🔗 SEQTA-specific utilities
│ ├── main.ts # 🎯 Core SEQTA modifications
│ ├── ui/ # 🎨 UI manipulation helpers
│ └── utils/ # 🛠️ Helper functions
└── css/ # 💄 Styles and themes
```
### Where to Start Reading?
1. **New to the project?** Start with `src/SEQTA.ts`
2. **Want to understand plugins?** Look at `src/plugins/core/types.ts`
3. **Want to see a simple plugin?** Check out `src/plugins/built-in/profilePicture/`
4. **Interested in the UI?** Explore `src/interface/main.ts`
## Data Flow
Here's how data flows through the system:
```
User visits SEQTA → SEQTA.ts detects page → Loads settings from storage
Plugin Manager initializes → Each plugin gets API access → Plugins modify SEQTA
User opens settings → Svelte UI loads → Settings changed → Storage updated
Storage change detected → Plugins notified → UI updates automatically
```
## Browser Extension Basics
Never worked on a browser extension before? Here's what you need to know:
### Content Scripts vs Background Scripts
- **Content Script** (`SEQTA.ts`): Runs on SEQTA pages, can access and modify the page
- **Background Script** (`background.ts`): Runs in the background, handles storage and messaging
### Manifest Files
Each browser needs a slightly different manifest file:
- `manifests/chrome.ts` - Chrome, Edge, Brave
- `manifests/firefox.ts` - Firefox
- `manifests/safari.ts` - Safari (experimental)
### Communication
Different parts of the extension communicate using:
- `browser.runtime.sendMessage()` - Send messages
- `browser.storage` - Shared storage, but we have created a custom storage system that is easier to use:
```ts
settingsState.[the setting name] = [whatever you want to set it to]
console.log(settingsState.[the setting name])
```
- Custom events for plugin communication
## Development Tips
### Debugging
1. **Chrome DevTools**: Right-click → Inspect → Console tab
2. **Extension Console**: `chrome://extensions` → BetterSEQTA+ → "Inspect views: background page"
3. **Look for logs**: We log everything with `[BetterSEQTA+]` prefix
### Making Changes
1. Edit code → Save → Browser auto-reloads extension → Refresh SEQTA page
2. For UI changes: The dev server hot-reloads automatically
3. For plugin changes: May need to disable/enable the plugin in settings
### Common Gotchas
- Settings take a moment to load (use `api.settings.loaded` promise)
- Some SEQTA elements load dynamically (use `api.seqta.onMount()`)
- Plugin cleanup is important (always return a cleanup function)
## Next Steps
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)
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!
## Questions?
Still confused about something? That's totally normal! Here are your options:
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
- 🐛 Open an issue on GitHub
- 📧 Email us at betterseqta.plus@gmail.com
Remember: **Every expert was once a beginner!** We're here to help you learn and contribute. 🚀
+285
View File
@@ -0,0 +1,285 @@
# Getting Started as a Contributor
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
- [Before You Start](#before-you-start)
- [Your First 30 Minutes](#your-first-30-minutes)
- [Making Your First Contribution](#making-your-first-contribution)
- [Types of Contributions](#types-of-contributions)
- [Finding Something to Work On](#finding-something-to-work-on)
- [Development Workflow](#development-workflow)
- [Getting Help](#getting-help)
## Before You Start
### What You'll Need
- **Node.js** (v16 or higher) - [Download here](https://nodejs.org/)
- **Git** - [Download here](https://git-scm.com/)
- **A code editor** - We recommend [VS Code](https://code.visualstudio.com/)
- **A Chromium browser** (Chrome, Edge, Brave) for testing (recommended, however you can use firefox although it requires being built every time you make a change)
### Helpful Background (but not required!)
- Basic JavaScript/TypeScript knowledge
- Some familiarity with HTML/CSS
- Understanding of browser extensions (we'll teach you!)
**Don't worry if you're missing some of these!** We're happy to help you learn. 🤗
## Your First 30 Minutes
Let's get you up and running quickly:
### 1. Get the Code (3 minutes)
```bash
# Fork the repository on GitHub first, then:
git clone https://github.com/YOUR_USERNAME/BetterSEQTA-plus.git
cd BetterSEQTA-plus
```
### 2. Install Dependencies (3 minutes)
```bash
npm install --legacy-peer-deps
```
### 3. Start Development Server (2 minutes)
```bash
npm run dev
```
### 4. Load Extension in Browser (4 minutes)
1. Open Chrome and go to `chrome://extensions`
2. Enable "Developer mode" (toggle in top right)
3. Click "Load unpacked"
4. Select the `dist` folder in your project
5. Visit a SEQTA Learn page to see BetterSEQTA+ in action!
### 5. Make a Tiny Change (5 minutes)
Let's prove everything works:
1. Open `src/SEQTA.ts`
2. Find the line that says `"[BetterSEQTA+] Successfully initialised"`
3. Change it to `"[BetterSEQTA+] Successfully initialised - Hello [YOUR_NAME]!"`
4. Save the file
5. Go to `chrome://extensions`, click the refresh icon on BetterSEQTA+
6. Refresh a SEQTA page and check the browser console (F12) - you should see your message!
### 6. Reset Your Change (3 minutes)
```bash
git checkout -- src/SEQTA.ts
```
**Congratulations! 🎉 You've successfully set up BetterSEQTA+ for development!**
## Making Your First Contribution
### Easy First Contributions
Here are some great starter contributions:
1. **Fix a typo in documentation** - Super easy and always appreciated!
2. **Improve error messages** - Make them more helpful
3. **Add comments to code** - Help other contributors understand
4. **Create a simple plugin** - Follow our plugin guide
5. **Fix a bug you found** - If you found a bug, fix it!
### Step-by-Step: Your First Pull Request
#### Step 1: Pick an Issue
- Go to our [Issues page](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues)
- Look for labels like:
- `good first issue` - Perfect for beginners
- `help wanted` - We'd love help with these
- `documentation` - Improve our docs
- `bug` - Fix something broken
#### Step 2: Claim the Issue
Comment on the issue saying "I'd like to work on this!" We'll assign it to you.
#### Step 3: Create a Branch
```bash
git checkout -b fix-issue-123 # Replace 123 with the issue number
```
#### Step 4: Make Your Changes
- Follow the patterns you see in existing code
- Test your changes thoroughly
- Keep changes focused and small
#### Step 5: Test Everything
```bash
# Test the extension still loads
npm run dev
# Test in browser
# 1. Reload extension at chrome://extensions
# 2. Visit SEQTA page
# 3. Verify everything still works
```
#### Step 6: Commit Your Changes
```bash
git add .
git commit -m "Fix issue #123: Brief description of what you fixed"
```
#### Step 7: Push and Create Pull Request
```bash
git push origin fix-issue-123
```
Then go to GitHub and create a pull request with:
- **Clear title**: "Fix issue #123: Brief description"
- **Description**: Explain what you changed and why
- **Testing**: Describe how you tested it
## Types of Contributions
### 🐛 Bug Fixes
- Fix broken features
- Improve error handling
- Resolve compatibility issues
**Example**: "The theme selector doesn't work on Firefox"
### ✨ New Features
- Add new plugins
- Enhance existing functionality
- Improve user experience
**Example**: "Add keyboard shortcuts for common actions"
### 📚 Documentation
- Fix typos and unclear explanations
- Add examples and tutorials
- Improve code comments
**Example**: "Add more examples to the plugin guide"
### 🎨 Design & UI
- Improve the settings interface
- Make things more user-friendly
- Add animations and polish
**Example**: "Make the theme creator more intuitive"
### 🔧 Technical Improvements
- Refactor code for clarity
- Add tests
- Improve performance
**Example**: "Simplify the plugin loading logic"
## Finding Something to Work On
### Browse Issues by Label
- [`good first issue`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/good%20first%20issue) - Perfect for beginners
- [`help wanted`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/help%20wanted) - We need help with these
- [`documentation`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/documentation) - Improve our docs
- [`bug`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/bug) - Fix something broken
- [`enhancement`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/enhancement) - Add new features
### Create Your Own Issue
Found a bug or have an idea? Create an issue first to discuss it!
### Plugin Ideas
Want to create a plugin? Here are some ideas:
- **Study Timer**: Track study time across SEQTA pages
- **Grade Tracker**: Better visualization of grades over time
- **Quick Notes**: Add notes to any SEQTA page
- **Homework Reminder**: Smart notifications for upcoming due dates
- **Custom Shortcuts**: User-defined keyboard shortcuts
## Development Workflow
### Daily Development
```bash
# Start working
git pull origin main
npm run dev
# Make changes, test, commit
git add .
git commit -m "Descriptive commit message"
# Push when ready
git push origin your-branch-name
```
### Before Submitting PR
1. **Test thoroughly** - Make sure nothing breaks
2. **Check console** - No new errors
3. **Test in different browsers** - Chrome and Firefox
4. **Update documentation** - If you changed how something works
### Code Style
- Use TypeScript where possible
- Follow existing naming conventions
- Add comments for complex logic
- Keep functions small and focused
## Getting Help
### Stuck? Here's How to Get Unstuck
1. **Check the docs** - [Architecture guide](./ARCHITECTURE.md) 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
### Discord Community
Join our [Discord server](https://discord.gg/YzmbnCDkat) for:
- Real-time help and discussion
- Collaboration on features
- Sharing ideas and feedback
- Getting to know the community
### Code Review Process
- All contributions need code review
- We'll provide helpful feedback
- Don't worry about making mistakes - we're here to help!
- Reviews usually happen within 24-48 hours
## Common Questions
**Q: I'm new to browser extensions. Is this too advanced for me?**
A: Not at all! We have lots of beginner-friendly issues, and our plugin system makes it easy to add features without understanding all the browser extension complexities.
**Q: How long does it take to get my first PR merged?**
A: For simple fixes, usually 1-3 days. For larger features, it might take a week or two as we discuss the best approach.
**Q: I made a mistake in my PR. What do I do?**
A: No worries! Just push more commits to the same branch and they'll be added to your PR automatically.
**Q: Can I work on multiple issues at once?**
A: It's better to focus on one issue at a time, especially when starting out. This makes code review easier and reduces conflicts.
**Q: What if I start working on something and get stuck?**
A: Ask for help! Create a draft PR with what you have so far, and we'll help you figure out the next steps.
## Recognition
All contributors get:
- Recognition in our README
- Contributor badge in Discord
- Our eternal gratitude! 🙏
Significant contributors may also get:
- Special Discord roles
- Input on project direction
- Maintainer status
## Next Steps
Ready to contribute? Here's what to do:
1.**Set up your development environment** (follow the 30-minute guide above)
2. 🔍 **Find an issue to work on** (check the "good first issue" label)
3. 💬 **Join our Discord** and introduce yourself
4. 🚀 **Make your first contribution** and submit a PR
Remember: **Every expert was once a beginner!** We're excited to help you learn and grow as a contributor. Welcome to the team! 🎉
---
*Questions? Suggestions for improving this guide? Open an issue or message us on Discord!*
+4 -1
View File
@@ -10,7 +10,10 @@ Welcome to the BetterSEQTA+ documentation! This documentation will help you unde
- [Project Overview](./README.md) - This file - [Project Overview](./README.md) - This file
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+ - [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+
- [Contributing Guide](../CONTRIBUTING.md) - How to contribute to 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
### Plugin System ### Plugin System
+585
View File
@@ -0,0 +1,585 @@
# Theme Creation Guide
This guide covers everything you need to know about creating custom themes for BetterSEQTA+.
## Table of Contents
1. [Overview](#overview)
2. [Theme Structure](#theme-structure)
3. [CSS Variables](#css-variables)
4. [CSS Selectors & Classes](#css-selectors--classes)
5. [Custom Images](#custom-images)
6. [Theme Settings](#theme-settings)
7. [Best Practices](#best-practices)
8. [Examples](#examples)
## Overview
Themes in BetterSEQTA+ allow you to completely customize the appearance of SEQTA Learn. A theme consists of:
- **Custom CSS**: CSS rules that override default styles
- **Custom Images**: Images that can be referenced via CSS variables
- **Theme Metadata**: Name, description, default color, etc.
- **Theme Settings**: Options like forcing dark/light mode
Themes are applied by injecting CSS into the SEQTA page and setting CSS custom properties (variables) on the document root.
## CSS Variables
BetterSEQTA+ provides a comprehensive set of CSS variables that you can use in your themes. These variables automatically adapt to light/dark mode and user preferences.
### Core Background Variables
| Variable | Light Mode | Dark Mode | Description |
|----------|------------|-----------|-------------|
| `--background-primary` | `#ffffff` | `#232323` | Main background color |
| `--background-secondary` | `#e5e7eb` | `#1a1a1a` | Secondary background color |
| `--theme-primary` | `#ffffff` | `#232323` | Primary theme color (same as background-primary) |
| `--theme-secondary` | `#e5e7eb` | `#1a1a1a` | Secondary theme color (same as background-secondary) |
| `--text-primary` | `black` | `white` | Primary text color |
| `--text-color` | `black` | `white` | Text color (alias for text-primary) |
### BetterSEQTA+ Specific Variables
| Variable | Description | Notes |
|----------|-------------|-------|
| `--better-main` | User's selected accent color | Dynamically set based on color picker |
| `--better-sub` | Dark navy color | Always `#161616` |
| `--better-pale` | Lightened version of accent color | Only available in light mode |
| `--better-light` | Lighter version of accent color | Calculated based on brightness |
| `--better-alert-highlight` | Alert/highlight color | `#c61851` |
| `--betterseqta-logo` | Logo URL | Changes based on dark/light mode |
| `--auto-background` | Auto background color | Falls back to `--better-pale` or `--background-secondary` |
| `--navy` | Navy color | `#1a1a1a` |
| `--theme-fg-parts` | Theme foreground parts | `white` |
### Subject/Item Color Variables
| Variable | Description |
|----------|-------------|
| `--item-colour` | Subject/item color | Set dynamically per subject/item |
| `--colour` | Generic color variable | Used in various contexts |
| `--person-colour` | Person/avatar color | `var(--better-light)` for staff |
### Transparency Effects
When transparency effects are enabled, background variables become semi-transparent:
| Variable | Light Mode (Transparent) | Dark Mode (Transparent) |
|----------|--------------------------|-------------------------|
| `--background-primary` | `rgba(255, 255, 255, 0.6)` | `rgba(35, 35, 35, 0.6)` |
| `--background-secondary` | `rgba(229, 231, 235, 0.6)` | `rgba(26, 26, 26, 0.6)` |
### Using CSS Variables
You can use these variables in your custom CSS:
```css
/* Example: Style a custom element */
.my-custom-element {
background: var(--background-primary);
color: var(--text-primary);
border: 1px solid var(--better-main);
}
/* Example: Create a gradient */
.gradient-box {
background: linear-gradient(
to bottom,
var(--better-main),
var(--background-secondary)
);
}
```
## CSS Selectors & Classes
BetterSEQTA+ uses specific CSS selectors and classes that you can target in your themes. Here are the most important ones:
### Main Layout Elements
| Selector | Description |
|----------|-------------|
| `#container` | Main container element |
| `#content` | Content area |
| `#main` | Main content wrapper |
| `#title` | Top title bar |
| `#menu` | Sidebar menu |
### Dark Mode
The `dark` class is added to `html` when dark mode is active:
```css
/* Target dark mode specifically */
html.dark #main {
background: var(--background-primary);
}
/* Target light mode */
html:not(.dark) #main {
background: var(--background-primary);
}
```
### Transparency Effects
When transparency effects are enabled, the `transparencyEffects` class is added to `html`:
```css
html.transparencyEffects .notice {
backdrop-filter: blur(80px);
}
```
### Common SEQTA Classes
| Class/Selector | Description |
|----------------|-------------|
| `.notice` | Notice cards |
| `.day` | Day containers in timetable |
| `.dashboard` | Dashboard sections |
| `.dashlet` | Dashboard widgets |
| `.document` | Document elements |
| `.quickbar` | Quick action bar |
| `.calendar` | Calendar elements |
| `.message` | Message elements |
| `.thread` | Forum threads |
| `.shortcut` | Shortcut buttons |
| `.upcoming-assessment` | Upcoming assessments |
| `.entry.class` | Timetable entries |
### BetterSEQTA+ Specific Classes
| Class | Description |
|-------|-------------|
| `.addedButton` | BetterSEQTA+ added buttons |
| `.tooltip` | Tooltip elements |
| `.notice-unified-content` | Unified notice content |
| `.home-container` | Home page container |
| `.timetable-container` | Timetable container |
| `.notices-container` | Notices container |
### Attribute Selectors
SEQTA uses data attributes that you can target:
```css
/* Target specific data types */
[data-type="student"] .header {
color: var(--text-primary);
}
/* Target specific labels */
[data-label="inbox"] {
/* Styles */
}
```
### CSS Modules
SEQTA uses CSS modules with hashed class names. You can target them using attribute selectors:
```css
/* Target CSS module classes */
[class*="MessageList__MessageList___"] {
background: var(--background-primary);
}
[class*="BasicPanel__BasicPanel___"] {
border-radius: 16px;
}
```
## Custom Images
Themes can include custom images that are made available as CSS variables.
### Adding Images
1. Upload an image in the theme creator
2. Set a CSS variable name (e.g., `custom-background`)
3. The image will be available as `var(--custom-background)`
### Using Image Variables
```css
/* Use as background */
.my-element {
background-image: var(--custom-background);
background-size: cover;
background-position: center;
}
/* Use in content */
.my-icon::before {
content: '';
background-image: var(--custom-icon);
width: 24px;
height: 24px;
}
```
### Image Variable Format
Images are stored as `url()` values:
```css
/* The variable contains: url(blob:...) */
--custom-background: url(blob:chrome-extension://...);
```
## Theme Settings
### Force Dark/Light Mode
You can force a theme to always use dark or light mode:
```typescript
forceDark: true // Force dark mode
forceDark: false // Force light mode
forceDark: undefined // Use user's preference (default)
```
When `forceDark` is set, users cannot toggle dark/light mode while the theme is active.
### Default Color
Set a default accent color for your theme:
```typescript
defaultColour: "rgba(0, 123, 255, 1)" // Blue
defaultColour: "#ff6b6b" // Red (hex format)
```
### Allow Color Changes
Control whether users can change the accent color:
```typescript
CanChangeColour: true // Users can change color
CanChangeColour: false // Color is locked
```
## Best Practices
### 1. Use CSS Variables
Always use CSS variables instead of hardcoded colors:
```css
/* Good */
.my-element {
background: var(--background-primary);
color: var(--text-primary);
}
/* Bad */
.my-element {
background: #ffffff;
color: #000000;
}
```
### 2. Support Both Light and Dark Modes
Unless your theme forces a specific mode, ensure it works in both:
```css
/* Use variables that adapt automatically */
.my-element {
background: var(--background-primary);
color: var(--text-primary);
}
/* Or explicitly handle both modes */
html.dark .my-element {
background: #1a1a1a;
}
html:not(.dark) .my-element {
background: #ffffff;
}
```
### 3. Use !important Sparingly
Only use `!important` when necessary to override SEQTA's default styles:
```css
/* Good - necessary override */
#title {
background: var(--background-primary) !important;
}
/* Bad - unnecessary */
.my-element {
color: var(--text-primary) !important;
}
```
### 4. Test Responsive Design
SEQTA is responsive. Test your theme at different screen sizes:
```css
/* Example: Mobile-specific styles */
@media (max-width: 900px) {
#menu {
transform: translate(-270px);
}
}
```
### 5. Use Semantic Selectors
Prefer semantic selectors over fragile ones:
```css
/* Good - stable selector */
#main > .dashboard > section {
border-radius: 16px;
}
/* Caution - CSS module classes may change */
[class*="Dashboard__Dashboard___"] {
border-radius: 16px;
}
```
### 6. Optimize Images
Keep image file sizes reasonable:
- Use appropriate formats (PNG for transparency, JPG for photos)
- Compress images before uploading
- Consider using CSS for simple graphics instead of images
### 7. Document Your Theme
Include comments in your CSS explaining complex styles:
```css
/*
* Custom gradient background for dashboard
* Uses the user's accent color for a cohesive look
*/
#main > .dashboard {
background: linear-gradient(
135deg,
var(--better-main),
var(--background-secondary)
);
}
```
## Examples
### Example 1: Simple Color Theme
```css
/* Change accent color throughout */
:root {
--better-main: #ff6b6b;
}
/* Style the menu */
#menu {
background: var(--background-primary);
border-right: 3px solid var(--better-main);
}
/* Style buttons */
.uiButton {
background: var(--better-main);
color: var(--text-color);
border-radius: 8px;
}
```
### Example 2: Custom Background Image
```css
/* Use a custom background image */
body {
background-image: var(--custom-background);
background-size: cover;
background-attachment: fixed;
}
/* Add overlay for readability */
#main::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: -1;
}
```
### Example 3: Rounded Corners Theme
```css
/* Make everything more rounded */
#main > .dashboard > section,
.dashlet,
.notice,
.document {
border-radius: 20px !important;
}
/* Round buttons */
.uiButton {
border-radius: 25px !important;
}
```
### Example 4: Minimal Theme
```css
/* Remove shadows and borders */
#main > .dashboard > section,
.dashlet,
.notice {
box-shadow: none !important;
border: 1px solid var(--background-secondary) !important;
}
/* Simplify colors */
#menu {
background: var(--background-primary) !important;
}
/* Remove gradients */
.day {
background: var(--background-primary) !important;
}
```
### Example 5: High Contrast Theme
```css
/* Increase contrast */
:root {
--background-primary: #000000;
--background-secondary: #1a1a1a;
--text-primary: #ffffff;
}
html:not(.dark) {
--background-primary: #ffffff;
--background-secondary: #f0f0f0;
--text-primary: #000000;
}
/* Add borders for clarity */
.dashlet,
.notice,
.document {
border: 2px solid var(--better-main) !important;
}
```
## Advanced Techniques
### CSS Custom Properties Override
You can override CSS variables in your theme:
```css
/* Override a variable */
:root {
--better-main: #your-color;
}
/* Override conditionally */
html.dark {
--background-primary: #your-dark-color;
}
```
### Animations
Add smooth transitions:
```css
/* Smooth color transitions */
#menu li {
transition: background-color 0.3s ease;
}
/* Hover effects */
.dashlet:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
}
```
### Pseudo-elements
Use pseudo-elements for decorative elements:
```css
/* Add decorative border */
.notice::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--better-main);
}
```
## Troubleshooting
### Theme Not Applying
1. Check browser console for CSS errors
2. Verify CSS syntax is correct
3. Ensure selectors are specific enough
4. Check if `!important` is needed
### Colors Not Changing
1. Verify you're using CSS variables
2. Check if `forceDark` is overriding your styles
3. Ensure variables are set on `:root` or `html`
### Images Not Showing
1. Verify image variable name matches CSS
2. Check image format is supported
3. Ensure image size is reasonable
4. Verify `url()` wrapper in CSS
### Dark Mode Issues
1. Test with `forceDark: true` and `forceDark: false`
2. Check if transparency effects are interfering
3. Verify `html.dark` selector is correct
## Resources
- **Theme Creator**: Access via BetterSEQTA+ settings
- **CSS Variables Reference**: See [CSS Variables](#css-variables) section above
- **SEQTA DOM Structure**: Inspect SEQTA pages in browser DevTools
- **BetterSEQTA+ Source**: Check `src/css/injected.scss` for default styles
## Contributing Themes
If you create a great theme, consider sharing it:
1. Export your theme (Share button in theme creator)
2. Submit to the BetterSEQTA+ theme store
3. Or share on GitHub/Discord
---
**Note**: This documentation is based on BetterSEQTA+ v3.4.13. Some details may change in future versions.
+348
View File
@@ -0,0 +1,348 @@
# Troubleshooting Guide
Having issues with BetterSEQTA+ development? This guide covers the most common problems and their solutions.
## Table of Contents
- [Installation Issues](#installation-issues)
- [Development Server Issues](#development-server-issues)
- [Browser Extension Issues](#browser-extension-issues)
- [Plugin Development Issues](#plugin-development-issues)
- [Build Issues](#build-issues)
- [Still Stuck?](#still-stuck)
## Installation Issues
### ❌ "npm install" fails with peer dependency errors
**Problem**: You see errors about peer dependencies or conflicting packages.
**Solution**:
```bash
rm -rf node_modules package-lock.json
npm install --legacy-peer-deps
```
### ❌ "Cannot find module" errors
**Problem**: Node.js can't find required packages.
**Solutions**:
1. **Clear and reinstall**:
```bash
rm -rf node_modules
npm install --legacy-peer-deps
```
2. **Check Node.js version**:
```bash
node --version # Should be v16 or higher
```
3. **Try with npm cache clean**:
```bash
npm cache clean --force
npm install --legacy-peer-deps
```
### ❌ Permission errors on macOS/Linux
**Problem**: "EACCES" or permission denied errors.
**Solution**:
```bash
sudo chown -R $(whoami) ~/.npm
sudo chown -R $(whoami) /usr/local/lib/node_modules
```
## Development Server Issues
### ❌ "npm run dev" fails
**Problem**: Development server won't start.
**Solutions**:
1. **Check if port is in use**:
```bash
lsof -i :5173 # Kill the process using the port
```
2. **Clear dist folder**:
```bash
rm -rf dist
npm run dev
```
3. **Check for TypeScript errors**:
```bash
npx tsc --noEmit # Check for type errors
```
### ❌ Changes not reflecting in browser
**Problem**: You make code changes but don't see them in the browser.
**Solutions**:
1. **Reload the extension**:
- Go to `chrome://extensions`
- Find BetterSEQTA+ and click the refresh icon
- Refresh your SEQTA page
2. **Check if dev server is running**:
- Look for "Build completed" in your terminal
- If not, restart `npm run dev`
3. **Hard refresh the page**:
- Press `Ctrl+Shift+R` (or `Cmd+Shift+R` on Mac)
## Browser Extension Issues
### ❌ Extension doesn't load in Chrome
**Problem**: Extension appears in `chrome://extensions` but doesn't work.
**Solutions**:
1. **Check for errors**:
- Go to `chrome://extensions`
- Click "Errors" button on BetterSEQTA+
- Fix any JavaScript errors shown
2. **Verify manifest**:
- Check if `dist/manifest.json` exists
- Ensure it has proper structure
3. **Check permissions**:
- Extension needs permission to access SEQTA pages
- Click "Details" → "Site access" → "On all sites"
### ❌ Extension doesn't appear on SEQTA pages
**Problem**: Extension loads but doesn't modify SEQTA.
**Solutions**:
1. **Check if you're on a SEQTA Learn page**:
- URL should contain "seqta" or "learn"
- Page title should include "SEQTA Learn"
2. **Check browser console**:
- Press `F12` → Console tab
- Look for "[BetterSEQTA+]" messages
- If no messages, extension isn't running
3. **Verify page detection**:
- Extension only runs on actual SEQTA Learn pages
- Test on a real SEQTA instance
### ❌ Settings page won't open
**Problem**: Clicking the extension icon doesn't open settings.
**Solutions**:
1. **Check popup errors**:
- Right-click extension icon → "Inspect popup"
- Look for JavaScript errors
2. **Clear extension storage**:
```javascript
// In browser console on any page:
chrome.storage.local.clear()
```
3. **Reload extension and try again**
## Plugin Development Issues
### ❌ My plugin doesn't appear in settings
**Problem**: Created a plugin but it's not showing up.
**Solutions**:
1. **Check plugin registration**:
- Ensure your plugin is imported in `src/plugins/index.ts`
- Verify `pluginManager.registerPlugin(yourPlugin)` is called
2. **Check plugin structure**:
```typescript
// Ensure your plugin has all required fields
const myPlugin: Plugin = {
id: "unique-id", // Must be unique
name: "Display Name",
description: "What it does",
version: "1.0.0",
run: async (api) => {
// Your code here
}
};
```
3. **Check for errors**:
- Look in browser console for plugin loading errors
### ❌ Plugin settings not working
**Problem**: Plugin settings don't save or load properly.
**Solutions**:
1. **Check settings definition**:
```typescript
import { defineSettings, booleanSetting } from "@/plugins/core/settingsHelpers";
const settings = defineSettings({
myOption: booleanSetting({
default: true,
title: "My Option",
description: "What this does"
})
});
```
2. **Wait for settings to load**:
```typescript
run: async (api) => {
await api.settings.loaded; // Wait for settings to load
console.log(api.settings.myOption); // Now you can use settings
}
```
### ❌ Plugin API functions not working
**Problem**: `api.seqta.onMount()` or other API functions don't work.
**Solutions**:
1. **Check selector specificity**:
```typescript
// Be specific with selectors
api.seqta.onMount(".home-page", (element) => {
// Your code
});
```
2. **Wait for elements**:
```typescript
// Some elements load after page navigation
api.seqta.onPageChange((page) => {
if (page === "home") {
api.seqta.onMount(".home-content", (element) => {
// Now element should exist
});
}
});
```
## Build Issues
### ❌ "npm run build" fails
**Problem**: Production build fails with errors.
**Solutions**:
1. **Check TypeScript errors**:
```bash
npx tsc --noEmit
```
2. **Clear cache and rebuild**:
```bash
rm -rf dist node_modules
npm install --legacy-peer-deps
npm run build
```
3. **Check for import errors**:
- Ensure all imports use correct paths
- Check for missing files
### ❌ Built extension doesn't work
**Problem**: `npm run build` succeeds but extension doesn't work.
**Solutions**:
1. **Test the built extension**:
- Load the `dist` folder as unpacked extension
- Check console for errors
2. **Compare with dev version**:
- If dev works but build doesn't, there might be a build configuration issue
3. **Check manifest generation**:
- Verify `dist/manifest.json` looks correct
- Compare with working version
## Common Error Messages
### "Cannot access contents of the URL"
- **Cause**: Extension permissions issue
- **Fix**: Go to `chrome://extensions` → BetterSEQTA+ → Details → Site access → "On all sites"
### "Extension context invalidated"
- **Cause**: Extension was reloaded while page was open
- **Fix**: Refresh the SEQTA page
### "Uncaught ReferenceError: browser is not defined"
- **Cause**: Missing webextension-polyfill import
- **Fix**: Add `import browser from "webextension-polyfill";` at top of file
### "Module not found: Can't resolve '@/...' "
- **Cause**: TypeScript path mapping issue
- **Fix**: Check `tsconfig.json` and `vite.config.ts` for path configuration
## Performance Issues
### Extension makes SEQTA slow
1. **Check for memory leaks**:
- Use Chrome DevTools → Performance tab
- Look for growing memory usage
2. **Optimize plugin code**:
- Remove unnecessary listeners
- Clean up intervals/timeouts
- Use efficient selectors
3. **Profile your changes**:
- Test with extension disabled vs enabled
- Identify which plugin is causing issues
## Still Stuck?
If none of these solutions work:
1. **🔍 Search existing issues**: [GitHub Issues](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues)
2. **💬 Ask on Discord**: [Join our server](https://discord.gg/YzmbnCDkat) - fastest way to get help!
3. **📝 Create a new issue**: Include:
- Your operating system
- Node.js version (`node --version`)
- Browser version
- Exact error message
- Steps to reproduce
- What you've already tried
4. **📧 Email us**: betterseqta.plus@gmail.com for urgent issues
## Getting More Debug Info
### Enable verbose logging
Add this to your plugin's `run` function:
```typescript
console.log("[DEBUG] Plugin starting:", api);
```
### Check extension background page
1. Go to `chrome://extensions`
2. Click "Details" on BetterSEQTA+
3. Click "Inspect views: background page"
4. Check console for background script errors
### Export debug info
Run this in browser console on a SEQTA page:
```javascript
console.log("Extension info:", {
version: chrome.runtime.getManifest().version,
url: window.location.href,
userAgent: navigator.userAgent,
storage: await chrome.storage.local.get()
});
```
Remember: **Don't give up!** Every developer faces these issues. The community is here to help, and solving these problems makes you a better developer. 💪
+335
View File
@@ -0,0 +1,335 @@
# Example Plugin Template
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
This plugin adds a friendly welcome message to the SEQTA homepage and lets users customize the message through settings.
## Complete Plugin Code
Create a new file in `src/plugins/built-in/my-first-plugin/index.ts`:
```typescript
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import {
defineSettings,
booleanSetting,
stringSetting
} from "@/plugins/core/settingsHelpers";
import { Setting } from "@/plugins/core/settingsHelpers";
// Define the plugin settings
const settings = defineSettings({
enabled: booleanSetting({
default: true,
title: "Show Welcome Message",
description: "Display a welcome message on the SEQTA homepage"
}),
customMessage: stringSetting({
default: "Welcome to SEQTA! 🎉",
title: "Custom Message",
description: "The message to display on the homepage",
maxLength: 100
}),
showEmoji: booleanSetting({
default: true,
title: "Show Emoji",
description: "Include emojis in the welcome message"
})
});
// Create settings class
class MyFirstPluginSettings extends BasePlugin<typeof settings> {
@Setting(settings.enabled)
enabled!: boolean;
@Setting(settings.customMessage)
customMessage!: string;
@Setting(settings.showEmoji)
showEmoji!: boolean;
}
// Create settings instance
const settingsInstance = new MyFirstPluginSettings();
// Define the plugin
const myFirstPlugin: Plugin<typeof settings> = {
id: "my-first-plugin",
name: "My First Plugin",
description: "Adds a customizable welcome message to the SEQTA homepage",
version: "1.0.0",
// Link our settings
settings: settingsInstance.settings,
// Mark as beta (optional)
beta: true,
// Add some CSS styles (optional)
styles: `
.my-plugin-welcome {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 12px;
margin: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
text-align: center;
font-size: 18px;
animation: slideIn 0.5s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.my-plugin-welcome .close-btn {
float: right;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
padding: 5px 10px;
border-radius: 20px;
cursor: pointer;
font-size: 12px;
}
.my-plugin-welcome .close-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
`,
// Main plugin function
run: async (api) => {
console.log("[My First Plugin] Starting up! 🚀");
// Wait for settings to load
await api.settings.loaded;
let welcomeElement: HTMLElement | null = null;
// Function to create the welcome message
const createWelcomeMessage = () => {
// Only show if enabled in settings
if (!api.settings.enabled) {
return;
}
// Remove existing message if it exists
if (welcomeElement) {
welcomeElement.remove();
}
// Create the message element
welcomeElement = document.createElement("div");
welcomeElement.className = "my-plugin-welcome";
// Build the message content
let message = api.settings.customMessage;
if (!api.settings.showEmoji) {
// Remove emojis if disabled
message = message.replace(/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '');
}
welcomeElement.innerHTML = `
<button class="close-btn" onclick="this.parentElement.remove()">×</button>
<div>${message}</div>
<small style="opacity: 0.8; margin-top: 10px; display: block;">
Powered by My First Plugin
</small>
`;
return welcomeElement;
};
// Function to add message to homepage
const addToHomepage = () => {
api.seqta.onMount(".home-page, .dashboard, [class*='home']", (homePage) => {
console.log("[My First Plugin] Found homepage, adding welcome message");
const message = createWelcomeMessage();
if (message) {
// Add to the top of the homepage
homePage.insertBefore(message, homePage.firstChild);
}
});
};
// Add message when plugin starts
addToHomepage();
// Re-add message when user navigates to homepage
api.seqta.onPageChange((page) => {
console.log("[My First Plugin] Page changed to:", page);
if (page.includes("home") || page.includes("dashboard")) {
// Small delay to let the page load
setTimeout(addToHomepage, 500);
}
});
// Listen for settings changes and update the message
api.settings.onChange("enabled", (enabled) => {
console.log("[My First Plugin] Enabled setting changed:", enabled);
if (enabled) {
addToHomepage();
} else if (welcomeElement) {
welcomeElement.remove();
welcomeElement = null;
}
});
api.settings.onChange("customMessage", (newMessage) => {
console.log("[My First Plugin] Message changed:", newMessage);
if (welcomeElement && api.settings.enabled) {
// Update existing message
addToHomepage();
}
});
api.settings.onChange("showEmoji", (showEmoji) => {
console.log("[My First Plugin] Show emoji changed:", showEmoji);
if (welcomeElement && api.settings.enabled) {
// Update existing message
addToHomepage();
}
});
// Return cleanup function (called when plugin is disabled)
return () => {
console.log("[My First Plugin] Cleaning up...");
if (welcomeElement) {
welcomeElement.remove();
welcomeElement = null;
}
};
}
};
export default myFirstPlugin;
```
## How to Use This Example
### Step 1: Create the Plugin File
1. Create a new folder: `src/plugins/built-in/my-first-plugin/`
2. Create `index.ts` in that folder
3. Copy the code above into `index.ts`
### Step 2: Register the Plugin
Add this to `src/plugins/index.ts`:
```typescript
// Add this import at the top
import myFirstPlugin from "./built-in/my-first-plugin";
// Add this line where other plugins are registered
pluginManager.registerPlugin(myFirstPlugin);
```
### Step 3: Test It
1. Run `npm run dev`
2. Reload your extension in Chrome
3. Visit a SEQTA page
4. You should see your welcome message!
5. Open BetterSEQTA+ settings to customize it
## Key Concepts Explained
### 1. Plugin Structure
```typescript
const myPlugin: Plugin = {
id: "unique-id", // Must be unique across all plugins
name: "Display Name", // Shown in settings
description: "What it does", // Shown in settings
version: "1.0.0", // Plugin version
settings: settingsObject, // User-configurable options
styles: "/* CSS here */", // Optional CSS styles
run: async (api) => { // Main plugin code
// Your code here
}
};
```
### 2. Settings System
```typescript
// Define what settings your plugin has
const settings = defineSettings({
myOption: booleanSetting({
default: true,
title: "My Option",
description: "What this option does"
})
});
// Use in your plugin
if (api.settings.myOption) {
// Do something
}
```
### 3. SEQTA Integration
```typescript
// Wait for elements to appear
api.seqta.onMount(".some-selector", (element) => {
// Modify the element
});
// Detect page changes
api.seqta.onPageChange((page) => {
if (page === "home") {
// User navigated to homepage
}
});
```
### 4. Cleanup
Always return a cleanup function to remove your changes when the plugin is disabled:
```typescript
run: async (api) => {
// Add your features
return () => {
// Remove your features
};
}
```
## Customization Ideas
Want to modify this example? Here are some ideas:
1. **Change the styling**: Modify the CSS to use different colors, animations, or layouts
2. **Add more settings**: Number settings, select dropdowns, hotkeys
3. **Different trigger**: Show on different pages, or based on time of day
4. **Add interactions**: Buttons that do things when clicked
5. **Store data**: Use `api.storage` to remember user preferences
6. **Communicate with other plugins**: Use `api.events` to send/receive events
## Next Steps
Once you've got this working:
1. **Experiment**: Try changing things and see what happens
2. **Read other plugins**: Look at the built-in plugins for inspiration
3. **Check the API docs**: Learn about all available API functions
4. **Share your creation**: Show it off in Discord!
## Need Help?
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
- 📚 Read our [Plugin Development Guide](./README.md)
- 🐛 Check the [Troubleshooting Guide](../TROUBLESHOOTING.md)
- 📝 Open an issue on GitHub
Happy coding! 🎉
+18 -25
View File
@@ -1,12 +1,14 @@
{ {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"version": "3.4.7", "version": "3.5.0",
"type": "module", "type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!", "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!",
"browserslist": "> 0.5%, last 2 versions, not dead", "browserslist": "> 0.5%, last 2 versions, not dead",
"scripts": { "scripts": {
"autoaudit": "npm audit && npm audit fix && npm run build",
"dev": "cross-env MODE=chrome vite dev", "dev": "cross-env MODE=chrome vite dev",
"dev:firefox": "cross-env MODE=firefox vite build --watch", "dev:firefox": "cross-env MODE=firefox vite build --watch",
"compile": "npm i && npm run build",
"build": "cross-env MODE=chrome vite build && cross-env MODE=firefox vite build", "build": "cross-env MODE=chrome vite build && cross-env MODE=firefox vite build",
"build:chrome": "cross-env MODE=chrome vite build", "build:chrome": "cross-env MODE=chrome vite build",
"build:firefox": "cross-env MODE=firefox vite build", "build:firefox": "cross-env MODE=firefox vite build",
@@ -28,26 +30,26 @@
"keywords": [], "keywords": [],
"author": { "author": {
"name": "SethBurkart123", "name": "SethBurkart123",
"email": "betterseqta@betterseqta.com", "email": "betterseqta.plus@gmail.com",
"url": "https://github.com/BetterSEQTA/BetterSEQTA-plus" "url": "https://github.com/BetterSEQTA/BetterSEQTA-plus"
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9", "@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9", "@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.91", "@bedframe/cli": "^0.0.95",
"@crxjs/vite-plugin": "2.0.0-beta.32", "@crxjs/vite-plugin": "^2.2.0",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"cross-env": "^7.0.3", "cross-env": "^10.0.0",
"dependency-cruiser": "^16.10.0", "dependency-cruiser": "^17.0.1",
"eslint": "9.22.0", "eslint": "^9.33.0",
"glob": "^11.0.1", "glob": "^11.0.1",
"mime-types": "^2.1.35", "mime-types": "^3.0.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"process": "^0.11.10", "process": "^0.11.10",
"publish-browser-extension": "^3.0.0", "publish-browser-extension": "^4.0.3",
"sass": "^1.85.1", "sass": "^1.85.1",
"sass-loader": "^16.0.5", "sass-loader": "^16.0.5",
"semver": "^7.7.1", "semver": "^7.7.1",
@@ -55,6 +57,7 @@
"url": "^0.11.4" "url": "^0.11.4"
}, },
"dependencies": { "dependencies": {
"@bedframe/core": "^0.0.46",
"@codemirror/autocomplete": "^6.18.6", "@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0", "@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
@@ -64,22 +67,12 @@
"@codemirror/view": "^6.36.4", "@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tiptap/core": "^2.14.0",
"@tiptap/extension-bubble-menu": "^2.14.0",
"@tiptap/extension-dropcursor": "^2.14.0",
"@tiptap/extension-image": "^2.14.0",
"@tiptap/extension-link": "^2.14.0",
"@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/extension-task-item": "^2.14.0",
"@tiptap/extension-task-list": "^2.14.0",
"@tiptap/extension-typography": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0",
"@tiptap/suggestion": "^2.14.0",
"@tsconfig/svelte": "^5.0.4", "@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.0.308", "@types/chrome": "^0.1.4",
"@types/color": "^4.2.0", "@types/color": "^4.2.0",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/node": "^22.13.10", "@types/node": "^24.3.0",
"@types/qrcode": "^1.5.6",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.3", "@types/webextension-polyfill": "^0.12.3",
@@ -103,15 +96,15 @@
"mathjs": "^14.4.0", "mathjs": "^14.4.0",
"million": "^3.1.11", "million": "^3.1.11",
"motion": "^12.4.12", "motion": "^12.4.12",
"motion-start": "^0.1.15", "pdfjs-dist": "^5.4.530",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"qrcode": "^1.5.4",
"react": "17", "react": "17",
"react-best-gradient-color-picker": "3.0.11", "react-best-gradient-color-picker": "3.0.11",
"react-dom": "17", "react-dom": "17",
"rss-parser": "^3.13.0", "rss-parser": "^3.13.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"svelte": "^5.22.6", "svelte": "^5.22.6",
"svelte-hero-icons": "^5.2.0",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vite": "^6.2.1", "vite": "^6.2.1",
-126
View File
@@ -1,126 +0,0 @@
--- a/Users/sethburkart/Documents/Coding/betterseqta-plus/src/plugins/core/settings.ts
+++ b/Users/sethburkart/Documents/Coding/betterseqta-plus/src/plugins/core/settings.ts
@@ -2,7 +2,7 @@
// Base interfaces for our settings
interface BaseSettingOptions {
- title: string;
+ readonly title: string; // Mark as readonly where appropriate
description?: string;
}
@@ -11,21 +11,21 @@
}
interface StringSettingOptions extends BaseSettingOptions {
- default: string;
+ readonly default: string;
maxLength?: number;
pattern?: string;
}
interface NumberSettingOptions extends BaseSettingOptions {
- default: number;
+ readonly default: number;
min?: number;
max?: number;
step?: number;
}
interface SelectSettingOptions<T extends string> extends BaseSettingOptions {
- default: T;
- options: readonly T[];
+ readonly default: T;
+ readonly options: readonly T[];
}
// The actual decorators
@@ -34,14 +34,16 @@
// Ensure the settings property exists on the constructor's prototype
const proto = target.constructor.prototype;
if (!proto.hasOwnProperty('settings')) {
- proto.settings = {};
+ // Initialize with a base type that can be extended
+ Object.defineProperty(proto, 'settings', {
+ value: {},
+ writable: true, // Allows adding properties
+ configurable: true,
+ enumerable: true
+ });
}
-
+
// Add the setting to the prototype's settings object with const assertion
proto.settings[propertyKey] = {
type: 'boolean' as const,
...options
};
- };
-}
-
-export function StringSetting(options: StringSettingOptions): PropertyDecorator {
- return (target: Object, propertyKey: string | symbol) => {
- // Ensure the settings property exists on the constructor's prototype
- const proto = target.constructor.prototype;
- if (!proto.hasOwnProperty('settings')) {
- proto.settings = {};
- }
-
- // Add the setting to the prototype's settings object with const assertion
- proto.settings[propertyKey] = {
- type: 'string' as const,
- ...options
- };
};
}
@@ -50,14 +52,16 @@
// Ensure the settings property exists on the constructor's prototype
const proto = target.constructor.prototype;
if (!proto.hasOwnProperty('settings')) {
- proto.settings = {};
+ Object.defineProperty(proto, 'settings', {
+ value: {},
+ writable: true,
+ configurable: true,
+ enumerable: true
+ });
}
-
+
// Add the setting to the prototype's settings object with const assertion
proto.settings[propertyKey] = {
type: 'number' as const,
...options
};
- };
-}
-
-export function SelectSetting<T extends string>(options: SelectSettingOptions<T>): PropertyDecorator {
- return (target: Object, propertyKey: string | symbol) => {
- // Ensure the settings property exists on the constructor's prototype
- const proto = target.constructor.prototype;
- if (!proto.hasOwnProperty('settings')) {
- proto.settings = {};
- }
-
- // Add the setting to the prototype's settings object with const assertion
- proto.settings[propertyKey] = {
- type: 'select' as const,
- ...options
- };
};
}
// Base plugin class that handles settings
export abstract class BasePlugin<T extends PluginSettings = PluginSettings> {
// The settings property will be populated by decorators
- settings!: T;
-
+ // Keep the instance property and constructor logic as is,
+ // as changing it would require changing animated-background/index.ts
+ settings!: T; // Use definite assignment assertion
+
constructor() {
// Copy settings from the prototype to the instance
// This ensures that each instance has its own settings object
+41 -22
View File
@@ -9,6 +9,7 @@ import browser from "webextension-polyfill";
import * as plugins from "@/plugins"; import * as plugins from "@/plugins";
import { main } from "@/seqta/main"; import { main } from "@/seqta/main";
import { delay } from "./seqta/utils/delay"; import { delay } from "./seqta/utils/delay";
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
export let MenuOptionsOpen = false; export let MenuOptionsOpen = false;
@@ -24,24 +25,8 @@ if (document.childNodes[1]) {
init(); init();
} }
/**
* Initializes BetterSEQTA+ on a SEQTA page.
*
* This function performs the following steps:
* 1. Verifies that the current page is a SEQTA page.
* 2. Injects CSS styles for document loading.
* 3. Changes the page's favicon.
* 4. Initializes the extension's settings state.
* 5. Sets default storage if settings are not already defined.
* 6. Calls the main function to apply core BetterSEQTA+ modifications.
* 7. Initializes legacy and new plugins if the extension is enabled.
* 8. Logs success or error messages during initialization.
*/
async function init() { async function init() {
const hasSEQTATitle = document.title.includes("SEQTA Learn"); if (hasSEQTAText && document.title.includes("SEQTA Learn") && !IsSEQTAPage) {
if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) {
// Verify we are on a SEQTA page
IsSEQTAPage = true; IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page"); console.info("[BetterSEQTA+] Verified SEQTA Page");
@@ -49,10 +34,30 @@ async function init() {
documentLoadStyle.textContent = documentLoadCSS; documentLoadStyle.textContent = documentLoadCSS;
document.head.appendChild(documentLoadStyle); document.head.appendChild(documentLoadStyle);
const icon = document.querySelector( replaceIcons();
'link[rel*="icon"]',
)! as HTMLLinkElement; const observer = new MutationObserver((mutations) => {
icon.href = icon48; // Change the icon for (const mutation of mutations) {
if (
mutation.type === "attributes" &&
mutation.target instanceof HTMLLinkElement &&
mutation.target.rel.includes("icon") &&
mutation.attributeName === "href"
) {
replaceIcons();
return;
}
}
});
observer.observe(document.head, {
subtree: true,
attributes: true,
attributeFilter: ["href"],
});
try { try {
await initializeSettingsState(); await initializeSettingsState();
@@ -70,11 +75,25 @@ async function init() {
await plugins.initializePlugins(); await plugins.initializePlugins();
} }
if (settingsState.devMode) {
initializeHideSensitiveToggle();
}
console.info( console.info(
"[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.", "[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.",
); );
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
} }
} }
} }
function replaceIcons() {
document
.querySelectorAll<HTMLLinkElement>('link[rel*="icon"]')
.forEach((link) => {
if (link.href !== icon48) {
link.href = icon48;
}
});
}
+239 -113
View File
@@ -14,57 +14,246 @@ function reloadSeqtaPages() {
result.then(open, console.error); result.then(open, console.error);
} }
// @ts-ignore /** Callback for sending a response back to the message sender */
type MessageSender = { (response?: unknown): void };
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 });
});
});
return true;
}
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 handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
const { refresh_token, client_id } = request;
if (!refresh_token || !client_id) {
sendResponse({ error: "Missing refresh_token or client_id" });
return false;
}
fetch("https://accounts.betterseqta.org/api/bsplus/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token, client_id }),
})
.then(async (r) => {
const data = await parseJsonResponse(r);
if (!r.ok) sendResponse({ error: data?.error ?? "Refresh failed" });
else sendResponse(data);
})
.catch((err) => {
console.error("[Background] cloudRefresh error:", err);
sendResponse({ error: err?.message ?? "Network error" });
});
return true;
}
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
const { themeId, token, action } = request;
if (!themeId || !token) {
sendResponse({ success: false, error: "Theme ID and token required" });
return false;
}
const isFavorite = action === "favorite";
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, {
method: isFavorite ? "POST" : "DELETE",
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.error("[Background] cloudFavorite error:", err);
sendResponse({ success: false, error: err?.message });
});
return true;
}
/** Handler for a message type; receives request and sendResponse callback */
type MessageHandler = { (request: any, sendResponse: MessageSender): boolean | void };
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
reloadTabs: () => reloadSeqtaPages(),
extensionPages: (req) => {
browser.tabs.query({}).then((tabs) => {
for (const tab of tabs) {
if (tab.url?.includes("chrome-extension://")) browser.tabs.sendMessage(tab.id!, req);
}
});
},
currentTab: (req, sendResponse) => {
browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => {
browser.tabs.sendMessage(tabs[0].id!, req).then(sendResponse);
});
return true;
},
githubTab: () => {
void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
},
setDefaultStorage: () => SetStorageValue(getDefaultValues()),
sendNews: (req, sendResponse) => {
fetchNews(req.source ?? "australia", sendResponse);
return true;
},
fetchThemes: handleFetchThemes,
fetchThemeDetails: handleFetchThemeDetails,
fetchFromUrl: handleFetchFromUrl,
cloudReserveClient: handleCloudReserveClient,
cloudLogin: handleCloudLogin,
cloudRefresh: handleCloudRefresh,
cloudFavorite: handleCloudFavorite,
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender) => {
(async () => {
try {
let baseUrl = req.baseUrl;
if (!baseUrl) {
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
const tab = tabs[0];
if (!tab?.url) {
sendResponse({ session: null });
return;
}
baseUrl = new URL(tab.url).origin;
}
const cookies = await browser.cookies.getAll({ url: baseUrl });
const jsession = cookies.find((c) => c.name === "JSESSIONID");
if (jsession?.value) {
sendResponse({ session: { baseUrl, jsessionId: jsession.value } });
} else {
sendResponse({ session: null });
}
} catch (err) {
console.error("[Background] getSeqtaSession error:", err);
sendResponse({ session: null });
}
})();
return true;
},
};
browser.runtime.onMessage.addListener( browser.runtime.onMessage.addListener(
(request: any, _: any, sendResponse: (response?: any) => void) => { // @ts-ignore - OnMessageListener expects literal true for async, we return boolean
switch (request.type) { (request: any, _: any, sendResponse: MessageSender) => {
case "reloadTabs": const handler = MESSAGE_HANDLERS[request.type];
reloadSeqtaPages(); if (handler) {
break; const result = handler(request, sendResponse);
return result === true;
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);
} }
}
});
break;
case "currentTab":
browser.tabs
.query({ active: true, currentWindow: true })
.then(function (tabs) {
browser.tabs
.sendMessage(tabs[0].id!, request)
.then(function (response) {
sendResponse(response);
});
});
return true;
case "githubTab":
browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
break;
case "setDefaultStorage":
SetStorageValue(DefaultValues);
break;
case "sendNews":
fetchNews(request.source ?? "australia", sendResponse);
return true;
default:
console.log("Unknown request type"); console.log("Unknown request type");
}
return false; return false;
}, },
); );
const DefaultValues: SettingsState = { function detectLowEndDevice(): boolean {
// Check for low-end hardware indicators
const lowCoreCount = navigator.hardwareConcurrency && navigator.hardwareConcurrency < 4;
const lowMemory = (navigator as any).deviceMemory && (navigator as any).deviceMemory <= 2;
return lowCoreCount || lowMemory;
}
function getDefaultValues(): SettingsState {
const isLowEndDevice = detectLowEndDevice();
return {
onoff: true, onoff: true,
animatedbk: true, animatedbk: true,
bksliderinput: "50", bksliderinput: "50",
@@ -96,8 +285,8 @@ const DefaultValues: SettingsState = {
"linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)", "linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)",
originalSelectedColor: "", originalSelectedColor: "",
DarkMode: true, DarkMode: true,
animations: true, animations: !isLowEndDevice,
assessmentsAverage: true, assessmentsAverage: false,
defaultPage: "home", defaultPage: "home",
shortcuts: [ shortcuts: [
{ {
@@ -116,7 +305,11 @@ const DefaultValues: SettingsState = {
customshortcuts: [], customshortcuts: [],
lettergrade: false, lettergrade: false,
newsSource: "australia", newsSource: "australia",
iconOnlySidebar: false,
adaptiveThemeColour: false,
adaptiveThemeGradient: false,
}; };
}
function SetStorageValue(object: any) { function SetStorageValue(object: any) {
for (var i in object) { for (var i in object) {
@@ -124,78 +317,11 @@ function SetStorageValue(object: any) {
} }
} }
function convertBksliderToSpeed(bksliderinput: number): number {
const minBase = 50;
const maxBase = 150;
const scaledValue =
2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4;
const baseSpeed = 3;
const speed = baseSpeed / scaledValue;
return speed;
}
async function migrateLegacySettings() {
const storage = (await browser.storage.local.get(
null,
)) as unknown as SettingsState;
// Animated Background Migration
if ("animatedbk" in storage || "bksliderinput" in storage) {
const animatedSettings = {
enabled: storage.animatedbk ?? true,
speed: storage.bksliderinput
? convertBksliderToSpeed(parseFloat(storage.bksliderinput))
: 1,
};
await browser.storage.local.set({
"plugin.animated-background.settings": animatedSettings,
});
}
// Assessments Average Migration
if ("assessmentsAverage" in storage || "lettergrade" in storage) {
const assessmentsSettings = {
enabled: storage.assessmentsAverage ?? true,
lettergrade: storage.lettergrade ?? false,
};
await browser.storage.local.set({
"plugin.assessments-average.settings": assessmentsSettings,
});
}
if ("selectedTheme" in storage) {
const themesSettings = { enabled: true };
await browser.storage.local.set({
"plugin.themes.settings": themesSettings,
});
}
if (storage.notificationCollector !== false) {
await browser.storage.local.set({
"plugin.notificationCollector.settings": { enabled: true },
});
} else {
await browser.storage.local.set({
"plugin.notificationCollector.settings": { enabled: false },
});
}
const keysToRemove = [
"animatedbk",
"bksliderinput",
"assessmentsAverage",
"lettergrade",
];
await browser.storage.local.remove(keysToRemove);
}
browser.runtime.onInstalled.addListener(function (event) { browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.remove(["justupdated"]); browser.storage.local.remove(["justupdated"]);
browser.storage.local.remove(["data"]); browser.storage.local.remove(["data"]);
if (event.reason == "install" || event.reason == "update") { if (event.reason == "install" || event.reason == "update") {
browser.storage.local.set({ justupdated: true }); browser.storage.local.set({ justupdated: true });
migrateLegacySettings();
} }
}); });
+13 -12
View File
@@ -92,8 +92,12 @@ const rssFeedsByCountry: Record<string, string[]> = {
* used to send the fetched news data back to the caller. * used to send the fetched news data back to the caller.
* It's called with an object like `{ news: { articles: [...] } }`. * It's called with an object like `{ news: { articles: [...] } }`.
*/ */
export async function fetchNews(source: string, sendResponse: any) { export async function fetchNews(source: string | undefined, sendResponse: any) {
if (source === "australia") { const normalizedSource = typeof source === "string" && source.trim()
? source.trim()
: "australia";
if (normalizedSource === "australia") {
const date = new Date(); const date = new Date();
const from = const from =
@@ -111,18 +115,15 @@ export async function fetchNews(source: string, sendResponse: any) {
const parser = new Parser(); const parser = new Parser();
let feeds: string[]; let feeds: string[];
console.log("fetchNews", source); console.log("fetchNews", normalizedSource);
if (rssFeedsByCountry[source.toLowerCase()]) { if (rssFeedsByCountry[normalizedSource.toLowerCase()]) {
// If the source is a country, fetch from predefined feeds feeds = rssFeedsByCountry[normalizedSource.toLowerCase()];
feeds = rssFeedsByCountry[source.toLowerCase()]; } else if (normalizedSource.startsWith("http")) {
} else if (source.startsWith("http")) { feeds = [normalizedSource];
// If the source is a URL, use it directly
feeds = [source];
} else { } else {
throw new Error( console.warn("[BetterSEQTA+] Invalid news source, falling back to Australia", normalizedSource);
"Invalid source. Provide a country code or a valid RSS feed URL.", return fetchNews("australia", sendResponse);
);
} }
const articlesPromises = feeds.map(async (feedUrl) => { const articlesPromises = feeds.map(async (feedUrl) => {
+87 -1
View File
@@ -17,10 +17,42 @@
@use "injected/popup.scss"; @use "injected/popup.scss";
@font-face {
font-family: "Roboto";
src: url("https://fonts.gstatic.com/s/roboto/v50/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2")
format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IconFamily";
src: url("@/resources/fonts/IconFamily.woff") format("woff");
font-weight: normal;
font-style: normal;
font-display: block;
}
@layer base, override;
@layer override {
* {
font-family: Rubik, sans-serif !important;
}
.iconFamily,
.iconFamily *,
[class~="iconFamily"],
[class~="iconFamily"] * {
font-family: "IconFamily" !important;
}
}
html { html {
background: #161616 !important; background: #161616 !important;
background-color: #161616; background-color: #161616;
font-family: Rubik, Roboto !important; font-family: Roboto, system-ui, -apple-system, sans-serif !important;
} }
.tooltip svg { .tooltip svg {
@@ -94,3 +126,57 @@ body:has(.outside-container:not(.hide))
background: var(--text-primary) !important; background: var(--text-primary) !important;
color: var(--theme-primary) !important; color: var(--theme-primary) !important;
} }
.fixed-tooltip {
display: inline-block;
z-index: 5 !important;
width: 28px;
background: none;
box-shadow: none;
padding: 2px;
position: absolute;
}
.fixed-tooltip svg {
fill: var(--theme-primary);
}
.tooltiptext-fixed {
width: 120px;
transform: scale(0);
transition: transform 0.2s;
transform-origin: top;
background: var(--background-primary);
color: var(--text-primary);
text-align: center;
border-radius: 6px;
padding: 2px;
position: fixed;
z-index: 1000;
top: 0;
left: 0;
margin-left: -62px;
}
.tooltiptext-fixed::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent transparent var(--text-primary) transparent;
}
.tooltiptext-fixed.show {
transform: scale(1);
transform-origin: top;
transition: transform 0.2s;
}
.tooltiptext-fixed p:hover {
cursor: pointer;
background: rgba(0, 0, 0, 0.3) !important;
transition: 200ms;
}
.tooltiptext-fixed p {
border-radius: 8px !important;
padding-top: 2px;
padding-bottom: 2px;
margin: 2px;
}
-1
View File
@@ -116,7 +116,6 @@ body {
} }
.cke_panel_listItem > a { .cke_panel_listItem > a {
&:hover { &:hover {
background: #3d3d3e !important; background: #3d3d3e !important;
} }
+460 -301
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -35,9 +35,19 @@
} }
#menu .sub { #menu .sub {
transition: transform 0.3s ease; transition: transform 0.3s ease, left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
} }
#menu > ul:has(li.hasChildren.active) > li.active { #menu > ul:has(li.hasChildren.active) > li.active {
background: transparent !important; background: transparent !important;
} }
/* Icon-only collapsed: submenu slides over narrow icons */
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li::before,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li::before,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li > label,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li > svg,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > label,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > svg {
transform: translateX(-70px);
}
+1
View File
@@ -46,6 +46,7 @@ html.transparencyEffects {
} }
.filter-select, .filter-select,
.uiShortText.search,
.report { .report {
backdrop-filter: blur(10px) !important; backdrop-filter: blur(10px) !important;
} }
+4
View File
@@ -0,0 +1,4 @@
{
"last_updated": "2024-06-15T12:00:00Z",
"whatsnew_html": "<div class=\"whatsnewTextContainer\" style=\"overflow-y: auto; font-size: 1.3rem; line-height: 1.6;\"><p>It has come to our attention that several schools have expressed concerns about BetterSEQTA+. This is very disheartening, so we have decided to release a statement on the situation.</p><p>To view our privacy policy, please click the <strong>shield icon</strong> in the settings&nbsp;menu, or <a href=\"https://betterseqta.org/privacy\" target=\"_blank\" rel=\"noopener noreferrer\" id=\"privacy-link\" style=\"color: inherit; text-decoration: underline; cursor: pointer; white-space: nowrap;\">click here</a>.</p><p style=\"font-weight: bold; margin-top: 15px;\">We never collect any information from you, and aim to provide the best features possible.</p></div>"
}
+10
View File
@@ -2,6 +2,16 @@ div:has(> #rbgcp-wrapper) {
background: transparent !important; background: transparent !important;
} }
#rbgcp-inputs-wrap {
padding-top: 4px !important;
margin-bottom: -8px;
#rbgcp-hex-input,
#rbgcp-input {
height: 28px !important;
}
}
.dark { .dark {
#rbgcp-wrapper { #rbgcp-wrapper {
div[style="padding-top: 11px; position: relative;"] div { div[style="padding-top: 11px; position: relative;"] div {
@@ -108,7 +108,6 @@ export default function Picker({
<ColorPicker <ColorPicker
disableDarkMode={true} disableDarkMode={true}
presets={presets} presets={presets}
hideInputs={customOnChange ? false : true}
value={customThemeColor ?? ""} value={customThemeColor ?? ""}
onChange={(color: string) => { onChange={(color: string) => {
if (customOnChange) { if (customOnChange) {
@@ -0,0 +1,177 @@
<script lang="ts">
import { fade } from "svelte/transition";
import browser from "webextension-polyfill";
import QRCode from "qrcode";
import { portal } from "../utils/portal";
const DEEPLINK_PREFIX = "desqta://connect/";
let showQrModal = $state(false);
let qrDataUrl = $state<string | null>(null);
let deeplink = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let isLoading = $state(false);
let isStandalone = $state(false);
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;
}
}
function buildDesqtaConnectPayload(baseUrl: string, jsessionId: string): string {
const payload = JSON.stringify({ u: baseUrl, s: jsessionId });
const base64 = btoa(unescape(encodeURIComponent(payload)));
const encoded = encodeURIComponent(base64);
return `${DEEPLINK_PREFIX}${encoded}`;
}
async function getSession(): Promise<{ baseUrl: string; jsessionId: string } | null> {
let baseUrl: string | undefined;
if (window.location.protocol === "chrome-extension:") {
// Extension popup: background will get URL from active tab
baseUrl = undefined;
} else {
// In-page (settings inside SEQTA): pass current page URL (cookies API not available in content scripts)
baseUrl = normalizeBaseUrl(window.location.href);
if (!isSeqtaUrl(baseUrl)) return null;
}
const { session } = (await browser.runtime.sendMessage({
type: "getSeqtaSession",
baseUrl,
})) as { session: { baseUrl: string; jsessionId: string } | null };
return session ?? null;
}
async function generateQrCode() {
errorMessage = null;
qrDataUrl = null;
isLoading = true;
try {
isStandalone = window.location.protocol === "chrome-extension:";
const session = await getSession();
if (!session) {
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 link = buildDesqtaConnectPayload(session.baseUrl, session.jsessionId);
const dataUrl = await QRCode.toDataURL(link, { width: 256, margin: 2 });
deeplink = 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;
deeplink = null;
errorMessage = null;
}
function openInDesqta() {
if (deeplink) window.location.href = deeplink;
}
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 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="DesQTA QR Code" class="w-64 h-64" />
</div>
<div class="flex flex-col gap-2 mt-4">
<button
type="button"
onclick={openInDesqta}
class="px-4 py-2.5 w-full text-sm font-medium text-white bg-indigo-600 rounded-lg transition-colors dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
Sign into DesQTA Desktop
</button>
<button
type="button"
onclick={downloadQrImage}
class="px-4 py-2 w-full text-xs font-medium rounded-lg border transition-colors text-zinc-500 dark:text-zinc-400 border-zinc-200 dark:border-zinc-600 hover:bg-zinc-50 dark:hover:bg-zinc-800/50">
Download QR as image
</button>
</div>
<p class="mt-2 text-sm text-center text-zinc-600 dark:text-zinc-400">
Or scan this QR code with DesQTA on your phone.
</p>
</div>
</div>
{/if}
@@ -0,0 +1,73 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { animate } from 'motion';
let { onConfirm, onCancel, title, message } = $props<{
onConfirm: () => void;
onCancel: () => void;
title: string;
message: string;
}>();
let modalElement: HTMLElement;
$effect(() => {
if (modalElement) {
animate(
modalElement,
{ scale: [0.9, 1], opacity: [0, 1] },
{
type: 'spring',
stiffness: 300,
damping: 25
}
);
}
});
</script>
<div
class="flex fixed inset-0 z-[10000] justify-center items-center bg-black/50"
style="position: fixed; top: 0; left: 0; right: 0; bottom: 0;"
onclick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
onkeydown={(e) => {
if (e.key === 'Escape') onCancel();
}}
role="button"
tabindex="-1"
transition:fade={{ duration: 150 }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={modalElement}
class="p-4 mx-4 w-full max-w-md bg-white rounded-2xl shadow-2xl dark:bg-zinc-800"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2 class="mb-3 text-xl font-bold text-gray-900 dark:text-white">
{title}
</h2>
<div class="mb-6 text-lg text-gray-700 whitespace-pre-line dark:text-gray-300">
{message}
</div>
<div class="flex gap-3 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg transition-colors hover:bg-gray-200 dark:bg-zinc-700 dark:text-gray-200 dark:hover:bg-zinc-600"
>
Cancel
</button>
<button
onclick={onConfirm}
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg shadow-inner transition-colors hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600"
>
Enable
</button>
</div>
</div>
</div>
+60 -2
View File
@@ -8,12 +8,12 @@
let select: HTMLSelectElement; let select: HTMLSelectElement;
</script> </script>
<div class="border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 shadow-2xl rounded-lg w-full overflow-clip"> <div class="select-wrapper relative w-full overflow-hidden rounded-2xl border shadow-2xl">
<select <select
bind:this={select} bind:this={select}
value={state} value={state}
onchange={() => onChange(select.value)} onchange={() => onChange(select.value)}
class="px-4 py-1 text-[0.75rem] dark:text-white w-full border-none bg-transparent focus:ring-0 focus:bg-white/20 dark:focus:bg-black/10" class="select-input w-full appearance-none border-none bg-transparent px-4 py-2.5 pr-10 text-[0.875rem] font-medium transition-colors"
> >
{#each options as option} {#each options as option}
<option value={option.value}> <option value={option.value}>
@@ -21,4 +21,62 @@
</option> </option>
{/each} {/each}
</select> </select>
<span class="select-icon pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd"></path>
</svg>
</span>
</div> </div>
<style>
.select-wrapper {
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 72%, transparent);
border-radius: 18px;
color: var(--text-primary);
transition:
background-color 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease,
transform 180ms ease;
}
.select-wrapper:hover {
background: color-mix(in srgb, var(--background-primary) 94%, var(--background-secondary) 6%);
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 88%, transparent);
}
.select-wrapper:focus-within {
background: color-mix(in srgb, var(--background-primary) 96%, var(--background-secondary) 4%);
border-color: color-mix(in srgb, var(--text-primary) 22%, var(--theme-offset-bg, var(--background-secondary)) 78%);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent);
}
.select-input {
color: var(--text-primary);
outline: none;
text-overflow: ellipsis;
}
.select-input:hover,
.select-input:focus {
background: transparent;
}
.select-input option {
background: var(--background-primary);
color: var(--text-primary);
}
.select-icon {
color: color-mix(in srgb, var(--text-primary) 60%, transparent);
}
.select-input {
color-scheme: light;
}
:global(.dark) .select-input {
color-scheme: dark;
}
</style>
@@ -0,0 +1,77 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { animate } from "motion";
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
let { onClose } = $props<{ onClose: () => void }>();
let modalElement: HTMLElement;
$effect(() => {
if (modalElement) {
animate(modalElement, { scale: [0.9, 1], opacity: [0, 1] }, { type: "spring", stiffness: 300, damping: 25 });
}
});
function handleSignIn() {
onClose();
if (document.getElementById("ExtensionPopup")) {
closeExtensionPopup();
} else {
window.close();
}
}
</script>
<div
class="flex fixed inset-0 z-[10000] justify-center items-center bg-black/50"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onkeydown={(e) => {
if (e.key === "Escape") onClose();
}}
role="button"
tabindex="-1"
transition:fade={{ duration: 150 }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={modalElement}
class="p-4 mx-4 w-full max-w-md bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2 class="mb-3 text-xl font-bold text-zinc-900 dark:text-white">
Sign in to favorite themes
</h2>
<p class="mb-6 text-zinc-600 dark:text-zinc-400">
Sign in in the Theme Store to save favorites across devices, or create an account to get started.
</p>
<div class="flex flex-wrap gap-2 justify-end">
<button
type="button"
onclick={onClose}
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
OK
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
>
Create account
</a>
<button
type="button"
onclick={handleSignIn}
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in
</button>
</div>
</div>
</div>
+1 -1
View File
@@ -9,7 +9,7 @@
let percentage = $derived(((state - min) / (max - min)) * 100); let percentage = $derived(((state - min) / (max - min)) * 100);
</script> </script>
<div class="relative mx-auto w-full max-w-lg"> <div class="relative w-full min-w-0">
<input <input
type="range" type="range"
min={min} min={min}
@@ -3,8 +3,7 @@
import './TabbedContainer.css'; import './TabbedContainer.css';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>(); let { tabs, activeTab = $bindable(0) } = $props<{ tabs: { title: string, Content: any, props?: any }[]; activeTab?: number }>();
let activeTab = $state(0);
let containerRef: HTMLElement | null = null; let containerRef: HTMLElement | null = null;
let tabWidth = $state(0); let tabWidth = $state(0);
@@ -0,0 +1,201 @@
<script lang="ts">
import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let username = $state("");
let password = $state("");
let loading = $state(false);
let error = $state<string | null>(null);
let cloudState = $state(cloudAuth.state);
let open = $state(false);
let dropdownEl: HTMLElement;
onMount(() => {
const unsubscribe = cloudAuth.subscribe((state) => {
cloudState = state;
});
return unsubscribe;
});
function handleClickOutside(e: MouseEvent) {
if (dropdownEl && !dropdownEl.contains(e.target as Node)) {
open = false;
}
}
$effect(() => {
if (open) {
const timer = setTimeout(() => {
document.addEventListener("click", handleClickOutside);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener("click", handleClickOutside);
};
}
});
async function handleLogin() {
if (loading) return;
error = null;
if (!username.trim() || !password) {
error = "Please enter username and password";
return;
}
loading = true;
try {
const result = await cloudAuth.login(username.trim(), password);
if (result.success) {
password = "";
open = false;
} else {
error = result.error ?? "Login failed";
}
} finally {
loading = false;
}
}
async function handleLogout() {
await cloudAuth.logout();
open = false;
}
function getInitials(): string {
const u = cloudState.user;
if (!u) return "?";
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
if (u.username) return u.username.slice(0, 2).toUpperCase();
if (u.email) return u.email.slice(0, 2).toUpperCase();
return "?";
}
</script>
<div class="relative flex items-center" bind:this={dropdownEl}>
<button
type="button"
onclick={() => (open = !open)}
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-100/80 dark:bg-zinc-700/80 hover:bg-zinc-200/80 dark:hover:bg-zinc-600/80 transition-colors duration-200 text-base font-medium text-zinc-900 dark:text-white"
>
{#if cloudState.isLoggedIn}
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-8 h-8 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-sm">
{getInitials()}
</div>
{/if}
<span class="hidden max-w-24 truncate sm:inline text-base">
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</span>
{:else}
<span class="text-xl font-IconFamily" aria-hidden="true">{'\ued53'}</span>
<span class="text-base font-medium">Sign in</span>
{/if}
</button>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute right-0 top-full mt-2 w-80 rounded-xl border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-xl z-[100] overflow-hidden"
onclick={(e) => e.stopPropagation()}
>
<div class="p-4 border-b border-zinc-200 dark:border-zinc-600">
<h3 class="text-xl font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
<p class="text-base text-zinc-500 dark:text-zinc-400">Sync favorites across devices</p>
</div>
<div class="p-4">
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()}
</div>
{/if}
<div class="min-w-0 flex-1">
<p class="text-base font-medium text-zinc-900 dark:text-white truncate">
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</p>
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
<p class="text-base text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
{/if}
</div>
</div>
<button
type="button"
onclick={handleLogout}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Sign out
</button>
</div>
{:else}
<p class="mb-4 text-base text-zinc-600 dark:text-zinc-400">
Sign in to favorite themes. Your favorites sync across devices when logged in.
</p>
<form
class="flex flex-col gap-3"
autocomplete="off"
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
<input
type="text"
name="betterseqta-cloud-username"
autocomplete="off"
placeholder="Email or username"
bind:value={username}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute('readonly')}
class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
/>
<input
type="password"
name="betterseqta-cloud-password"
autocomplete="new-password"
placeholder="Password"
bind:value={password}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute('readonly')}
class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
/>
{#if error}
<p class="text-base text-red-600 dark:text-red-400">{error}</p>
{/if}
<button
type="submit"
disabled={loading}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200"
>
{loading ? "Signing in..." : "Sign in"}
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
>
Create account
</a>
</form>
{/if}
</div>
</div>
{/if}
</div>
@@ -29,7 +29,7 @@
{#if coverThemes.length > 0} {#if coverThemes.length > 0}
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade> <div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
<div <div
class="w-full aspect-8/3" class="w-full aspect-[5/1] max-h-[500px]"
use:emblaCarouselSvelte={{ options, plugins }} use:emblaCarouselSvelte={{ options, plugins }}
onemblaInit={onInit} onemblaInit={onInit}
> >
@@ -42,7 +42,7 @@
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }} onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
onclick={() => setDisplayTheme(theme)} onclick={() => setDisplayTheme(theme)}
> >
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-full" /> <img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-full" />
<div class='absolute bottom-0 left-0 p-8 z-[1]'> <div class='absolute bottom-0 left-0 p-8 z-[1]'>
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2> <h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
<p class='text-lg text-white'>{theme.description}</p> <p class='text-lg text-white'>{theme.description}</p>
@@ -3,6 +3,7 @@
import logoDark from '@/resources/icons/betterseqta-light-full.png'; import logoDark from '@/resources/icons/betterseqta-light-full.png';
import { closeStore } from '@/seqta/ui/renderStore' import { closeStore } from '@/seqta/ui/renderStore'
import browser from 'webextension-polyfill'; import browser from 'webextension-polyfill';
import CloudHeader from './CloudHeader.svelte';
// Props // Props
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{ let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
@@ -39,6 +40,8 @@
> >
Backgrounds Backgrounds
</button> </button>
<CloudHeader />
</div> </div>
<div class="flex relative gap-2"> <div class="flex relative gap-2">
@@ -1,19 +1,110 @@
<script lang="ts"> <script lang="ts">
import type { Theme } from '@/interface/types/Theme' import type { Theme } from '@/interface/types/Theme'
let { theme, onClick } = $props<{ theme: Theme; onClick: () => void }>();
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte';
let { theme, onClick, toggleFavorite, isLoggedIn } = $props<{
theme: Theme;
onClick: () => void;
toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean;
}>();
let menuOpen = $state(false);
let showSignInModal = $state(false);
let menuRef: HTMLDivElement;
onMount(() => {
const closeMenu = (e: MouseEvent) => {
if (menuOpen && menuRef && !menuRef.contains(e.target as Node)) {
menuOpen = false;
}
};
document.addEventListener('click', closeMenu);
return () => document.removeEventListener('click', closeMenu);
});
function handleCardClick(e: MouseEvent) {
if ((e.target as HTMLElement).closest('[data-theme-menu]')) return;
onClick();
}
function handleFavoriteClick(e: MouseEvent) {
e.stopPropagation();
if (isLoggedIn) {
toggleFavorite(theme);
} else {
showSignInModal = true;
}
menuOpen = false;
}
</script> </script>
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={onClick}> <div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={handleCardClick}>
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade> <div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white"> <!-- Menu dropdown -->
{theme.name} <div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}>
<button
type="button"
class="flex justify-center items-center w-8 h-8 rounded-lg bg-black/40 hover:bg-black/60 text-white transition-all"
onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}
aria-label="Theme options"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
</button>
{#if menuOpen}
<div
class="absolute right-0 top-full mt-1 py-1 min-w-[140px] rounded-lg bg-white dark:bg-zinc-800 shadow-lg border border-zinc-200 dark:border-zinc-700"
role="menu"
>
<button
type="button"
class="flex gap-2 items-center w-full px-3 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700"
role="menuitem"
onclick={handleFavoriteClick}
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill={theme.is_favorited ? 'currentColor' : 'none'}
stroke="currentColor"
stroke-width="2"
class="w-5 h-5 {theme.is_favorited ? 'text-red-500' : ''}"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{theme.is_favorited ? 'Favorited' : 'Favorite'}
</button>
</div>
{/if}
</div>
<div class="absolute bottom-1 left-3 right-3 z-10 mb-1 flex flex-col gap-0.5">
<span class="text-xl font-bold text-white drop-shadow-md">{theme.name}</span>
<div class="flex gap-3 text-xs font-medium text-white/90 drop-shadow-sm">
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{(theme.download_count ?? 0).toLocaleString()}
</span>
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{(theme.favorite_count ?? 0).toLocaleString()}
</span>
</div>
</div> </div>
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div> <div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
<div class='w-full'> <div class='w-full'>
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" /> <img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
</div> </div>
</div> </div>
</div> </div>
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if}
@@ -2,7 +2,13 @@
import type { Theme } from '@/interface/types/Theme' import type { Theme } from '@/interface/types/Theme'
import ThemeCard from './ThemeCard.svelte'; import ThemeCard from './ThemeCard.svelte';
let { themes, searchTerm, setDisplayTheme } = $props<{ themes: Theme[]; searchTerm: string, setDisplayTheme: (theme: Theme) => void }>(); let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn } = $props<{
themes: Theme[];
searchTerm: string;
setDisplayTheme: (theme: Theme) => void;
toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean;
}>();
let filteredThemes = $derived(themes.filter((theme: Theme) => let filteredThemes = $derived(themes.filter((theme: Theme) =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase()) theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
@@ -12,7 +18,12 @@
<div class="relative" > <div class="relative" >
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
{#each filteredThemes as theme (theme.id)} {#each filteredThemes as theme (theme.id)}
<ThemeCard theme={theme} onClick={() => setDisplayTheme(theme)} /> <ThemeCard
{theme}
onClick={() => setDisplayTheme(theme)}
{toggleFavorite}
{isLoggedIn}
/>
{/each} {/each}
{#if filteredThemes.length !== 0} {#if filteredThemes.length !== 0}
@@ -2,8 +2,9 @@
import type { Theme } from '@/interface/types/Theme' import type { Theme } from '@/interface/types/Theme'
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { animate } from 'motion'; import { animate } from 'motion';
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte';
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme } = $props<{ let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme, toggleFavorite, isLoggedIn } = $props<{
theme: Theme | null; theme: Theme | null;
currentThemes: string[]; currentThemes: string[];
setDisplayTheme: (theme: Theme | null) => void; setDisplayTheme: (theme: Theme | null) => void;
@@ -11,15 +12,30 @@
onRemove: (themeId: string) => void; onRemove: (themeId: string) => void;
allThemes: Theme[]; allThemes: Theme[];
displayTheme: Theme | null; displayTheme: Theme | null;
toggleFavorite?: (theme: Theme) => void;
isLoggedIn?: boolean;
}>(); }>();
let installing = $state(false); let installing = $state(false);
let showSignInModal = $state(false);
let modalElement: HTMLElement; let modalElement: HTMLElement;
function handleFavoriteClick() {
if (isLoggedIn && toggleFavorite && theme) {
toggleFavorite(theme);
} else {
showSignInModal = true;
}
}
// Function to get related themes // Function to get related themes
function getRelatedThemes() { function getRelatedThemes() {
if (!theme) return [];
return allThemes return allThemes
.filter((t: Theme) => t.id !== theme.id) .filter((t: Theme) => !!t && t.id !== theme.id)
.sort((a: Theme, b: Theme) => a.name.localeCompare(theme.name) - b.name.localeCompare(theme.name)) .sort(
(a: Theme, b: Theme) =>
a.name.localeCompare(theme.name) - b.name.localeCompare(theme.name),
)
.slice(0, 4); .slice(0, 4);
} }
@@ -72,19 +88,51 @@
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}
> >
{#if theme}
<div class="relative h-auto"> <div class="relative h-auto">
<button class="absolute top-0 right-0 p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}> <div class="absolute top-0 right-0 flex gap-1 items-center">
<button class="p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
{'\ued8a'} {'\ued8a'}
</button> </button>
<h2 class="mb-4 text-2xl font-bold"> </div>
<h2 class="mb-2 text-2xl font-bold">
{theme.name} {theme.name}
</h2> </h2>
<img src={theme.marqueeImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" /> <div class="flex gap-4 mb-4 text-sm text-zinc-600 dark:text-zinc-400">
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{(theme.download_count ?? 0).toLocaleString()} downloads
</span>
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{(theme.favorite_count ?? 0).toLocaleString()} favorites
</span>
</div>
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" />
<p class="mb-4 text-gray-700 dark:text-gray-300"> <p class="mb-4 text-gray-700 dark:text-gray-300">
{theme.description} {theme.description}
</p> </p>
<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>
{theme.is_favorited ? 'Favorited' : 'Favorite'}
</button>
{/if}
{#if currentThemes.includes(theme.id)} {#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"> <button onclick={async () => {installing = true; await onRemove(theme.id); installing = false}} class="flex relative justify-center items-center px-4 py-2 w-32 text-black rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95">
{#if installing} {#if installing}
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/> <path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
@@ -93,7 +141,7 @@
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Remove</span> <span class="{ installing ? 'opacity-0' : 'opacity-100' }">Remove</span>
</button> </button>
{:else} {: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"> <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} {#if installing}
<svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg class="absolute w-4 h-4 { installing ? 'opacity-100' : 'opacity-0' }" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/> <path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
@@ -102,6 +150,7 @@
<span class="{ installing ? 'opacity-0' : 'opacity-100' }">Install</span> <span class="{ installing ? 'opacity-0' : 'opacity-100' }">Install</span>
</button> </button>
{/if} {/if}
</div>
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div> <div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
@@ -116,11 +165,22 @@
{relatedTheme.name} {relatedTheme.name}
</div> </div>
<div class="absolute bottom-0 z-0 w-full h-3/4 to-transparent from-black/80 bg-linear-to-t"></div> <div class="absolute bottom-0 z-0 w-full h-3/4 to-transparent from-black/80 bg-linear-to-t"></div>
<img src={relatedTheme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48" /> <img src={relatedTheme.marqueeImage || relatedTheme.coverImage} alt="Theme Preview" class="object-cover w-full h-48" />
</div> </div>
</button> </button>
{/each} {/each}
</div> </div>
</div> </div>
{:else}
<div class="flex justify-center items-center h-full text-zinc-600 dark:text-zinc-300">
<button class="px-4 py-2 rounded-lg bg-zinc-200 dark:bg-zinc-700 transition-all duration-200 hover:scale-105 active:scale-95" onclick={() => hideModal()}>
Close
</button>
</div>
{/if}
</div> </div>
</div> </div>
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if}
@@ -1,11 +1,14 @@
<script lang="ts"> <script lang="ts">
import type { CustomTheme, ThemeList } from '@/types/CustomThemes' import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import browser from 'webextension-polyfill'
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator' import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { OpenStorePage } from '@/seqta/ui/renderStore' import { OpenStorePage } from '@/seqta/ui/renderStore'
import { themeUpdates } from '@/interface/hooks/ThemeUpdates' import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup' import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager' import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte'
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
@@ -13,6 +16,17 @@
let { isEditMode } = $props<{ isEditMode: boolean }>(); let { isEditMode } = $props<{ isEditMode: boolean }>();
let isDragging = $state(false); let isDragging = $state(false);
let tempTheme = $state(null); let tempTheme = $state(null);
let favoriteStatus = $state<Record<string, boolean>>({});
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
let prevLoggedIn = $state(false);
let showSignInModal = $state(false);
cloudAuth.subscribe((s) => {
const now = s.isLoggedIn;
if (now && !prevLoggedIn && themes) void fetchThemes();
prevLoggedIn = now;
cloudLoggedIn = now;
});
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => { const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
if (isEditMode) return; if (isEditMode) return;
@@ -87,11 +101,55 @@
themes: await themeManager.getAvailableThemes(), themes: await themeManager.getAvailableThemes(),
selectedTheme: themeManager.getSelectedThemeId() || '', selectedTheme: themeManager.getSelectedThemeId() || '',
} }
if (themes && cloudLoggedIn) {
const token = await cloudAuth.getStoredToken();
if (token) {
const status: Record<string, boolean> = {};
await Promise.all(
themes.themes.map(async (t) => {
try {
const res = (await browser.runtime.sendMessage({
type: 'fetchThemeDetails',
themeId: t.id,
token,
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
if (res?.success && res?.data?.theme) {
status[t.id] = !!res.data.theme.is_favorited;
}
} catch {
// Theme may not exist on store (e.g. locally created)
}
})
);
favoriteStatus = status;
}
} else {
favoriteStatus = {};
}
}
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
e.stopPropagation();
if (!cloudLoggedIn) {
showSignInModal = true;
return;
}
const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !favoriteStatus[theme.id];
const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite',
themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean };
if (result?.success) {
favoriteStatus = { ...favoriteStatus, [theme.id]: isFavorite };
}
} }
onMount(async () => { onMount(async () => {
await fetchThemes(); await fetchThemes();
themeUpdates.addListener(fetchThemes); themeUpdates.addListener(fetchThemes);
}) })
@@ -144,6 +202,18 @@
{/if} {/if}
{#if !isEditMode} {#if !isEditMode}
<div
class="flex absolute right-24 top-1/4 z-20 place-items-center p-2 w-8 h-8 text-center rounded-full opacity-0 transition-all -translate-y-1/2 group-hover:opacity-100 group-hover:top-1/2 {(favoriteStatus[theme.id] ?? false) ? 'text-red-400' : 'text-white/80'} bg-black/50"
onclick={(event) => handleToggleFavorite(theme, event)}
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') handleToggleFavorite(theme, event as any) }}
role="button"
tabindex="-1"
title={cloudLoggedIn ? ((favoriteStatus[theme.id] ?? false) ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={(favoriteStatus[theme.id] ?? false) ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</div>
<div <div
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2" class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }} onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
@@ -211,3 +281,7 @@
</button> </button>
</div> </div>
</div> </div>
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if}
+269 -36
View File
@@ -1,43 +1,48 @@
<script lang="ts"> <script lang="ts">
import TabbedContainer from '../components/TabbedContainer.svelte'; import TabbedContainer from "../components/TabbedContainer.svelte";
import Settings from './settings/general.svelte'; import Settings from "./settings/general.svelte";
import Shortcuts from './settings/shortcuts.svelte'; import Shortcuts from "./settings/shortcuts.svelte";
import Theme from './settings/theme.svelte'; import Theme from "./settings/theme.svelte";
import browser from 'webextension-polyfill'; import browser from "webextension-polyfill";
import { standalone as StandaloneStore } from '../utils/standalone.svelte'; import { standalone as StandaloneStore } from "../utils/standalone.svelte";
import { onMount } from 'svelte' import { onMount } from "svelte";
import { settingsState } from '@/seqta/utils/listeners/SettingsState' import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup" import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
import { OpenAboutPage } from "@/seqta/utils/Openers/OpenAboutPage" import { OpenAboutPage } from "@/seqta/utils/Openers/OpenAboutPage";
import { OpenWhatsNewPopup } from "@/seqta/utils/Whatsnew" import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
import ColourPicker from '../components/ColourPicker.svelte' import ColourPicker from "../components/ColourPicker.svelte";
import { settingsPopup } from '../hooks/SettingsPopup' import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "../hooks/SettingsPopup";
let devModeSequence = ''; let devModeSequence = "";
let settingsActiveTab = $state(0);
let showDisclaimerModal = $state(false);
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
const handleDevModeToggle = () => { const handleDevModeToggle = () => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
devModeSequence += event.key.toLowerCase(); devModeSequence += event.key.toLowerCase();
if (devModeSequence.includes('dev')) { if (devModeSequence.includes("dev")) {
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
settingsState.devMode = true; settingsState.devMode = true;
alert('Dev mode is now enabled'); alert("Dev mode is now enabled");
} }
}; };
document.addEventListener('keydown', handleKeyDown); document.addEventListener("keydown", handleKeyDown);
setTimeout(() => { setTimeout(() => {
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
devModeSequence = ''; devModeSequence = "";
}, 10000); }, 10000);
}; };
const openColourPicker = () => { const openColourPicker = () => {
showColourPicker = true; showColourPicker = true;
} };
const openChangelog = () => { const openChangelog = () => {
OpenWhatsNewPopup(); OpenWhatsNewPopup();
@@ -49,43 +54,271 @@
closeExtensionPopup(); closeExtensionPopup();
}; };
/* const openMinecraftServer = () => {
OpenMinecraftServerPopup();
closeExtensionPopup();
}; */
const openPrivacyStatement = () => {
window.open("https://betterseqta.org/privacy", "_blank");
closeExtensionPopup();
};
let { standalone } = $props<{ standalone?: boolean }>(); let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state<boolean>(false); let showColourPicker = $state<boolean>(false);
onMount(async () => { const showDisclaimer = (onConfirm: () => void, onCancel: () => void) => {
disclaimerCallbacks = { onConfirm, onCancel };
showDisclaimerModal = true;
};
onMount(() => {
settingsPopup.addListener(() => { settingsPopup.addListener(() => {
showColourPicker = false; showColourPicker = false;
}); });
if (!standalone) return; if (standalone) {
StandaloneStore.setStandalone(true); StandaloneStore.setStandalone(true);
}
}); });
</script> </script>
<div class="w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode ? 'dark' : ''} { standalone ? 'h-[600px]' : 'h-full rounded-xl' } overflow-clip"> <div
<div class="flex relative flex-col gap-2 h-full overflow-clip bg-white dark:bg-zinc-800 dark:text-white"> class="w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode
<div class="grid place-items-center border-b border-b-zinc-200/40 dark:border-b-zinc-700/40"> ? 'dark'
: ''} {standalone ? 'h-[600px]' : 'h-full rounded-xl'} overflow-clip"
>
<div
class="flex relative flex-col gap-2 h-full overflow-clip bg-white dark:bg-zinc-800 dark:text-white"
>
<div
class="grid place-items-center border-b border-b-zinc-200/40 dark:border-b-zinc-700/40"
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<img src={browser.runtime.getURL('resources/icons/betterseqta-dark-full.png')} class="w-4/5 dark:hidden" alt="Light logo" onclick={handleDevModeToggle} /> <img
src={browser.runtime.getURL(
"resources/icons/betterseqta-dark-full.png",
)}
class="w-4/5 dark:hidden"
alt="Light logo"
onclick={handleDevModeToggle}
/>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<img src={browser.runtime.getURL('resources/icons/betterseqta-light-full.png')} class="hidden w-4/5 dark:block" alt="Dark logo" onclick={handleDevModeToggle} /> <img
src={browser.runtime.getURL(
"resources/icons/betterseqta-light-full.png",
)}
class="hidden w-4/5 dark:block"
alt="Dark logo"
onclick={handleDevModeToggle}
/>
{#if !standalone} {#if !standalone}
<button onclick={openChangelog} class="absolute top-1 right-1 w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700">{'\ue929'}</button> <div class="flex absolute top-1 right-1 gap-1 items-center">
<button onclick={openAbout} class="absolute top-1 right-10 w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700">{'\ueb73'}</button> <button
onclick={openAbout}
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
>
{"\ueb73"}
</button>
<button
onclick={openChangelog}
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
>
{"\ue929"}
</button>
<button
onclick={openPrivacyStatement}
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
aria-label="Privacy Statement"
>
{"\uecba"}
</button>
<!-- <button
onclick={openMinecraftServer}
class="flex justify-center items-center p-1 w-8 h-8 rounded-xl bg-zinc-100 dark:bg-zinc-700"
aria-label="Open Minecraft Server"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 64 70"
fill="none"
class="w-full h-full"
>
<path
d="M0 0 C3.96 0 7.92 0 12 0 C12 3.96 12 7.92 12 12 C10.68 12 9.36 12 8 12 C8 10.68 8 9.36 8 8 C6.68 8 5.36 8 4 8 C4 6.68 4 5.36 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(42,10)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 6.6 4 13.2 4 20 C2.68 20 1.36 20 0 20 C0 13.4 0 6.8 0 0 Z "
fill="currentColor"
transform="translate(54,22)"
/>
<path
d="M0 0 C6.6 0 13.2 0 20 0 C20 1.32 20 2.64 20 4 C13.4 4 6.8 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(22,6)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 5.28 4 10.56 4 16 C2.68 16 1.36 16 0 16 C0 10.72 0 5.44 0 0 Z "
fill="currentColor"
transform="translate(46,26)"
/>
<path
d="M0 0 C5.28 0 10.56 0 16 0 C16 1.32 16 2.64 16 4 C10.72 4 5.44 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(22,14)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C5.32 4 6.64 4 8 4 C8 5.32 8 6.64 8 8 C5.36 8 2.72 8 0 8 C0 5.36 0 2.72 0 0 Z "
fill="currentColor"
transform="translate(6,50)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(14,50)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(18,46)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(10,46)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(50,42)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(22,42)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(14,42)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(26,38)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(18,38)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(30,34)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(22,34)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(34,30)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(26,30)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(38,26)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(30,26)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(42,22)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(34,22)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(38,18)"
/>
<path
d="M0 0 C1.32 0 2.64 0 4 0 C4 1.32 4 2.64 4 4 C2.68 4 1.36 4 0 4 C0 2.68 0 1.36 0 0 Z "
fill="currentColor"
transform="translate(18,10)"
/>
</svg>
</button> -->
</div>
{/if} {/if}
</div> </div>
<TabbedContainer tabs={[ <TabbedContainer
{ title: 'Settings', Content: Settings, props: { showColourPicker: openColourPicker } }, bind:activeTab={settingsActiveTab}
{ title: 'Shortcuts', Content: Shortcuts }, tabs={[
{ title: 'Themes', Content: Theme }, {
]} /> title: "Settings",
Content: Settings,
props: { showColourPicker: openColourPicker, showDisclaimer },
},
{ title: "Shortcuts", Content: Shortcuts },
{ title: "Themes", Content: Theme },
]}
/>
</div> </div>
{#if showColourPicker} {#if showColourPicker}
<ColourPicker hidePicker={() => { showColourPicker = false }} /> <ColourPicker
hidePicker={() => {
showColourPicker = false;
}}
/>
{/if} {/if}
</div> </div>
{#if showDisclaimerModal && disclaimerCallbacks}
<DisclaimerModal
title="Assessment Averages Disclaimer"
message="This feature calculates a simple average of your assessment grades. It does not take into account:
• Assessment weightings
• Different grading scales
• Other factors used in official reports
The displayed average may be inaccurate compared to your actual marks found in reports.
Do you want to enable this feature?"
onConfirm={() => {
disclaimerCallbacks?.onConfirm();
showDisclaimerModal = false;
disclaimerCallbacks = null;
}}
onCancel={() => {
disclaimerCallbacks?.onCancel();
showDisclaimerModal = false;
disclaimerCallbacks = null;
}}
/>
{/if}
+95 -7
View File
@@ -10,7 +10,9 @@
import type { SettingsList } from "@/interface/types/SettingsProps" import type { SettingsList } from "@/interface/types/SettingsProps"
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts" import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import PickerSwatch from "@/interface/components/PickerSwatch.svelte" import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent" import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getAllPluginSettings } from "@/plugins" import { getAllPluginSettings } from "@/plugins"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types" import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
@@ -91,7 +93,10 @@
loadPluginSettings(); loadPluginSettings();
}) })
const { showColourPicker } = $props<{ showColourPicker: () => void }>(); const { showColourPicker, showDisclaimer } = $props<{
showColourPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
}>();
</script> </script>
{#snippet Setting({ title, description, Component, props }: SettingsList) } {#snippet Setting({ title, description, Component, props }: SettingsList) }
@@ -108,6 +113,13 @@
<div class="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700"> <div class="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700">
{#each [ {#each [
{
title: "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: "Transparency Effects", title: "Transparency Effects",
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)", description: "Enables transparency effects on certain elements such as blur. (May impact battery life)",
@@ -137,6 +149,16 @@
text: "Edit" text: "Edit"
} }
}, },
{
title: "Icon Only Sidebar",
description: "Show only icons in the sidebar for a compact layout.",
id: 14,
Component: Switch,
props: {
state: $settingsState.iconOnlySidebar ?? false,
onChange: (isOn: boolean) => settingsState.iconOnlySidebar = isOn
}
},
{ {
title: "Animations", title: "Animations",
description: "Enables animations on certain pages.", description: "Enables animations on certain pages.",
@@ -187,21 +209,53 @@
options: [ options: [
{ value: "australia", label: "Australia" }, { value: "australia", label: "Australia" },
{ value: "usa", label: "USA" }, { value: "usa", label: "USA" },
{ value: "uk", label: "UK" },
{ value: "taiwan", label: "Taiwan" }, { value: "taiwan", label: "Taiwan" },
{ value: "hong_kong", label: "Hong Kong" }, { value: "hong_kong", label: "Hong Kong" },
{ value: "panama", label: "Panama" }, { value: "panama", label: "Panama" },
{ value: "canada", label: "Canada" }, { value: "canada", label: "Canada" },
{ value: "singapore", label: "Singapore" }, { value: "singapore", label: "Singapore" },
{ value: "uk", label: "UK" },
{ value: "japan", label: "Japan" }, { value: "japan", label: "Japan" },
{ value: "netherlands", label: "Netherlands" } { value: "netherlands", label: "Netherlands" }
] ]
} }
} }
] as option} ] as option}
{@render Setting(option)} {@render Setting(option)}
{/each} {/each}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Adaptive Theme Colour</h2>
<p class="text-xs">Change the theme colour based on the current class (e.g. when viewing a course or assessments page).</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeColour ?? false}
onChange={(isOn: boolean) => settingsState.adaptiveThemeColour = isOn}
/>
</div>
</div>
{#if $settingsState.adaptiveThemeColour}
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
<div class="pr-4">
<h2 class="text-sm font-bold">Soft Gradient</h2>
<p class="text-xs">Use a soft gradient instead of a solid colour when viewing a class.</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeGradient ?? false}
onChange={(isOn: boolean) => settingsState.adaptiveThemeGradient = isOn}
/>
</div>
</div>
{/if}
</div>
</div>
{#each pluginSettings as plugin} {#each pluginSettings as plugin}
<div class="border-none"> <div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40 {!(plugin as any).disableToggle && Object.keys(plugin.settings).length === 0 ? 'hidden' : ''}"> <div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40 {!(plugin as any).disableToggle && Object.keys(plugin.settings).length === 0 ? 'hidden' : ''}">
@@ -222,7 +276,20 @@
<div> <div>
<Switch <Switch
state={pluginSettingsValues[plugin.pluginId]?.enabled ?? true} state={pluginSettingsValues[plugin.pluginId]?.enabled ?? true}
onChange={(value) => updatePluginSetting(plugin.pluginId, 'enabled', value)} onChange={async (value) => {
if (plugin.pluginId === 'assessments-average' && value === true) {
showDisclaimer(
async () => {
await updatePluginSetting(plugin.pluginId, 'enabled', true);
},
() => {
// Do nothing on cancel
}
);
return;
}
await updatePluginSetting(plugin.pluginId, 'enabled', value);
}}
/> />
</div> </div>
</div> </div>
@@ -244,6 +311,7 @@
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)} onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/> />
{:else if setting.type === 'number'} {:else if setting.type === 'number'}
<div class="w-28 shrink-0">
<Slider <Slider
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default} state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)} onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
@@ -251,6 +319,7 @@
max={setting.max} max={setting.max}
step={setting.step} step={setting.step}
/> />
</div>
{:else if setting.type === 'string'} {:else if setting.type === 'string'}
<input <input
type="text" type="text"
@@ -322,9 +391,9 @@
<p class="text-xs">Replace sensitive content with mock data</p> <p class="text-xs">Replace sensitive content with mock data</p>
</div> </div>
<div> <div>
<Button <Switch
onClick={() => hideSensitiveContent()} state={$settingsState.hideSensitiveContent ?? false}
text="Hide" onChange={(isOn: boolean) => settingsState.hideSensitiveContent = isOn}
/> />
</div> </div>
</div> </div>
@@ -340,6 +409,25 @@
/> />
</div> </div>
</div> </div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Show Privacy Notification</h2>
<p class="text-xs">Show the privacy notification popup on next page load</p>
</div>
<div>
<Button
onClick={async () => {
settingsState.privacyStatementShown = false;
settingsState.privacyStatementLastUpdated = undefined;
closeExtensionPopup();
// Small delay to ensure popup is closed before showing notification
await new Promise(resolve => setTimeout(resolve, 100));
await showPrivacyNotification();
}}
text="Show Now"
/>
</div>
</div>
</div> </div>
{/if} {/if}
</div> </div>
+21 -15
View File
@@ -24,12 +24,18 @@
}); });
const switchChange = (shortcut: any) => { const switchChange = (shortcut: any) => {
const value = $settingsState.shortcuts.find(s => s.name === shortcut); const idx = $settingsState.shortcuts.findIndex(s => s.name === shortcut);
if (value) { if (idx !== -1) {
value.enabled = !value.enabled; // Create a new array with the toggled value to ensure reactivity
settingsState.shortcuts = settingsState.shortcuts; const updated = settingsState.shortcuts.map(s =>
s.name === shortcut ? { ...s, enabled: !s.enabled } : s
);
settingsState.shortcuts = updated;
} else { } else {
settingsState.shortcuts = [...settingsState.shortcuts, { name: shortcut, enabled: true }]; settingsState.shortcuts = [
...settingsState.shortcuts,
{ name: shortcut, enabled: true }
];
} }
} }
@@ -196,16 +202,6 @@
</MotionDiv> </MotionDiv>
</div> </div>
{#each Object.entries(Shortcuts) as shortcut}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<!-- Use DisplayName if it exists, otherwise use the key (shortcut[0]) as a fallback -->
<h2 class="text-sm">{shortcut[1].DisplayName || shortcut[0]}</h2>
</div>
<Switch state={$settingsState.shortcuts.find(s => s.name === shortcut[0])?.enabled ?? false} onChange={() => switchChange(shortcut[0])} />
</div>
{/each}
<!-- Custom Shortcuts Section --> <!-- Custom Shortcuts Section -->
{#each $settingsState.customshortcuts as shortcut, index} {#each $settingsState.customshortcuts as shortcut, index}
<div class="flex justify-between items-center px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
@@ -217,6 +213,16 @@
</button> </button>
</div> </div>
{/each} {/each}
{#each Object.entries(Shortcuts) as shortcut}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<!-- Use DisplayName if it exists, otherwise use the key (shortcut[0]) as a fallback -->
<h2 class="text-sm">{shortcut[1].DisplayName || shortcut[0]}</h2>
</div>
<Switch state={$settingsState.shortcuts.find(s => s.name === shortcut[0])?.enabled ?? false} onChange={() => switchChange(shortcut[0])} />
</div>
{/each}
{:else} {:else}
<div class="p-4 text-center"> <div class="p-4 text-center">
Loading shortcuts... Loading shortcuts...
+5 -2
View File
@@ -21,13 +21,16 @@
<div class="relative w-full"> <div class="relative w-full">
<button <button
onclick={() => editMode = !editMode} onclick={() => editMode = !editMode}
class="absolute top-0 right-0 z-10 w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700">{editMode ? '\ue9e4' : '\uec38'}</button> class="absolute top-0 right-0 z-10 px-2 h-8 text-lg rounded-xl bg-zinc-100 dark:bg-zinc-700">
<span class="mr-2">{editMode ? 'Done' : 'Edit'}</span>
<span class="font-IconFamily">{editMode ? '\ue9e4' : '\uec38'}</span>
</button>
<BackgroundSelector isEditMode={editMode} bind:selectedBackground={selectedBackground} bind:selectNoBackground={selectNoBackground} /> <BackgroundSelector isEditMode={editMode} bind:selectedBackground={selectedBackground} bind:selectNoBackground={selectNoBackground} />
<ThemeSelector isEditMode={editMode} /> <ThemeSelector isEditMode={editMode} />
</div> </div>
{:else} {:else}
<div class="flex items-center justify-center w-full h-full"> <div class="flex justify-center items-center w-full h-full">
<div class="text-lg"> <div class="text-lg">
Open SEQTA and use the embedded settings to access theme settings. 🫠 Open SEQTA and use the embedded settings to access theme settings. 🫠
</div> </div>
+67 -7
View File
@@ -15,8 +15,12 @@
import { loadBackground } from '@/seqta/ui/ImageBackgrounds' import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
import Backgrounds from '../components/store/Backgrounds.svelte' import Backgrounds from '../components/store/Backgrounds.svelte'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
// State variables // State variables
let searchTerm = $state(''); let searchTerm = $state('');
@@ -48,20 +52,57 @@
activeTab = tab; activeTab = tab;
}; };
// Fetch themes and initialize app const toggleFavorite = async (theme: Theme) => {
const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !theme.is_favorited;
const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite',
themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean };
if (result?.success) {
const delta = isFavorite ? 1 : -1;
themes = themes.map((t) =>
t.id === theme.id
? { ...t, is_favorited: isFavorite, favorite_count: Math.max(0, (t.favorite_count ?? 0) + delta) }
: t
);
if (displayTheme?.id === theme.id) {
displayTheme = {
...displayTheme,
is_favorited: isFavorite,
favorite_count: Math.max(0, (displayTheme.favorite_count ?? 0) + delta),
};
}
}
};
// Fetch themes via background script (avoids CORS when store runs inside SEQTA page)
const fetchThemes = async () => { const fetchThemes = async () => {
try { try {
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${(new Date()).getTime()}`, { cache: 'no-store' }); const token = await cloudAuth.getStoredToken();
const data = await response.json(); const data = (await browser.runtime.sendMessage({
themes = data.themes; type: 'fetchThemes',
token: token ?? undefined,
})) as {
success?: boolean;
data?: { themes: Theme[] };
error?: string;
};
if (!data?.success || !data?.data?.themes) {
throw new Error(data?.error || 'Failed to fetch themes');
}
themes = data.data.themes;
// Shuffle for cover themes // Shuffle for cover themes
const shuffled = [...themes].sort(() => 0.5 - Math.random()); const shuffled = [...themes].sort(() => 0.5 - Math.random());
coverThemes = shuffled.slice(0, 3); coverThemes = shuffled.slice(0, 3);
loading = false; loading = false;
} catch (error) { } catch (err) {
console.error('Failed to fetch themes', error); console.error('Failed to fetch themes', err);
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs
} }
}; };
@@ -91,6 +132,17 @@
console.error(error); console.error(error);
} }
}); });
// Refetch themes when user logs in (from another tab) to get is_favorited
let lastLoggedIn = $state(false);
$effect(() => {
if (cloudLoggedIn && !lastLoggedIn) {
lastLoggedIn = true;
fetchThemes();
} else if (!cloudLoggedIn) {
lastLoggedIn = false;
}
});
</script> </script>
<div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}"> <div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}">
@@ -111,7 +163,13 @@
{/if} {/if}
<!-- ThemeGrid to display filtered themes --> <!-- ThemeGrid to display filtered themes -->
<ThemeGrid themes={filteredThemes} {searchTerm} {setDisplayTheme} /> <ThemeGrid
themes={filteredThemes}
{searchTerm}
{setDisplayTheme}
{toggleFavorite}
isLoggedIn={cloudLoggedIn}
/>
{#if displayTheme} {#if displayTheme}
<ThemeModal <ThemeModal
@@ -120,6 +178,8 @@
theme={displayTheme} theme={displayTheme}
{displayTheme} {displayTheme}
{setDisplayTheme} {setDisplayTheme}
{toggleFavorite}
isLoggedIn={cloudLoggedIn}
onInstall={async () => { onInstall={async () => {
if (displayTheme) { if (displayTheme) {
await themeManager.downloadTheme(displayTheme); await themeManager.downloadTheme(displayTheme);
+6 -2
View File
@@ -1,7 +1,11 @@
export type Theme = { export type Theme = {
id: string;
name: string; name: string;
description: string; description: string;
coverImage: string; coverImage: string;
marqueeImage: string; marqueeImage?: string;
id: string; theme_json_url?: string;
is_favorited?: boolean;
favorite_count?: number;
download_count?: number;
}; };
+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();
},
};
};
+1 -1
View File
@@ -14,7 +14,7 @@ const updatedFirefoxManifest = {
}, },
browser_specific_settings: { browser_specific_settings: {
gecko: { gecko: {
id: pkg.author.email, id: "betterseqta@betterseqta.com",
}, },
}, },
}; };
+3 -3
View File
@@ -15,13 +15,13 @@
"64": "resources/icons/icon-64.png" "64": "resources/icons/icon-64.png"
} }
}, },
"permissions": ["tabs", "notifications", "storage"], "permissions": ["tabs", "notifications", "storage", "cookies"],
"host_permissions": ["https://newsapi.org/", "*://*/*"], "host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
"background": { "background": {
"service_worker": "background.ts" "service_worker": "background.ts"
}, },
"content_security_policy": { "content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'" "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org"
}, },
"content_scripts": [ "content_scripts": [
{ {
+400 -40
View File
@@ -10,6 +10,20 @@ class ReactFiber {
console.log("Selected Nodes:", this.nodes); console.log("Selected Nodes:", this.nodes);
console.log("🔍 Found Fibers:", this.fibers); console.log("🔍 Found Fibers:", this.fibers);
console.log("🛠 Found Components:", this.components); console.log("🛠 Found Components:", this.components);
// Debug fiber info
this.fibers.forEach((fiber, index) => {
if (fiber) {
console.log(`Fiber ${index}:`, {
tag: fiber.tag,
type: fiber.type?.name || fiber.type,
elementType: fiber.elementType,
stateNode: fiber.stateNode,
hasState: !!fiber.stateNode?.state,
hasMemoizedState: !!fiber.memoizedState
});
}
});
} }
} }
@@ -19,10 +33,27 @@ class ReactFiber {
getFiberNode(node) { getFiberNode(node) {
if (!node) return null; if (!node) return null;
// Try multiple property name patterns for different React versions
const possibleKeys = [
'__reactFiber$', // React 16+
'__reactInternalFiber$', // React 15
'__reactInternalInstance$', // Older versions
'__reactFiber',
'__reactInternalInstance'
];
// Check for exact matches first
for (const key of possibleKeys) {
if (node[key]) return node[key];
}
// Fall back to pattern matching
const fiberKey = Object.getOwnPropertyNames(node).find( const fiberKey = Object.getOwnPropertyNames(node).find(
(name) => (name) =>
name.startsWith("__reactFiber") || name.startsWith("__reactFiber") ||
name.startsWith("__reactInternalInstance"), name.startsWith("__reactInternalInstance") ||
name.startsWith("__reactInternalFiber")
); );
return fiberKey ? node[fiberKey] : null; return fiberKey ? node[fiberKey] : null;
} }
@@ -30,20 +61,71 @@ class ReactFiber {
getOwnerComponent(fiberNode) { getOwnerComponent(fiberNode) {
let current = fiberNode; let current = fiberNode;
while (current) { while (current) {
// Use React's internal tag system to identify component types
// Based on React's WorkTags: ClassComponent = 1, FunctionComponent = 0
if (current.tag === 1) { // ClassComponent
return current.stateNode; // For class components, stateNode is the component instance
}
// For function components, look for hooks in memoizedState
if (current.tag === 0 || current.tag === 15) { // FunctionComponent or MemoComponent
// Function components don't have setState, but we can still track them
if (current.memoizedState && current.type) {
return {
type: 'function',
hooks: current.memoizedState,
fiber: current,
forceUpdate: () => {
// Trigger re-render by updating fiber
if (current.alternate) {
current.alternate.expirationTime = 1;
}
current.expirationTime = 1;
}
};
}
}
// Legacy fallback: check if stateNode has React component methods
if ( if (
current.stateNode && current.stateNode &&
current.stateNode !== null &&
typeof current.stateNode === 'object' &&
(current.stateNode.setState || current.stateNode.forceUpdate) (current.stateNode.setState || current.stateNode.forceUpdate)
) { ) {
return current.stateNode; return current.stateNode;
} }
current = current.return; current = current.return;
} }
return null; return null;
} }
getState(key) { getState(key) {
if (!this.components.length) return null; if (!this.components.length && !this.fibers.length) return null;
const state = this.components[0]?.state || null;
const component = this.components[0];
const fiber = this.fibers[0];
let state = null;
// Handle class components
if (component?.state) {
state = component.state;
}
// Handle function components with hooks - look directly at fiber
else if (fiber?.memoizedState) {
if (this.debug) {
console.log("🔍 Raw fiber.memoizedState:", fiber.memoizedState);
}
// Extract useState values from the hook chain
const states = this.extractStateFromHooks(fiber.memoizedState);
state = states.length === 1 ? states[0] : states;
}
// Fallback: try component hooks if available
else if (component?.type === 'function' && component?.hooks) {
const states = this.extractStateFromHooks(component.hooks);
state = states.length === 1 ? states[0] : states;
}
if (key === undefined) { if (key === undefined) {
return state; return state;
@@ -61,8 +143,137 @@ class ReactFiber {
return null; return null;
} }
extractStateFromHooks(hookChain) {
const states = [];
let mainStateFound = false;
let currentHook = hookChain;
let hookIndex = 0;
if (this.debug) {
console.log("🔍 Hook chain analysis:");
}
while (currentHook) {
if (this.debug) {
console.log(`Hook ${hookIndex}:`, {
type: currentHook.tag || 'unknown',
memoizedState: currentHook.memoizedState,
queue: currentHook.queue,
next: !!currentHook.next
});
}
// Try different approaches to extract state
if (currentHook.memoizedState !== undefined && currentHook.memoizedState !== null) {
const state = currentHook.memoizedState;
// Priority 1: Check for useRef hooks with complex state in .current
if (!currentHook.queue &&
typeof state === 'object' &&
state !== null &&
state.current !== undefined &&
typeof state.current === 'object' &&
state.current !== null) {
// Check if this looks like a substantial state object (has multiple properties)
const currentKeys = Object.keys(state.current);
if (currentKeys.length > 2) {
states.push(state.current);
mainStateFound = true;
if (this.debug) console.log(` 🎯 Found main state in useRef:`, state.current);
}
}
// Priority 2: useState hooks with queue
else if (currentHook.queue && typeof state !== 'function') {
states.push(state);
if (this.debug) console.log(` ✅ Found useState state:`, state);
}
// Priority 3: Other potential state objects (only if we haven't found main state)
else if (!mainStateFound && !currentHook.queue && typeof state === 'object' && state !== null) {
// Skip useEffect hooks (they have tag 36)
if (!(state.tag === 36 && state.create)) {
states.push(state);
if (this.debug) console.log(` 📦 Found potential state object:`, state);
}
}
// Priority 4: Simple primitive state
else if (typeof state !== 'function' && typeof state !== 'object') {
states.push(state);
if (this.debug) console.log(` 🔹 Found primitive state:`, state);
}
}
currentHook = currentHook.next;
hookIndex++;
}
if (this.debug) {
console.log(`🎯 Extracted ${states.length} state values:`, states);
}
// If we found main state objects, prioritize and deduplicate them
if (mainStateFound && states.length > 1) {
const mainStates = states.filter(state =>
typeof state === 'object' &&
state !== null &&
Object.keys(state).length > 2
);
if (mainStates.length > 1) {
// If we have multiple main state objects, find the most comprehensive one
// or merge them if they seem complementary
const largestState = mainStates.reduce((largest, current) => {
const largestKeys = Object.keys(largest).length;
const currentKeys = Object.keys(current).length;
// Prefer the one with more properties
if (currentKeys > largestKeys) return current;
// If same number of properties, prefer the one with more complex data
if (currentKeys === largestKeys) {
const largestComplexity = this.calculateStateComplexity(largest);
const currentComplexity = this.calculateStateComplexity(current);
return currentComplexity > largestComplexity ? current : largest;
}
return largest;
});
if (this.debug) {
console.log(`🎯 Selected most comprehensive state from ${mainStates.length} candidates:`, largestState);
}
return [largestState];
}
return mainStates;
}
return states;
}
calculateStateComplexity(state) {
if (!state || typeof state !== 'object') return 0;
let complexity = 0;
for (const [key, value] of Object.entries(state)) {
complexity += 1; // Base point for each property
if (Array.isArray(value)) {
complexity += value.length * 0.1; // Arrays get points based on length
} else if (typeof value === 'object' && value !== null) {
complexity += Object.keys(value).length * 0.5; // Nested objects get points
} else if (typeof value === 'function') {
complexity += 2; // Functions are valuable
}
}
return complexity;
}
setState(update) { setState(update) {
this.components.forEach((component) => { this.components.forEach((component) => {
// Handle class components
if (component?.setState) { if (component?.setState) {
if (typeof update === "function") { if (typeof update === "function") {
// Functional update // Functional update
@@ -85,6 +296,13 @@ class ReactFiber {
}); });
} }
} }
// Handle function components - force re-render since we can't directly update hooks
else if (component?.type === 'function' && component?.forceUpdate) {
if (this.debug) {
console.log("⚠️ Function component detected - triggering re-render. Direct state update not possible.");
}
component.forceUpdate();
}
}); });
return this; return this;
} }
@@ -99,7 +317,7 @@ class ReactFiber {
return this.fibers[0]?.memoizedProps?.[propName]; return this.fibers[0]?.memoizedProps?.[propName];
} }
setProp(propName) { setProp(propName, value) {
this.fibers.forEach((fiber) => { this.fibers.forEach((fiber) => {
if (fiber?.memoizedProps) { if (fiber?.memoizedProps) {
fiber.memoizedProps[propName] = value; fiber.memoizedProps[propName] = value;
@@ -119,38 +337,176 @@ class ReactFiber {
} }
} }
function makeSerializable(obj) { function makeSerializable(obj, visited = new WeakSet(), depth = 0, maxDepth = 10) {
if (typeof obj !== "object" || obj === null) { // Handle primitives first
if (obj === null || obj === undefined) {
return obj; return obj;
} }
if (Array.isArray(obj)) { // Catch ALL functions early
return obj.map((item) => makeSerializable(item)); if (typeof obj === "function") {
return `[Function: ${obj.name || 'anonymous'}]`;
} }
if (typeof obj !== "object") {
// Handle other primitives
if (typeof obj === "symbol") return obj.toString();
if (typeof obj === "bigint") return obj.toString() + "n";
return obj;
}
// Prevent infinite recursion - depth limit
if (depth > maxDepth) {
return "[Max Depth Reached]";
}
// Prevent circular references
if (visited.has(obj)) {
return "[Circular Reference]";
}
visited.add(obj);
try {
// Handle special objects first
if (obj instanceof HTMLElement) {
return {
type: "HTMLElement",
tagName: obj.tagName,
id: obj.id || null,
className: obj.className || null,
attributes: obj.attributes ? Array.from(obj.attributes).map(attr => ({ name: attr.name, value: attr.value })) : []
};
}
if (obj instanceof Event) {
return {
type: "Event",
eventType: obj.type,
target: obj.target?.tagName || null
};
}
if (obj instanceof Date) {
return { type: "Date", value: obj.toISOString() };
}
if (obj instanceof RegExp) {
return { type: "RegExp", source: obj.source, flags: obj.flags };
}
if (obj instanceof Error) {
return { type: "Error", message: obj.message, name: obj.name };
}
// Handle React Fiber nodes - these are super circular
if (obj.tag !== undefined && obj.elementType !== undefined) {
return {
type: "ReactFiber",
tag: obj.tag,
elementType: typeof obj.elementType === 'function' ? obj.elementType.name || 'AnonymousComponent' : String(obj.elementType),
key: obj.key,
hasState: !!obj.stateNode?.state,
hasMemoizedState: !!obj.memoizedState,
hasProps: !!obj.memoizedProps
};
}
// Handle arrays
if (Array.isArray(obj)) {
return obj.slice(0, 50).map((item, index) => {
if (index >= 25) return "[...truncated]"; // Smaller limit
return makeSerializable(item, visited, depth + 1, maxDepth);
});
}
// Handle regular objects
const serializableObj = {}; const serializableObj = {};
for (const key in obj) {
if (Object.hasOwn(obj, key)) { // Get own enumerable properties only to avoid prototype pollution
const ownKeys = Object.getOwnPropertyNames(obj).filter(key => {
try {
return obj.propertyIsEnumerable(key);
} catch {
return false;
}
});
// Limit number of properties to avoid huge objects
const maxKeys = 30; // Smaller limit for safety
const processedKeys = ownKeys.slice(0, maxKeys);
for (const key of processedKeys) {
try {
// Skip problematic keys early
const dangerousKeys = [
'parentNode', 'parentElement', 'ownerDocument', 'children', 'childNodes',
'return', 'child', 'sibling', 'alternate', 'ref', // React Fiber circular refs
'_owner', '_source', '_self', '_debugOwner', '_debugSource', // React internals
'window', 'document', 'global', 'self', 'top', 'parent', // Global objects
'constructor', 'prototype', '__proto__', // Constructor/prototype chains
'addEventListener', 'removeEventListener', // Event handlers
'setState', 'forceUpdate', 'render' // React methods that might be functions
];
if (dangerousKeys.includes(key)) {
serializableObj[key] = `[Skipped: ${key}]`;
continue;
}
const descriptor = Object.getOwnPropertyDescriptor(obj, key);
if (descriptor && (descriptor.get || descriptor.set)) {
serializableObj[key] = "[Getter/Setter]";
continue;
}
let value = obj[key]; let value = obj[key];
if (typeof value === "function") { // Handle symbols specifically (React context symbols)
value = "[Function]"; if (typeof value === "symbol") {
} else if (value instanceof HTMLElement) { value = `[Symbol: ${value.description || 'anonymous'}]`;
value = { }
type: "HTMLElement", // Extra function check
id: value.id, else if (typeof value === "function") {
tagName: value.tagName, value = `[Function: ${value.name || 'anonymous'}]`;
}; // Replace DOM node with ID/tag info } else if (value && typeof value === "object") {
} else if (typeof value === "symbol") { value = makeSerializable(value, visited, depth + 1, maxDepth);
value = value.toString();
} else if (typeof value === "object" && value !== null) {
value = makeSerializable(value);
} }
serializableObj[key] = value; serializableObj[key] = value;
} catch (error) {
serializableObj[key] = `[Error: ${error.message}]`;
} }
} }
if (ownKeys.length > maxKeys) {
serializableObj['...'] = `[${ownKeys.length - maxKeys} more properties]`;
}
return serializableObj; return serializableObj;
} catch (error) {
return `[Serialization Error: ${error.message}]`;
} finally {
visited.delete(obj); // Clean up for potential reuse
}
}
// Final safety check - recursively scan for any remaining functions
function deepFunctionCheck(obj, path = "") {
if (typeof obj === "function") {
throw new Error(`Found function at path: ${path}`);
}
if (obj && typeof obj === "object") {
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
deepFunctionCheck(item, `${path}[${index}]`);
});
} else {
Object.keys(obj).forEach(key => {
deepFunctionCheck(obj[key], path ? `${path}.${key}` : key);
});
}
}
} }
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {
@@ -196,6 +552,28 @@ window.addEventListener("message", (event) => {
response = makeSerializable(response); response = makeSerializable(response);
} }
// Final safety check before postMessage
try {
deepFunctionCheck(response);
} catch (functionError) {
console.warn("[pageState] Function detected in response, cleaning:", functionError.message);
response = `[Cleaned Response - Function found at: ${functionError.message}]`;
}
// Additional structured clone test
try {
// Test if the object can be cloned (same algorithm as postMessage)
if (typeof structuredClone === 'function') {
structuredClone(response);
} else {
// Fallback for older browsers - try JSON round-trip
JSON.parse(JSON.stringify(response));
}
} catch (cloneError) {
console.warn("[pageState] Response not cloneable, fallback:", cloneError.message);
response = `[Uncloneable Response: ${cloneError.message}]`;
}
window.postMessage( window.postMessage(
{ {
type: "reactFiberResponse", type: "reactFiberResponse",
@@ -222,23 +600,5 @@ window.addEventListener("message", (event) => {
}); });
document.dispatchEvent(keyboardEvent); document.dispatchEvent(keyboardEvent);
} else if (event.data.type === "ckeditorSetData") {
// Handle CKEditor data setting
const { editorId, content } = event.data;
if (window.CKEDITOR && window.CKEDITOR.instances && window.CKEDITOR.instances[editorId]) {
window.CKEDITOR.instances[editorId].setData(content);
} else {
console.warn(`[pageState] CKEditor instance '${editorId}' not found`);
}
} else if (event.data.type === "ckeditorGetData") {
const { editorId } = event.data;
if (window.CKEDITOR && window.CKEDITOR.instances && window.CKEDITOR.instances[editorId]) {
const data = window.CKEDITOR.instances[editorId].getData();
window.postMessage({
type: "ckeditorGetDataResponse",
data,
}, "*");
}
} }
}); });
@@ -7,6 +7,20 @@ import {
import { type Plugin } from "@/plugins/core/types"; import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML"; import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import {
clearStuck,
getClassByPattern,
initStorage,
letterToNumber,
parseAssessments,
processAssessments,
} from "./utils.ts";
interface weightingsStorage {
weightings: Record<string, string>;
assessments: Record<string, string>;
}
const settings = defineSettings({ const settings = defineSettings({
lettergrade: booleanSetting({ lettergrade: booleanSetting({
@@ -23,7 +37,7 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
const instance = new AssessmentsAveragePluginClass(); const instance = new AssessmentsAveragePluginClass();
const assessmentsAveragePlugin: Plugin<typeof settings> = { const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
id: "assessments-average", id: "assessments-average",
name: "Assessment Averages", name: "Assessment Averages",
description: "Adds an average grade to the Assessments page", description: "Adds an average grade to the Assessments page",
@@ -32,8 +46,10 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
settings: instance.settings, settings: instance.settings,
run: async (api) => { run: async (api) => {
await initStorage(api);
clearStuck(api);
api.seqta.onMount(".assessmentsWrapper", async () => { api.seqta.onMount(".assessmentsWrapper", async () => {
// Wait for any assessment item to load first
await waitForElm( await waitForElm(
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']", "#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
true, true,
@@ -41,26 +57,13 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
1000, 1000,
); );
// Helper function to find actual class names by their base pattern await parseAssessments(api);
const getClassByPattern = (
element: Element | Document,
basePattern: string,
): string => {
// Find all classes on the element
const classes = Array.from(element.querySelectorAll("*"))
.flatMap((el) => Array.from(el.classList))
.filter((className) => className.startsWith(basePattern));
return classes.length ? classes[0] : "";
};
// Find actual class names from the DOM
const sampleAssessmentItem = document.querySelector( const sampleAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___']", "[class*='AssessmentItem__AssessmentItem___']",
); );
if (!sampleAssessmentItem) return; if (!sampleAssessmentItem) return;
// Extract all necessary class patterns from a sample assessment item
const assessmentItemClass = const assessmentItemClass =
Array.from(sampleAssessmentItem.classList).find((c) => Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"), c.startsWith("AssessmentItem__AssessmentItem___"),
@@ -83,7 +86,6 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
"AssessmentItem__title___", "AssessmentItem__title___",
); );
// Get Thermoscore classes
const thermoscoreElement = document.querySelector( const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']", "[class*='Thermoscore__Thermoscore___']",
); );
@@ -102,62 +104,34 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
"Thermoscore__text___", "Thermoscore__text___",
); );
// Find assessment list
const assessmentsList = document.querySelector( const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']", "#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
); );
if (!assessmentsList) return; if (!assessmentsList) return;
const gradeElements = document.querySelectorAll( const state = await ReactFiber.find(
"[class*='Thermoscore__text___']", "[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
if (!marks || !marks.length) return;
const assessmentItems = Array.from(
assessmentsList.querySelectorAll(
`[class*='AssessmentItem__AssessmentItem___']`,
),
).filter(
(item) =>
!item
.querySelector(`[class*='AssessmentItem__title___']`)
?.textContent?.includes("Subject Average"),
); );
if (!gradeElements.length) return;
// Parse and average grades const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
const letterToNumber: Record<string, number> = { await processAssessments(api, assessmentItems);
"A+": 100,
A: 95,
"A-": 90,
"B+": 85,
B: 80,
"B-": 75,
"C+": 70,
C: 65,
"C-": 60,
"D+": 55,
D: 50,
"D-": 45,
"E+": 40,
E: 35,
"E-": 30,
F: 0,
};
function parseGrade(text: string): number { if (!count || totalWeight === 0) return;
const str = text.trim().toUpperCase();
if (str.includes("/")) {
const [raw, max] = str.split("/").map((n) => parseFloat(n));
return (raw / max) * 100;
}
if (str.includes("%")) {
return parseFloat(str.replace("%", "")) || 0;
}
return letterToNumber[str] ?? 0;
}
let total = 0; const avg = weightedTotal / totalWeight;
let count = 0;
gradeElements.forEach((el) => {
const grade = parseGrade(el.textContent || "");
if (grade > 0) {
total += grade;
count++;
}
});
if (!count) return;
const avg = total / count;
const rounded = Math.ceil(avg / 5) * 5; const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce( const numberToLetter = Object.entries(letterToNumber).reduce(
(acc, [k, v]) => { (acc, [k, v]) => {
@@ -172,33 +146,86 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
? letterAvg ? letterAvg
: `${avg.toFixed(2)}%`; : `${avg.toFixed(2)}%`;
// Prevent duplicate
const existing = assessmentsList.querySelector( const existing = assessmentsList.querySelector(
`[class*='AssessmentItem__title___']`, `[class*='AssessmentItem__title___']`,
); );
if (existing?.textContent === "Subject Average") return; if (existing?.textContent === "Subject Average") return;
// Use the dynamic class names in the HTML template let warningHTML = "";
const averageElement = stringToHTML(/* html */ ` if (hasInaccurateWeighting) {
warningHTML = /* html */ `
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
Some weightings unavailable
</div>
`;
}
assessmentsList.insertBefore(
stringToHTML(/* html */ `
<div class="${assessmentItemClass}"> <div class="${assessmentItemClass}">
<div class="${metaContainerClass}"> <div class="${metaContainerClass}">
<div class="${metaClass}"> <div class="${metaClass}">
<div class="${simpleResultClass}"> <div class="${simpleResultClass}">
<div class="${titleClass}">Subject Average</div> <div class="${titleClass}">Subject Average</div>
${warningHTML}
</div> </div>
</div> </div>
</div> </div>
<div class="${thermoscoreClass}"> <div class="${thermoscoreClass}">
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%"> <div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
<div class="${textClass}" title="${display}">${display}</div> <div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
</div> </div>
</div> </div>
</div> </div>
`).firstChild; `).firstChild!,
assessmentsList.firstChild,
);
assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild); applySubjectColourToOverallResult();
const observer = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
}
}); });
}, },
}; };
function applySubjectColourToOverallResult() {
const selectedAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
) || document.querySelector(
"[class*='Collapsible__content___'] [class*='AssessmentItem__AssessmentItem___']",
);
const assessmentThermoscore = selectedAssessmentItem?.querySelector(
"[class*='Thermoscore__Thermoscore___']",
) as HTMLElement | null;
const overallResult = document.querySelector(
"[class*='OverallResult__OverallResult___']",
) as HTMLElement | null;
const assessableCriterionHeaders = document.querySelectorAll(
"[class*='AssessableCriterion__header___']",
);
if (assessmentThermoscore && (overallResult || assessableCriterionHeaders.length > 0)) {
const accentColour =
getComputedStyle(assessmentThermoscore).getPropertyValue("--assessment-accent-colour").trim() ||
getComputedStyle(assessmentThermoscore).getPropertyValue("--fill-colour").trim() ||
getComputedStyle(assessmentThermoscore.closest("[class*='Collapsible__Collapsible___']") || assessmentThermoscore).getPropertyValue("--assessment-accent-colour").trim() ||
getComputedStyle(assessmentThermoscore.closest("[class*='Collapsible__Collapsible___']") || assessmentThermoscore).getPropertyValue("--item-colour").trim();
if (accentColour) {
overallResult?.style.setProperty("--assessment-accent-colour", accentColour);
overallResult?.style.setProperty("--fill-colour", accentColour);
assessableCriterionHeaders.forEach((el) => {
(el as HTMLElement).style.setProperty("--assessment-accent-colour", accentColour);
(el as HTMLElement).style.setProperty("--fill-colour", accentColour);
});
}
}
}
export default assessmentsAveragePlugin; export default assessmentsAveragePlugin;
@@ -0,0 +1,572 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import * as pdfjs from "pdfjs-dist";
pdfjs.GlobalWorkerOptions.workerSrc =
`https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
export async function initStorage(api: any) {
await api.storage.loaded;
if (!api.storage.weightings) {
api.storage.weightings = {};
}
if (!api.storage.assessments) {
api.storage.assessments = {};
}
}
export function clearStuck(api: any) {
let hasStuckProcessing = false;
for (const key in api.storage.weightings) {
if (api.storage.weightings[key] === "processing") {
delete api.storage.weightings[key];
hasStuckProcessing = true;
}
}
if (hasStuckProcessing) {
api.storage.weightings = { ...api.storage.weightings };
}
}
// Helper function to find actual class names by their base pattern
export const getClassByPattern = (
element: Element | Document,
basePattern: string,
): string => {
const classes = Array.from(element.querySelectorAll("*"))
.flatMap((el) => Array.from(el.classList))
.filter((className) => className.startsWith(basePattern));
return classes.length ? classes[0] : "";
};
export const letterToNumber: Record<string, number> = {
"A+": 100,
A: 95,
"A-": 90,
"B+": 85,
B: 80,
"B-": 75,
"C+": 70,
C: 65,
"C-": 60,
"D+": 55,
D: 50,
"D-": 45,
"E+": 40,
E: 35,
"E-": 30,
F: 0,
};
function parseGrade(text: string): number {
const str = text.trim().toUpperCase();
if (str.includes("/")) {
const [raw, max] = str.split("/").map((n) => parseFloat(n));
return (raw / max) * 100;
}
if (str.includes("%")) {
return parseFloat(str.replace("%", "")) || 0;
}
return letterToNumber[str] ?? 0;
}
function createWeightLabel(
assessmentItem: Element,
weighting: string | undefined,
) {
const statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___']`,
) as HTMLElement;
if (
!statsContainer ||
statsContainer.querySelector(".betterseqta-weight-label")
)
return;
const label = statsContainer.querySelector(
`[class*='Label__Label___']`,
) as HTMLElement;
if (!label) return;
const weightLabel = label.cloneNode(true) as HTMLElement;
weightLabel.classList.add("betterseqta-weight-label");
const innerTextDiv = weightLabel.querySelector(
`[class*='Label__innerText___']`,
);
if (innerTextDiv) innerTextDiv.textContent = "Weight";
const textNodes = Array.from(weightLabel.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE,
);
if (textNodes.length) {
textNodes[0].textContent =
weighting && weighting !== "processing"
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
: "N/A";
}
statsContainer.style.position = "relative";
weightLabel.style.position = "absolute";
weightLabel.style.right = "0";
weightLabel.style.top = "50%";
weightLabel.style.transform = "translateY(-50%)";
statsContainer.appendChild(weightLabel);
}
export const isFirefox =
navigator.userAgent.toLowerCase().indexOf("firefox") > -1 &&
!navigator.userAgent.toLowerCase().includes("seamonkey") &&
!navigator.userAgent.toLowerCase().includes("waterfox");
async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
const isBlobUrl = url.startsWith("blob:");
if (isBlobUrl || isFirefox) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`;
const escapedUrl = url.replace(/'/g, "\\'");
script.textContent = `
(function() {
fetch('${escapedUrl}')
.then(response => {
if (!response.ok) {
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
}
return response.arrayBuffer();
})
.then(arrayBuffer => {
window.postMessage({
type: '${requestId}',
success: true,
data: Array.from(new Uint8Array(arrayBuffer))
}, '*');
})
.catch(error => {
window.postMessage({
type: '${requestId}',
success: false,
error: error.message || String(error)
}, '*');
});
})();
`;
const messageHandler = (event: MessageEvent) => {
if (event.data?.type === requestId) {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
if (event.data.success) {
resolve(new Uint8Array(event.data.data).buffer);
} else {
reject(new Error(event.data.error || "Failed to fetch PDF"));
}
}
};
window.addEventListener("message", messageHandler);
(document.head || document.documentElement).appendChild(script);
setTimeout(() => {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
reject(new Error("Timeout fetching PDF"));
}, 30000);
});
}
try {
const response = await fetch(url, {
credentials: "include",
redirect: "follow",
});
if (response.url && response.url.startsWith("blob:")) {
return await fetchPDFAsArrayBuffer(response.url);
}
if (!response.ok) {
throw new Error(
`Failed to fetch PDF: ${response.status} ${response.statusText}`,
);
}
return await response.arrayBuffer();
} catch (error: any) {
if (
error?.message?.includes("blob") ||
error?.message?.includes("Security") ||
error?.message?.includes("CSP")
) {
return await fetchPDFAsArrayBuffer(url);
}
throw error;
}
}
export async function extractPDFText(url: string): Promise<string> {
try {
if (isFirefox) {
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}';
if (window.pdfjsLib) {
extractPDF();
} else {
const pdfjsScript = document.createElement('script');
pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.min.js';
pdfjsScript.type = 'text/javascript';
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 = '';
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'arraybuffer';
xhr.withCredentials = true;
xhr.onload = function() {
if (xhr.status !== 200) {
window.postMessage({
type: requestId,
success: false,
error: 'HTTP ' + xhr.status + ': ' + xhr.statusText
}, '*');
return;
}
try {
const arrayBuffer = xhr.response;
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
throw new Error('PDF response is empty');
}
window.pdfjsLib.getDocument({
data: arrayBuffer,
useSystemFonts: true,
verbosity: 0,
useWorkerFetch: false,
isEvalSupported: false
}).promise
.then(pdf => {
const pagePromises = [];
for (let i = 1; i <= pdf.numPages; i++) {
pagePromises.push(
pdf.getPage(i).then(page => {
return page.getTextContent().then(content => {
return content.items.map(item => item.str).join(' ');
});
})
);
}
return Promise.all(pagePromises);
})
.then(pages => {
const text = pages.join('\\n');
window.postMessage({
type: requestId,
success: true,
text: text
}, '*');
})
.catch(error => {
window.postMessage({
type: requestId,
success: false,
error: 'PDF parsing error: ' + (error.message || String(error))
}, '*');
});
} catch (error) {
window.postMessage({
type: requestId,
success: false,
error: 'ArrayBuffer error: ' + (error.message || String(error))
}, '*');
}
};
xhr.onerror = function() {
window.postMessage({
type: requestId,
success: false,
error: 'Network error fetching PDF'
}, '*');
};
xhr.ontimeout = function() {
window.postMessage({
type: requestId,
success: false,
error: 'Timeout fetching PDF'
}, '*');
};
xhr.timeout = 30000;
xhr.send();
} catch (error) {
window.postMessage({
type: requestId,
success: false,
error: 'Setup error: ' + (error.message || String(error))
}, '*');
}
}
})();
`;
const messageHandler = (event: MessageEvent) => {
if (event.data?.type === requestId) {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
if (event.data.success) {
resolve(event.data.text);
} else {
reject(
new Error(event.data.error || "Failed to extract PDF text"),
);
}
}
};
window.addEventListener("message", messageHandler);
(document.head || document.documentElement).appendChild(script);
setTimeout(() => {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
reject(new Error("Timeout extracting PDF text"));
}, 60000);
});
}
const arrayBuffer = await fetchPDFAsArrayBuffer(url);
if (arrayBuffer.byteLength === 0) {
throw new Error("PDF response is empty");
}
const pdf = await pdfjs.getDocument({
data: arrayBuffer,
useSystemFonts: true,
}).promise;
let text = "";
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
text += content.items.map((item: any) => item.str).join(" ") + "\n";
}
return text;
} catch (error) {
console.error("[BetterSEQTA+] Failed to extract PDF text:", error);
throw error;
}
}
async function handleWeightings(mark: any, api: any) {
const assessmentID = mark.id;
const metaclassID = mark.metaclassID;
const userInfo = await getUserInfo();
const userID = userInfo.id;
const title = mark.title;
if (
api.storage.weightings[assessmentID] != undefined &&
api.storage.weightings[assessmentID] !== "processing"
) {
return;
}
api.storage.weightings = {
...api.storage.weightings,
[assessmentID]: "processing",
};
api.storage.assessments = {
...api.storage.assessments,
[title.trim()]: assessmentID,
};
try {
const filename =
"BetterSEQTA-" +
String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
const printResponse = await fetch(
`${location.origin}/seqta/student/print/assessment`,
{
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({
fileName: filename,
id: assessmentID,
metaclass: metaclassID,
student: userID,
}),
},
);
if (!printResponse.ok) {
throw new Error(
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
);
}
await new Promise((resolve) => setTimeout(resolve, 1000));
const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
if (pdfUrl.startsWith("blob:")) {
throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`);
}
let text: string;
try {
text = await extractPDFText(pdfUrl);
} catch (error: any) {
if (
isFirefox &&
(error?.message?.includes("blob") ||
error?.message?.includes("Security") ||
error?.message?.includes("CSP"))
) {
await new Promise((resolve) => setTimeout(resolve, 2000));
text = await extractPDFText(pdfUrl);
} else {
throw new Error(`PDF extraction failed: ${error.message}`);
}
}
const match = text.match(/weight:\s*(\d+\.?\d*)/i);
api.storage.weightings = {
...api.storage.weightings,
[assessmentID]: match ? match[1] : "N/A",
};
} catch (error: any) {
api.storage.weightings = {
...api.storage.weightings,
[assessmentID]: "N/A",
};
}
}
export async function parseAssessments(api: any) {
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
if (!marks) return;
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
}
export async function processAssessments(api: any, assessmentItems: Element[]) {
let weightedTotal = 0;
let totalWeight = 0;
let hasInaccurateWeighting = false;
let count = 0;
for (const assessmentItem of assessmentItems) {
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
const titleEl = assessmentItem.querySelector(
`[class*='AssessmentItem__title___']`,
);
if (!titleEl) continue;
const title = titleEl.textContent?.trim();
if (!title) continue;
const assessmentID = api.storage.assessments?.[title];
const weighting = assessmentID
? api.storage.weightings?.[assessmentID]
: undefined;
createWeightLabel(assessmentItem, weighting);
if (
weighting === null ||
weighting === undefined ||
weighting === "N/A" ||
weighting === "processing"
) {
hasInaccurateWeighting = true;
weightedTotal += grade;
totalWeight += 1;
} else {
const weight = parseFloat(weighting);
if (!isNaN(weight) && weight >= 0) {
weightedTotal += grade * weight;
totalWeight += weight;
} else {
weightedTotal += grade;
totalWeight += 1;
hasInaccurateWeighting = true;
}
}
count++;
}
return {
weightedTotal,
totalWeight,
hasInaccurateWeighting,
count,
};
}
@@ -7,9 +7,11 @@
interface FilterOptions { interface FilterOptions {
subject: string; subject: string;
sortBy: "due" | "grade" | "subject" | "title"; sortBy: "due" | "grade" | "subject" | "title" | "year";
} }
const HIDDEN_ASSESSMENTS_KEY = "betterseqta-hidden-assessments";
function percentageToLetter(percentage: number): string { function percentageToLetter(percentage: number): string {
const letterMap: Record<number, string> = { const letterMap: Record<number, string> = {
100: "A+", 100: "A+",
@@ -41,48 +43,108 @@
let filteredAssessments: any[] = []; let filteredAssessments: any[] = [];
let statusGroups: Record<string, any[]> = {}; let statusGroups: Record<string, any[]> = {};
let columns: { key: string; title: string; className: string; icon: string }[] = [];
function updateAssessments() { function getAssessmentYear(a: any): number {
filteredAssessments = data.assessments.filter((a: any) => { const dateStr = a.due || a.date || a.dueDate || a.created;
const subjectMatch = return dateStr ? new Date(dateStr).getFullYear() : 0;
currentFilters.subject === "all" || a.code === currentFilters.subject; }
return subjectMatch;
});
filteredAssessments.sort((a: any, b: any) => { 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) { switch (currentFilters.sortBy) {
case "due": case "due":
return new Date(a.due).getTime() - new Date(b.due).getTime(); return determineStatus(assessment);
case "grade": case "year":
const gradeA = getGradeValue(a); return String(getAssessmentYear(assessment) || "Unknown");
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": case "subject":
return a.code.localeCompare(b.code); return assessment.code || "Unknown";
case "grade":
return getAssessmentGrade(assessment);
case "title": case "title":
return a.title.localeCompare(b.title); const first = (assessment.title || "?")[0].toUpperCase();
return /[A-Z0-9]/.test(first) ? first : "#";
default: default:
return 0; 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;
}); });
statusGroups = { const groups: Record<string, any[]> = {};
UPCOMING: [], filtered.forEach((assessment) => {
DUE_SOON: [], const key = getGroupKey(assessment);
OVERDUE: [], if (!groups[key]) groups[key] = [];
SUBMITTED: [], groups[key].push(assessment);
MARKS_RELEASED: [],
};
filteredAssessments.forEach((assessment) => {
const status = determineStatus(assessment);
if (statusGroups[status]) {
statusGroups[status].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() {
const result = buildGroupsAndColumns();
filteredAssessments = result.filteredAssessments;
statusGroups = result.statusGroups;
columns = result.columns;
} }
function getDueDateClass(assessment: any): string { function getDueDateClass(assessment: any): string {
@@ -123,6 +185,56 @@
} }
} }
function hideAssessment(assessment: any) {
const hidden = JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]");
const id = String(assessment.id);
if (!hidden.includes(id)) {
hidden.push(id);
localStorage.setItem(HIDDEN_ASSESSMENTS_KEY, JSON.stringify(hidden));
visibilityRefresh++;
closeAllMenus();
updateAssessments();
}
}
function hideSubject(subjectCode: string) {
const filters = { ...(settingsState.subjectfilters || {}) };
filters[subjectCode] = false;
settingsState.subjectfilters = filters;
closeAllMenus();
updateAssessments();
}
function unhideSubject(subjectCode: string) {
const filters = { ...(settingsState.subjectfilters || {}) };
filters[subjectCode] = true;
settingsState.subjectfilters = filters;
updateAssessments();
}
function unhideAssessment(assessmentId: string) {
const hidden = JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]");
const idStr = String(assessmentId);
const filtered = hidden.filter((id: string) => id !== idStr);
localStorage.setItem(HIDDEN_ASSESSMENTS_KEY, JSON.stringify(filtered));
visibilityRefresh++;
updateAssessments();
}
function initSubjectFilters() {
const filters = settingsState.subjectfilters || {};
let updated = false;
data.subjects.forEach((s: any) => {
if (!Object.prototype.hasOwnProperty.call(filters, s.code)) {
filters[s.code] = true;
updated = true;
}
});
if (updated) {
settingsState.subjectfilters = filters;
}
}
function checkForCelebration() { function checkForCelebration() {
const overdueCount = statusGroups.OVERDUE?.length || 0; const overdueCount = statusGroups.OVERDUE?.length || 0;
const dueSoonCount = statusGroups.DUE_SOON?.length || 0; const dueSoonCount = statusGroups.DUE_SOON?.length || 0;
@@ -201,6 +313,20 @@
} }
let openMenuId: string | null = null; let openMenuId: string | null = null;
let showVisibilityPanel = false;
let visibilityRefresh = 0;
$: hiddenSubjects = data?.subjects?.filter(
(s: any) => (settingsState.subjectfilters || {})[s.code] === false
) || [];
$: hiddenAssessmentIds = (() => {
visibilityRefresh; // Dependency for reactivity
return new Set((JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]")).map(String));
})();
$: hiddenAssessmentsWithInfo = data?.assessments?.filter(
(a: any) => hiddenAssessmentIds.has(String(a.id))
) || [];
$: hasHiddenItems = hiddenSubjects.length > 0 || hiddenAssessmentsWithInfo.length > 0;
function toggleMenu(assessmentId: string, event: Event) { function toggleMenu(assessmentId: string, event: Event) {
event.stopPropagation(); event.stopPropagation();
@@ -211,44 +337,13 @@
openMenuId = null; openMenuId = null;
} }
$: { $: if (data) {
if (data) { initSubjectFilters();
updateAssessments(); updateAssessments();
} void currentFilters.sortBy;
void currentFilters.subject;
} }
const columns = [
{
key: "UPCOMING",
title: "Upcoming",
className: "column-upcoming",
icon: "📅",
},
{
key: "DUE_SOON",
title: "Due Soon",
className: "column-due-soon",
icon: "⏰",
},
{
key: "OVERDUE",
title: "Overdue",
className: "column-overdue",
icon: "🚨",
},
{
key: "SUBMITTED",
title: "Submitted",
className: "column-submitted",
icon: "📝",
},
{
key: "MARKS_RELEASED",
title: "Marked",
className: "column-marked",
icon: "✅",
},
];
</script> </script>
<svelte:window on:click={closeAllMenus} /> <svelte:window on:click={closeAllMenus} />
@@ -263,15 +358,58 @@
<option value={subject.code}>{subject.code} - {subject.title}</option> <option value={subject.code}>{subject.code} - {subject.title}</option>
{/each} {/each}
</select> </select>
<select class="filter-select" bind:value={currentFilters.sortBy}> <select class="filter-select" bind:value={currentFilters.sortBy} title="Group by - columns change based on this">
<option value="due">Sort by Due Date</option> <option value="due">Group: Status</option>
<option value="grade">Sort by Grade</option> <option value="year">Group: Year</option>
<option value="subject">Sort by Subject</option> <option value="subject">Group: Subject</option>
<option value="title">Sort by Title</option> <option value="grade">Group: Grade</option>
<option value="title">Group: Title (A-Z)</option>
</select> </select>
{#if hasHiddenItems}
<button
class="visibility-toggle"
class:active={showVisibilityPanel}
on:click={() => (showVisibilityPanel = !showVisibilityPanel)}
title="Manage hidden subjects and assessments"
>
👁 Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})
</button>
{/if}
</div> </div>
</div> </div>
{#if showVisibilityPanel && hasHiddenItems}
<div class="visibility-panel">
<h4 class="visibility-panel-title">Hidden items</h4>
{#if hiddenSubjects.length > 0}
<div class="visibility-section">
<span class="visibility-label">Subjects:</span>
<div class="visibility-chips">
{#each hiddenSubjects as subject}
<span class="visibility-chip">
{subject.code}
<button class="visibility-unhide" on:click={() => unhideSubject(subject.code)}>Show</button>
</span>
{/each}
</div>
</div>
{/if}
{#if hiddenAssessmentsWithInfo.length > 0}
<div class="visibility-section">
<span class="visibility-label">Assessments:</span>
<div class="visibility-chips">
{#each hiddenAssessmentsWithInfo as assessment}
<span class="visibility-chip">
{assessment.title}
<button class="visibility-unhide" on:click={() => unhideAssessment(assessment.id)}>Show</button>
</span>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<div id="main-grid-content"> <div id="main-grid-content">
{#if filteredAssessments.length === 0} {#if filteredAssessments.length === 0}
<div class="empty-state"> <div class="empty-state">
@@ -340,6 +478,12 @@
Mark as Not Complete Mark as Not Complete
</button> </button>
{/if} {/if}
<button class="menu-item menu-item-hide" on:click={() => hideAssessment(assessment)}>
Hide assessment
</button>
<button class="menu-item menu-item-hide" on:click={() => hideSubject(assessment.code)}>
Hide subject ({assessment.code})
</button>
</div> </div>
</div> </div>
{/if} {/if}
@@ -349,7 +493,7 @@
{#if !assessment.results && !isCompleted} {#if !assessment.results && !isCompleted}
<div class="assessment-meta"> <div class="assessment-meta">
<div class="due-date {dueDateClass}"> <div class="due-date {dueDateClass}">
📅 {formatDate(assessment.due, assessment.submitted)} 📅 {formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)}
</div> </div>
</div> </div>
{/if} {/if}
@@ -9,6 +9,9 @@ interface PrefItem {
value: string; value: string;
} }
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { getMockAssessmentsData } from "@/seqta/ui/dev/hideSensitiveContent";
let cache: { time: number; data: any } | null = null; let cache: { time: number; data: any } | null = null;
const CACHE_MS = 10 * 60 * 1000; const CACHE_MS = 10 * 60 * 1000;
const student = 69; const student = 69;
@@ -53,6 +56,18 @@ async function loadUpcoming(student: number) {
return res.payload; return res.payload;
} }
function normalizeAssessmentDates(t: any, subject: Subject): any {
const normalized = { ...t };
// Past API may use different date fields - ensure we have 'due' for year filter & display
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
}
if (!normalized.programmeID) normalized.programmeID = subject.programme;
if (!normalized.metaclassID) normalized.metaclassID = subject.metaclass;
if (!normalized.code && t.subject) normalized.code = t.subject;
return normalized;
}
async function loadPast(student: number, subjects: Subject[]) { async function loadPast(student: number, subjects: Subject[]) {
const map: Record<number, any> = {}; const map: Record<number, any> = {};
await Promise.all( await Promise.all(
@@ -62,10 +77,22 @@ async function loadPast(student: number, subjects: Subject[]) {
metaclass: s.metaclass, metaclass: s.metaclass,
student, student,
}); });
if (res.payload.tasks) { const processAssessment = (t: any) => {
res.payload.tasks.forEach((t: any) => { if (t && t.id) {
map[t.id] = t; const merged = {
}); ...t,
programmeID: t.programmeID || t.programme || s.programme,
metaclassID: t.metaclassID || t.metaclass || s.metaclass,
code: t.code || t.subject || s.code,
};
map[t.id] = normalizeAssessmentDates(merged, s);
}
};
if (res.payload?.pending && Array.isArray(res.payload.pending)) {
res.payload.pending.forEach(processAssessment);
}
if (res.payload?.tasks && Array.isArray(res.payload.tasks)) {
res.payload.tasks.forEach(processAssessment);
} }
}), }),
); );
@@ -102,6 +129,10 @@ async function loadSubmissions(student: number, assessments: any[]) {
} }
export async function getAssessmentsData() { export async function getAssessmentsData() {
if (settingsState.mockNotices) {
return getMockAssessmentsData();
}
if (cache && Date.now() - cache.time < CACHE_MS) return cache.data; if (cache && Date.now() - cache.time < CACHE_MS) return cache.data;
const [subjects, colors, upcoming] = await Promise.all([ const [subjects, colors, upcoming] = await Promise.all([
loadSubjects(), loadSubjects(),
@@ -1,7 +1,7 @@
import type { Plugin } from "../../core/types"; import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api"; import { getAssessmentsData } from "./api";
import { renderSkeletonLoader, renderErrorState } from "./ui"; import { renderErrorState, renderSkeletonLoader } from "./ui";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
@@ -34,19 +34,38 @@
} }
.filter-select { .filter-select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background: #ffffff !important; background: #ffffff !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2364748b'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
background-position: right 0.9rem center !important;
background-repeat: no-repeat !important;
background-size: 1rem !important;
border: 2px solid #e2e8f0; border: 2px solid #e2e8f0;
border-radius: 8px; border-radius: 10px;
color: #1a1a1a; color: #1a1a1a;
padding: 0.75rem 1rem; color-scheme: light;
padding: 0.75rem 2.5rem 0.75rem 1rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
font-family: Rubik, sans-serif;
line-height: 1.2;
transition: all 0.2s ease; transition: all 0.2s ease;
cursor: pointer; cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
min-width: 180px; min-width: 180px;
} }
.filter-select::-ms-expand {
display: none;
}
.filter-select option {
background: #ffffff;
color: #1a1a1a;
}
.filter-select:focus { .filter-select:focus {
outline: none; outline: none;
border-color: #d41e3a; border-color: #d41e3a;
@@ -61,8 +80,10 @@
/* Dark mode dropdowns */ /* Dark mode dropdowns */
.dark .filter-select { .dark .filter-select {
background: var(--background-primary) !important; background: var(--background-primary) !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='rgba(255,255,255,0.72)'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
border-color: var(--background-secondary); border-color: var(--background-secondary);
color: var(--text-primary); color: var(--text-primary);
color-scheme: dark;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
} }
@@ -73,7 +94,8 @@
.dark .filter-select:hover { .dark .filter-select:hover {
border-color: var(--background-secondary); border-color: var(--background-secondary);
background: var(--background-secondary); background: var(--background-secondary) !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='rgba(255,255,255,0.72)'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
} }
.dark .filter-select option { .dark .filter-select option {
@@ -106,7 +128,6 @@
max-height: 100%; max-height: 100%;
background: #f8fafc; background: #f8fafc;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 0 0 2px #e2e8f0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
@@ -336,11 +357,146 @@
color: #ef4444; color: #ef4444;
} }
.menu-item.menu-item-hide {
color: #64748b;
}
.dark .menu-item.menu-item-hide {
color: var(--text-primary);
opacity: 0.8;
}
.visibility-toggle {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 8px;
border: 2px solid #e2e8f0;
background: #ffffff;
color: #64748b;
cursor: pointer;
transition: all 0.2s ease;
}
.visibility-toggle:hover {
border-color: #cbd5e1;
color: #1a1a1a;
}
.visibility-toggle.active {
border-color: #d41e3a;
background: rgba(212, 30, 58, 0.08);
color: #d41e3a;
}
.dark .visibility-toggle {
background: var(--background-primary);
border-color: var(--background-secondary);
color: var(--text-primary);
}
.dark .visibility-toggle:hover {
border-color: rgba(255, 255, 255, 0.2);
}
.dark .visibility-toggle.active {
border-color: #d41e3a;
background: rgba(212, 30, 58, 0.15);
color: #d41e3a;
}
.visibility-panel {
padding: 1rem 1.25rem;
margin: 0 1rem 1rem;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.dark .visibility-panel {
background: var(--background-secondary);
border-color: rgba(255, 255, 255, 0.1);
}
.visibility-panel-title {
font-size: 0.875rem;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 0.75rem;
}
.dark .visibility-panel-title {
color: var(--text-primary);
}
.visibility-section {
margin-bottom: 0.5rem;
}
.visibility-section:last-child {
margin-bottom: 0;
}
.visibility-label {
font-size: 0.75rem;
font-weight: 500;
color: #64748b;
display: block;
margin-bottom: 0.25rem;
}
.dark .visibility-label {
color: var(--text-primary);
opacity: 0.7;
}
.visibility-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.visibility-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: #e2e8f0;
border-radius: 6px;
font-size: 0.8125rem;
color: #1a1a1a;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.dark .visibility-chip {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
.visibility-unhide {
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 4px;
border: none;
background: #d41e3a;
color: white;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.visibility-unhide:hover {
background: #b91c33;
}
.assessment-title { .assessment-title {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: #1a1a1a; color: #1a1a1a;
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem;
line-height: 1.4; line-height: 1.4;
padding-right: 2rem; /* Make room for menu button */ padding-right: 2rem; /* Make room for menu button */
} }
@@ -456,6 +612,10 @@
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%); background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
} }
.column-custom .column-header {
background: linear-gradient(135deg, #ffffff 0%, #f1f5f9 100%);
}
/* Dark mode column headers */ /* Dark mode column headers */
.dark .column-upcoming .column-header { .dark .column-upcoming .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%); background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%);
@@ -477,6 +637,10 @@
background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%); background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%);
} }
.dark .column-custom .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a5f 100%);
}
/* Subject filter view */ /* Subject filter view */
.subject-section { .subject-section {
margin-bottom: 2rem; margin-bottom: 2rem;
@@ -0,0 +1,120 @@
<script lang="ts">
import localforage from 'localforage'
import { onMount } from 'svelte'
let fileInput = $state<HTMLInputElement | undefined>(undefined)
let dragging = $state(false)
let filename = $state<string | undefined>(undefined)
let durationText = $state<string | undefined>(undefined)
const store = localforage.createInstance({
name: 'background-music-store',
storeName: 'music',
})
async function loadExisting() {
const name = await store.getItem<string>('audio-name')
filename = name ?? undefined
}
onMount(() => { loadExisting() })
function triggerSelect() { fileInput?.click() }
async function handleFiles(files: FileList | null) {
const file = files?.[0]
if (!file) return
// Accept WAV and MP3 files
const isSupported = file.type === 'audio/wav' || file.type === 'audio/mpeg' ||
file.name.toLowerCase().endsWith('.wav') || file.name.toLowerCase().endsWith('.mp3')
if (!isSupported) {
alert('Please select a .wav or .mp3 audio file')
return
}
await store.setItem('audio-blob', file)
await store.setItem('audio-name', file.name)
filename = file.name
// Probe duration
try {
const url = URL.createObjectURL(file)
const audio = new Audio(url)
await new Promise<void>((resolve, reject) => {
audio.onloadedmetadata = () => resolve()
audio.onerror = () => reject()
})
if (!isNaN(audio.duration) && audio.duration !== Infinity) {
const minutes = Math.floor(audio.duration / 60)
const seconds = Math.round(audio.duration % 60)
durationText = `${minutes}:${seconds.toString().padStart(2, '0')}`
} else {
durationText = undefined
}
URL.revokeObjectURL(url)
} catch {
durationText = undefined
}
window.dispatchEvent(new Event('betterseqta-background-music-updated'))
}
function onFileChange() { handleFiles(fileInput?.files || null) }
function onDrop(event: DragEvent) {
event.preventDefault()
dragging = false
handleFiles(event.dataTransfer?.files || null)
}
async function removeAudio() {
await store.removeItem('audio-blob')
await store.removeItem('audio-name')
filename = undefined
durationText = undefined
window.dispatchEvent(new Event('betterseqta-background-music-stop'))
}
</script>
<div
class="relative cursor-pointer select-none"
onclick={() => triggerSelect()}
ondragover={(e) => { e.stopPropagation(); dragging = true }}
ondragleave={() => dragging = false}
ondrop={onDrop}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
triggerSelect()
}
}}
role="button"
tabindex="0"
>
<div class="flex gap-3 items-center">
{#if filename}
<div class="flex items-center px-3 py-1 rounded-lg bg-zinc-200 dark:bg-zinc-800">
<div class="text-xs text-zinc-600 dark:text-zinc-300">
{filename}
<p>{durationText}</p>
</div>
<button
class="flex justify-center items-center m-1 text-lg dark:text-white size-7"
onclick={(e) => { e.stopPropagation(); removeAudio() }}
aria-label="Remove audio"
>&#215;</button>
</div>
{:else}
<div class="flex gap-2 items-center px-3 py-1 text-xs rounded-lg border border-dashed transition border-zinc-300 dark:border-zinc-600 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 text-nowrap">
<span class="text-lg font-IconFamily">{'\ued47'}</span>
<span>Upload audio</span>
</div>
{/if}
</div>
<input type="file" accept="audio/wav,audio/mpeg" class="hidden" bind:this={fileInput} onchange={onFileChange} />
{#if dragging}
<div class="absolute inset-0 rounded-lg bg-zinc-200/40 dark:bg-zinc-700/40"></div>
{/if}
</div>
@@ -0,0 +1,187 @@
import type { Plugin } from "@/plugins/core/types";
import { booleanSetting, componentSetting, defineSettings, numberSetting } from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline";
import BackgroundMusicSetting from "./BackgroundMusicSetting.svelte";
import localforage from "localforage";
const settings = defineSettings({
uploader: componentSetting({
title: "Background Music",
description: "Upload a .wav or .mp3 audio file to play in the background.",
component: BackgroundMusicSetting,
}),
volume: numberSetting({
title: "Volume",
description: "Set background music volume",
default: 0.5,
min: 0,
max: 1,
step: 0.05,
}),
pauseOnHidden: booleanSetting({
title: "Pause when tab hidden",
description: "Pause music when switching to another tab or minimizing the browser",
default: true,
}),
});
const store = localforage.createInstance({
name: "background-music-store",
storeName: "music",
});
let currentAudio: HTMLAudioElement | null = null;
let currentObjectUrl: string | null = null;
let cleanupRegistered = false;
let pendingGestureCancel: (() => void) | null = null;
let visibilityResumeTimeout: number | null = null;
async function loadAudioBlob(): Promise<Blob | null> {
const blob = await store.getItem<Blob>("audio-blob");
return blob && blob instanceof Blob ? blob : null;
}
function stopAndCleanupAudio(): void {
if (currentAudio) {
currentAudio.pause();
currentAudio.src = "";
currentAudio.remove();
currentAudio = null;
}
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = null;
}
}
function ensureGestureStart(handler: () => void): () => void {
const eventTypes = ["pointerdown", "keydown", "touchstart"]; // broad user gesture coverage
const listener = () => {
handler();
for (const type of eventTypes) {
window.removeEventListener(type, listener);
}
};
for (const type of eventTypes) {
window.addEventListener(type, listener, { once: true, passive: true });
}
return () => {
for (const type of eventTypes) {
window.removeEventListener(type, listener);
}
};
}
async function startPlayback(volume: number): Promise<void> {
const blob = await loadAudioBlob();
if (!blob) return;
stopAndCleanupAudio();
currentObjectUrl = URL.createObjectURL(blob);
const audio = new Audio(currentObjectUrl);
audio.loop = true;
audio.volume = Math.max(0, Math.min(1, volume));
audio.preload = "auto";
audio.crossOrigin = "anonymous";
audio.style.display = "none";
document.body.appendChild(audio);
currentAudio = audio;
try {
// Attempt immediate play; may be blocked until gesture
await audio.play();
} catch {
// Ignore; will be started after gesture if enabled
}
}
const backgroundMusicPlugin: Plugin<typeof settings> = {
id: "background-music",
name: "Background Music",
description: "Play your own music in the background while SEQTA is open.",
version: "1.0.0",
settings,
styles,
disableToggle: true,
defaultEnabled: false,
run: async (api) => {
await api.storage.loaded;
// react to specific setting changes
api.settings.onChange("volume" as any, (value: any) => {
const vol = (typeof value === "number" ? value : 0.5) as number;
if (currentAudio) currentAudio.volume = Math.max(0, Math.min(1, vol));
});
api.settings.onChange("pauseOnHidden" as any, (value: any) => {
const pauseOnHidden = (typeof value === "boolean" ? value : true) as boolean;
// If the setting is disabled and audio is currently paused due to tab being hidden, resume it
if (!pauseOnHidden && currentAudio && currentAudio.paused && document.visibilityState === "hidden") {
currentAudio.play().catch(() => {});
}
});
// Note: Stop button/event removed by user; no stop handling needed
// Start if we have audio and autoplay is enabled
const tryStart = async () => {
const vol = (api.settings as any).volume ?? 0.5;
await startPlayback(vol);
};
// Always arm gesture start and attempt immediate start
const cancel = ensureGestureStart(() => { tryStart(); });
cleanupRegistered = true;
(window as any).__betterseqta_bg_music_cancel__ = cancel;
tryStart();
// Pause on tab hide, resume on show with a small delay (if enabled)
const visHandler = () => {
if (!currentAudio) return;
const pauseOnHidden = (api.settings as any).pauseOnHidden ?? true;
if (!pauseOnHidden) return;
if (document.visibilityState === "hidden") {
if (visibilityResumeTimeout !== null) {
clearTimeout(visibilityResumeTimeout);
visibilityResumeTimeout = null;
}
currentAudio.pause();
} else if (document.visibilityState === "visible") {
if (visibilityResumeTimeout !== null) {
clearTimeout(visibilityResumeTimeout);
}
visibilityResumeTimeout = window.setTimeout(() => {
visibilityResumeTimeout = null;
currentAudio?.play().catch(() => {});
}, 200);
}
};
document.addEventListener("visibilitychange", visHandler);
// Allow uploads to trigger refresh
const uploadedHandler = () => {
const vol = (api.settings as any).volume ?? 0.5;
startPlayback(vol);
};
window.addEventListener("betterseqta-background-music-updated", uploadedHandler);
return () => {
document.removeEventListener("visibilitychange", visHandler);
window.removeEventListener("betterseqta-background-music-updated", uploadedHandler);
if (cleanupRegistered && (window as any).__betterseqta_bg_music_cancel__) {
(window as any).__betterseqta_bg_music_cancel__();
(window as any).__betterseqta_bg_music_cancel__ = undefined;
}
if (pendingGestureCancel) { pendingGestureCancel(); pendingGestureCancel = null; }
if (visibilityResumeTimeout !== null) { clearTimeout(visibilityResumeTimeout); visibilityResumeTimeout = null; }
stopAndCleanupAudio();
};
},
};
export default backgroundMusicPlugin;
@@ -0,0 +1,2 @@
.background-music-hidden{display:none}
@@ -1,65 +0,0 @@
<script lang="ts">
import Editor from './Editor/Editor.svelte';
import EditorStyles from './Editor/EditorStyles.css?raw';
import EditorOverrideStyles from './Editor/EditorOverrideStyles.css?raw';
import TiptapStyles from './Editor/TiptapStyles.css?raw';
import { onMount } from 'svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState';
interface Props {
onchange: (value: string) => void;
initialContent?: string;
scale?: number; // Scale factor for the editor (1.0 = normal, 1.2 = 120%, etc.)
}
let { onchange, initialContent = '', scale = 1.3 }: Props = $props();
let content = $state('');
let betterEditor = $state<HTMLElement | null>(null);
// Watch for content changes and call the callback
$effect(() => {
if (onchange) {
onchange(content);
}
});
onMount(async () => {
if (betterEditor) {
const styles = EditorStyles + EditorOverrideStyles + TiptapStyles;
const scalingCSS = `
.better-editor {
--scale-factor: ${scale};
}
.better-editor .editor-prose {
transform-origin: top left;
zoom: ${scale};
-moz-transform: scale(${scale});
-moz-transform-origin: 0 0;
}
/* For Firefox which doesn't support zoom */
@-moz-document url-prefix() {
.better-editor .editor-prose {
transform: scale(${scale});
width: ${100 / scale}%;
}
}
`;
const styleElement = document.createElement('style');
styleElement.textContent = styles + scalingCSS;
betterEditor.appendChild(styleElement);
}
});
</script>
<div
class="h-full better-editor {settingsState.DarkMode ? 'dark' : ''}"
bind:this={betterEditor}
style="font-size: {scale * 16}px; --editor-scale: {scale};"
>
<Editor bind:content {initialContent} />
</div>
@@ -1,154 +0,0 @@
<script lang="ts">
import Placeholder from '@tiptap/extension-placeholder';
import Commands from './Plugins/Commands/command';
import { Dropcursor } from '@tiptap/extension-dropcursor';
import Image from '@tiptap/extension-image'
import BubbleMenu from '@tiptap/extension-bubble-menu';
import Typography from '@tiptap/extension-typography';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import { Editor } from '@tiptap/core';
import CommandList from './Plugins/Commands/CommandList.svelte';
import suggestion from './Plugins/Commands/suggestion';
import { slashVisible } from './Plugins/Commands/stores';
import { get } from 'svelte/store';
import BubbleMenuComponent from './Plugins/BubbleMenu.svelte';
import { onMount, onDestroy } from 'svelte';
import EditorStyles from './EditorOverrideStyles.css?raw';
// Make htmlContent bindable from parent components
let { content = $bindable(''), initialContent = '' } = $props<{ content: string; initialContent?: string }>();
let commandListInstance = $state<any>(null);
let element = $state<HTMLElement | null>(null);
let editor = $state<Editor | null>(null);
onMount(() => {
editor = new Editor({
element: element!,
content: initialContent || '',
editorProps: {
attributes: {
class: 'focus:outline-none px-3 md:px-0',
},
handleKeyDown: (_, event) => {
// Handle keyboard events when slash menu is visible
if (get(slashVisible) && commandListInstance) {
if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const handled = commandListInstance.handleKeydown(event, editor);
if (handled) {
return true; // Prevent TipTap from handling this event
}
}
}
return false; // Let TipTap handle other events
},
},
extensions: [
StarterKit,
Placeholder.configure({
placeholder: ({ node }: { node: any }) => {
if (node.type.name === 'heading') {
return 'Heading';
} else if (node.type.name === 'paragraph') {
return "Type '/' for commands";
}
return 'Type something...';
},
}),
TaskList,
TaskItem,
Link,
Typography,
Commands.configure({
suggestion,
}),
BubbleMenu.configure({
element: document.querySelector('.menu') as HTMLElement,
}),
Dropcursor.configure({ width: 5, color: '#ddeeff' }),
Image.configure({
allowBase64: true,
}),
],
onTransaction: () => {
// force re-render so `editor.isActive` works as expected
editor = editor;
},
onUpdate: ({ editor }: { editor: Editor }) => {
// Update the htmlContent with the editor's HTML plus CSS
const editorHTML = editor.getHTML();
content = `<div class="editor-prose">${editorHTML}<${''}style>${EditorStyles}</${''}style></div>`;
},
});
});
onMount(() => {
if (initialContent) {
content = initialContent;
}
});
onDestroy(() => {
if (editor) {
editor.destroy();
}
});
function handleKeydownCapture(event: KeyboardEvent) {
if (commandListInstance && editor && get(slashVisible)) {
if (event.key === 'Escape') {
if (commandListInstance.handleKeydown(event, editor)) {
event.preventDefault();
event.stopPropagation();
}
}
}
}
function handleClick(event: MouseEvent) {
if (!editor) return;
// Check if the click happened in empty space below content
const editorElement = element;
if (!editorElement) return;
const clickY = event.clientY;
// Get the last node in the editor
const lastNode = editorElement.lastElementChild;
if (lastNode) {
const lastNodeRect = lastNode.getBoundingClientRect();
// If click is below the last content node, move cursor to end
if (clickY > lastNodeRect.bottom) {
const docSize = editor.state.doc.content.size;
editor.commands.setTextSelection(docSize);
editor.commands.focus();
event.preventDefault();
}
}
}
</script>
<div class="relative h-full">
<div
class="w-full min-h-full editor-prose"
bind:this={element}
onkeydown={handleKeydownCapture}
onclick={handleClick}
role="textbox"
tabindex="-1">
</div>
<CommandList bind:this={commandListInstance} />
</div>
<BubbleMenuComponent bind:editor />
@@ -1,398 +0,0 @@
.editor-prose {
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
'Noto Sans',
sans-serif !important;
line-height: 1.6 !important;
color: #374151 !important;
font-size: 14px !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
box-sizing: border-box !important;
.dark & * {
color: #d1d5db !important;
}
* {
color: #374151 !important;
}
h1,
h2,
h3,
h4,
h5,
h6,
p,
ul,
ol {
width: 100% !important;
min-width: 2px !important;
box-sizing: border-box !important;
}
h1 {
font-size: 1.5rem !important;
font-weight: 700 !important;
margin: 0.75rem 0 0.5rem 0 !important;
line-height: 1.3 !important;
color: #111827 !important;
padding: 0 !important;
border: none !important;
background: none !important;
text-shadow: none !important;
.dark & {
color: #f9fafb !important;
}
}
h2 {
font-size: 1.25rem !important;
font-weight: 600 !important;
margin: 0.6rem 0 0.4rem 0 !important;
line-height: 1.4 !important;
color: #1f2937 !important;
padding: 0 !important;
border: none !important;
background: none !important;
text-shadow: none !important;
.dark & {
color: #e5e7eb !important;
}
}
h3 {
font-size: 1.125rem !important;
font-weight: 600 !important;
margin: 0.5rem 0 0.3rem 0 !important;
line-height: 1.4 !important;
color: #374151 !important;
padding: 0 !important;
border: none !important;
background: none !important;
text-shadow: none !important;
.dark & {
color: #d1d5db !important;
}
}
p {
margin: 0.4rem 0 !important;
line-height: 1.6 !important;
font-size: 0.875rem !important;
padding: 0 !important;
border: none !important;
background: none !important;
color: inherit !important;
text-shadow: none !important;
}
ul {
margin: 0.5rem 0 !important;
padding-left: 1.25rem !important;
list-style-type: disc !important;
border: none !important;
background: none !important;
ul {
list-style-type: circle !important;
ul {
list-style-type: square !important;
}
}
&[data-type='taskList'] {
list-style: none !important;
padding: 0 !important;
margin: 0.5rem 0 !important;
border: none !important;
background: none !important;
p {
margin: 0 !important;
font-size: 0.875rem !important;
line-height: 1.5 !important;
padding: 0 !important;
border: none !important;
background: none !important;
color: inherit !important;
text-shadow: none !important;
}
li {
display: flex !important;
align-items: flex-start !important;
margin: 0.25rem 0 !important;
padding: 0 !important;
border: none !important;
background: none !important;
color: inherit !important;
text-shadow: none !important;
list-style: none !important;
> label {
flex: 0 0 auto !important;
margin-right: 0.5rem !important;
margin-top: 0.125rem !important;
user-select: none !important;
padding: 0 !important;
border: none !important;
background: none !important;
input[type='checkbox'] {
width: 1rem !important;
height: 1rem !important;
border-radius: 0.25rem !important;
border: 2px solid #d1d5db !important;
background-color: #fff !important;
cursor: pointer !important;
appearance: none !important;
-webkit-appearance: none !important;
-moz-appearance: none !important;
position: relative !important;
margin: 0 !important;
padding: 0 !important;
box-shadow: none !important;
&:hover {
border-color: #3b82f6 !important;
}
&:checked {
background-color: #3b82f6 !important;
border-color: #3b82f6 !important;
&::after {
content: '' !important;
position: absolute !important;
left: 0.125rem !important;
top: 0.0625rem !important;
width: 0.375rem !important;
height: 0.625rem !important;
border: 2px solid white !important;
border-top: 0 !important;
border-left: 0 !important;
transform: rotate(45deg) !important;
}
}
.dark & {
border-color: #4b5563 !important;
background-color: #374151 !important;
&:hover {
border-color: #60a5fa !important;
}
&:checked {
background-color: #60a5fa !important;
border-color: #60a5fa !important;
}
}
}
}
> div {
flex: 1 1 auto !important;
margin: 0 !important;
padding: 0 !important;
border: none !important;
background: none !important;
color: inherit !important;
}
}
}
li {
margin: 0.25rem 0 !important;
line-height: 1.5 !important;
font-size: 0.875rem !important;
display: list-item !important;
list-style-type: disc !important;
padding: 0 !important;
border: none !important;
background: none !important;
color: inherit !important;
text-shadow: none !important;
}
}
ol {
margin: 0.5rem 0 !important;
padding-left: 1.25rem !important;
list-style-type: decimal !important;
border: none !important;
background: none !important;
li {
margin: 0.25rem 0 !important;
line-height: 1.5 !important;
font-size: 0.875rem !important;
display: list-item !important;
list-style-type: decimal !important;
padding: 0 !important;
border: none !important;
background: none !important;
color: inherit !important;
text-shadow: none !important;
}
}
strong {
font-weight: 600 !important;
color: #111827 !important;
text-shadow: none !important;
.dark & {
color: #f9fafb !important;
}
}
em {
font-style: italic !important;
text-shadow: none !important;
}
a {
color: #3b82f6 !important;
text-decoration: underline !important;
text-decoration-color: rgba(59, 130, 246, 0.3) !important;
text-shadow: none !important;
background: none !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
&:hover {
text-decoration-color: #3b82f6 !important;
background: none !important;
}
.dark & {
color: #60a5fa !important;
&:hover {
text-decoration-color: #60a5fa !important;
}
}
}
blockquote {
padding: 0.2rem 1rem !important;
margin: 1rem 0 !important;
font-style: italic !important;
color: #6b7280 !important;
text-align: left !important;
border-right: none !important;
border-top: none !important;
border-bottom: none !important;
box-shadow: none !important;
text-shadow: none !important;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background-color: #d1d5db;
z-index: 1;
border-radius: 0.5rem;
}
.dark &::before {
background-color: #4b5563;
}
.dark & {
color: #9ca3af !important;
}
}
pre {
background-color: #f3f4f6 !important;
color: #1f2937 !important;
padding: 1rem !important;
border-radius: 0.5rem !important;
margin: 1rem 0 !important;
overflow-x: auto !important;
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 0.875rem !important;
line-height: 1.5 !important;
text-align: left !important;
white-space: pre !important;
border: none !important;
box-shadow: none !important;
text-shadow: none !important;
.dark & {
background-color: rgba(35, 36, 41, 0.5) !important;
border: 1px solid rgba(35, 36, 41, 0.5) !important;
color: #e5e7eb !important;
}
code {
background-color: transparent !important;
color: inherit !important;
padding: 0 !important;
border-radius: 0 !important;
font-size: inherit !important;
font-family: inherit !important;
border: none !important;
margin: 0 !important;
.dark & {
background-color: transparent !important;
color: inherit !important;
}
}
}
hr {
border: none !important;
border-top: 1px solid #e5e7eb !important;
margin: 1rem 0 !important;
width: 100% !important;
background: none !important;
height: 0 !important;
padding: 0 !important;
.dark & {
border-top-color: #3f4854 !important;
}
}
code {
background-color: #f3f4f6 !important;
color: #d97706 !important;
padding: 0.125rem 0.25rem !important;
border-radius: 0.25rem !important;
font-size: 0.8125rem !important;
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace !important;
border: none !important;
margin: 0 !important;
text-shadow: none !important;
.dark & {
background-color: #374151 !important;
color: #fbbf24 !important;
}
}
}
@@ -1,256 +0,0 @@
/* Editor-specific styles (animations, transitions, editor-only features) - !these are not applied to sent messages! */
/* Nested content styling with animated borders */
.editor-prose li > *:not(:first-child) {
position: relative;
margin-left: -0.5rem;
}
.editor-prose li:not(:has(> label)) > *:not(:first-child)::before {
content: '';
position: absolute;
left: -0.75rem;
top: 0;
bottom: 0;
width: 1.5px;
background-color: #e5e7eb7e;
transform-origin: top;
animation: expandDown 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.dark .editor-prose li > *:not(:first-child)::before {
background-color: #4b55637b;
}
/* Special handling for nested lists to extend the line properly */
.editor-prose li > ul,
.editor-prose li > ol {
margin-left: -0.5rem;
}
.editor-prose li > ul::before,
.editor-prose li > ol::before {
bottom: -0.25rem; /* Extend slightly below for better visual connection */
}
@keyframes expandDown {
0% {
transform: scaleY(0);
opacity: 0;
}
100% {
transform: scaleY(1);
opacity: 1;
}
}
/* Placeholders for editor-only */
.editor-prose p::before,
.editor-prose h1::before,
.editor-prose h2::before,
.editor-prose h3::before,
.editor-prose h4::before,
.editor-prose h5::before,
.editor-prose h6::before {
content: attr(data-placeholder);
color: #9ca3af;
float: left;
height: 0;
}
.dark .editor-prose p::before,
.dark .editor-prose h1::before,
.dark .editor-prose h2::before,
.dark .editor-prose h3::before,
.dark .editor-prose h4::before,
.dark .editor-prose h5::before,
.dark .editor-prose h6::before {
color: #6b7280;
}
.bnEditor {
outline: none;
padding-inline: 50px;
border-radius: 8px;
/* Define a set of colors to be used throughout the app for consistency
see https://atlassian.design/foundations/color for more info */
--N800: #172b4d; /* Dark neutral used for tooltips and text on light background */
--N40: #dfe1e6; /* Light neutral used for subtle borders and text on dark background */
}
/*
bnRoot should be applied to all top-level elements
This includes the Prosemirror editor, but also <div> element such as
Tippy popups that are appended to document.body directly
*/
.bnRoot {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.bnRoot *,
.bnRoot *::before,
.bnRoot *::after {
-webkit-box-sizing: inherit;
-moz-box-sizing: inherit;
box-sizing: inherit;
}
/* reset styles, they will be set on blockContent */
.defaultStyles p,
.defaultStyles h1,
.defaultStyles h2,
.defaultStyles h3,
.defaultStyles li {
all: unset !important;
margin: 0;
padding: 0;
font-size: inherit;
/* min width to make sure cursor is always visible */
min-width: 2px !important;
}
.defaultStyles {
font-size: 16px;
font-weight: normal;
font-family:
'Inter',
'SF Pro Display',
-apple-system,
BlinkMacSystemFont,
'Open Sans',
'Segoe UI',
'Roboto',
'Oxygen',
'Ubuntu',
'Cantarell',
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.dragPreview {
position: absolute;
top: -1000px;
}
@keyframes fadeInScale {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
/* Animate headers only */
.editor-prose h1,
.editor-prose h2,
.editor-prose h3,
.editor-prose h4,
.editor-prose h5,
.editor-prose h6 {
animation: fadeInScale 0.2s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: left center;
}
/* Smooth transitions for all interactive elements */
.editor-prose {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Bold and italic transitions */
.editor-prose strong,
.editor-prose em {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Selected node styling (Notion-like) */
.ProseMirror-selectednode {
box-shadow: 0 0 0 4px #3b82f6;
border-radius: 4px;
background-color: rgba(59, 130, 246, 0.05);
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.dark .ProseMirror-selectednode {
box-shadow: 0 0 0 2px #3a3e44;
background-color: rgba(96, 165, 250, 0.08);
}
/* Ensure selected nodes have proper spacing */
.ProseMirror-selectednode {
margin: 2px;
}
/* Drag and drop containment */
.editor-prose {
position: relative;
overflow: hidden;
contain: layout style;
}
/* Image drag styling */
.editor-prose img.tiptap-image {
cursor: grab;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 4px;
max-width: 100%;
height: auto;
}
.editor-prose img.tiptap-image:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: scale(1.02);
}
.editor-prose img.tiptap-image:active {
cursor: grabbing;
transform: scale(0.98);
}
/* Dropcursor styling */
.tiptap-dropcursor {
pointer-events: none;
border-radius: 2px;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Prevent drag operations outside editor */
.editor-prose * {
-webkit-user-drag: auto;
-moz-user-drag: auto;
user-drag: auto;
}
/* Ensure only images within editor are draggable */
.editor-prose img {
-webkit-user-drag: element;
-moz-user-drag: element;
user-drag: element;
}
/* Prevent text selection during drag */
.editor-prose.ProseMirror-dragover * {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
}
@@ -1,196 +0,0 @@
<script lang="ts">
import { Icon, Bold, Italic, Strikethrough, CodeBracket, ChevronDown } from 'svelte-hero-icons';
import { M } from 'motion-start';
import type { Editor } from '@tiptap/core';
let { editor = $bindable() } = $props<{ editor: Editor | null }>();
// Turn into dropdown state
let showTurnInto = $state(false);
// Turn into options
const turnIntoOptions = [
{ id: 'paragraph', label: 'Text', icon: 'T', iconClass: 'font-mono' },
{ id: 'heading1', label: 'Heading 1', icon: 'H1', iconClass: 'font-bold' },
{ id: 'heading2', label: 'Heading 2', icon: 'H2', iconClass: 'font-bold' },
{ id: 'heading3', label: 'Heading 3', icon: 'H3', iconClass: 'font-bold' },
{ id: 'separator' },
{ id: 'bulletList', label: 'Bulleted list', icon: '•' },
{ id: 'orderedList', label: 'Numbered list', icon: '1.' },
{ id: 'taskList', label: 'To-do list', icon: '☐' },
{ id: 'separator' },
{ id: 'codeBlock', label: 'Code', icon: '</>' },
{ id: 'blockquote', label: 'Quote', icon: '"' }
];
function getCurrentBlockType(): string {
if (!editor) return 'Text';
if (editor.isActive('heading', { level: 1 })) return 'Heading 1';
if (editor.isActive('heading', { level: 2 })) return 'Heading 2';
if (editor.isActive('heading', { level: 3 })) return 'Heading 3';
if (editor.isActive('bulletList')) return 'Bulleted list';
if (editor.isActive('orderedList')) return 'Numbered list';
if (editor.isActive('taskList')) return 'To-do list';
if (editor.isActive('codeBlock')) return 'Code';
if (editor.isActive('blockquote')) return 'Quote';
return 'Text';
}
function turnInto(type: string) {
if (!editor) return;
switch (type) {
case 'paragraph':
editor.chain().focus().setParagraph().run();
break;
case 'heading1':
editor.chain().focus().toggleHeading({ level: 1 }).run();
break;
case 'heading2':
editor.chain().focus().toggleHeading({ level: 2 }).run();
break;
case 'heading3':
editor.chain().focus().toggleHeading({ level: 3 }).run();
break;
case 'bulletList':
editor.chain().focus().toggleBulletList().run();
break;
case 'orderedList':
editor.chain().focus().toggleOrderedList().run();
break;
case 'taskList':
editor.chain().focus().toggleTaskList().run();
break;
case 'codeBlock':
editor.chain().focus().toggleCodeBlock().run();
break;
case 'blockquote':
editor.chain().focus().toggleBlockquote().run();
break;
}
showTurnInto = false;
}
function handleKeydown(event: KeyboardEvent) {
// Close modals/dropdowns on Escape
if (event.key === 'Escape') {
if (showTurnInto) {
showTurnInto = false;
event.preventDefault();
}
}
}
function handleClick(event: MouseEvent) {
// Close turn into dropdown if clicking outside
if (showTurnInto && !(event.target as Element).closest('.turn-into-dropdown')) {
showTurnInto = false;
}
}
</script>
<svelte:window onkeydown={handleKeydown} onclick={handleClick} />
<!-- Main Bubble Menu -->
<M.div
class="flex gap-1 items-center p-1 rounded-lg border shadow-xl backdrop-blur-lg menu dark:bg-zinc-900/90 bg-white/90 dark:border-zinc-700/30 border-zinc-200/50"
layout
transition={{ duration: 0.3, ease: "easeInOut" }}
>
{#if editor}
<M.div
class="flex gap-1 items-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<!-- Turn Into Dropdown -->
<div class="relative turn-into-dropdown">
<M.button
onclick={() => showTurnInto = !showTurnInto}
class="flex gap-1 items-center px-3 py-2 text-sm rounded-md transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800"
title="Turn into"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{getCurrentBlockType()}
<M.div
animate={{ rotate: showTurnInto ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<Icon src={ChevronDown} size="14" />
</M.div>
</M.button>
{#if showTurnInto}
<M.div
class="absolute left-0 top-full z-50 mt-1 w-48 bg-white rounded-lg border shadow-xl dark:bg-zinc-800 border-zinc-200/40 dark:border-zinc-700/40"
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.15 }}
>
{#each turnIntoOptions as option}
{#if option.id === 'separator'}
<div class="my-1 h-px bg-zinc-200/60 dark:bg-zinc-600/60"></div>
{:else}
<button
onclick={() => turnInto(option.id)}
class="flex gap-2 items-center px-3 py-2 w-full text-sm text-left transition-colors hover:bg-zinc-100/60 dark:hover:bg-zinc-700/40"
>
<span class="{option.iconClass || ''}">{option.icon}</span>
{option.label}
</button>
{/if}
{/each}
</M.div>
{/if}
</div>
<div class="mx-1 w-px h-6 bg-zinc-300 dark:bg-zinc-600"></div>
<M.button
onclick={() => editor.chain().focus().toggleBold().run()}
class="p-2 rounded-md transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800 {editor.isActive('bold') ? 'bg-zinc-200 dark:bg-zinc-700' : '' }"
title="Bold"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<Icon src={Bold} size="16" />
</M.button>
<M.button
onclick={() => editor.chain().focus().toggleItalic().run()}
class="p-2 rounded-md transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800 {editor.isActive('italic') ? 'bg-zinc-200 dark:bg-zinc-700' : '' }"
title="Italic"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<Icon src={Italic} size="16" />
</M.button>
<M.button
onclick={() => editor.chain().focus().toggleStrike().run()}
class="p-2 rounded-md transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800 {editor.isActive('strike') ? 'bg-zinc-200 dark:bg-zinc-700' : '' }"
title="Strikethrough"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<Icon src={Strikethrough} size="16" />
</M.button>
<M.button
onclick={() => editor.chain().focus().toggleCode().run()}
class="p-2 rounded-md transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800 {editor.isActive('code') ? 'bg-zinc-200 dark:bg-zinc-700' : '' }"
title="Code"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<Icon src={CodeBracket} size="16" />
</M.button>
</M.div>
{/if}
</M.div>
@@ -1,172 +0,0 @@
<script lang="ts">
import { slashVisible, slashItems, slashLocation, slashProps, selectedIndex } from './stores';
import { fly } from 'svelte/transition';
import { get } from 'svelte/store';
let height = $state(0);
let elements = $state<any[]>([]);
export function handleKeydown(event: any, editor: any) {
if (!get(slashVisible)) return;
if (event.key === 'ArrowUp') {
event.preventDefault();
upHandler();
return true;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
downHandler();
return true;
}
if (event.key === 'Enter') {
event.preventDefault();
selectItem(editor);
return true;
}
return false;
}
function upHandler() {
const currentIndex = get(selectedIndex);
const itemsLength = get(slashItems).length;
const newIndex = currentIndex === 0 ? itemsLength - 1 : currentIndex - 1;
selectedIndex.set(newIndex);
}
function downHandler() {
const currentIndex = get(selectedIndex);
const itemsLength = get(slashItems).length;
const newIndex = currentIndex === itemsLength - 1 ? 0 : currentIndex + 1;
selectedIndex.set(newIndex);
}
$effect(() => {
const element = elements[$selectedIndex];
if (!element) return;
const container = element.closest('.overflow-auto');
if (!container) return;
const elementRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const elementTop = elementRect.top - containerRect.top + container.scrollTop;
const elementBottom = elementTop + elementRect.height;
const containerHeight = containerRect.height;
// Check if element is outside visible area
if (elementTop < container.scrollTop) {
// Element is above visible area
container.scrollTop = elementTop - 8;
} else if (elementBottom > container.scrollTop + containerHeight) {
// Element is below visible area
container.scrollTop = elementBottom - containerHeight + 8;
}
});
function selectItem(editor: any) {
const item = get(slashItems)[get(selectedIndex)];
if (item) {
let range = get(slashProps).range;
item.command({ editor, range });
slashVisible.set(false);
}
}
function closeSlashMenu() {
slashVisible.set(false);
selectedIndex.set(0);
}
function handleItemClick(item: any) {
const editor = get(slashProps).editor;
const range = get(slashProps).range;
slashVisible.set(false);
selectedIndex.set(0);
item.command({ editor, range });
}
function getCommandIcon(title: string): string {
const icons: Record<string, string> = {
'To Dos':
'<svg class="w-5 h-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path></svg>',
'Heading 1':
'<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path><text x="2" y="18" font-size="12" font-weight="bold" fill="currentColor">H1</text></svg>',
'Heading 2':
'<svg class="w-5 h-5 text-purple-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path><text x="2" y="18" font-size="12" font-weight="bold" fill="currentColor">H2</text></svg>',
'Heading 3':
'<svg class="w-5 h-5 text-purple-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path><text x="2" y="18" font-size="12" font-weight="bold" fill="currentColor">H3</text></svg>',
'Bullet List':
'<svg class="w-5 h-5 text-green-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path></svg>',
'Numbered List':
'<svg class="w-5 h-5 text-orange-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path></svg>',
Text: '<svg class="w-5 h-5 text-zinc-300" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 0v12h8V4H6z" clip-rule="evenodd"></path><path d="M8 6h4M8 8h4M8 10h2"></path></svg>',
Quote:
'<svg class="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0-3a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0-3a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0-3a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm8-3a3 3 0 11-6 0 3 3 0 016 0z" clip-rule="evenodd"></path></svg>',
'Code Block':
'<svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.316 3.051a1 1 0 01.633 1.265l-4 12a1 1 0 11-1.898-.632l4-12a1 1 0 011.265-.633zM5.707 6.293a1 1 0 010 1.414L3.414 10l2.293 2.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0zm8.586 0a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 11-1.414-1.414L16.586 10l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>',
Divider:
'<svg class="w-5 h-5 text-zinc-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path></svg>',
'Bold Text':
'<svg class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20"><path d="M6 4v12h3.5c2.5 0 4.5-2 4.5-4.5S12 7 9.5 7H9V4H6zm3 5.5h.5c.8 0 1.5.7 1.5 1.5s-.7 1.5-1.5 1.5H9V9.5z"></path></svg>',
'Italic Text':
'<svg class="w-5 h-5 text-pink-400" fill="currentColor" viewBox="0 0 20 20"><path d="M8 4h4l-2 12H6l2-12z"></path></svg>',
Link: '<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"></path></svg>',
'Inline Code':
'<svg class="w-5 h-5 text-cyan-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.707 3.293a1 1 0 010 1.414L5.414 7l2.293 2.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0zm4.586 0a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 11-1.414-1.414L14.586 7l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>',
};
return (
icons[title] ||
'<svg class="w-5 h-5 text-zinc-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>'
);
}
</script>
<svelte:window bind:innerHeight={height} />
{#if $slashVisible}
<div
class="fixed top-0 w-full h-screen"
onkeydown={() => {}}
onclick={closeSlashMenu}
role="menu"
tabindex="-1">
</div>
<div
transition:fly={{ y: 10, duration: 300 }}
class="overflow-auto absolute pb-2 w-80 max-w-full max-h-80 rounded-xl border shadow-xl backdrop-blur-lg origin-top-left scale-125 dark:bg-zinc-900/70 bg-zinc-100/70 dark:border-zinc-700/20 border-zinc-200"
style="left: {$slashLocation.x}px; top: {$slashLocation.y + $slashLocation.height + 320 > height
? $slashLocation.y - $slashLocation.height - 320
: $slashLocation.y + $slashLocation.height}px;">
<div class="p-2 text-sm text-zinc-500">Basic Blocks</div>
{#each $slashItems as { title, subtitle, command }, i}
<div
class="p-2 flex gap-3 cursor-pointer {i == $selectedIndex &&
'dark:bg-zinc-950/50 bg-zinc-300/50'} dark:hover:bg-zinc-950/30 hover:bg-zinc-300/20 rounded-lg mx-2"
onclick={() => handleItemClick({ command })}
onkeydown={() => {}}
role="menuitem"
tabindex="-1"
bind:this={elements[i]}>
<div class="flex justify-center items-center w-8 h-8 rounded-lg bg-zinc-800">
{@html getCommandIcon(title)}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate dark:text-white">
{title}
</div>
<p class="text-xs truncate text-zinc-400">
{subtitle ? subtitle : ''}
</p>
</div>
</div>
{/each}
</div>
{/if}
@@ -1,26 +0,0 @@
import { Extension } from '@tiptap/core';
import Suggestion from '@tiptap/suggestion';
export default Extension.create({
name: 'slash',
addOptions() {
return {
suggestion: {
char: '/',
command: ({ editor, range, props }: any) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
@@ -1,46 +0,0 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
type SlashItems = SlashItem[];
type SlashItem = {
title: string;
subtitle: string;
command: ({ editor, range }: EditorProps) => void;
};
type Component = {
name: string;
description: string;
code: string;
};
type Components = Component[];
type EditorProps = {
editor: any;
range: number | null;
};
type Location = {
x: number;
y: number;
height: number;
};
// For now we'll keep using stores until we can fully convert to runes in all components
export const slashItems: Writable<SlashItems> = writable([]);
export const slashVisible: Writable<boolean> = writable(false);
export const slashLocation: Writable<Location> = writable({
x: 0,
y: 0,
height: 0,
});
export const slashProps: Writable<EditorProps> = writable({
editor: null,
range: null,
});
export const desktopMenu: Writable<boolean> = writable(true);
export const components: Writable<Components> = writable([]);
export const editorWidth: Writable<number> = writable(0);
export const selectedIndex: Writable<number> = writable(0);
@@ -1,159 +0,0 @@
import { slashVisible, slashItems, slashLocation, slashProps, selectedIndex } from './stores';
export default {
items: ({ query }: any) => {
return [
{
title: 'To Dos',
subtitle: 'Create a to do list with checkboxes',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: 'Heading 1',
subtitle: 'BIG heading',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run();
},
},
{
title: 'Heading 2',
subtitle: 'Less Big heading',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run();
},
},
{
title: 'Heading 3',
subtitle: 'Medium big heading',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run();
},
},
{
title: 'Bullet List',
subtitle: 'Pew pew pew',
command: ({ editor, range }: any) => {
editor.commands.deleteRange(range);
editor.commands.toggleBulletList();
},
},
{
title: 'Numbered List',
subtitle: '1, 2, 3, 4...',
command: ({ editor, range }: any) => {
editor.commands.deleteRange(range);
editor.commands.toggleOrderedList();
},
},
{
title: 'Text',
subtitle: 'Just plain text paragraph',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setNode('paragraph').run();
},
},
{
title: 'Quote',
subtitle: 'Capture important quotes',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
},
},
{
title: 'Code Block',
subtitle: 'Formatted code snippet',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
},
},
{
title: 'Divider',
subtitle: 'Add a horizontal line',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: 'Bold Text',
subtitle: 'Make text bold',
command: ({ editor, range }: any) => {
editor.commands.deleteRange(range);
editor.commands.toggleBold();
},
},
{
title: 'Italic Text',
subtitle: 'Make text italic',
command: ({ editor, range }: any) => {
editor.commands.deleteRange(range);
editor.commands.toggleItalic();
},
},
{
title: 'Link',
subtitle: 'Add a web link',
command: ({ editor, range }: any) => {
const url = prompt('Enter the URL:');
if (url) {
editor
.chain()
.focus()
.deleteRange(range)
.setLink({ href: url })
.insertContent('Link text')
.run();
}
},
},
{
title: 'Inline Code',
subtitle: 'Inline code snippet',
command: ({ editor, range }: any) => {
editor.commands.deleteRange(range);
editor.commands.toggleCode();
},
},
]
.filter((item) => item.title.toLowerCase().startsWith(query.toLowerCase()))
.slice(0, 10);
},
render: () => {
return {
onStart: (props: any) => {
let editor = props.editor;
let range = props.range;
let location = props.clientRect();
const editorRect = editor.view.dom.getBoundingClientRect();
slashProps.set({ editor, range });
slashVisible.set(true);
slashLocation.set({
x: location.x - editorRect.left,
y: location.y - editorRect.top + location.height / 2 + 4,
height: location.height,
});
slashItems.set(props.items);
selectedIndex.set(0);
},
onUpdate(props: any) {
slashItems.set(props.items);
selectedIndex.set(0);
},
onKeyDown(props: any) {
if (props.event.key === 'Escape') {
slashVisible.set(false);
return true;
}
},
onExit() {
slashVisible.set(false);
selectedIndex.set(0);
},
};
},
};
@@ -1,75 +0,0 @@
.ProseMirror {
position: relative;
}
.ProseMirror {
word-wrap: break-word;
white-space: pre-wrap;
white-space: break-spaces;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
}
.ProseMirror [contenteditable="false"] {
white-space: normal;
}
.ProseMirror [contenteditable="false"] [contenteditable="true"] {
white-space: pre-wrap;
}
.ProseMirror pre {
white-space: pre-wrap;
}
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
width: 0 !important;
height: 0 !important;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
margin: 0;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection * {
caret-color: transparent;
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
.tippy-box[data-animation=fade][data-state=hidden] {
opacity: 0
}
@@ -1,238 +0,0 @@
/* SEQTA Applied styles on DMs (applied to ensure consistency) */
.editor-prose {
font-family: 'Roboto', sans-serif;
border: 0;
padding: 0 8px;
margin: 0;
line-height: 1.2;
/* Removed font size because drag and drop text content within the editor insert html span with font sizing */
font-size: 10pt;
/* Macro: Image */
/* Macro: Image gallery (display) */
/* Fake macro element from plugin "seqta-macro" */
img[data-macro],
a[data-macro] {
border: 2px dashed #ccc;
padding: 8px;
border-radius: 4px;
position: relative;
box-sizing: border-box;
}
img[data-macro].selected,
a[data-macro].selected {
border: 2px solid #204a87;
box-shadow: inset 0 0 4px #204a87;
}
img[data-macro='Resource'] {
background-image: repeating-linear-gradient(
-45deg,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 0) 12px,
rgba(0, 0, 0, 0.05) 12px,
rgba(0, 0, 0, 0.05) 24px
);
}
img[data-macro='Embed'] {
background-image: repeating-linear-gradient(
45deg,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 0) 12px,
rgba(0, 0, 0, 0.05) 12px,
rgba(0, 0, 0, 0.05) 24px
);
}
img[data-macro='Embed'][data-full] {
width: 100%;
}
img[data-macro='Gallery'] {
display: block;
margin: 0 auto;
max-width: 100%;
padding: 0;
}
/* Direqt message-specific styling */
blockquote.forward {
margin: 0;
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
}
blockquote.forward > .preamble {
background: rgba(255, 255, 255, 0.1);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 8px;
}
blockquote.forward > .preamble > .date > .label,
blockquote.forward > .preamble > .sender > .label {
color: rgba(0, 0, 0, 0.7);
}
blockquote.forward > .preamble > .date > .value,
blockquote.forward > .preamble > .sender > .value {
color: rgba(0, 0, 0, 0.9);
}
blockquote.forward > .body {
padding: 8px;
}
/** Assessment display **/
.assessmentWrapper {
position: relative;
display: inline-block;
bottom: -15px;
}
.macro-assessment {
padding: 8px 16px;
margin: 0 8px;
border: 4px solid rgba(0, 0, 0, 0.25);
display: inline-block;
width: 256px;
background-color: #fff;
overflow: hidden;
text-shadow: none;
position: relative;
word-wrap: break-word;
min-height: 30px;
}
.macro-assessment > .title {
font-size: 150%;
max-width: 230px;
}
.macro-assessment > .due > span.weight {
padding-left: 24px;
}
.macro-assessment > .due > span.marked {
padding-left: 24px;
font-weight: bold;
}
.macro-assessment > .hidden,
.macro-assessment > .deleted {
font-style: italic;
max-width: 230px;
}
/** Syllabus display **/
.macro-syllabus {
padding: 8px;
margin: 0 8px;
border: 4px solid #eee;
display: inline-block;
max-width: 200px;
background-color: #fff;
overflow: hidden;
color: #444;
text-shadow: none;
position: relative;
bottom: -15px;
}
.macro-syllabus > .label {
font-weight: bold;
}
.macro-syllabus > .extra {
font-style: italic;
}
.macro-syllabus > .meta {
text-transform: uppercase;
font-size: var(--small-text);
color: #999;
}
/* Drop-down menu for plugins like "seqta-macro" */
.cke_panel_block > h1 {
display: none;
}
.cke_panel_block > .cke_panel_list {
list-style: none;
padding: 0;
margin: 0;
}
.cke_panel_block > .cke_panel_list > li {
color: #888;
margin: 0;
cursor: pointer;
}
.cke_panel_block > .cke_panel_list > li:hover {
color: white;
background: #1b315e;
}
.cke_panel_block > .cke_panel_list > li > a {
display: block;
color: inherit;
text-decoration: inherit;
text-transform: uppercase;
font-size: 90%;
padding: 8px; /* Padding on the <a>, not the <li>, so our click target is full size. */
text-shadow: none;
}
.cke_panel_block > .cke_panel_list > li > a > p,
.cke_panel_block > .cke_panel_list > li > a > h1,
.cke_panel_block > .cke_panel_list > li > a > h2,
.cke_panel_block > .cke_panel_list > li > a > h3,
.cke_panel_block > .cke_panel_list > li > a > pre {
margin: 0;
color: inherit;
padding: 0;
}
.moodleFrame > .userHTML {
width: 100%;
height: 600px;
margin: 16px 0 16px 0;
}
.application.restricted {
display: block;
max-width: 320px;
margin: 32px auto;
border: 1px dashed #ccc;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
padding: 24px;
background: #f8f8f8;
background-image: -webkit-linear-gradient(315deg, #fff, #f8f8f8);
background-image: linear-gradient(135deg, #fff, #f8f8f8);
border-radius: 8px;
box-sizing: border-box;
}
.application.restricted > .title {
margin: 0;
padding: 0;
font-size: 100%;
font-weight: bold;
color: #666;
}
.application.restricted > .message {
margin: 0;
padding: 0;
font-size: var(--small-text);
}
}
@@ -1,236 +0,0 @@
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import { defineSettings } from "@/plugins/core/settingsHelpers";
import { waitForElm } from "@/seqta/utils/waitForElm";
import renderSvelte from "@/interface/main";
import BetterEditor from "./BetterEditor.svelte";
import { unmount } from "svelte";
const settings = defineSettings({});
class CustomMessageEditorPlugin extends BasePlugin<typeof settings> {}
const settingsInstance = new CustomMessageEditorPlugin();
const customMessageEditorPlugin: Plugin<typeof settings> = {
id: "custom-message-editor",
name: "Custom Message Editor",
description: "Enhanced message editor with better editing capabilities",
version: "1.0.0",
settings: settingsInstance.settings,
defaultEnabled: true,
run: async (api) => {
let currentShadowContainer: HTMLElement | null = null;
let currentSvelteApp: any = null;
let currentEditorId: string | null = null;
let lastCKEditorContent: string = "";
const cleanup = (resetEditorId = true) => {
if (currentSvelteApp) {
unmount(currentSvelteApp);
currentSvelteApp = null;
}
if (currentShadowContainer) {
currentShadowContainer.remove();
currentShadowContainer = null;
}
if (resetEditorId) {
currentEditorId = null;
}
};
const handleEditorChange = (value: string) => {
if (currentEditorId) {
window.postMessage(
{
type: "ckeditorSetData",
editorId: currentEditorId,
content: value,
},
"*",
);
}
};
const getCKEditorContent = () => {
if (currentEditorId) {
window.postMessage(
{
type: "ckeditorGetData",
editorId: currentEditorId,
},
"*",
);
}
};
const messageListener = (event: MessageEvent) => {
if (event.data.type === "ckeditorGetDataResponse") {
lastCKEditorContent = event.data.data;
console.log("Retrieved CKEditor content:", lastCKEditorContent);
}
};
window.addEventListener("message", messageListener);
const injectBetterEditorButton = async (composer: Element) => {
try {
const pillbox = await waitForElm(
".coneqtMessage.composer .footer .pillbox",
true,
100,
50,
);
if (!pillbox) {
console.error("Could not find pillbox element");
return;
}
if (pillbox.querySelector(".better-editor-btn")) {
return;
}
const betterEditorBtn = document.createElement("button");
betterEditorBtn.type = "button";
betterEditorBtn.className = "notLast editorMode better-editor-btn";
betterEditorBtn.textContent = "Better Editor";
betterEditorBtn.setAttribute("data-key", "better");
const htmlEditorBtn = pillbox.querySelector(
'button[data-key="html"]',
) as HTMLButtonElement;
if (!htmlEditorBtn) {
console.error("Could not find HTML editor button");
return;
}
pillbox.insertBefore(betterEditorBtn, htmlEditorBtn);
betterEditorBtn.addEventListener("click", async () => {
const simpleEditorBtn = pillbox.querySelector(
'button[data-key="content"]',
) as HTMLButtonElement;
if (simpleEditorBtn) {
simpleEditorBtn.click();
}
pillbox.querySelectorAll(".editorMode").forEach((btn) => {
btn.classList.remove("depressed");
});
if (simpleEditorBtn) {
simpleEditorBtn.classList.add("depressed");
}
const wrapper = composer.querySelector(
".prime .body .formattedText .wrapper",
);
const ckeElement = wrapper?.querySelector(".cke");
if (!wrapper || !ckeElement) {
console.error("Could not find wrapper or CKE elements");
return;
}
if (ckeElement.id) {
const ckeMatch = ckeElement.id.match(/^cke_(.+)$/);
if (ckeMatch) {
currentEditorId = ckeMatch[1];
console.log("Found CKEditor ID:", currentEditorId);
}
}
let initialContent = "";
if (currentEditorId) {
window.postMessage(
{
type: "ckeditorGetData",
editorId: currentEditorId,
},
"*",
);
initialContent = await new Promise<string>((resolve) => {
const timeout = setTimeout(() => resolve(""), 1000);
const responseListener = (event: MessageEvent) => {
if (event.data.type === "ckeditorGetDataResponse") {
clearTimeout(timeout);
window.removeEventListener("message", responseListener);
resolve(event.data.data || "");
}
};
window.addEventListener("message", responseListener);
});
}
(ckeElement as HTMLElement).style.display = "none";
cleanup(false);
const shadowContainer = document.createElement("div");
shadowContainer.className = "better-editor-container";
shadowContainer.style.cssText =
"width: 100%; height: 100%; min-height: 200px; overflow-y: scroll; background: var(--background-primary); border-radius: 16px; padding: 4px;";
const shadowRoot = shadowContainer.attachShadow({ mode: "open" });
currentSvelteApp = renderSvelte(BetterEditor, shadowRoot, {
initialContent,
onchange: handleEditorChange,
});
wrapper.appendChild(shadowContainer);
currentShadowContainer = shadowContainer;
pillbox.querySelectorAll(".editorMode").forEach((btn) => {
btn.classList.remove("depressed");
});
betterEditorBtn.classList.add("depressed");
});
pillbox
.querySelectorAll(".editorMode:not(.better-editor-btn)")
.forEach((btn) => {
btn.addEventListener("click", () => {
getCKEditorContent();
cleanup(false);
const wrapper = composer.querySelector(
".prime .body .formattedText .wrapper",
);
const ckeElement = wrapper?.querySelector(".cke");
if (ckeElement) {
(ckeElement as HTMLElement).style.display = "";
}
});
});
} catch (error) {
console.error("Error injecting Better Editor button:", error);
}
};
const { unregister } = api.seqta.onMount(".uiSlidePane", (slidePane) => {
console.log("Found slide pane, checking for message composer");
const messageComposer = slidePane.querySelector(
".coneqtMessage.composer",
);
if (messageComposer) {
console.log("Found message composer, injecting Better Editor button");
injectBetterEditorButton(messageComposer);
}
});
return () => {
cleanup();
unregister();
window.removeEventListener("message", messageListener);
};
},
};
export default customMessageEditorPlugin;
+128
View File
@@ -0,0 +1,128 @@
import { defineLazyPlugin } from "../../core/dynamicLoader";
import {
booleanSetting,
buttonSetting,
defineSettings,
hotkeySetting,
} from "../../core/settingsHelpers";
import styles from "./src/core/styles.css?inline";
// Platform-aware default hotkey
const getDefaultHotkey = () => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
return isMac ? "cmd+k" : "ctrl+k";
};
const settings = defineSettings({
searchHotkey: hotkeySetting({
default: getDefaultHotkey(),
title: "Search Hotkey",
description: "Keyboard shortcut to open the search",
}),
showRecentFirst: booleanSetting({
default: true,
title: "Show Recent First",
description: "Sort dynamic content by most recent first",
}),
transparencyEffects: booleanSetting({
default: true,
title: "Transparency Effects",
description: "Enable transparency effects for the search bar",
}),
runIndexingOnLoad: booleanSetting({
default: true,
title: "Index on Page Load",
description: "Run content indexing when SEQTA loads",
}),
resetIndex: buttonSetting({
title: "Reset Index",
description: "Reset the search index and storage",
trigger: async () => {
const confirmed = confirm("Are you sure you want to reset the search index and storage?");
if (confirmed) {
try {
// Dynamically import modules to avoid loading heavy dependencies
const { VectorWorkerManager } = await import("./src/indexing/worker/vectorWorkerManager");
const { resetDatabase } = await import("./src/indexing/db");
// 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 index: " + String(e));
}
}
},
}),
});
// Create the lazy plugin definition - this loads immediately but doesn't import heavy dependencies
export default defineLazyPlugin({
id: "global-search",
name: "Global Search",
description: "Quick search for everything in SEQTA",
version: "1.0.0",
settings,
disableToggle: true,
defaultEnabled: false,
beta: true,
styles: styles,
// Lazy loader - only imports the heavy plugin when actually needed
loader: () => import("./src/core/index")
});
@@ -35,6 +35,8 @@
let isIndexing = $state(false); let isIndexing = $state(false);
let completedJobs = $state(0); let completedJobs = $state(0);
let totalJobs = $state(0); let totalJobs = $state(0);
let indexingStatus = $state<string | null>(null);
let indexingDetail = $state<string | null>(null);
let commandPalleteOpen = $state(false); let commandPalleteOpen = $state(false);
let searchTerm = $state(''); let searchTerm = $state('');
@@ -110,10 +112,12 @@
onMount(() => { onMount(() => {
const progressHandler = (event: CustomEvent) => { const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing } = event.detail; const { completed, total, indexing, status, detail } = event.detail;
completedJobs = completed; completedJobs = completed;
totalJobs = total; totalJobs = total;
isIndexing = indexing; isIndexing = indexing;
indexingStatus = status || null;
indexingDetail = detail || null;
}; };
window.addEventListener('indexing-progress', progressHandler as EventListener); window.addEventListener('indexing-progress', progressHandler as EventListener);
@@ -168,6 +172,9 @@
term, term,
commandsFuse, commandsFuse,
commandIdToItemMap, commandIdToItemMap,
dynamicContentFuse,
dynamicIdToItemMap,
true, // sortByRecent
); );
} else { } else {
combinedResults = []; combinedResults = [];
@@ -176,13 +183,19 @@
isLoading = false; isLoading = false;
}; };
const debouncedPerformSearch = debounce(performSearch, 20); // Optimized debounce: shorter delay for better responsiveness
const debouncedPerformSearch = debounce(performSearch, 50);
$effect(() => { $effect(() => {
if (commandPalleteOpen) { if (commandPalleteOpen) {
if (searchTerm === '') { if (searchTerm === '') {
// Immediate search for empty query (shows recent items)
performSearch();
} else if (searchTerm.length <= 2) {
// Immediate search for very short queries
performSearch(); performSearch();
} else { } else {
// Debounced search for longer queries
debouncedPerformSearch(); debouncedPerformSearch();
} }
tick().then(() => searchbar?.focus()); tick().then(() => searchbar?.focus());
@@ -389,19 +402,6 @@
{@render Shortcut({ text: 'Select', keybind: ['↵']})} {@render Shortcut({ text: 'Select', keybind: ['↵']})}
{/if} {/if}
</div> </div>
{#if isIndexing}
<div class="inset-x-0 top-0">
<div class="absolute right-2 -bottom-4 text-[10px] text-zinc-500 dark:text-zinc-400">
Indexing
</div>
<div class="overflow-hidden h-0.5 bg-zinc-200 dark:bg-zinc-700">
<div
class="h-full bg-blue-500 transition-all duration-300 ease-out"
style="width: {(completedJobs / totalJobs) * 100}%"
></div>
</div>
</div>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -4,8 +4,8 @@ import {
booleanSetting, booleanSetting,
buttonSetting, buttonSetting,
defineSettings, defineSettings,
Setting,
hotkeySetting, hotkeySetting,
Setting,
} from "@/plugins/core/settingsHelpers"; } from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
@@ -14,6 +14,7 @@ import { initVectorSearch } from "../search/vector/vectorSearch";
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar"; import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
import { IndexedDbManager } from "embeddia"; import { IndexedDbManager } from "embeddia";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager"; import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { checkAndHandleUpdate } from "../utils/versionCheck";
// Platform-aware default hotkey // Platform-aware default hotkey
const getDefaultHotkey = () => { const getDefaultHotkey = () => {
@@ -50,7 +51,11 @@ const settings = defineSettings({
if (confirmed) { if (confirmed) {
try { try {
// Import resetDatabase function to properly close connections
const { resetDatabase } = await import("../indexing/db");
// Reset the vector worker first // Reset the vector worker first
try {
const workerManager = VectorWorkerManager.getInstance(); const workerManager = VectorWorkerManager.getInstance();
await workerManager.resetWorker(); await workerManager.resetWorker();
console.log("Vector worker reset successfully"); console.log("Vector worker reset successfully");
@@ -58,23 +63,55 @@ const settings = defineSettings({
console.warn("Failed to reset vector worker:", e); console.warn("Failed to reset vector worker:", e);
} }
// Delete both 'embeddiaDB' and 'betterseqta-index' using native IndexedDB APIs // Close all database connections properly before deletion
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) => { const deleteDb = (dbName: string) => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName); const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve(); req.onsuccess = () => {
req.onerror = () => reject(req.error); console.log(`Successfully deleted database: ${dbName}`);
resolve();
};
req.onerror = () => {
console.error(`Error deleting database ${dbName}:`, req.error);
reject(req.error);
};
req.onblocked = () => { req.onblocked = () => {
reject(new Error(`One database is open, failed to remove: ${dbName}`)); 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 { try {
await deleteDb("embeddiaDB"); await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index"); await deleteDb("betterseqta-index");
alert("Search index and storage have been reset."); alert("Search index and storage have been reset successfully.");
} catch (e) { } catch (e) {
alert("Failed to reset one or more databases: " + String(e)); alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
}
} catch (e) {
alert("Failed to reset index: " + String(e));
} }
} }
}, },
@@ -114,6 +151,27 @@ const globalSearchPlugin: Plugin<typeof settings> = {
run: async (api) => { run: async (api) => {
const appRef = { current: null }; const appRef = { current: null };
// Check for extension updates and clear caches if needed
// Use a timeout to avoid blocking initialization
setTimeout(async () => {
try {
const wasUpdated = await checkAndHandleUpdate();
if (wasUpdated) {
console.log("[Global Search] Extension updated - caches cleared");
}
} catch (error: any) {
// Handle CSS preload errors and other failures gracefully
// These can happen in Firefox or when assets aren't available
if (error?.message?.includes("preload CSS") ||
error?.message?.includes("MIME type") ||
error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")) {
console.debug("[Global Search] Version check skipped due to asset loading restrictions:", error.message);
} else {
console.warn("[Global Search] Failed to check for updates:", error);
}
}
}, 100);
try { try {
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", { await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
primaryKey: "id", primaryKey: "id",
@@ -126,10 +184,16 @@ const globalSearchPlugin: Plugin<typeof settings> = {
initVectorSearch(); initVectorSearch();
// Warm up vector worker in background to improve initial response time // Warm up vector worker in background to improve initial response time (skip in Firefox)
setTimeout(async () => { setTimeout(async () => {
try { try {
// Only initialize worker if vector search is supported
const { isVectorSearchSupported } = await import("../utils/browserDetection");
if (isVectorSearchSupported()) {
VectorWorkerManager.getInstance(); VectorWorkerManager.getInstance();
} else {
console.debug("[Global Search] Skipping vector worker warm-up (Firefox detected - using text search only)");
}
} catch (error) { } catch (error) {
console.warn("[Global Search] Vector worker warm-up failed:", error); console.warn("[Global Search] Vector worker warm-up failed:", error);
} }
@@ -8,7 +8,7 @@ import browser from "webextension-polyfill";
export function mountSearchBar( export function mountSearchBar(
titleElement: Element, titleElement: Element,
api: any, api: any,
appRef: { current: any; storageChangeHandler?: any }, appRef: { current: any; storageChangeHandler?: any; progressHandler?: any },
) { ) {
if (titleElement.querySelector(".search-trigger")) { if (titleElement.querySelector(".search-trigger")) {
return; return;
@@ -21,6 +21,72 @@ export function mountSearchBar(
const searchButton = document.createElement("div"); const searchButton = document.createElement("div");
searchButton.className = "search-trigger"; searchButton.className = "search-trigger";
// Create progress indicator container
const progressContainer = document.createElement("div");
progressContainer.className = "search-progress-container";
progressContainer.style.cssText = "display: flex; align-items: center; gap: 8px; margin-left: 8px; min-width: 120px;";
// Create progress bar
const progressBarWrapper = document.createElement("div");
progressBarWrapper.className = "search-progress-bar-wrapper";
progressBarWrapper.style.cssText = "flex: 1; height: 4px; background: rgba(0, 0, 0, 0.1); border-radius: 2px; overflow: hidden; display: none;";
const progressBar = document.createElement("div");
progressBar.className = "search-progress-bar";
progressBar.style.cssText = "height: 100%; background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6); transition: width 0.3s ease-out; width: 0%; position: relative;";
// Add shimmer effect
const shimmer = document.createElement("div");
shimmer.style.cssText = "position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); animation: shimmer 2s infinite;";
progressBar.appendChild(shimmer);
progressBarWrapper.appendChild(progressBar);
// Create progress text
const progressText = document.createElement("span");
progressText.className = "search-progress-text";
progressText.style.cssText = "font-size: 11px; color: #666; white-space: nowrap; display: none;";
progressContainer.appendChild(progressBarWrapper);
progressContainer.appendChild(progressText);
// Indexing state
let isIndexing = false;
let completedJobs = 0;
let totalJobs = 0;
let indexingStatus: string | null = null;
const updateProgressDisplay = () => {
if (isIndexing && totalJobs > 0) {
const percentage = Math.round((completedJobs / totalJobs) * 100);
progressBar.style.width = `${Math.max(2, percentage)}%`;
progressBarWrapper.style.display = "block";
if (indexingStatus) {
progressText.textContent = indexingStatus.length > 20 ? indexingStatus.substring(0, 20) + "..." : indexingStatus;
progressText.style.display = "block";
} else {
progressText.textContent = `${completedJobs}/${totalJobs} (${percentage}%)`;
progressText.style.display = "block";
}
} else {
progressBarWrapper.style.display = "none";
progressText.style.display = "none";
}
};
// Listen for indexing progress events
const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing, status } = event.detail;
completedJobs = completed || 0;
totalJobs = total || 0;
isIndexing = indexing || false;
indexingStatus = status || null;
updateProgressDisplay();
};
window.addEventListener('indexing-progress', progressHandler as EventListener);
appRef.progressHandler = progressHandler;
const updateSearchButtonDisplay = () => { const updateSearchButtonDisplay = () => {
searchButton.innerHTML = /* html */ ` searchButton.innerHTML = /* html */ `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -34,6 +100,7 @@ export function mountSearchBar(
updateSearchButtonDisplay(); updateSearchButtonDisplay();
titleElement.appendChild(searchButton); titleElement.appendChild(searchButton);
titleElement.appendChild(progressContainer);
// Listen for hotkey setting changes // Listen for hotkey setting changes
const handleStorageChange = (changes: any, area: string) => { const handleStorageChange = (changes: any, area: string) => {
@@ -72,7 +139,7 @@ export function mountSearchBar(
} }
} }
export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any }) { export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any; progressHandler?: any }) {
if (appRef.current) { if (appRef.current) {
try { try {
unmount(appRef.current); unmount(appRef.current);
@@ -82,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 // Remove search trigger button
const searchTrigger = document.querySelector(".search-trigger"); const searchTrigger = document.querySelector(".search-trigger");
if (searchTrigger) { if (searchTrigger) {
searchTrigger.remove(); searchTrigger.remove();
} }
// Remove progress container
const progressContainer = document.querySelector(".search-progress-container");
if (progressContainer) {
progressContainer.remove();
}
// Remove search root // Remove search root
const searchRoot = document.querySelector("div[data-search-root]"); const searchRoot = document.querySelector("div[data-search-root]");
if (searchRoot) { if (searchRoot) {
@@ -69,3 +69,71 @@
.dark .highlight { .dark .highlight {
background-color: rgba(255, 230, 100, 0.4); background-color: rgba(255, 230, 100, 0.4);
} }
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
/* Progress indicator next to search trigger */
.search-progress-container {
display: flex;
align-items: center;
gap: 8px;
margin-left: 8px;
min-width: 120px;
max-width: 200px;
height: 32px;
}
.search-progress-bar-wrapper {
flex: 1;
height: 4px;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
overflow: hidden;
display: none;
min-width: 60px;
}
.dark .search-progress-bar-wrapper {
background: rgba(255, 255, 255, 0.1);
}
.search-progress-bar {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6);
transition: width 0.3s ease-out;
width: 0%;
position: relative;
border-radius: 2px;
}
.search-progress-bar::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
animation: shimmer 2s infinite;
border-radius: 2px;
}
.search-progress-text {
font-size: 11px;
color: #666;
white-space: nowrap;
display: none;
font-weight: 500;
}
.dark .search-progress-text {
color: #999;
}
@@ -59,17 +59,132 @@ export const actionMap: Record<string, ActionHandler<any>> = {
}) as ActionHandler<any>, }) as ActionHandler<any>,
assessment: (async (item: IndexItem & { metadata: AssessmentMetadata }) => { assessment: (async (item: IndexItem & { metadata: AssessmentMetadata }) => {
if (item.metadata.isMessageBased) { // Deep clone the entire item to avoid Firefox XrayWrapper issues
// Firefox XrayWrapper prevents direct access to nested properties
let itemClone: IndexItem & { metadata: AssessmentMetadata };
let metadata: AssessmentMetadata;
try {
// First try to clone the entire item
itemClone = JSON.parse(JSON.stringify(item));
metadata = itemClone.metadata || {};
} catch (e) {
console.warn("[Assessment Action] Failed to clone item, trying to clone metadata separately:", e);
try {
// If full clone fails, try cloning just metadata
metadata = JSON.parse(JSON.stringify(item.metadata || {}));
itemClone = { ...item, metadata };
} catch (e2) {
console.warn("[Assessment Action] Failed to clone metadata, using direct access:", e2);
itemClone = item;
metadata = item.metadata || {} as AssessmentMetadata;
}
}
// Try to extract metadata values using multiple methods to handle XrayWrapper
const getMetadataValue = (key: string, altKey?: string): any => {
try {
// Try direct access first
const value = metadata[key];
if (value !== undefined && value !== null) {
return value;
}
if (altKey) {
const altValue = metadata[altKey];
if (altValue !== undefined && altValue !== null) {
return altValue;
}
}
// Try accessing via Object.keys iteration (works around XrayWrapper)
try {
const keys = Object.keys(metadata);
for (const k of keys) {
if (k === key || k === altKey) {
const val = metadata[k];
if (val !== undefined && val !== null) {
return val;
}
}
}
} catch (e) {
// Object.keys might fail on XrayWrapper, that's okay
}
return undefined;
} catch (e) {
console.warn(`[Assessment Action] Failed to access metadata.${key}:`, e);
return undefined;
}
};
if (getMetadataValue('isMessageBased')) {
window.location.hash = `#?page=/messages`; window.location.hash = `#?page=/messages`;
await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20); await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20);
// Select the specific direct message // Select the specific direct message
ReactFiber.find('[class*="Viewer__Viewer___"] > div').setState({ ReactFiber.find('[class*="Viewer__Viewer___"] > div').setState({
selected: new Set([item.metadata.messageId]), selected: new Set([getMetadataValue('messageId')]),
}); });
} else { } else {
window.location.hash = `#?page=/assessments&id=${item.metadata.assessmentId}`; // Extract values - check both camelCase and PascalCase, and try multiple access methods
let programmeId = getMetadataValue('programmeId', 'programmeID');
let metaclassId = getMetadataValue('metaclassId', 'metaclassID');
let assessmentId = getMetadataValue('assessmentId', 'assessmentID');
// Fallback: try to extract assessmentId from item ID if metadata is missing
if ((assessmentId === undefined || assessmentId === null) && itemClone.id && itemClone.id.startsWith('assignment-')) {
const extractedId = itemClone.id.replace('assignment-', '');
assessmentId = Number(extractedId) || extractedId;
console.log("[Assessment Action] Extracted assessmentId from item ID:", assessmentId);
}
// Convert to numbers, but preserve 0 as valid
if (programmeId !== undefined && programmeId !== null && programmeId !== '') {
const num = Number(programmeId);
programmeId = isNaN(num) ? programmeId : num;
}
if (metaclassId !== undefined && metaclassId !== null && metaclassId !== '') {
const num = Number(metaclassId);
metaclassId = isNaN(num) ? metaclassId : num;
}
if (assessmentId !== undefined && assessmentId !== null && assessmentId !== '') {
const num = Number(assessmentId);
assessmentId = isNaN(num) ? assessmentId : num;
}
// Check if values exist (including 0, which is a valid ID)
// Use typeof check to properly handle 0
const hasProgrammeId = programmeId !== undefined && programmeId !== null && programmeId !== '' && typeof programmeId === 'number';
const hasMetaclassId = metaclassId !== undefined && metaclassId !== null && metaclassId !== '' && typeof metaclassId === 'number';
const hasAssessmentId = assessmentId !== undefined && assessmentId !== null && assessmentId !== '' && typeof assessmentId === 'number';
if (hasProgrammeId && hasMetaclassId && hasAssessmentId) {
const url = `#?page=/assessments/${programmeId}:${metaclassId}&item=${assessmentId}`;
console.log("[Assessment Action] ✅ Navigating to:", url);
window.location.hash = url;
} else {
// Fallback: try to navigate to assessments page if metadata is incomplete
console.error("[Assessment Action] ❌ Missing required metadata:", {
programmeId,
metaclassId,
assessmentId,
hasProgrammeId,
hasMetaclassId,
hasAssessmentId,
metadataKeys: Object.keys(metadata),
metadataString: JSON.stringify(metadata),
itemId: itemClone.id,
});
// If we at least have an assessmentId, try to navigate to the general assessments page
if (hasAssessmentId) {
window.location.hash = `#?page=/assessments/upcoming&item=${assessmentId}`;
} else {
console.warn("[Assessment Action] No valid assessment ID, redirecting to upcoming");
window.location.hash = `#?page=/assessments/upcoming`;
}
}
} }
}) as ActionHandler<any>, }) as ActionHandler<any>,
@@ -213,25 +213,54 @@ export async function clear(store: string): Promise<void> {
} }
export async function resetDatabase(): Promise<void> { export async function resetDatabase(): Promise<void> {
// Close cached database connection
if (cachedDb) { if (cachedDb) {
try {
cachedDb.close(); cachedDb.close();
} catch (e) {
console.warn("[DB] Error closing cached database:", e);
}
cachedDb = null; cachedDb = null;
} }
// Close pending database promise
if (dbPromise) { if (dbPromise) {
try { try {
const db = await dbPromise; const db = await dbPromise;
db.close(); db.close();
} catch (e) {} } catch (e) {
// Database might not be open yet, that's okay
}
dbPromise = null; dbPromise = null;
} }
// Wait a bit for connections to fully close
await new Promise(resolve => setTimeout(resolve, 100));
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(DB_NAME); const req = indexedDB.deleteDatabase(DB_NAME);
req.onsuccess = () => { req.onsuccess = () => {
localStorage.removeItem(VERSION_KEY); localStorage.removeItem(VERSION_KEY);
resolve(); resolve();
}; };
req.onerror = () => reject(req.error); req.onerror = () => {
console.error("[DB] Error deleting database:", req.error);
reject(req.error);
};
req.onblocked = () => {
console.warn("[DB] Database deletion blocked - waiting for connections to close");
// Wait a bit longer and try again
setTimeout(() => {
const retryReq = indexedDB.deleteDatabase(DB_NAME);
retryReq.onsuccess = () => {
localStorage.removeItem(VERSION_KEY);
resolve();
};
retryReq.onerror = () => reject(retryReq.error);
retryReq.onblocked = () => {
reject(new Error(`Database is still open. Please close other tabs/windows and try again.`));
};
}, 500);
};
}); });
} }
@@ -1,4 +1,4 @@
import { clear, getAll, get, put, remove } from "./db"; import { clear, get, getAll, put, remove } from "./db";
import { jobs } from "./jobs"; import { jobs } from "./jobs";
import { renderComponentMap } from "./renderComponents"; import { renderComponentMap } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types"; import type { IndexItem, Job, JobContext } from "./types";
@@ -396,18 +396,34 @@ export async function runIndexing(): Promise<void> {
stopHeartbeat(); stopHeartbeat();
allItemsInPrimaryStores = await loadAllStoredItems(); allItemsInPrimaryStores = await loadAllStoredItems();
allItemsInPrimaryStores.forEach(item => { // Create new objects to avoid XrayWrapper issues in Firefox
const itemsWithComponents = allItemsInPrimaryStores.map(item => {
try {
const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId]; const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) { if (jobDef) {
const renderComponent = renderComponentMap[jobDef.renderComponentId]; renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
if (renderComponent) {
item.renderComponent = renderComponent;
}
} else if (renderComponentMap[item.renderComponentId]) { } else if (renderComponentMap[item.renderComponentId]) {
item.renderComponent = renderComponentMap[item.renderComponentId]; renderComponent = renderComponentMap[item.renderComponentId];
}
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
// Use JSON serialization to ensure all nested properties are accessible
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch (e) {
// Fallback to shallow copy if deep clone fails
console.warn("[Indexer] Failed to deep clone item, using shallow copy:", e);
return { ...item, renderComponent };
}
} catch (error) {
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
console.warn("[Indexer] Failed to add render component to item (Firefox XrayWrapper):", error);
return item;
} }
}); });
loadDynamicItems(allItemsInPrimaryStores); loadDynamicItems(itemsWithComponents);
window.dispatchEvent(new Event("dynamic-items-updated")); window.dispatchEvent(new Event("dynamic-items-updated"));
} }
@@ -3,10 +3,12 @@ import { messagesJob } from "./jobs/messages";
import { notificationsJob } from "./jobs/notifications"; import { notificationsJob } from "./jobs/notifications";
import { forumsJob } from "./jobs/forums"; import { forumsJob } from "./jobs/forums";
import { subjectsJob } from "./jobs/subjects"; import { subjectsJob } from "./jobs/subjects";
import { assignmentsJob } from "./jobs/assignments";
export const jobs: Record<string, Job> = { export const jobs: Record<string, Job> = {
messages: messagesJob, messages: messagesJob,
notifications: notificationsJob, notifications: notificationsJob,
forums: forumsJob, forums: forumsJob,
subjects: subjectsJob, subjects: subjectsJob,
assignments: assignmentsJob,
}; };
@@ -0,0 +1,369 @@
import type { IndexItem, Job } from "../types";
const fetchJSON = async (url: string, body: any) => {
const res = await fetch(`${location.origin}${url}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
});
return res.json();
};
const fetchUpcomingAssessments = async (student: number = 69) => {
try {
const res = await fetchJSON("/seqta/student/assessment/list/upcoming?", {
student,
});
// Match analytics.rs: payload is an array, return empty array if not found
return Array.isArray(res.payload) ? res.payload : [];
} catch (e) {
console.error("[Assignments job] Failed to fetch upcoming assessments:", e);
return [];
}
};
const fetchSubjects = async () => {
try {
const res = await fetchJSON("/seqta/student/load/subjects?", {});
return res.payload
?.filter((s: any) => s.active === 1)
?.flatMap((s: any) => s.subjects) || [];
} catch (e) {
console.error("[Assignments job] Failed to fetch subjects:", e);
return [];
}
};
const fetchPastAssessments = async (student: number = 69, subjects: any[]) => {
const map: Record<number, any> = {};
// Fetch past assessments for all subjects in parallel (like assessmentsOverview does)
// This is much faster than sequential fetching
await Promise.all(
subjects.map(async (subject) => {
try {
// Match analytics.rs exactly: parameter order is programme, metaclass, student
const res = await fetchJSON("/seqta/student/assessment/list/past?", {
programme: subject.programme,
metaclass: subject.metaclass,
student,
});
// Past assessments API can return data in payload.tasks OR payload.pending (or both)
// Based on analytics.rs fetch_past_assessments, we need to check both arrays
const processAssessment = (assessment: any) => {
if (assessment && assessment.id) {
// Ensure programme and metaclass are included from the subject
// Use the assessment's IDs if available, otherwise fall back to subject's
map[assessment.id] = {
...assessment,
programme: assessment.programme || assessment.programmeID || subject.programme,
programmeID: assessment.programmeID || assessment.programme || subject.programme,
metaclass: assessment.metaclass || assessment.metaclassID || subject.metaclass,
metaclassID: assessment.metaclassID || assessment.metaclass || subject.metaclass,
};
}
};
// Match analytics.rs: Check both pending and tasks arrays
// Check for pending array first (matching Rust code order)
if (res.payload?.pending && Array.isArray(res.payload.pending)) {
res.payload.pending.forEach(processAssessment);
}
// Check for tasks array
if (res.payload?.tasks && Array.isArray(res.payload.tasks)) {
res.payload.tasks.forEach(processAssessment);
}
} catch (e) {
console.warn(`[Assignments job] Failed to fetch past assessments for subject ${subject.code || subject.subject || 'unknown'}:`, e);
}
})
);
return Object.values(map);
};
export const assignmentsJob: Job = {
id: "assignments",
label: "Assignments",
renderComponentId: "assessment",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 }, // Daily
boostCriteria: (item, searchTerm) => {
if (searchTerm === "") {
return -100;
}
let score = 0;
// Boost upcoming assignments
if (item.metadata.dueDate) {
const dueDate = new Date(item.metadata.dueDate).getTime();
const now = Date.now();
const daysUntilDue = (dueDate - now) / (1000 * 60 * 60 * 24);
if (daysUntilDue >= 0 && daysUntilDue <= 7) {
score += 0.05; // Boost assignments due within a week
}
if (daysUntilDue < 0) {
score -= 0.1; // Penalty for overdue assignments
}
}
// Boost if submitted
if (item.metadata.submitted) {
score += 0.02;
}
return score;
},
run: async (ctx) => {
// Don't filter by existing IDs - we want to process ALL assessments (both new and old)
// to ensure metadata is up-to-date and all past assignments are indexed
const existingItems = await ctx.getStoredItems("assignments");
const existingIds = new Set(existingItems.map((i) => i.id));
const student = 69; // TODO: Get from context if available
console.debug("[Assignments job] Starting indexing - fetching all assessments (upcoming and past)...");
// Fetch data in parallel
const [upcoming, subjects] = await Promise.all([
fetchUpcomingAssessments(student),
fetchSubjects(),
]);
console.debug(`[Assignments job] Fetched ${upcoming.length} upcoming assessments and ${subjects.length} subjects`);
// Fetch past assessments for ALL subjects to ensure we get all historical assignments
const past = await fetchPastAssessments(student, subjects);
console.debug(`[Assignments job] Fetched ${past.length} past assessments`);
// Create a lookup map from subject code to programme/metaclass
const subjectLookup = new Map<string, { programme: number; metaclass: number }>();
subjects.forEach((s: any) => {
if (s.code && s.programme && s.metaclass) {
subjectLookup.set(s.code, { programme: s.programme, metaclass: s.metaclass });
}
});
// Combine and deduplicate
const allAssessments = new Map<number, any>();
upcoming.forEach((a: any) => {
if (a && a.id) {
// Prioritize capital ID fields (programmeID, metaclassID) as that's what the API returns
let programme = a.programmeID || a.programme;
let metaclass = a.metaclassID || a.metaclass;
// If missing, try to get from subject lookup
if ((!programme || !metaclass) && a.code) {
const subjectInfo = subjectLookup.get(a.code);
if (subjectInfo) {
programme = programme || subjectInfo.programme;
metaclass = metaclass || subjectInfo.metaclass;
}
}
allAssessments.set(a.id, {
...a,
programme,
metaclass,
programmeID: programme, // Ensure both formats are available
metaclassID: metaclass,
isUpcoming: true,
});
}
});
past.forEach((a: any) => {
if (a && a.id) {
// Prioritize capital ID fields (programmeID, metaclassID) as that's what the API returns
let programme = a.programmeID || a.programme;
let metaclass = a.metaclassID || a.metaclass;
const existing = allAssessments.get(a.id);
if (existing) {
// Merge past assessment data, ensuring programme/metaclass are preserved
// Use existing values if new ones are missing
programme = programme || existing.programme || existing.programmeID;
metaclass = metaclass || existing.metaclass || existing.metaclassID;
Object.assign(existing, {
...a,
programme,
metaclass,
programmeID: programme,
metaclassID: metaclass,
});
} else {
allAssessments.set(a.id, {
...a,
programme,
metaclass,
programmeID: programme,
metaclassID: metaclass,
isUpcoming: false
});
}
}
});
const items: IndexItem[] = [];
const processedIds = new Set<string>();
// Process assessments in batches to avoid overwhelming the API
const assessmentArray = Array.from(allAssessments.values());
const pastCount = assessmentArray.filter(a => !a.isUpcoming).length;
const upcomingCount = assessmentArray.filter(a => a.isUpcoming).length;
console.debug(`[Assignments job] Processing ${assessmentArray.length} total assessments (${upcomingCount} upcoming, ${pastCount} past)`);
const batchSize = 15; // Increased batch size for better performance
// Skip fetching assessment details - the API endpoint doesn't exist or returns 404
// Details are optional and not critical for search functionality
// Process ALL assessments (both upcoming and past) to ensure everything is indexed
for (let i = 0; i < assessmentArray.length; i += batchSize) {
const batch = assessmentArray.slice(i, i + batchSize);
const batchItems = await Promise.all(
batch.map(async (assessment) => {
const id = `assignment-${assessment.id}`;
// Skip if already processed in this batch
if (processedIds.has(id)) {
return null;
}
processedIds.add(id);
// Process ALL assessments (both new and existing, upcoming and past)
// This ensures all historical assignments are indexed and metadata is up-to-date
// Skip fetching details - API endpoint doesn't exist
const description = "";
const subjectName = assessment.subject || assessment.code || "Unknown Subject";
const dueDate = assessment.due ? new Date(assessment.due).getTime() : null;
// Prioritize capital ID fields (programmeID, metaclassID) as that's what the API returns
const programmeId = assessment.programmeID || assessment.programme;
const metaclassId = assessment.metaclassID || assessment.metaclass;
// Validate that we have the required IDs for navigation
if (!programmeId || !metaclassId || !assessment.id) {
console.warn(`[Assignments job] Skipping assignment ${assessment.id} - missing required IDs:`, {
programmeId,
metaclassId,
assessmentId: assessment.id,
programmeID: assessment.programmeID,
metaclassID: assessment.metaclassID,
programme: assessment.programme,
metaclass: assessment.metaclass,
assessment,
});
return null;
}
// Convert to numbers, preserving 0 as valid
let finalProgrammeId: number | undefined;
let finalMetaclassId: number | undefined;
if (programmeId !== undefined && programmeId !== null && programmeId !== '') {
const num = Number(programmeId);
finalProgrammeId = isNaN(num) ? undefined : num;
}
if (metaclassId !== undefined && metaclassId !== null && metaclassId !== '') {
const num = Number(metaclassId);
finalMetaclassId = isNaN(num) ? undefined : num;
}
// Final validation - check for actual numbers (including 0)
if (finalProgrammeId === undefined || finalMetaclassId === undefined || !assessment.id) {
console.error(`[Assignments job] ❌ Skipping assignment ${assessment.id} - invalid IDs after conversion:`, {
programmeId: finalProgrammeId,
metaclassId: finalMetaclassId,
assessmentId: assessment.id,
rawProgrammeId: programmeId,
rawMetaclassId: metaclassId,
assessment,
});
return null;
}
const item: IndexItem = {
id,
text: assessment.title || assessment.name || "Untitled Assignment",
category: "assignments",
content: `${description}\nSubject: ${subjectName}\nDue: ${assessment.due || "No due date"}`.trim(),
dateAdded: dueDate || Date.now(),
metadata: {
assessmentId: assessment.id,
assessmentID: assessment.id, // Store both variants for compatibility
subject: subjectName,
subjectCode: assessment.code,
dueDate: assessment.due,
programmeId: finalProgrammeId,
programmeID: finalProgrammeId, // Store both variants for compatibility
metaclassId: finalMetaclassId,
metaclassID: finalMetaclassId, // Store both variants for compatibility
submitted: assessment.submitted || false,
isUpcoming: assessment.isUpcoming || false,
term: assessment.term,
timestamp: assessment.due || new Date().toISOString(), // Required by AssessmentMetadata interface
},
actionId: "assessment",
renderComponentId: "assessment",
};
console.debug(`[Assignments job] ✅ Created item for assignment ${assessment.id}:`, {
id: item.id,
programmeId: item.metadata.programmeId,
programmeID: item.metadata.programmeID,
metaclassId: item.metadata.metaclassId,
metaclassID: item.metadata.metaclassID,
assessmentId: item.metadata.assessmentId,
assessmentID: item.metadata.assessmentID,
});
return item;
})
);
// Filter out nulls and add to items
batchItems.forEach(item => {
if (item) {
items.push(item);
}
});
// Small delay between batches to avoid rate limiting
if (i + batchSize < assessmentArray.length) {
await new Promise(resolve => setTimeout(resolve, 50)); // Reduced delay
}
}
const newItemsCount = items.filter(item => !existingIds.has(item.id)).length;
const updatedItemsCount = items.length - newItemsCount;
console.debug(`[Assignments job] Indexed ${items.length} assignment items (${newItemsCount} new, ${updatedItemsCount} updated)`);
return items;
},
purge: (items) => {
// Keep ALL assignments - don't purge old ones as users may want to search for them
// Only remove items that are truly invalid (missing required metadata)
return items.filter((i) => {
// Keep all items that have valid metadata
return i.metadata &&
i.metadata.assessmentId &&
i.metadata.programmeId !== undefined &&
i.metadata.metaclassId !== undefined;
});
},
};
@@ -1,4 +1,4 @@
import type { Job, IndexItem } from "../types"; import type { IndexItem, Job } from "../types";
const fetchForums = async () => { const fetchForums = async () => {
const res = await fetch(`${location.origin}/seqta/student/load/forums`, { const res = await fetch(`${location.origin}/seqta/student/load/forums`, {
@@ -1,4 +1,4 @@
import type { Job, IndexItem } from "../types"; import type { IndexItem, Job } from "../types";
import { htmlToPlainText } from "../utils"; import { htmlToPlainText } from "../utils";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
import { VectorWorkerManager } from "../worker/vectorWorkerManager"; import { VectorWorkerManager } from "../worker/vectorWorkerManager";
@@ -604,22 +604,34 @@ export const messagesJob: Job = {
if (processedItems.length > 0) { if (processedItems.length > 0) {
try { try {
const currentItems = await loadAllStoredItems(); const currentItems = await loadAllStoredItems();
currentItems.forEach((item) => { // Create new objects to avoid XrayWrapper issues in Firefox
const itemsWithComponents = currentItems.map((item) => {
try {
const jobDef = const jobDef =
jobs[item.category] || jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) || Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId]; jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) { if (jobDef) {
const renderComponent = renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
renderComponentMap[jobDef.renderComponentId];
if (renderComponent) {
item.renderComponent = renderComponent;
}
} else if (renderComponentMap[item.renderComponentId]) { } else if (renderComponentMap[item.renderComponentId]) {
item.renderComponent = renderComponentMap[item.renderComponentId]; renderComponent = renderComponentMap[item.renderComponentId];
}
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch (e) {
// Fallback to shallow copy if deep clone fails
return { ...item, renderComponent };
}
} catch (error) {
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
return item;
} }
}); });
loadDynamicItems(currentItems); loadDynamicItems(itemsWithComponents);
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("dynamic-items-updated", { new CustomEvent("dynamic-items-updated", {
detail: { detail: {
@@ -1,4 +1,4 @@
import type { Job, IndexItem } from "../types"; import type { IndexItem, Job } from "../types";
import { htmlToPlainText } from "../utils"; import { htmlToPlainText } from "../utils";
import { fetchMessageContent } from "./messages"; import { fetchMessageContent } from "./messages";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
@@ -372,23 +372,34 @@ export const notificationsJob: Job = {
if (items.length > 0) { if (items.length > 0) {
try { try {
const currentItems = await loadAllStoredItems(); const currentItems = await loadAllStoredItems();
currentItems.forEach((item) => { // Create new objects to avoid XrayWrapper issues in Firefox
const itemsWithComponents = currentItems.map((item) => {
try {
const jobDef = const jobDef =
jobs[item.category] || jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) || Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId]; jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) { if (jobDef) {
const renderComponent = renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
renderComponentMap[jobDef.renderComponentId];
if (renderComponent) {
item.renderComponent = renderComponent;
}
} else if (renderComponentMap[item.renderComponentId]) { } else if (renderComponentMap[item.renderComponentId]) {
item.renderComponent = renderComponent = renderComponentMap[item.renderComponentId];
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( window.dispatchEvent(
new CustomEvent("dynamic-items-updated", { new CustomEvent("dynamic-items-updated", {
detail: { detail: {
@@ -3,9 +3,24 @@ import type { IndexItem } from "../types";
let vectorIndex: EmbeddingIndex | null = null; let vectorIndex: EmbeddingIndex | null = null;
let isInitialized = false; let isInitialized = false;
let initializationFailed = false;
let currentAbortController: AbortController | null = null; let currentAbortController: AbortController | null = null;
let loadedItemIds = new Set<string>(); let loadedItemIds = new Set<string>();
// Detect Firefox in worker context
function isFirefoxWorker(): boolean {
try {
// Check for Firefox-specific APIs or user agent
if (typeof navigator !== "undefined") {
return navigator.userAgent.toLowerCase().includes("firefox");
}
// In worker context, check for Firefox-specific behavior
return false;
} catch {
return false;
}
}
let streamingSession: { let streamingSession: {
isActive: boolean; isActive: boolean;
totalExpected: number; totalExpected: number;
@@ -21,6 +36,16 @@ async function initWorker() {
console.debug("Vector worker already initialized."); console.debug("Vector worker already initialized.");
return; return;
} }
// Skip initialization in Firefox
if (isFirefoxWorker()) {
console.debug("[Vector Worker] Vector search not supported in Firefox - skipping initialization");
isInitialized = true;
initializationFailed = true;
vectorIndex = null;
return;
}
console.debug("Initializing vector worker..."); console.debug("Initializing vector worker...");
try { try {
await initializeModel(); await initializeModel();
@@ -48,8 +73,9 @@ async function initWorker() {
isInitialized = true; isInitialized = true;
console.debug("Vector worker initialized successfully."); console.debug("Vector worker initialized successfully.");
} catch (e) { } catch (e) {
console.error("Failed to initialize vector worker:", e); console.warn("[Vector Worker] Failed to initialize vector worker (will use text search only):", e);
isInitialized = true; isInitialized = true;
initializationFailed = true;
vectorIndex = null; vectorIndex = null;
} }
} }
@@ -80,18 +106,29 @@ async function startStreamingSession(
totalExpected: number, totalExpected: number,
batchSize: number = 5, batchSize: number = 5,
) { ) {
if (initializationFailed || isFirefoxWorker()) {
self.postMessage({
type: "progress",
data: {
status: "complete",
message: "Vector search not available in Firefox - using text search only",
},
});
return;
}
if (!vectorIndex) { if (!vectorIndex) {
console.warn( console.warn(
"Streaming requested but vector index not ready. Attempting init.", "Streaming requested but vector index not ready. Attempting init.",
); );
await initWorker(); await initWorker();
if (!vectorIndex) { if (!vectorIndex || initializationFailed) {
self.postMessage({ self.postMessage({
type: "progress", type: "progress",
data: { data: {
status: "error", status: "complete",
message: message:
"Vector index not available for streaming after init attempt.", "Vector index not available - using text search only",
}, },
}); });
return; return;
@@ -306,18 +343,29 @@ async function endStreamingSession() {
async function processItems(items: IndexItem[], signal: AbortSignal) { async function processItems(items: IndexItem[], signal: AbortSignal) {
console.debug("Worker received process request."); console.debug("Worker received process request.");
if (initializationFailed || isFirefoxWorker()) {
self.postMessage({
type: "progress",
data: {
status: "complete",
message: "Vector search not available - using text search only",
},
});
return;
}
if (!vectorIndex) { if (!vectorIndex) {
console.warn( console.warn(
"Processing requested but vector index not ready. Attempting init.", "Processing requested but vector index not ready. Attempting init.",
); );
await initWorker(); await initWorker();
if (!vectorIndex) { if (!vectorIndex || initializationFailed) {
self.postMessage({ self.postMessage({
type: "progress", type: "progress",
data: { data: {
status: "error", status: "complete",
message: message:
"Vector index not available for processing after init attempt.", "Vector index not available - using text search only",
}, },
}); });
return; return;
@@ -1,5 +1,6 @@
import { refreshVectorCache } from "../../search/vector/vectorSearch"; import { refreshVectorCache } from "../../search/vector/vectorSearch";
import type { IndexItem } from "../types"; import type { IndexItem } from "../types";
import { isVectorSearchSupported } from "../../utils/browserDetection";
import vectorWorker from "./vectorWorker.ts?inlineWorker"; import vectorWorker from "./vectorWorker.ts?inlineWorker";
export type ProgressCallback = (data: { export type ProgressCallback = (data: {
@@ -42,6 +43,13 @@ export class VectorWorkerManager {
} }
private async initWorker(): Promise<void> { private async initWorker(): Promise<void> {
// Skip initialization if vector search is not supported (e.g., Firefox)
if (!isVectorSearchSupported()) {
console.debug("[VectorWorkerManager] Vector search not supported - skipping worker initialization");
this.isInitialized = false;
return Promise.resolve();
}
if (this.isInitialized) return Promise.resolve(); if (this.isInitialized) return Promise.resolve();
if (this.readyPromise) return this.readyPromise; if (this.readyPromise) return this.readyPromise;
@@ -234,6 +242,17 @@ export class VectorWorkerManager {
} }
async processItems(items: IndexItem[], onProgress?: ProgressCallback) { async processItems(items: IndexItem[], onProgress?: ProgressCallback) {
// Skip if vector search is not supported
if (!isVectorSearchSupported()) {
if (onProgress) {
onProgress({
status: "complete",
message: "Vector search not available - using text search only"
});
}
return;
}
// Only initialize worker if we actually have items to process // Only initialize worker if we actually have items to process
if (items.length === 0) { if (items.length === 0) {
if (onProgress) { if (onProgress) {
@@ -298,6 +317,18 @@ export class VectorWorkerManager {
batchSize: number = 10, batchSize: number = 10,
jobId?: string, jobId?: string,
): Promise<void> { ): Promise<void> {
// Skip if vector search is not supported
if (!isVectorSearchSupported()) {
console.debug("[VectorWorker] Vector search not supported - skipping streaming session");
if (onProgress) {
onProgress({
status: "complete",
message: "Vector search not available - using text search only",
});
}
return;
}
// Only initialize if we expect items to process // Only initialize if we expect items to process
if (totalExpectedItems === 0) { if (totalExpectedItems === 0) {
console.debug("[VectorWorker] No items expected, not starting streaming session"); console.debug("[VectorWorker] No items expected, not starting streaming session");
@@ -0,0 +1,280 @@
import type { IndexItem } from "../indexing/types";
import type { CombinedResult } from "../core/types";
import { searchVectors, type VectorSearchResult } from "./vector/vectorSearch";
import { jobs } from "../indexing/jobs";
/**
* Hybrid Search Implementation
*
* Flow:
* 1. BM25 (Fuse.js) gets top N results fast
* 2. Vector search reranks by semantic similarity
* 3. Apply optional boosting (recency, popularity, tags)
*/
export interface HybridSearchOptions {
/** Maximum number of BM25 results to retrieve before reranking */
bm25TopK?: number;
/** Maximum number of final results to return */
finalLimit?: number;
/** Whether to apply recency boost */
recencyBoost?: boolean;
/** Weight for BM25 scores (0-1) */
bm25Weight?: number;
/** Weight for vector similarity scores (0-1) */
vectorWeight?: number;
/** Weight for recency boost */
recencyWeight?: number;
}
const DEFAULT_OPTIONS: Required<HybridSearchOptions> = {
bm25TopK: 50, // Get top 50 from BM25, then rerank
finalLimit: 10,
recencyBoost: true,
bm25Weight: 0.4, // 40% BM25, 60% vector
vectorWeight: 0.6,
recencyWeight: 0.1,
};
/**
* Normalizes a score to 0-1 range
*/
function normalizeScore(score: number, min: number, max: number): number {
if (max === min) return 0.5;
return Math.max(0, Math.min(1, (score - min) / (max - min)));
}
/**
* Calculates recency boost based on item age
*/
function calculateRecencyBoost(item: IndexItem, now: number): number {
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
// Exponential decay: newer items get higher boost
// Items from today get boost of 1, items from 30 days ago get ~0.03
return 1 / (1 + ageInDays / 7); // Half-life of 7 days
}
/**
* Calculates popularity boost (can be extended with click tracking, etc.)
*/
function calculatePopularityBoost(item: IndexItem): number {
// For now, boost based on category and metadata
let boost = 0;
// Boost assignments/assessments
if (item.category === "assignments") {
boost += 0.1;
}
// Boost upcoming items
if (item.metadata?.isUpcoming) {
boost += 0.15;
}
// Boost items with subject codes (more structured)
if (item.metadata?.subjectCode) {
boost += 0.05;
}
return Math.min(boost, 0.3); // Cap at 0.3
}
/**
* Reranks BM25 results using vector search
*/
export async function hybridSearch(
bm25Results: CombinedResult[],
query: string,
options: HybridSearchOptions = {},
): Promise<CombinedResult[]> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const trimmedQuery = query.trim().toLowerCase();
// If no BM25 results, return empty
if (bm25Results.length === 0) {
return [];
}
// Limit BM25 results to top K
const topBm25Results = bm25Results.slice(0, opts.bm25TopK);
// Get vector search results for reranking
// We'll search the full index and then filter to our BM25 results
let vectorResults: VectorSearchResult[] = [];
if (trimmedQuery.length > 2) {
try {
// Get more vector results than BM25 results to ensure coverage
// This allows us to find semantic matches that BM25 might have missed
const vectorSearchResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
// Create a map of item ID to vector similarity
const vectorMap = new Map<string, number>();
vectorSearchResults.forEach(v => {
// Use the highest similarity if item appears multiple times
const existing = vectorMap.get(v.object.id);
if (!existing || v.similarity > existing) {
vectorMap.set(v.object.id, v.similarity);
}
});
// Now rerank BM25 results with vector scores
const now = Date.now();
const rerankedResults = topBm25Results.map(result => {
const item = result.item;
// Normalize BM25 score to 0-1
// Fuse.js scores: lower is better (0 = perfect match)
// We need to invert: higher score = better match
// Result.score is typically 0-100, where higher = better
// So we normalize it to 0-1
const normalizedBm25Score = Math.max(0, Math.min(1, result.score / 100));
// Get vector similarity (0-1, already normalized)
// If item wasn't in vector results, use a default low score
const vectorSimilarity = vectorMap.get(item.id) || 0.3; // Default to 0.3 if not found
// Calculate recency boost (0-1 range)
const recencyBoost = opts.recencyBoost
? calculateRecencyBoost(item, now) * opts.recencyWeight
: 0;
// Calculate popularity boost (0-1 range)
const popularityBoost = calculatePopularityBoost(item);
// Apply job-specific boost if available
const job = jobs[item.category];
let jobBoost = 0;
if (job && typeof job.boostCriteria === 'function') {
const boost = job.boostCriteria(item, trimmedQuery);
if (boost) {
jobBoost = boost / 100; // Normalize boost to 0-1
}
}
// Combine scores using weighted average
// BM25 and vector are weighted, boosts are additive
const hybridScore =
(normalizedBm25Score * opts.bm25Weight) +
(vectorSimilarity * opts.vectorWeight) +
recencyBoost +
popularityBoost +
jobBoost;
return {
...result,
score: hybridScore * 100, // Scale back to 0-100 for consistency
// Store component scores for debugging (optional, can be removed in production)
_hybridScores: {
bm25: normalizedBm25Score,
vector: vectorSimilarity,
recency: recencyBoost,
popularity: popularityBoost,
jobBoost: jobBoost,
final: hybridScore,
},
};
});
// Sort by hybrid score descending
rerankedResults.sort((a, b) => b.score - a.score);
// Return top results
return rerankedResults.slice(0, opts.finalLimit);
} catch (e) {
console.warn("[Hybrid Search] Vector reranking failed, using BM25 only:", e);
// Fallback to BM25 only
return topBm25Results.slice(0, opts.finalLimit);
}
}
// If query is too short for vector search, just return BM25 results
return topBm25Results.slice(0, opts.finalLimit);
}
/**
* Enhanced hybrid search that also includes vector-only results not found by BM25
*/
export async function hybridSearchWithExpansion(
bm25Results: CombinedResult[],
query: string,
allItems: IndexItem[],
options: HybridSearchOptions = {},
): Promise<CombinedResult[]> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const trimmedQuery = query.trim().toLowerCase();
// First, rerank BM25 results
const rerankedBm25 = await hybridSearch(bm25Results, query, options);
// If query is too short, skip vector expansion
if (trimmedQuery.length <= 2) {
return rerankedBm25;
}
// Get vector search results
let vectorResults: VectorSearchResult[] = [];
try {
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK);
} catch (e) {
console.warn("[Hybrid Search] Vector search failed:", e);
return rerankedBm25;
}
// Find vector results that weren't in BM25 results
const bm25Ids = new Set(bm25Results.map(r => r.item.id));
const vectorOnlyResults: CombinedResult[] = [];
const now = Date.now();
vectorResults.forEach(v => {
if (!bm25Ids.has(v.object.id)) {
// This is a semantic match that BM25 missed
const item = v.object;
// Calculate boosts
const recencyBoost = opts.recencyBoost
? calculateRecencyBoost(item, now) * opts.recencyWeight
: 0;
const popularityBoost = calculatePopularityBoost(item);
// Vector-only results get lower base score but high vector similarity
const vectorScore = v.similarity * opts.vectorWeight + recencyBoost + popularityBoost;
// Apply job-specific boost if available
const job = jobs[item.category];
let jobBoost = 0;
if (job && typeof job.boostCriteria === 'function') {
const boost = job.boostCriteria(item, trimmedQuery);
if (boost) {
jobBoost = boost / 100; // Normalize boost
}
}
vectorOnlyResults.push({
id: item.id,
type: "dynamic" as const,
score: (vectorScore + jobBoost) * 100,
item,
_hybridScores: {
bm25: 0,
vector: v.similarity,
recency: recencyBoost,
popularity: popularityBoost,
final: vectorScore + jobBoost,
},
});
}
});
// Combine reranked BM25 results with vector-only results
const allResults = [...rerankedBm25, ...vectorOnlyResults];
// Sort by score and return top results
allResults.sort((a, b) => b.score - a.score);
return allResults.slice(0, opts.finalLimit);
}
@@ -6,32 +6,79 @@ import type { IndexItem } from "../indexing/types";
import { searchVectors } from "./vector/vectorSearch"; import { searchVectors } from "./vector/vectorSearch";
import type { VectorSearchResult } from "./vector/vectorTypes"; import type { VectorSearchResult } from "./vector/vectorTypes";
import { jobs } from "../indexing/jobs"; import { jobs } from "../indexing/jobs";
import { hybridSearchWithExpansion } from "./hybridSearch";
// Search result cache for better performance
const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>();
const CACHE_TTL = 1000 * 60 * 5; // 5 minutes
const MAX_CACHE_SIZE = 100;
function getCachedResults(query: string): CombinedResult[] | null {
const cached = searchCache.get(query);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.results;
}
return null;
}
function setCachedResults(query: string, results: CombinedResult[]) {
// Limit cache size
if (searchCache.size >= MAX_CACHE_SIZE) {
const firstKey = searchCache.keys().next().value;
searchCache.delete(firstKey);
}
searchCache.set(query, { results, timestamp: Date.now() });
}
/**
* Clears the search result cache
*/
export function clearSearchCache(): void {
searchCache.clear();
console.debug("[Search] Search result cache cleared");
}
// Listen for cache clear events (e.g., on extension update)
if (typeof window !== 'undefined') {
window.addEventListener('betterseqta-clear-search-cache', () => {
clearSearchCache();
});
}
export function createSearchIndexes() { export function createSearchIndexes() {
const commands = getStaticCommands(); const commands = getStaticCommands();
const dynamicItems = getDynamicItems(); const dynamicItems = getDynamicItems();
// Optimized command search options
const commandOptions = { const commandOptions = {
keys: ["text", "category", "keywords"], keys: ["text", "category", "keywords"],
includeScore: true, includeScore: true,
includeMatches: true, includeMatches: true,
threshold: 0.4, threshold: 0.35, // Slightly more permissive for better recall
minMatchCharLength: 2, minMatchCharLength: 2,
useExtendedSearch: false, useExtendedSearch: false,
ignoreLocation: false,
findAllMatches: false, // Performance optimization
}; };
// Optimized dynamic content search options
const dynamicOptions = { const dynamicOptions = {
keys: [ keys: [
{ name: "text", weight: 2 }, { name: "text", weight: 3 }, // Increased weight for title matches
{ name: "content", weight: 1 }, { name: "content", weight: 1 },
{ name: "category", weight: 1 }, { name: "category", weight: 0.5 }, // Lower weight for category
{ name: "metadata.subjectName", weight: 1.5 }, // Boost subject name matches
{ name: "metadata.subjectCode", weight: 1.5 }, // Boost subject code matches
], ],
includeScore: true, includeScore: true,
includeMatches: true, includeMatches: true,
threshold: 0.4, threshold: 0.5, // More permissive for better partial word matching (increased from 0.4)
minMatchCharLength: 2, minMatchCharLength: 2, // Minimum 2 characters for Fuse.js matches (substring fallback handles shorter queries)
distance: 100, distance: 100, // Increased to allow matches across longer strings
useExtendedSearch: true, useExtendedSearch: true,
ignoreLocation: true, // Allow matches anywhere in the string for better partial word matching
findAllMatches: true, // Enable to find all matches for better partial word support
shouldSort: true,
}; };
return { return {
@@ -105,18 +152,64 @@ export function searchDynamicItems(
} }
const now = Date.now(); const now = Date.now();
const searchResults = dynamicContentFuse.search(query, { limit }); const queryLower = query.toLowerCase();
const queryTrimmed = query.trim();
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 item = result.item;
const fuseScore = 10 * (1 - (result.score || 0.5)); const fuseScore = 10 * (1 - (result.score || 0.5));
let score = fuseScore; let score = fuseScore;
// Recency boost
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24); const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0; const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
score += recencyBoost; score += recencyBoost;
// Boost for exact text matches (especially at the start)
const textLower = item.text.toLowerCase();
if (textLower.startsWith(queryLower)) {
score += 5; // Strong boost for prefix matches
} else if (textLower.includes(queryLower)) {
score += 2; // Boost for substring matches
}
// Boost for category matches
if (item.category.toLowerCase().includes(queryLower)) {
score += 1;
}
return { return {
id: item.id, id: item.id,
type: "dynamic" as const, type: "dynamic" as const,
@@ -125,60 +218,124 @@ export function searchDynamicItems(
matches: result.matches, matches: result.matches,
}; };
}); });
// Add additional matches from simple substring search
additionalMatches.forEach((item) => {
// Check if already in results
if (!results.find(r => r.id === item.id)) {
const textLower = item.text.toLowerCase();
let score = 5; // Base score for substring matches
// Boost for prefix matches
if (textLower.startsWith(queryLower)) {
score += 5;
}
// Recency boost
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
score += recencyBoost;
results.push({
id: item.id,
type: "dynamic" as const,
score,
item,
});
}
});
// Sort by score and return top results
return results.sort((a, b) => b.score - a.score).slice(0, limit);
} }
export async function performSearch( export async function performSearch(
query: string, query: string,
commandsFuse: Fuse<StaticCommandItem>, commandsFuse: Fuse<StaticCommandItem>,
commandIdToItemMap: Map<string, StaticCommandItem>, commandIdToItemMap: Map<string, StaticCommandItem>,
dynamicContentFuse?: Fuse<IndexItem>,
dynamicIdToItemMap?: Map<string, IndexItem>,
sortByRecent: boolean = true,
): Promise<CombinedResult[]> { ): Promise<CombinedResult[]> {
// Get all results first const trimmedQuery = query.trim().toLowerCase();
// Check cache first
if (trimmedQuery.length > 2) {
const cached = getCachedResults(trimmedQuery);
if (cached) {
return cached;
}
}
// Step 1: Get command results (these don't need hybrid search)
const commandResults = searchCommands( const commandResults = searchCommands(
commandsFuse, commandsFuse,
query, trimmedQuery,
commandIdToItemMap, commandIdToItemMap,
); );
// Get vector results in parallel // Step 2: Get BM25 results for dynamic items
let vectorResults: VectorSearchResult[] = []; let dynamicResults: CombinedResult[] = [];
if (dynamicContentFuse && dynamicIdToItemMap) {
// Get BM25 results first (fast text-based search)
const bm25Results = searchDynamicItems(
dynamicContentFuse,
trimmedQuery,
dynamicIdToItemMap,
50, // Get top 50 for reranking
sortByRecent,
);
// Step 3: Apply hybrid search (BM25 + Vector reranking + boosting)
if (trimmedQuery.length > 2 && bm25Results.length > 0) {
try { try {
vectorResults = await searchVectors(query); // Get all items for expansion
} catch (e) {} const allItems = Array.from(dynamicIdToItemMap.values());
// Create a map to store our final results, using ID as key to avoid duplicates // Apply hybrid search with expansion
const resultMap = new Map<string, CombinedResult>(); dynamicResults = await hybridSearchWithExpansion(
bm25Results,
// Add command results first (they keep their original scores) trimmedQuery,
commandResults.forEach((r) => resultMap.set(r.id, r)); allItems,
{
// Process dynamic results and vector results together bm25TopK: 50,
const seenIds = new Set<string>(); finalLimit: 20, // Return top 20 after reranking
recencyBoost: sortByRecent,
vectorResults.forEach((v) => { bm25Weight: 0.4, // 40% BM25, 60% vector
const id = v.object.id; vectorWeight: 0.6,
recencyWeight: 0.1,
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 } catch (e) {
const job = jobs[v.object.category]; console.warn("[Search] Hybrid search failed, using BM25 only:", e);
if (job && typeof job.boostCriteria === 'function') { // Fallback to BM25 only
const boost = job.boostCriteria(v.object, query); dynamicResults = bm25Results.slice(0, 20);
if (boost) { }
score += boost; } else {
// For very short queries or no BM25 results, use BM25 only
dynamicResults = bm25Results.slice(0, 20);
} }
} }
resultMap.set(id, {
id, // Step 4: Combine command and dynamic results
type: "dynamic" as const, const allResults = [...commandResults, ...dynamicResults];
score,
item: v.object, // Sort by score (commands typically have higher priority)
}); allResults.sort((a, b) => {
// Commands always come first if scores are similar
if (a.type === "command" && b.type === "dynamic") {
return b.score - a.score - 10; // Commands get +10 boost
} }
if (a.type === "dynamic" && b.type === "command") {
return b.score - a.score + 10; // Commands get +10 boost
}
return b.score - a.score;
}); });
// Convert to array and sort by score // Cache results for queries longer than 2 chars
const results = Array.from(resultMap.values()); if (trimmedQuery.length > 2) {
results.sort((a, b) => b.score - a.score); setCachedResults(trimmedQuery, allResults);
}
return results;
return allResults;
} }
@@ -1,16 +1,36 @@
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia"; import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
import type { IndexItem } from "../../indexing/types"; import type { IndexItem } from "../../indexing/types";
import type { SearchResult } from "embeddia"; import type { SearchResult } from "embeddia";
import { isVectorSearchSupported } from "../../utils/browserDetection";
let vectorIndex: EmbeddingIndex | null = null; let vectorIndex: EmbeddingIndex | null = null;
let initializationAttempted = false;
let initializationFailed = false;
export async function initVectorSearch() { export async function initVectorSearch() {
// Skip initialization if already attempted and failed, or if not supported
if (initializationFailed || !isVectorSearchSupported()) {
if (!isVectorSearchSupported()) {
console.debug("[Vector Search] Vector search not supported in Firefox - using text search only");
}
return;
}
if (initializationAttempted) {
return;
}
initializationAttempted = true;
try { try {
await initializeModel(); await initializeModel();
vectorIndex = new EmbeddingIndex([]); vectorIndex = new EmbeddingIndex([]);
vectorIndex.preloadIndexedDB(); vectorIndex.preloadIndexedDB();
console.debug("[Vector Search] Initialized successfully");
} catch (e) { } catch (e) {
console.error("Error initializing vector search", e); console.warn("[Vector Search] Failed to initialize vector search (will use text search only):", e);
initializationFailed = true;
vectorIndex = null;
} }
} }
@@ -18,28 +38,111 @@ export interface VectorSearchResult extends SearchResult {
object: IndexItem & { embedding: number[] }; object: IndexItem & { embedding: number[] };
} }
// Cache for query embeddings to avoid recomputing
const embeddingCache = new Map<string, number[]>();
const EMBEDDING_CACHE_TTL = 1000 * 60 * 30; // 30 minutes
const MAX_EMBEDDING_CACHE_SIZE = 50;
function getCachedEmbedding(query: string): number[] | null {
const cached = embeddingCache.get(query);
if (cached) {
return cached;
}
return null;
}
function setCachedEmbedding(query: string, embedding: number[]) {
// Limit cache size
if (embeddingCache.size >= MAX_EMBEDDING_CACHE_SIZE) {
const firstKey = embeddingCache.keys().next().value;
embeddingCache.delete(firstKey);
}
embeddingCache.set(query, embedding);
}
/**
* Clears the embedding cache
*/
export function clearEmbeddingCache(): void {
embeddingCache.clear();
console.debug("[Vector Search] Embedding cache cleared");
}
// Listen for cache clear events (e.g., on extension update)
if (typeof window !== 'undefined') {
window.addEventListener('betterseqta-clear-embedding-cache', () => {
clearEmbeddingCache();
});
}
export async function searchVectors( export async function searchVectors(
query: string, query: string,
topK: number = 20, topK: number = 20,
): Promise<VectorSearchResult[]> { ): Promise<VectorSearchResult[]> {
if (!vectorIndex) await initVectorSearch(); // Return empty array if vector search is not supported or failed to initialize
if (!isVectorSearchSupported() || initializationFailed) {
return [];
}
const queryEmbedding = await getEmbedding(query.slice(0, 100)); if (!vectorIndex) {
await initVectorSearch();
if (!vectorIndex) {
return [];
}
}
// Normalize query for caching
const normalizedQuery = query.trim().toLowerCase().slice(0, 100);
// Check cache first
let queryEmbedding = getCachedEmbedding(normalizedQuery);
if (!queryEmbedding) {
try {
queryEmbedding = await getEmbedding(normalizedQuery);
setCachedEmbedding(normalizedQuery, queryEmbedding);
} catch (e) {
console.warn("[Vector Search] Failed to get embedding:", e);
return [];
}
}
try {
const results = await vectorIndex!.search(queryEmbedding, { const results = await vectorIndex!.search(queryEmbedding, {
topK, topK: Math.min(topK * 2, 30), // Get more results, filter later
useStorage: "indexedDB", useStorage: "indexedDB",
dedupeEntries: true, dedupeEntries: true,
}); });
// filter results with a similarity below 0.81 // Filter results with a similarity below 0.80 (slightly more permissive)
const filteredResults = results.filter((r) => r.similarity > 0.81); // 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[]; return filteredResults as VectorSearchResult[];
} catch (e) {
console.warn("[Vector Search] Search failed:", e);
return [];
}
} }
export async function refreshVectorCache() { export async function refreshVectorCache() {
if (!vectorIndex) await initVectorSearch(); if (!isVectorSearchSupported() || initializationFailed) {
vectorIndex!.clearIndexedDBCache(); return;
vectorIndex!.preloadIndexedDB(); }
if (!vectorIndex) {
await initVectorSearch();
}
if (vectorIndex) {
try {
vectorIndex.clearIndexedDBCache();
vectorIndex.preloadIndexedDB();
} catch (e) {
console.warn("[Vector Search] Failed to refresh cache:", e);
}
}
} }
@@ -0,0 +1,30 @@
import browser from "webextension-polyfill";
/**
* Detects if the current browser is Firefox
*/
export function isFirefox(): boolean {
try {
// Firefox-specific API
if (typeof (browser.runtime as any).getBrowserInfo === "function") {
return true;
}
// Fallback: check user agent
if (typeof navigator !== "undefined") {
return navigator.userAgent.toLowerCase().includes("firefox");
}
return false;
} catch {
// If we can't detect, assume not Firefox (safer for Chrome/Edge)
return false;
}
}
/**
* Checks if vector search is supported in the current browser
* Currently disabled for Firefox due to security restrictions
*/
export function isVectorSearchSupported(): boolean {
return !isFirefox();
}
@@ -0,0 +1,115 @@
import browser from "webextension-polyfill";
const VERSION_STORAGE_KEY = "betterseqta-global-search-version";
const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version";
/**
* Gets the current extension version from the manifest
*/
export function getCurrentVersion(): string {
try {
return browser.runtime.getManifest().version;
} catch (e) {
console.warn("[Version Check] Failed to get manifest version:", e);
return "0.0.0";
}
}
/**
* Gets the last stored version from localStorage
*/
export function getStoredVersion(): string | null {
try {
return localStorage.getItem(VERSION_STORAGE_KEY);
} catch (e) {
console.warn("[Version Check] Failed to get stored version:", e);
return null;
}
}
/**
* Stores the current version in localStorage
*/
export function storeVersion(version: string): void {
try {
localStorage.setItem(VERSION_STORAGE_KEY, version);
localStorage.setItem(VERSION_CACHE_KEY, version);
} catch (e) {
console.warn("[Version Check] Failed to store version:", e);
}
}
/**
* Checks if the extension has been updated and clears caches if needed
* Returns true if an update was detected
*/
export async function checkAndHandleUpdate(): Promise<boolean> {
const currentVersion = getCurrentVersion();
const storedVersion = getStoredVersion();
// If no stored version, this is first run - store current version
if (!storedVersion) {
console.debug(`[Version Check] First run detected, storing version ${currentVersion}`);
storeVersion(currentVersion);
return false;
}
// If versions match, no update
if (storedVersion === currentVersion) {
return false;
}
// Version mismatch detected - extension was updated
console.log(`[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, clearing caches...`);
// Clear all caches
await clearAllCaches();
// Store new version
storeVersion(currentVersion);
return true;
}
/**
* Clears all search-related caches
*/
export async function clearAllCaches(): Promise<void> {
try {
// Clear search result cache (in-memory Map)
if (typeof window !== 'undefined') {
// Dispatch event to clear caches in other modules
window.dispatchEvent(new CustomEvent('betterseqta-clear-search-cache'));
window.dispatchEvent(new CustomEvent('betterseqta-clear-embedding-cache'));
}
// Also try to directly clear caches if modules are already loaded
// Use setTimeout to avoid blocking and handle CSS preload errors
setTimeout(async () => {
try {
const { clearSearchCache } = await import("../search/searchUtils");
clearSearchCache();
} catch (e: any) {
// Module might not be loaded yet, or CSS preload error - that's okay
if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) {
console.debug("[Version Check] Could not clear search cache:", e);
}
}
try {
const { clearEmbeddingCache } = await import("../search/vector/vectorSearch");
clearEmbeddingCache();
} catch (e: any) {
// Module might not be loaded yet, or CSS preload error - that's okay
if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) {
console.debug("[Version Check] Could not clear embedding cache:", e);
}
}
}, 50);
console.debug("[Version Check] All caches cleared");
} catch (e) {
console.error("[Version Check] Error clearing caches:", e);
}
}
@@ -39,7 +39,7 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
"[class*='notifications__bubble___']", "[class*='notifications__bubble___']",
) as HTMLElement; ) as HTMLElement;
if (api.storage.lastNotificationCount !== 0) { if (alertDiv && api.storage.lastNotificationCount !== 0) {
alertDiv.textContent = api.storage.lastNotificationCount.toString(); alertDiv.textContent = api.storage.lastNotificationCount.toString();
} }
@@ -74,13 +74,17 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
} }
} catch (error) { } catch (error) {
console.error("[BetterSEQTA+] Error fetching notifications:", error); console.error("[BetterSEQTA+] Error fetching notifications:", error);
api.storage.consecutiveErrors = (api.storage.consecutiveErrors || 0) + 1; api.storage.consecutiveErrors =
(api.storage.consecutiveErrors || 0) + 1;
} }
}; };
const getNextInterval = () => { const getNextInterval = () => {
// Exponential backoff on errors, max 5 minutes // Exponential backoff on errors, max 5 minutes
const errorMultiplier = Math.min(Math.pow(2, api.storage.consecutiveErrors || 0), 10); const errorMultiplier = Math.min(
Math.pow(2, api.storage.consecutiveErrors || 0),
10,
);
return Math.min(baseInterval * errorMultiplier, maxInterval); return Math.min(baseInterval * errorMultiplier, maxInterval);
}; };
@@ -92,7 +96,8 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
const interval = getNextInterval(); const interval = getNextInterval();
pollInterval = window.setTimeout(() => { pollInterval = window.setTimeout(() => {
checkNotifications().then(() => { checkNotifications().then(() => {
if (pollInterval) { // Only continue if not stopped if (pollInterval) {
// Only continue if not stopped
scheduleNext(); scheduleNext();
} }
}); });
@@ -124,14 +129,16 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
isVisible = !document.hidden; isVisible = !document.hidden;
if (isVisible && !pollInterval) { if (isVisible && !pollInterval) {
// Resume polling when tab becomes visible // Resume polling when tab becomes visible
const alertDiv = document.querySelector("[class*='notifications__bubble___']"); const alertDiv = document.querySelector(
"[class*='notifications__bubble___']",
);
if (alertDiv) { if (alertDiv) {
startPolling(); startPolling();
} }
} }
}; };
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener("visibilitychange", handleVisibilityChange);
api.seqta.onMount("[class*='notifications__bubble___']", (_) => { api.seqta.onMount("[class*='notifications__bubble___']", (_) => {
startPolling(); startPolling();
@@ -139,7 +146,7 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
return () => { return () => {
stopPolling(); stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}, },
}; };
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Plugin } from "@/plugins/core/types"; import type { Plugin } from "@/plugins/core/types";
import { defineSettings, componentSetting } from "@/plugins/core/settingsHelpers"; import { componentSetting, defineSettings } from "@/plugins/core/settingsHelpers";
import ProfilePictureSetting from "./ProfilePictureSetting.svelte"; import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
@@ -8,8 +8,16 @@
object-fit: cover; object-fit: cover;
z-index: 4; z-index: 4;
box-shadow: 0 0 0 3px #000000; box-shadow: 0 0 0 3px #000000;
transition: box-shadow 0.05s ease-in-out;
} }
.dark .userInfoImg { .dark .userInfoImg {
box-shadow: 0 0 0 3px #ffffff; box-shadow: 0 0 0 3px #ffffff;
transition: box-shadow 0.05s ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
.userInfoImg {
transition: none !important;
}
} }
+47 -9
View File
@@ -1,4 +1,5 @@
import localforage from "localforage"; import localforage from "localforage";
import browser from "webextension-polyfill";
import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes"; import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import debounce from "@/seqta/utils/debounce"; import debounce from "@/seqta/utils/debounce";
@@ -147,14 +148,21 @@ export class ThemeManager {
public async initialize(): Promise<void> { public async initialize(): Promise<void> {
console.debug("[ThemeManager] Starting initialization"); console.debug("[ThemeManager] Starting initialization");
try { try {
// Check if theme creator was open during reload const neumorphicThemeId = "9a9786d1-b5fc-4a91-8c7a-f8bf7f7679ad";
const migrationCSS = "#title {\nbackground: transparent !important;\n}";
const theme = (await localforage.getItem(neumorphicThemeId)) as CustomTheme | null;
if (theme && theme.CustomCSS && !theme.CustomCSS.includes("#title {\nbackground: transparent !important;\n}")) {
theme.CustomCSS = theme.CustomCSS + "\n" + migrationCSS;
await localforage.setItem(neumorphicThemeId, theme);
}
const themeCreatorOpen = localStorage.getItem("themeCreatorOpen"); const themeCreatorOpen = localStorage.getItem("themeCreatorOpen");
if (themeCreatorOpen === "true") { if (themeCreatorOpen === "true") {
console.debug( console.debug(
"[ThemeManager] Theme creator was open, clearing preview state", "[ThemeManager] Theme creator was open, clearing preview state",
); );
this.clearPreview(); this.clearPreview();
// Clean up the flag
localStorage.removeItem("themeCreatorOpen"); localStorage.removeItem("themeCreatorOpen");
} }
@@ -463,23 +471,53 @@ export class ThemeManager {
} }
} }
private readonly THEME_API_BASE = 'https://betterseqta.org/api';
private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes';
/** /**
* Download and install a theme from the store * Fetch JSON from a URL via background script (avoids CORS when running inside SEQTA page)
*/
private async fetchFromUrl(url: string): Promise<any> {
const result = (await browser.runtime.sendMessage({
type: 'fetchFromUrl',
url,
})) as { data?: unknown; error?: string };
if (result?.error) throw new Error(result.error);
return result?.data;
}
/**
* Download and install a theme from the store.
* Uses API first (increments download_count), falls back to GitHub if unreachable.
*/ */
public async downloadTheme(themeContent: { public async downloadTheme(themeContent: {
id: string; id: string;
name: string; name: string;
description: string; description?: string;
coverImage: string; coverImage?: string;
theme_json_url?: string;
}): Promise<void> { }): Promise<void> {
console.debug("[ThemeManager] Downloading theme:", themeContent.name); console.debug("[ThemeManager] Downloading theme:", themeContent.name);
try { try {
if (!themeContent.id) return; if (!themeContent.id) return;
const response = await fetch( let themeData: ThemeContent;
`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes/${themeContent.id}/theme.json`,
); try {
const themeData = (await response.json()) as ThemeContent; // Try API first (increments download_count)
const downloadData = (await this.fetchFromUrl(
`${this.THEME_API_BASE}/themes/${themeContent.id}/download`
)) as { success?: boolean; data?: { theme_json_url: string } };
if (!downloadData?.success || !downloadData?.data?.theme_json_url) {
throw new Error("Failed to get theme download URL");
}
themeData = (await this.fetchFromUrl(downloadData.data.theme_json_url)) as ThemeContent;
} catch (apiError) {
// Fallback to GitHub if API is unreachable
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
const githubUrl = `${this.GITHUB_THEMES_BASE}/${themeContent.id}/theme.json`;
themeData = (await this.fetchFromUrl(githubUrl)) as ThemeContent;
}
await this.installTheme(themeData); await this.installTheme(themeData);
} catch (error) { } catch (error) {
+11 -136
View File
@@ -39,43 +39,14 @@ const zoomHandlers = new WeakMap<
>(); >();
function resetTimetableStyles(): void { function resetTimetableStyles(): void {
const firstDayColumn = document.querySelector( // Reset entry opacity (for assessment hide feature)
".dailycal .content .days td",
) as HTMLElement;
if (!firstDayColumn) return;
const baseContainerHeight =
parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight;
const dayColumns = document.querySelectorAll(".dailycal .content .days td");
dayColumns.forEach((td: Element) => {
(td as HTMLElement).style.height = `${baseContainerHeight}px`;
});
const timeColumn = document.querySelector(".times");
if (timeColumn) {
const times = timeColumn.querySelectorAll(".time");
const timeHeight = baseContainerHeight / times.length;
times.forEach((time: Element) => {
(time as HTMLElement).style.height = `${timeHeight}px`;
});
}
const lessons = document.querySelectorAll(".dailycal .lesson");
lessons.forEach((lesson: Element) => {
const lessonEl = lesson as HTMLElement;
const originalHeight = lessonEl.getAttribute("data-original-height");
if (originalHeight) {
lessonEl.style.height = `${originalHeight}px`;
}
});
const entries = document.querySelectorAll(".entry"); const entries = document.querySelectorAll(".entry");
entries.forEach((entry: Element) => { entries.forEach((entry: Element) => {
const entryEl = entry as HTMLElement; const entryEl = entry as HTMLElement;
entryEl.style.opacity = "1"; entryEl.style.opacity = "1";
}); });
// Clean up zoom control event handlers
const zoomControls = document.querySelector(".timetable-zoom-controls"); const zoomControls = document.querySelector(".timetable-zoom-controls");
if (zoomControls) { if (zoomControls) {
const handlers = zoomHandlers.get(zoomControls); const handlers = zoomHandlers.get(zoomControls);
@@ -94,19 +65,9 @@ function resetTimetableStyles(): void {
async function handleTimetable(): Promise<void> { async function handleTimetable(): Promise<void> {
await waitForElm(".time", true, 10); await waitForElm(".time", true, 10);
// Store original heights when timetable loads // Convert time format if needed
const lessons = document.querySelectorAll(".dailycal .lesson");
lessons.forEach((lesson: Element) => {
const lessonEl = lesson as HTMLElement;
lessonEl.setAttribute(
"data-original-height",
lessonEl.offsetHeight.toString(),
);
});
// Existing time format code
if (settingsState.timeFormat == "12") { if (settingsState.timeFormat == "12") {
const times = document.querySelectorAll(".timetablepage .times .time"); const times = document.querySelectorAll(".timetablepage .times .time, .timetablepage .entry.new");
for (const time of times) { for (const time of times) {
if (!time.textContent) continue; if (!time.textContent) continue;
time.textContent = convertTo12HourFormat(time.textContent, true); time.textContent = convertTo12HourFormat(time.textContent, true);
@@ -120,14 +81,6 @@ async function handleTimetable(): Promise<void> {
function handleTimetableZoom(): void { function handleTimetableZoom(): void {
console.log("Initializing timetable zoom controls"); console.log("Initializing timetable zoom controls");
// Lazy initialize state variables only when function is first called
let timetableZoomLevel = 1;
let baseContainerHeight: number | null = null;
const originalEntryPositions = new Map<
Element,
{ topRatio: number; heightRatio: number }
>();
// Create zoom controls // Create zoom controls
const zoomControls = document.createElement("div"); const zoomControls = document.createElement("div");
zoomControls.className = "timetable-zoom-controls"; zoomControls.className = "timetable-zoom-controls";
@@ -148,16 +101,16 @@ function handleTimetableZoom(): void {
// Store event listener references // Store event listener references
const zoomInHandler = () => { const zoomInHandler = () => {
if (timetableZoomLevel < 2) { const seqtaZoomIn = document.querySelector('.uiButton.zoom.in') as HTMLElement;
timetableZoomLevel += 0.2; if (seqtaZoomIn) {
updateZoom(); seqtaZoomIn.click();
} }
}; };
const zoomOutHandler = () => { const zoomOutHandler = () => {
if (timetableZoomLevel > 0.6) { const seqtaZoomOut = document.querySelector('.uiButton.zoom.out') as HTMLElement;
timetableZoomLevel -= 0.2; if (seqtaZoomOut) {
updateZoom(); seqtaZoomOut.click();
} }
}; };
@@ -169,84 +122,6 @@ function handleTimetableZoom(): void {
zoomIn: zoomInHandler, zoomIn: zoomInHandler,
zoomOut: zoomOutHandler, zoomOut: zoomOutHandler,
}); });
const initializePositions = () => {
// Get the base container height from the first TD
const firstDayColumn = document.querySelector(
".dailycal .content .days td",
) as HTMLElement;
if (!firstDayColumn) return false;
baseContainerHeight =
parseInt(firstDayColumn.style.height) || firstDayColumn.offsetHeight;
// Store original ratios
const entries = document.querySelectorAll(".entriesWrapper .entry");
entries.forEach((entry: Element) => {
const entryEl = entry as HTMLElement;
// Calculate ratios relative to detected base height
if (baseContainerHeight === null) return;
const topRatio = parseInt(entryEl.style.top) / baseContainerHeight;
const heightRatio = parseInt(entryEl.style.height) / baseContainerHeight;
originalEntryPositions.set(entry, { topRatio, heightRatio });
});
return true;
};
const updateZoom = () => {
// Initialize positions if not already done
if (baseContainerHeight === null && !initializePositions()) {
console.error("Failed to initialize positions");
return;
}
console.debug(`Updating zoom level to: ${timetableZoomLevel}`);
// Calculate new container height
if (baseContainerHeight === null) return;
const newContainerHeight = baseContainerHeight * timetableZoomLevel;
// Update all day columns (TDs)
const dayColumns = document.querySelectorAll(".dailycal .content .days td");
dayColumns.forEach((td: Element) => {
(td as HTMLElement).style.height = `${newContainerHeight}px`;
});
// Update all entries using stored ratios
const entries = document.querySelectorAll(".entriesWrapper .entry");
entries.forEach((entry: Element) => {
const entryEl = entry as HTMLElement;
const originalRatios = originalEntryPositions.get(entry);
if (originalRatios) {
// Calculate new positions from original ratios
const newTop = originalRatios.topRatio * newContainerHeight;
const newHeight = originalRatios.heightRatio * newContainerHeight;
// Apply new values
entryEl.style.top = `${Math.round(newTop)}px`;
entryEl.style.height = `${Math.round(newHeight)}px`;
}
});
// Update time column to match
const timeColumn = document.querySelector(".times");
if (timeColumn) {
const times = timeColumn.querySelectorAll(".time");
const timeHeight = newContainerHeight / times.length;
times.forEach((time: Element) => {
(time as HTMLElement).style.height = `${timeHeight}px`;
});
}
entries[Math.round((entries.length - 1) / 2)].scrollIntoView({
behavior: "instant",
block: "center",
});
};
} }
function handleTimetableAssessmentHide(): void { function handleTimetableAssessmentHide(): void {
@@ -255,7 +130,7 @@ function handleTimetableAssessmentHide(): void {
const hideOn = document.createElement("button"); const hideOn = document.createElement("button");
hideOn.className = "uiButton timetable-hide iconFamily"; hideOn.className = "uiButton timetable-hide iconFamily";
hideOn.innerHTML = "&#128065;"; hideOn.innerHTML = "&#xeab3;";
hideControls.appendChild(hideOn); hideControls.appendChild(hideOn);
+338
View File
@@ -0,0 +1,338 @@
import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm";
import styles from "./styles.css?inline";
interface TimetableEntryData {
ci: number;
description: string;
room: string;
staff: string;
}
interface TimetableOverrides {
[ci: string]: { room?: string; staff?: string };
}
interface TimetableOverridesBySubject {
[description: string]: { room?: string; staff?: string };
}
interface TimetableStorage {
timetableOverrides?: TimetableOverrides;
timetableOverridesBySubject?: TimetableOverridesBySubject;
}
/** SEQTA timetable entries use .teacher and .room as direct children, and data-instance for ci */
function getRoomAndTeacherElements(entry: HTMLElement): {
roomEl: HTMLElement | null;
teacherEl: HTMLElement | null;
} {
const roomEl = entry.querySelector(".room") as HTMLElement | null;
const teacherEl = entry.querySelector(".teacher") as HTMLElement | null;
return { roomEl, teacherEl };
}
const EDIT_ICON_SVG =
'<svg width="24" height="24" viewBox="0 0 24 24"><g style="fill: currentcolor;"><path d="M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z"/></g></svg>';
function showEditModal(
item: TimetableEntryData,
overrides: TimetableOverrides | undefined,
overridesBySubject: TimetableOverridesBySubject | undefined,
onSave: (
ci: number,
room: string,
staff: string,
applyToFuture: boolean,
) => void,
onClear: (ci: number) => void,
): void {
const overlay = document.createElement("div");
overlay.className = "timetable-edit-modal-overlay";
const modal = document.createElement("div");
modal.className = "timetable-edit-modal";
const override = overrides?.[String(item.ci)] ?? overridesBySubject?.[item.description];
const roomValue = override?.room ?? item.room ?? "";
const staffValue = override?.staff ?? item.staff ?? "";
const escapeHtml = (s: string) =>
s.replace(/&/g, "&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.visible");
if (quickbar && quickbar.getAttribute("data-type") === "class") {
const titleEl = quickbar.querySelector(".title");
const roomEl = quickbar.querySelector(".meta .room");
const teacherEl = quickbar.querySelector(".meta .teacher");
if (titleEl && roomEl && teacherEl && lastClickedCi !== null && lastClickedEntry) {
addEditButtonToQuickbar(quickbar as HTMLElement);
}
}
};
const setupQuickbarObserver = () => {
const timetablePage = document.querySelector(".timetablepage");
if (!timetablePage || quickbarObserver) return;
quickbarObserver = new MutationObserver(() => {
const quickbar = document.querySelector(".timetablepage .quickbar.visible");
if (quickbar?.getAttribute("data-type") === "class") {
addEditButtonToQuickbar(quickbar as HTMLElement);
}
});
quickbarObserver.observe(timetablePage, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class"],
});
};
const handleTimetable = async () => {
await waitForElm(".timetablepage .entry", true, 10, 100);
processAllEntries();
setupQuickbarObserver();
syncQuickbarFromDOM();
const timetablePage = document.querySelector(".timetablepage");
if (timetablePage && !observer) {
observer = new MutationObserver(() => {
document.querySelectorAll(".timetablepage .entry.class").forEach((entry) => {
if (!entry.hasAttribute("data-timetable-edit-processed")) {
processEntry(entry as HTMLElement);
}
});
});
observer.observe(timetablePage, { childList: true, subtree: true });
}
};
const { unregister } = api.seqta.onMount(".timetablepage", handleTimetable);
return () => {
unregister();
observer?.disconnect();
quickbarObserver?.disconnect();
styleEl.remove();
document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => {
el.removeAttribute("data-timetable-edit-processed");
});
document.querySelectorAll(".timetable-edit-quickbar-btn").forEach((el) => el.remove());
};
},
};
export default timetableEditPlugin;
@@ -0,0 +1,188 @@
/* Timetable Edit Plugin - BetterSEQTA Plus style */
/* Edit button in quickbar */
.timetable-edit-quickbar-btn {
padding: 0;
margin: 0;
background: transparent !important;
border: none !important;
cursor: pointer;
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
}
.timetable-edit-quickbar-btn:hover {
transform: scale(1.05);
}
.timetable-edit-quickbar-btn:active {
transform: scale(0.95);
}
.timetable-edit-quickbar-btn svg {
fill: currentColor;
width: 24px;
height: 24px;
}
/* Edit modal animations */
@keyframes timetable-edit-overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes timetable-edit-modal-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Edit modal overlay - fix click-through with proper stacking */
.timetable-edit-modal-overlay {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
pointer-events: auto;
animation: timetable-edit-overlay-in 0.2s ease-out forwards;
}
.timetable-edit-modal {
padding: 1rem 1.5rem;
margin: 0 1rem;
min-width: 18rem;
max-width: 24rem;
width: 100%;
box-sizing: border-box;
background: var(--background-primary);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
pointer-events: auto;
border: 1px solid var(--background-secondary);
animation: timetable-edit-modal-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.timetable-edit-modal h3 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.timetable-edit-modal label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
opacity: 0.8;
}
.timetable-edit-modal input[type="text"] {
width: 100%;
min-width: 0;
padding: 0.5rem 1rem 0.5rem 0.75rem;
margin-bottom: 1rem;
font-size: 0.875rem;
border: 1px solid var(--background-secondary);
border-radius: 0.5rem;
background: var(--background-secondary);
color: var(--text-primary);
box-sizing: border-box;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
user-select: text;
-webkit-user-select: text;
}
.timetable-edit-modal input[type="text"]:focus {
outline: none;
border-color: var(--better-main, #007bff);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.timetable-edit-modal-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.timetable-edit-modal-checkbox input {
width: auto;
margin: 0;
}
.timetable-edit-modal-checkbox label {
margin: 0;
cursor: pointer;
}
.timetable-edit-modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1rem;
flex-wrap: wrap;
}
.timetable-edit-modal-actions button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.timetable-edit-modal-actions .timetable-edit-btn-clear {
background: transparent;
border: 1px solid var(--background-secondary);
color: var(--text-primary);
}
.timetable-edit-modal-actions .timetable-edit-btn-clear:hover {
background: var(--background-secondary);
transform: translateY(-1px);
}
.timetable-edit-modal-actions .timetable-edit-btn-cancel {
background: transparent;
border: 1px solid var(--background-secondary);
color: var(--text-primary);
}
.timetable-edit-modal-actions .timetable-edit-btn-cancel:hover {
background: var(--background-secondary);
transform: translateY(-1px);
}
.timetable-edit-modal-actions .timetable-edit-btn-save {
background: var(--better-main, #007bff);
border: none;
color: var(--text-color, white);
}
.timetable-edit-modal-actions .timetable-edit-btn-save:hover {
transform: scale(1.03) translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.35);
}
.timetable-edit-modal-actions .timetable-edit-btn-save:active {
transform: scale(0.98) translateY(0);
box-shadow: none;
}
+76
View File
@@ -0,0 +1,76 @@
import type { Plugin, PluginSettings } from "./types";
/**
* Interface for lazy-loaded plugin definitions
*/
export interface LazyPlugin<T extends PluginSettings = PluginSettings, S = any> {
id: string;
name: string;
description: string;
version: string;
settings: T;
styles?: string;
disableToggle?: boolean;
defaultEnabled?: boolean;
beta?: boolean;
// Instead of a run function, we have a loader that imports the actual plugin
loader: () => Promise<{ default: Plugin<T, S> }>;
}
/**
* Converts a lazy plugin into a regular plugin by wrapping the run function
* with dynamic import logic
*/
export function createLazyPlugin<T extends PluginSettings = PluginSettings, S = any>(
lazyPlugin: LazyPlugin<T, S>
): Plugin<T, S> {
return {
id: lazyPlugin.id,
name: lazyPlugin.name,
description: lazyPlugin.description,
version: lazyPlugin.version,
settings: lazyPlugin.settings,
styles: lazyPlugin.styles,
disableToggle: lazyPlugin.disableToggle,
defaultEnabled: lazyPlugin.defaultEnabled,
beta: lazyPlugin.beta,
run: async (api) => {
console.info(`[BetterSEQTA+] Dynamically loading plugin "${lazyPlugin.id}"...`);
try {
// Dynamically import the actual plugin implementation
const { default: actualPlugin } = await lazyPlugin.loader();
console.info(`[BetterSEQTA+] Successfully loaded plugin "${lazyPlugin.id}"`);
// Execute the actual plugin's run function
return await actualPlugin.run(api);
} 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;
}
}
};
}
/**
* Helper function to create a lazy plugin definition
*/
export function defineLazyPlugin<T extends PluginSettings = PluginSettings, S = any>(
config: LazyPlugin<T, S>
): Plugin<T, S> {
return createLazyPlugin(config);
}
+3 -3
View File
@@ -1,13 +1,13 @@
import type { import type {
BooleanSetting, BooleanSetting,
ButtonSetting,
ComponentSetting,
HotkeySetting,
NumberSetting, NumberSetting,
Plugin, Plugin,
PluginSettings, PluginSettings,
SelectSetting, SelectSetting,
StringSetting, StringSetting,
ButtonSetting,
HotkeySetting,
ComponentSetting,
} from "./types"; } from "./types";
import { createPluginAPI } from "./createAPI"; import { createPluginAPI } from "./createAPI";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
+3 -3
View File
@@ -1,12 +1,12 @@
import type { import type {
BooleanSetting, BooleanSetting,
ButtonSetting, ButtonSetting,
ComponentSetting,
HotkeySetting,
NumberSetting, NumberSetting,
PluginSettings,
SelectSetting, SelectSetting,
StringSetting, StringSetting,
HotkeySetting,
PluginSettings,
ComponentSetting,
} from "./types"; } from "./types";
/** /**
+11 -5
View File
@@ -1,17 +1,20 @@
import { PluginManager } from "./core/manager"; import { PluginManager } from "./core/manager";
// plugins // Lightweight plugins (load immediately)
import timetablePlugin from "./built-in/timetable"; import timetablePlugin from "./built-in/timetable";
import timetableEditPlugin from "./built-in/timetableEdit";
import notificationCollectorPlugin from "./built-in/notificationCollector"; import notificationCollectorPlugin from "./built-in/notificationCollector";
import themesPlugin from "./built-in/themes"; import themesPlugin from "./built-in/themes";
import animatedBackgroundPlugin from "./built-in/animatedBackground"; import animatedBackgroundPlugin from "./built-in/animatedBackground";
import assessmentsAveragePlugin from "./built-in/assessmentsAverage"; import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
import globalSearchPlugin from "./built-in/globalSearch/src/core";
import profilePicturePlugin from "./built-in/profilePicture"; import profilePicturePlugin from "./built-in/profilePicture";
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview"; import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
import customMessageEditorPlugin from "./built-in/customMessageEditor"; import backgroundMusicPlugin from "./built-in/backgroundMusic";
//import testPlugin from './built-in/test'; //import testPlugin from './built-in/test';
// Heavy plugins (lazy-loaded only when enabled)
import globalSearchPluginLazy from "./built-in/globalSearch/lazy";
// Initialize plugin manager // Initialize plugin manager
const pluginManager = PluginManager.getInstance(); const pluginManager = PluginManager.getInstance();
@@ -21,12 +24,15 @@ pluginManager.registerPlugin(animatedBackgroundPlugin);
pluginManager.registerPlugin(assessmentsAveragePlugin); pluginManager.registerPlugin(assessmentsAveragePlugin);
pluginManager.registerPlugin(notificationCollectorPlugin); pluginManager.registerPlugin(notificationCollectorPlugin);
pluginManager.registerPlugin(timetablePlugin); pluginManager.registerPlugin(timetablePlugin);
pluginManager.registerPlugin(globalSearchPlugin); pluginManager.registerPlugin(timetableEditPlugin);
pluginManager.registerPlugin(profilePicturePlugin); pluginManager.registerPlugin(profilePicturePlugin);
pluginManager.registerPlugin(assessmentsOverviewPlugin); pluginManager.registerPlugin(assessmentsOverviewPlugin);
pluginManager.registerPlugin(customMessageEditorPlugin); pluginManager.registerPlugin(backgroundMusicPlugin);
//pluginManager.registerPlugin(testPlugin); //pluginManager.registerPlugin(testPlugin);
// Register heavy plugins with lazy loading
pluginManager.registerPlugin(globalSearchPluginLazy);
export { init as Monofile } from "./monofile"; export { init as Monofile } from "./monofile";
export async function initializePlugins(): Promise<void> { export async function initializePlugins(): Promise<void> {

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