Compare commits

..

232 Commits

Author SHA1 Message Date
Seth Burkart f75d2566dd Merge pull request #448 from StroepWafel/Releases-Updater-forsideload
release build and other CI
2026-06-06 19:29:04 +10:00
StroepWafel 3f1910f610 release build and other CI 2026-06-05 16:58:11 +09:30
AdenMGB b535e87023 chore: update changelog 2026-06-05 10:00:38 +09:30
AdenMGB dd0830d349 feat: add new copy rubric button 2026-06-05 09:31:42 +09:30
AdenMGB b4a59330c5 fix: fix assement overview not choosing actuve subjects and improve styling 2026-06-05 09:18:33 +09:30
AdenMGB 314c555d87 fix: ensure the ability to override weightings 2026-06-05 08:55:06 +09:30
Aden Lindsay 24e38870a9 Merge pull request #446 from StroepWafel/SyncPFP
Sync PFP on change
2026-06-04 13:36:57 +09:30
StroepWafel 0878910043 Sync PFP on change 2026-06-04 12:44:34 +09:30
SethBurkart123 ce18412405 fix: text on analytics page reload button 2026-06-03 10:32:36 +10:00
AdenMGB 4f6c978043 finalise compat 2026-06-02 13:33:45 +09:30
AdenMGB 9093553ff1 compat: improve compat for nueromphic theme 2026-06-02 13:11:34 +09:30
AdenMGB acb2c682f3 tweak: tweak priv policy placement and style 2026-06-02 12:52:52 +09:30
SethBurkart123 3987871a6a fix: theme store stuck on loading skeleton after fetch failures
Harden theme list fetching with normalized API responses, timeouts, retries,
and a visible error state so the store no longer stays blank when messaging
or payloads fail (common after extension updates without a SEQTA tab reload).
2026-06-02 12:43:35 +10:00
SethBurkart123 9000cb28cd Replace analytics grade filter with dual-handle range slider.
Use a single track with min/max thumbs instead of two separate sliders for clearer filtering UX.
2026-06-02 12:32:07 +10:00
AdenMGB 337f85c3cc feat: prep fopr v3.7.0 as well as minor tweaks 2026-06-02 08:42:24 +09:30
AdenMGB 9e521722f1 Merge branch 'main' of https://github.com/BetterSEQTA/BetterSEQTA-Plus 2026-06-01 19:53:58 +09:30
AdenMGB 2b7c5e17b6 feat: analytics page 2026-06-01 19:43:47 +09:30
Aden Lindsay 21bc6078d7 Merge pull request #441 from StroepWafel/main
fix: TOTM
2026-06-01 18:37:59 +09:30
Aden Lindsay 6c966a8c38 Merge pull request #444 from StroepWafel/theme-stuff
feat: handlers for night city theme's features
2026-06-01 18:37:24 +09:30
StroepWafel 774be0ceed fix issues with injected sidebar 2026-06-01 14:16:38 +09:30
codefactor-io 2356a49fcd [CodeFactor] Apply fixes to commit 3d13202 2026-05-29 02:01:49 +00:00
StroepWafel efbc734d61 Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus 2026-05-29 11:30:05 +09:30
StroepWafel f5034ca0bc clean up TOTM popup. properly remember closes 2026-05-29 11:29:50 +09:30
StroepWafel a1d95ebd40 Update package.json 2026-05-29 11:02:36 +09:30
StroepWafel 943623e0fb Update OpenWhatsNewPopup.ts 2026-05-29 11:02:29 +09:30
StroepWafel a23eda1162 show TOTM popup until dismissed - forgotten commit
i forgot to commit this
basically just shows until dismissed even persistent over reloads so the user doesn't lose it the moment they navigate away or if they load the page and close it immediately
2026-05-29 10:55:55 +09:30
StroepWafel 3d13202779 feat: handlers for night city theme's features 2026-05-29 00:44:02 +09:30
StroepWafel 9f263c8c02 Update package.json 2026-05-28 15:17:38 +09:30
StroepWafel e8d1dadfa7 Update OpenWhatsNewPopup.ts 2026-05-28 15:16:43 +09:30
StroepWafel 7a3cbb50cc fix style 2026-05-25 13:49:46 +09:30
StroepWafel eb49e8d7f1 fix up TOTM 2026-05-25 13:26:33 +09:30
Aden Lindsay 65cd0a1c4f Merge pull request #435 from StroepWafel/improved-global-search
Improved global search
2026-05-25 13:18:28 +09:30
StroepWafel 0007b55c03 Merge branch 'main' into improved-global-search 2026-05-25 13:13:57 +09:30
StroepWafel 93e0a2b123 Update OpenWhatsNewPopup.ts 2026-05-25 13:11:46 +09:30
AdenMGB 1a8f025a04 feat: engage shortcuts on homepage 2026-05-24 17:30:03 +09:30
AdenMGB f0358bec07 feat: make assement overview for SEQTA Engage 2026-05-24 17:28:20 +09:30
AdenMGB 4f6916d8b3 feat: bring assement weighting to engage 2026-05-24 17:21:21 +09:30
AdenMGB fee79e8623 temp: disable global search on engage 2026-05-24 17:14:06 +09:30
AdenMGB 475b865000 feat: apply our exisitng icons to engage sidebar 2026-05-24 17:11:47 +09:30
SethBurkart123 304ce2e128 feat: refine startup announcement cards 2026-05-23 22:53:06 +10:00
AdenMGB 0bc6beb0f1 chore: bump ver & release notes 2026-05-23 09:08:31 +09:30
AdenMGB 68173a8b75 fix: fix custom teacher names not applying to popup 2026-05-23 08:58:21 +09:30
Aden Lindsay 7583d0ee47 Merge pull request #439 from StroepWafel/main
feat: Theme of the Month
2026-05-19 20:36:01 +09:30
codefactor-io 6c79fe3588 [CodeFactor] Apply fixes 2026-05-19 10:53:27 +00:00
StroepWafel c0a8a76105 feat: Theme Of The Month 2026-05-19 20:19:50 +09:30
StroepWafel 6ad214bb09 Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus 2026-05-18 20:29:35 +09:30
AdenMGB b4598668d4 feat: re enable message folders with improvments 2026-05-13 13:30:27 +09:30
AdenMGB 01e679eab6 Revert "fix: add some better detection logic for assements widget #429"
This reverts commit 01cd5d1428.
2026-05-06 17:31:41 +09:30
Aden Lindsay f57bd107b9 Merge pull request #436 from Jaxx7594/asessment-average-manual-input
Feat: ability to manually input weightings for assessments
2026-05-05 19:51:00 +09:30
Jaxon Lewis-Wilson aa5d193e55 assessmentsAverage: Fix inaccurate weight when a weight == N/A
N/A weights were automatically set to a weight of 1 for some reason. I removed it from the calculations completely with this commit.
2026-05-05 18:14:06 +08:00
Jaxon Lewis-Wilson da5bc7ab11 assessmentsAverage: Fix weight display upon setting override 2026-05-05 18:10:13 +08:00
Jaxon Lewis-Wilson b0857054eb assessmentsAverage: Fix unmarked/upcoming assessment indexing and weight display 2026-05-05 17:56:06 +08:00
Jaxon Lewis-Wilson f721bf6609 Revert "feat: dont inject weightings page in assements without results"
This reverts commit 2aecd63850.
Reverting so that I can solve the indexing issue. Only marked assessments are getting indexed, which is incorrect behaviour that slipped testing when the plugin was first made.
2026-05-05 16:32:12 +08:00
AdenMGB 2aecd63850 feat: dont inject weightings page in assements without results 2026-05-05 17:44:58 +09:30
Jaxx7594 f35520029f assessmentAverage: Remove remnant comment 2026-05-04 22:53:05 +08:00
Jaxx7594 95994fcd3a Merge branch 'main' into asessment-average-manual-input 2026-05-04 22:46:29 +08:00
Jaxon Lewis-Wilson 999f12e958 assessmentsAverage: Add changes to changelog 2026-05-04 22:39:53 +08:00
Jaxon Lewis-Wilson 260afac294 assessmentsAverage: Fix display of missing weighting, and minor change to override section. 2026-05-04 18:36:16 +08:00
Jaxon Lewis-Wilson 678a958351 assessmentsAverage: Add ability to override/set weighting per assessment. 2026-05-04 18:32:32 +08:00
SethBurkart123 608fc96c4e chore: temporarily disable message folders plugin and remove from changelog 2026-05-01 15:39:20 +10:00
StroepWafel 577478ba7e titles > Content 2026-05-01 14:34:15 +09:30
SethBurkart123 23ccac4836 update bun.lock 2026-05-01 12:15:53 +10:00
AdenMGB f6472ea9bd fix: add timeout lock to ensure completion of vecotrisation 2026-05-01 08:49:00 +09:30
StroepWafel f3f4491f04 fix @SethBurkart123 's comments 2026-04-30 21:17:48 +09:30
StroepWafel c987e4d54e patch: default to on
Forgot to make it default to on
2026-04-30 18:40:32 +09:30
StroepWafel cc7f2bc634 update what's new 2026-04-30 18:30:19 +09:30
StroepWafel 189a30a611 fix: various ui/ux improvements and duplicate rm'd 2026-04-30 18:20:19 +09:30
StroepWafel 710c03f463 Feat: Updated global search
- Add shared SEQTA fetch layer, extract helpers, passive JSON capture, many new index jobs
- Schema version + shared reset; auto-reset on extension update; fix manual reset (no dynamic import)
- Stabilize hybrid search: lexical title scoring, stale-query guard, vector guardrails, drop route from Fuse keys
- Improve passive titles/routing; tighten message/people handling
- Fix assignment/course indexing (lenient envelopes, subjects mode:list, student id fallback)
- Top bar: indexing label + stacked progress bar under quick search trigger
2026-04-30 17:09:34 +09:30
Aden Lindsay a875f35f1a Merge pull request #432 from StroepWafel/patch-3 2026-04-29 22:25:18 +09:30
StroepWafel e2cf9afbf9 Fix typos in WhatsNewPopup text 2026-04-29 22:05:46 +09:30
StroepWafel a1131cf6cd Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus 2026-04-29 21:47:11 +09:30
Aden Lindsay 3f493ac716 chore: add forgoten custom messages editor to whatsnew 2026-04-29 18:53:13 +09:30
Aden Lindsay e64ef7f95c Custom Message Folders (#431)
* feat: start custom messages plugin

* feat: finish custom message folders
2026-04-29 18:51:05 +09:30
AdenMGB c118b5b8dd chore: update whats new 2026-04-29 11:59:21 +09:30
AdenMGB 6535ec6932 feat: update whatsnew and update video 2026-04-29 11:59:03 +09:30
AdenMGB fba5d09c75 feat: theme flavours for theme varients 2026-04-29 11:13:32 +09:30
AdenMGB b88d29967d fix: ensure the theme store triggers the cloud sync upon install 2026-04-29 10:25:41 +09:30
AdenMGB 1b87d20a27 feat: auto install themes if not present locally with BS Cloud 2026-04-29 10:21:45 +09:30
Aden Lindsay 7bd3158b05 fix: fix wording 2026-04-29 09:56:00 +09:30
StroepWafel 7a4fa1e5bf feat: add RGB handler for alpine theme (#427)
* add handlers for individual Channels

* add notes

* patch fix theme overrides for adaptive colour

* idk

* Update package.json

* fix issue spelling

* Update OpenWhatsNewPopup.ts

* Update OpenWhatsNewPopup.ts

* fix: remove debug line from .gitignore

* chore: fix up patch notes to be a bit more user friendly

* chore: finalise patch notes and fix grammer

* Add empty line to .gitignore

---------

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
2026-04-29 09:54:26 +09:30
Jaxon Lewis-Wilson f7d9199500 assessmentsAverage: Minor WEIGHT label styling fixes 2026-04-27 00:28:51 +08:00
AdenMGB 01cd5d1428 fix: add some better detection logic for assements widget #429 2026-04-23 17:26:58 +09:30
StroepWafel 5178408f39 Update OpenWhatsNewPopup.ts 2026-04-22 06:28:48 +09:30
StroepWafel 0b51db5434 Update OpenWhatsNewPopup.ts 2026-04-22 06:27:01 +09:30
StroepWafel 9c47fa38ae fix issue spelling 2026-04-22 06:25:47 +09:30
StroepWafel 5c4d7e1be3 Update package.json 2026-04-21 21:35:41 +09:30
StroepWafel acbbac8266 Merge branch 'main' into main 2026-04-21 21:34:17 +09:30
StroepWafel fa8f36f3d5 idk 2026-04-21 20:31:06 +09:30
SethBurkart123 fcc856e798 fix: resolve assessments overview failing to load in production builds
Replace dynamic import of ./ui with static import to prevent Vite from
code-splitting into a separate chunk that CRXJS cannot resolve at runtime.

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

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

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

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

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

* feat: Themes can adapt to colour

* quick fix to forced mode as well

* [CodeFactor] Apply fixes

---------

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

* Make soft gradient description consistent

* Consistent description for music plugin descriptionns

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Formatting

* Formatting

* Formatting

* Formatting

* Formatting

* Prettier ignore

* Formatting

* format

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

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

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

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

* Surely thats it

* Finally

* Add back stuff

* fix: fix formatting

---------

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Co-authored-by: Aden Lindsay <lindsaya542@gmail.com>
2026-03-24 12:22:53 +10:30
Aden Lindsay aa6b15aa1b Merge pull request #410 from Jaxx7594/qr
Fix: QR code gen issues on Firefox
2026-03-21 09:10:44 +10:30
Jaxon Lewis-Wilson 08342c3873 Fix: QR code in embedded settings (firefox)
Firefox has an issue where a backdrop filter within a shadow dom breaks the drawing of the popup. This disables the filter only in the embedded menu to avoid this issue altogether.
2026-03-20 19:43:40 +08:00
Aden Lindsay 6527a33e38 Merge pull request #407 from Jaxx7594/background
Fix: Animated background race condition
2026-03-20 08:05:58 +10:30
Jaxon Lewis-Wilson 43f125e45d Fix: QR code gen in extension settings (firefox) 2026-03-19 23:24:51 +08:00
Jaxon Lewis-Wilson 49cc1e26c0 Fix: Animated background race condition 2026-03-19 23:09:53 +08:00
SethBurkart123 809a82f31d fix: update publish-extension 2026-03-18 13:01:55 +11:00
SethBurkart123 3c8c68ce2f fix: re-enable clicks on menu items during sidebar editing 2026-03-18 11:54:19 +11:00
SethBurkart123 18603026a3 fix: update aspect ratio for WhatsNew image 2026-03-18 11:52:27 +11:00
SethBurkart123 52d13cbdc2 fix: dropdowns on windows hopefully 2026-03-18 11:47:46 +11:00
SethBurkart123 26c04f1c24 feat: update changelog 2026-03-18 11:43:42 +11:00
SethBurkart123 e11e402c80 fix: improve dropdown styling on windows 2026-03-18 11:35:23 +11:00
SethBurkart123 bcc7d58ddd fix: keep compact sidebar items icon-only on focus 2026-03-18 11:31:50 +11:00
SethBurkart123 685c6ad771 fix: sidebar breaking on tab 2026-03-18 11:19:54 +11:00
SethBurkart123 4b0372aa56 fix: hide empty titlebar metadata 2026-03-18 10:45:12 +11:00
SethBurkart123 27aa28740e fix: reduce unnecessary notice modal scrolling 2026-03-18 10:45:12 +11:00
AdenMGB 6291b7d0a7 feat: add the RIGHT blurred update video 2026-03-18 10:07:14 +10:30
SethBurkart123 d9f0d89450 fix: align news loader animation 2026-03-18 10:29:50 +11:00
SethBurkart123 6ad221fcb5 fix: use iconfamily eye for timetable toggle 2026-03-18 10:26:56 +11:00
SethBurkart123 f1c55e127c fix: adaptive theme colour looking all wonky 2026-03-18 10:21:37 +11:00
SethBurkart123 2f6e551e22 fix: align home empty state sizing 2026-03-18 10:06:17 +11:00
SethBurkart123 6edffd0306 fix: icons not loading on seqta pages 2026-03-18 09:52:43 +11:00
SethBurkart123 50de668d01 style: add iconfamily icon to cloud signin 2026-03-18 09:52:30 +11:00
SethBurkart123 8a05d85344 fix: news not loading 2026-03-18 09:43:32 +11:00
SethBurkart123 915ce6f5f1 chore: clean up debug logging 2026-03-18 09:27:08 +11:00
SethBurkart123 67f98b13ad feat: add empty state to notices 2026-03-18 09:26:11 +11:00
SethBurkart123 2a147c1d3a fix: auto apply icon only sidebar on load 2026-03-18 09:17:53 +11:00
Aden Lindsay aae9aa6073 Merge pull request #403 from StroepWafel/main
actually do merge well
2026-03-18 08:46:00 +10:30
StroepWafel 9a9885066f actually do WISP correctly 2026-03-18 08:44:49 +10:30
StroepWafel 760d3349c2 Merge branch 'BetterSEQTA:main' into main 2026-03-18 08:43:55 +10:30
StroepWafel ec0dd70a4b ok ONL:Y wisp content this time 2026-03-18 08:42:02 +10:30
Aden Lindsay 4b2184955a Merge pull request #401 from BetterSEQTA/revert-400-main
Revert "automatic WISP content support"
2026-03-18 08:41:26 +10:30
StroepWafel 8d214ff6a3 Revert "Update monofile.ts"
This reverts commit 725d2b2987.
2026-03-18 08:41:17 +10:30
Aden Lindsay 441df9cdf2 Revert "automatic WISP content support" 2026-03-18 08:41:09 +10:30
SethBurkart123 e6e2789a82 fix: transparent background on rich text composer controls 2026-03-18 09:11:05 +11:00
Seth Burkart 70ceb50acd Merge pull request #400 from StroepWafel/main
automatic WISP content support
2026-03-18 09:08:02 +11:00
SethBurkart123 46d5c2e9fc fix: inter font overriden by seqta 2026-03-18 09:07:01 +11:00
StroepWafel 725d2b2987 Update monofile.ts 2026-03-18 08:36:09 +10:30
SethBurkart123 9581b793b5 feat: render floating popup at extension root 2026-03-18 08:44:20 +11:00
Seth Burkart 3fc3f1191c Merge pull request #399 from StroepWafel/hide-more-sensitive-info
Update hideSensitiveContent.ts
2026-03-18 08:19:07 +11:00
StroepWafel 098c79bc99 Update hideSensitiveContent.ts
hides more info
2026-03-18 01:56:42 +10:30
AdenMGB 45b558373b feat: bump to 3.5.0 and minor fixes 2026-03-17 21:38:10 +10:30
AdenMGB 3a2c438223 feat: adaptive themeing 2026-03-16 15:40:16 +10:30
AdenMGB 577287b8a8 feat: timetable editor plugin 2026-03-16 15:16:02 +10:30
Aden Lindsay 1d13b054ee Merge pull request #396 from AdenMGB/assement-overview
Assement overview improvements, notices fix and icon only sidebar
2026-03-15 10:59:35 +10:30
AdenMGB dc3423df13 feat: icon only sidebar 2026-03-15 10:58:48 +10:30
AdenMGB 9791454d62 fix: fix colouring on assement details result bars 2026-03-15 10:39:43 +10:30
AdenMGB 17f648f3ce fix: fix notices on the homepage not being scrollable 2026-03-15 10:30:22 +10:30
AdenMGB 7d89733f96 feat: add more sorting options to kaban view 2026-03-15 10:28:55 +10:30
Aden Lindsay a0e6bdfb20 Merge pull request #391 from BetterSEQTA/revert-390-fix-389
Revert "Fix notice modal scrolling on Home tab (#389)"
2026-03-10 08:26:13 +10:30
Aden Lindsay ac76ce3f03 Revert "Fix notice modal scrolling on Home tab (#389)" 2026-03-10 08:25:51 +10:30
Aden Lindsay 4bef51a3be Merge pull request #390 from jsodf78h823f/fix-389 2026-03-10 08:20:25 +10:30
FemtoClaw Bot 781171d60a Fix notice modal scrolling on home tab 2026-03-09 20:44:01 +00:00
AdenMGB c01342a86c feat: add desqta qr code instant sign in for schools without normal qr code 2026-03-08 09:18:04 +10:30
Aden Lindsay d73a9b2acf Merge pull request #384 from AdenMGB/theme-store
Online Enhanced Theme store
2026-02-25 12:59:59 +10:30
AdenMGB 1d3643a1fc chore: remove unused code 2026-02-25 12:46:36 +10:30
AdenMGB e50de00d08 feat: move bs cloud to a more out of the way location and some theme store tweaks 2026-02-25 10:55:36 +10:30
Alphons Joseph 8c87278850 Merge pull request #385 from AdenMGB/reload-fix
fix: fix bug in Vanilla SEQTA Causing unesasary reloads
2026-02-22 18:49:21 +08:00
AdenMGB 520da46daf fix: fix bug in Vanilla SEQTA Causing unesasary reloads 2026-02-22 19:23:48 +10:30
AdenMGB 01f5e8f61d fix: cf is very annoying 2026-02-20 19:13:06 +10:30
AdenMGB 2faef2ae8d fix: fix cf like too many times 2026-02-20 19:08:36 +10:30
AdenMGB 9d24d07c12 chore: appease codefactor AGAIN 2026-02-20 18:43:02 +10:30
AdenMGB d21ce90a5c feat: download & like count + UI tweaks and cleanup 2026-02-20 18:29:11 +10:30
AdenMGB 889175f3de chore: appease codefactor 2026-02-20 18:06:43 +10:30
AdenMGB 7a70b008c8 feat: betterseqta cloud for favouriting items and future stuff 2026-02-20 10:49:38 +10:30
AdenMGB 4b251e0ea4 feat: add github fallback 2026-02-20 10:28:13 +10:30
AdenMGB f242928682 feat: query the download api for download counts 2026-02-20 10:27:53 +10:30
AdenMGB d64962147a feat: implement cloud store 2026-02-20 10:27:17 +10:30
SethBurkart123 c2cd034556 feat: update to 3.4.16 2026-02-20 07:36:01 +11:00
SethBurkart123 1039ea8137 feat: update publish-browser-extension 2026-02-20 07:31:01 +11:00
Seth Burkart ead8cf80f3 Merge pull request #383 from Jaxx7594/fix_pdfjs_api_mismatch
fix: PDF.js api mismatch
2026-02-20 07:30:15 +11:00
Jaxon Lewis-Wilson 1f0b2d6627 fix: dynamically get the correct pdfjs worker version based on pdfjs package version 2026-02-19 18:07:56 +08:00
199 changed files with 27205 additions and 1945 deletions
+21 -22
View File
@@ -5,31 +5,30 @@
"es2021": true,
"node": true
},
"extends": "eslint:recommended",
"extends": ["eslint:recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
// allow importing ts extensions
"sort-imports": [
"error",
{
"ignoreCase": true,
"ignoreDeclarationSort": true,
"ignoreMemberSort": false,
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
}
],
"import/extensions": [
"error",
"ignorePackages",
{
"js": "never",
"ts": "never",
"tsx": "never"
}
]
"plugins": ["@typescript-eslint", "import"],
"ignorePatterns": ["**/*.d.ts"],
"globals": {
"__ENABLE_GH_RELEASE_UPDATE_CHECK__": "readonly",
"__GH_RELEASE_REPO__": "readonly",
"__UPDATE_CHANNEL__": "readonly",
"__BUILD_LABEL__": "readonly"
},
"plugins": ["import"]
"rules": {
"no-unused-vars": "off",
"no-undef": "off",
"no-useless-escape": "off",
"no-prototype-builtins": "off",
"no-empty": "off",
"no-case-declarations": "off",
"no-irregular-whitespace": "off",
"sort-imports": "off",
"import/extensions": "off",
"no-async-promise-executor": "off"
}
}
@@ -0,0 +1,67 @@
name: Build Extension
description: Install dependencies, build Chrome and Firefox extensions, and package zips.
inputs:
gh_release_update_check:
description: Enable GitHub release update checker in the built extension.
required: false
default: "false"
update_channel:
description: Update channel baked into the build (stable or nightly).
required: false
default: stable
build_label:
description: Optional build label for nightly display (e.g. run number).
required: false
default: ""
release_repo:
description: GitHub repo slug for the update checker (owner/name).
required: false
default: BetterSEQTA/BetterSEQTA-Plus
outputs:
version:
description: Version from package.json
value: ${{ steps.version.outputs.version }}
chrome_zip:
description: Path to the Chrome extension zip
value: ${{ steps.zip.outputs.chrome_zip }}
firefox_zip:
description: Path to the Firefox extension zip
value: ${{ steps.zip.outputs.firefox_zip }}
runs:
using: composite
steps:
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Install dependencies
shell: bash
run: npm install --legacy-peer-deps
- name: Read version
id: version
shell: bash
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Build extension
shell: bash
env:
GH_RELEASE_UPDATE_CHECK: ${{ inputs.gh_release_update_check }}
UPDATE_CHANNEL: ${{ inputs.update_channel }}
GH_RELEASE_REPO: ${{ inputs.release_repo }}
BUILD_LABEL: ${{ inputs.build_label }}
run: npm run build
- name: Package zips
id: zip
shell: bash
run: |
VERSION="${{ steps.version.outputs.version }}"
(cd dist/chrome && zip -r "../betterseqtaplus-${VERSION}-chrome.zip" .)
(cd dist/firefox && zip -r "../betterseqtaplus-${VERSION}-firefox.zip" .)
echo "chrome_zip=dist/betterseqtaplus-${VERSION}-chrome.zip" >> "$GITHUB_OUTPUT"
echo "firefox_zip=dist/betterseqtaplus-${VERSION}-firefox.zip" >> "$GITHUB_OUTPUT"
+1
View File
@@ -0,0 +1 @@
Experimental nightly build from `main`. May break. **Do not upload to Chrome Web Store or Firefox Add-ons.** Download, replace your sideloaded copy, and reload the extension.
-2
View File
@@ -3,8 +3,6 @@ name: NodeJS Build
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
+46
View File
@@ -0,0 +1,46 @@
# Nightly release workflow — updates the same "nightly" release with fresh builds from main.
# Runs only on BetterSEQTA/BetterSEQTA-Plus. Uses the default GITHUB_TOKEN.
name: Nightly Release
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
permissions:
contents: write
env:
NIGHTLY_TAG: nightly
jobs:
nightly:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build extension
id: build
uses: ./.github/actions/build-extension
with:
gh_release_update_check: "true"
update_channel: nightly
build_label: ${{ github.run_number }}
release_repo: ${{ github.repository }}
- name: Ensure nightly release exists
run: |
if ! gh release view "${{ env.NIGHTLY_TAG }}" 2>/dev/null; then
gh release create "${{ env.NIGHTLY_TAG }}" \
--prerelease \
--title "Nightly" \
--notes-file .github/nightly-release-notes.md
fi
- name: Upload nightly assets
run: |
gh release upload "${{ env.NIGHTLY_TAG }}" \
--clobber \
"${{ steps.build.outputs.chrome_zip }}" \
"${{ steps.build.outputs.firefox_zip }}"
+29
View File
@@ -0,0 +1,29 @@
name: PR CI
on:
pull_request:
branches: ["main"]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Lint
run: npm run lint
env:
ESLINT_USE_FLAT_CONFIG: "false"
- name: Build extension
uses: ./.github/actions/build-extension
with:
gh_release_update_check: "false"
+56
View File
@@ -0,0 +1,56 @@
# Manual release workflow only — runs on BetterSEQTA/BetterSEQTA-Plus via workflow_dispatch.
# Bump package.json version on main, confirm when dispatching, then run.
# Uses the default GITHUB_TOKEN (contents: write).
name: Release
on:
workflow_dispatch:
inputs:
version_updated:
description: I have already updated the version in package.json
type: boolean
required: true
default: false
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Confirm version was updated
if: inputs.version_updated != true
run: |
echo "Check the confirmation box: you must update package.json version before releasing."
exit 1
- uses: actions/checkout@v4
- name: Read version
id: version
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Build extension
id: build
uses: ./.github/actions/build-extension
with:
gh_release_update_check: "true"
update_channel: stable
release_repo: ${{ github.repository }}
- name: Create release if missing
run: |
if ! gh release view "${{ steps.version.outputs.version }}" 2>/dev/null; then
gh release create "${{ steps.version.outputs.version }}" \
--title "${{ steps.version.outputs.version }}" \
--notes "Edit this release description on GitHub."
fi
- name: Upload release assets
run: |
gh release upload "${{ steps.version.outputs.version }}" \
--clobber \
"${{ steps.build.outputs.chrome_zip }}" \
"${{ steps.build.outputs.firefox_zip }}"
+9 -6
View File
@@ -4,12 +4,11 @@ package-lock.json
bun.lockb
pnpm-lock.yaml
yarn.lock
bun.lock
.parcel-cache
.env
.env.submit
dependency-graph.svg
# PDF.js extension assets (copied by postinstall from pdfjs-dist)
src/public/resources/pdfjs/pdf.worker.min.mjs
src/public/resources/pdfjs/pdf.legacy.min.mjs
# Build
extension.zip
@@ -19,5 +18,9 @@ betterseqtaplus-safari/
.million/
.vscode/
**/.DS_Store
.parcel-cache
.env
.env.submit
dependency-graph.svg
+5 -5
View File
@@ -6,9 +6,9 @@ Hey there! 👋 Thanks for your interest in contributing to BetterSEQTA+! We're
**Never contributed to an open source project before?** No worries! We've made it super easy to get started:
- **📖 Read our [Getting Started Guide](./docs/GETTING_STARTED_CONTRIBUTING.md)** - This walks you through everything step-by-step, from setting up your development environment to making your first pull request.
- **🏗️ Understand the codebase** with our [Architecture Guide](./docs/ARCHITECTURE.md)
- **🔧 Having issues?** Check our [Troubleshooting Guide](./docs/TROUBLESHOOTING.md)
- **📖 Read our [contributing guide](https://docs.betterseqta.org/contributing/)** - This walks you through everything step-by-step, from setting up your development environment to making your first pull request.
- **🏗️ Understand the codebase** with the [architecture guide](https://docs.betterseqta.org/architecture/)
- **🔧 Having issues?** Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
We have lots of [`good first issue`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/good%20first%20issue) labels that are perfect for beginners!
@@ -33,8 +33,8 @@ Join our community channels to discuss the project, get help, and connect with o
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Creating Your First Plugin](./docs/plugins/creating-plugins.md)
- [Plugin API Reference](./docs/advanced/plugin-api.md)
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
## Pull Request Process
+25 -56
View File
@@ -16,17 +16,15 @@
<img src="https://img.shields.io/chrome-web-store/rating/afdgaoaclhkhemfkkkonemoapeinchel" />
</div>
## Table of contents
## 📚 Documentation
All documentation has been moved to the [official docs site](https://docs.betterseqta.org):
- [Features](#features)
- [Creating Custom Themes](#creating-custom-themes)
- [Getting Started](#getting-started)
- [Running Development](#running-development)
- [Building for production](#building-for-production)
- [Folder Structure](#folder-structure)
- [Contributors](#contributors)
- [Credits](#credits)
- [Star History](#star-history)
Includes:
- Getting started
- Development setup
- Architecture
- Plugin system
- Theme creation
## Features
@@ -50,64 +48,32 @@
## Creating Custom Themes
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://betterseqta.gitbook.io/betterseqta-docs). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://docs.betterseqta.org/theme-creation/). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
Don't worry- if you get stuck feel free to ask around in the [discord](https://discord.gg/YzmbnCDkat). We're open and happy to help out! Happy creating :)
## 🚀 Want to Contribute?
## 🚀 Contributing
**New contributors welcome!**
- 📖 Start here: https://docs.betterseqta.org/install/
- 🧠 Architecture: https://docs.betterseqta.org/architecture/
- 🧩 Plugins: https://docs.betterseqta.org/plugins/
- 💬 Discord: https://discord.gg/YzmbnCDkat
**New contributors welcome!** 🎉 We've made it easy to get started:
- **👋 New to the project?** Start with our [Getting Started Guide](./docs/GETTING_STARTED_CONTRIBUTING.md)
- **🏗️ Want to understand the code?** Check out our [Architecture Guide](./docs/ARCHITECTURE.md)
- **🧩 Interested in plugins?** Read our [Plugin Development Guide](./docs/plugins/README.md)
- **🐛 Found a bug?** Open an [issue](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) or fix it yourself!
- **💬 Need help?** Join our [Discord community](https://discord.gg/YzmbnCDkat)
## ⚡ Quick Start
We have lots of https://github.com/BetterSEQTA/BetterSEQTA-Plus/labels/good%20first%20issue labels perfect for beginners!
## Quick Development Setup
&nbsp;&nbsp;&nbsp; **1. Fork & Clone**
```bash
git clone https://github.com/YOUR_USERNAME/BetterSEQTA-Plus
git clone https://github.com/YOUR_USERNAME_FORKED_WITH/BetterSEQTA-Plus
cd BetterSEQTA-Plus
```
&nbsp;&nbsp;&nbsp; **2. Install & Run**
```bash
npm install --legacy-peer-deps
npm run dev
```
````
&nbsp;&nbsp;&nbsp; **3. Load in Browser**
1. Go to `chrome://extensions`
2. Enable "Developer mode"
3. Click "Load unpacked" → Select `dist` folder
4. Visit a SEQTA page to see it work! 🎉
> [!WARNING]
> Whenever you update the extension while not in dev mode, you will need to use the reload button on the extension page.
Then load `dist` in `chrome://extensions` (Developer Mode → Load unpacked).
📚 **Need more details?** Check our [detailed setup guide](./docs/GETTING_STARTED_CONTRIBUTING.md#your-first-30-minutes)
### Building for Production
```bash
npm run build # Build for all browsers
npm run zip # Package for distribution (requires 7-Zip)
```
## Folder Structure
The folder structure is as follows:
- The `src` folder contains source files that are compiled to the build directory.
- The `src/plugins` folder contains vital loaders required for BetterSEQTA+ functionality.
- The `src/interface` folder contains source React & Svelte files that are required for the Settings page.
- The `dist` folder is where the compiled code ends up, this is the folder what you need to load into chrome as an unpacked extension for development.
Full setup guide:
[https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment](https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment)
## Contributors
@@ -115,11 +81,14 @@ The folder structure is as follows:
<img src="https://contrib.rocks/image?repo=betterseqta/betterseqta-plus" />
</a>
Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plus/blob/main/CONTRIBUTING.md)
Want to contribute? [Click Here!](https://docs.betterseqta.org/contributing/)
## Credits
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers.
Originally developed by [Nulkem](https://github.com/Nulkem/betterseqta)
Ported to Manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68)
Maintained by [SethBurkart123](https://github.com/SethBurkart123), [Crazypersonalph](https://github.com/Crazypersonalph) & the rest of the BetterSEQTA team!
## Star History
+231 -44
View File
@@ -1,10 +1,11 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "betterseqtaplus",
"dependencies": {
"@bedframe/core": "^0.0.46",
"@bedframe/core": "^0.1.0",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1",
@@ -12,13 +13,14 @@
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.1.4",
"@types/color": "^4.2.0",
"@types/lodash": "^4.17.16",
"@types/node": "^24.3.0",
"@types/qrcode": "^1.5.6",
"@types/sortablejs": "^1.15.8",
"@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.3",
@@ -44,22 +46,23 @@
"motion": "^12.4.12",
"pdfjs-dist": "^5.4.530",
"postcss": "^8.5.3",
"qrcode": "^1.5.4",
"react": "17",
"react-best-gradient-color-picker": "3.0.11",
"react-dom": "17",
"rss-parser": "^3.13.0",
"sortablejs": "^1.15.6",
"svelte": "^5.22.6",
"svelte": "^5.46.4",
"typescript": "^5.8.2",
"uuid": "^11.1.0",
"vite": "^6.2.1",
"vite": "^8.0.5",
"webextension-polyfill": "^0.12.0",
},
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.95",
"@crxjs/vite-plugin": "^2.2.0",
"@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.4.0",
"@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
@@ -70,7 +73,7 @@
"mime-types": "^3.0.1",
"prettier": "^3.5.3",
"process": "^0.11.10",
"publish-browser-extension": "^4.0.0",
"publish-browser-extension": "^4.0.4",
"sass": "^1.85.1",
"sass-loader": "^16.0.5",
"semver": "^7.7.1",
@@ -124,9 +127,9 @@
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
"@bedframe/cli": ["@bedframe/cli@0.0.95", "", { "dependencies": { "@bedframe/core": "0.0.46", "commander": "14.0.0", "execa": "9.6.0", "kolorist": "1.8.0", "listr": "0.14.3", "nanospinner": "1.2.2", "node-fetch": "3.3.2", "pkg-install": "1.0.0", "prompts": "2.4.2", "vite": "6.3.5" }, "peerDependencies": { "concurrently": "8.2.2" }, "bin": { "bedframe": "dist/bedframe.js", "create-bedframe": "dist/create-bedframe.js" } }, "sha512-WSb0HhHCfH7/tS5dDC/HL/VgKrIFGLmI0OesmcwQntrJdHVirtjrDVjcTFG3lC3LB5Ax85P1CY50Gy5aDNc0sQ=="],
"@bedframe/cli": ["@bedframe/cli@0.1.2", "", { "dependencies": { "@bedframe/core": "0.1.0", "commander": "^14.0.2", "execa": "^9.6.1", "kolorist": "^1.8.0", "listr": "^0.14.3", "nanospinner": "^1.2.2", "node-fetch": "^3.3.2", "pkg-install": "^1.0.0", "prompts": "^2.4.2", "vite": "^6.4.1" }, "peerDependencies": { "concurrently": "^8.2.1" }, "bin": { "bedframe": "dist/bedframe.js", "create-bedframe": "dist/create-bedframe.js" } }, "sha512-nu0VSfGLhY9f62w+fDRQi2YnfoY9c6u28ZlJ8rH6f57ItLo5TNrZetfw37fYNnh8yK2RSAWU7+6KCkdVm0Fokg=="],
"@bedframe/core": ["@bedframe/core@0.0.46", "", { "dependencies": { "@crxjs/vite-plugin": "2.0.2" }, "peerDependencies": { "vite-plugin-dts": "3.9.1", "vite-plugin-externalize-deps": "0.7.0", "vitest": "0.34.6" } }, "sha512-cOshFUrBksWnVQ08chunlvAetwhuytkX7NdH6blNNylYzsgCaLGBbCJ2EZ0d18kimFVNZoODrc+812if5dio/w=="],
"@bedframe/core": ["@bedframe/core@0.1.0", "", { "dependencies": { "@crxjs/vite-plugin": "2.3.0" }, "peerDependencies": { "vite-plugin-dts": "^3.7.0", "vite-plugin-externalize-deps": "^0.7.0", "vitest": "^0.34.6" } }, "sha512-bM9vuYG67m9lVTui966AmkoxPPdEHEDOKKjzAWV/Ymgur818fRhMMpblx3+PLs8kTCek1m79fjYKoE8PhqJ22g=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "6.11.3", "@codemirror/state": "6.5.2", "@codemirror/view": "6.38.1", "@lezer/common": "1.2.3" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="],
@@ -144,7 +147,13 @@
"@codemirror/view": ["@codemirror/view@6.38.1", "", { "dependencies": { "@codemirror/state": "6.5.2", "crelt": "1.0.6", "style-mod": "4.1.2", "w3c-keyname": "2.2.8" } }, "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ=="],
"@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.2.0", "", { "dependencies": { "@rollup/pluginutils": "4.2.1", "@webcomponents/custom-elements": "1.6.0", "acorn-walk": "8.3.4", "cheerio": "1.1.2", "convert-source-map": "1.9.0", "debug": "4.4.1", "es-module-lexer": "0.10.5", "fast-glob": "3.3.3", "fs-extra": "10.1.0", "jsesc": "3.1.0", "magic-string": "0.30.18", "pathe": "2.0.3", "picocolors": "1.1.1", "react-refresh": "0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-HpT1GLbUQy42nlpN4sGzFgulacBraMM778s8Q+oPo4cb26DwO9tTwdndlvAS8fe6vEProFXvbdt37objp/0IQA=="],
"@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.4.0", "", { "dependencies": { "@rollup/pluginutils": "^4.1.2", "@webcomponents/custom-elements": "^1.5.0", "acorn-walk": "^8.2.0", "convert-source-map": "^1.7.0", "debug": "^4.3.3", "es-module-lexer": "^0.10.0", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "jsesc": "^3.0.2", "magic-string": "^0.30.12", "node-html-parser": "^7.0.2", "pathe": "^2.0.1", "picocolors": "^1.1.1", "react-refresh": "^0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" }, "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bDLdq0W2V1SkMQDJjrcYyjK9/uKtdl4joT7GRImcootCjZdKRiRYt+cv9z8tJoU/tK3o1lX48LTqN7JMsk5AQg=="],
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
@@ -290,12 +299,16 @@
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "1.19.1" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "1.0.3", "is-glob": "4.0.3", "micromatch": "4.0.8", "node-addon-api": "7.1.1" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
@@ -346,6 +359,38 @@
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
"@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "2.0.2", "picomatch": "2.3.1" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="],
@@ -406,14 +451,14 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "4.0.1", "debug": "4.4.1", "deepmerge": "4.3.1", "kleur": "4.1.5", "magic-string": "0.30.18", "vitefu": "1.1.1" }, "peerDependencies": { "svelte": "5.38.6", "vite": "6.3.5" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "5.1.1", "svelte": "5.38.6", "vite": "6.3.5" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="],
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.10", "", { "dependencies": { "mini-svg-data-uri": "1.4.4" }, "peerDependencies": { "tailwindcss": "3.4.17" } }, "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw=="],
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.5", "", {}, "sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="],
"@types/chai": ["@types/chai@4.3.20", "", {}, "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ=="],
@@ -448,6 +493,8 @@
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "3.1.3" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
"@types/react-dom": ["@types/react-dom@19.1.8", "", { "peerDependencies": { "@types/react": "19.1.12" } }, "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ=="],
@@ -506,7 +553,7 @@
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-uri": "3.1.0", "json-schema-traverse": "1.0.0", "require-from-string": "2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "1.1.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="],
@@ -522,7 +569,7 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="],
@@ -568,6 +615,8 @@
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "1.5.1", "ieee754": "1.2.1" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "1.3.0", "function-bind": "1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@@ -576,6 +625,8 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001737", "", {}, "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw=="],
@@ -598,9 +649,9 @@
"cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="],
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "5.0.0", "string-width": "7.2.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
"cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "4.2.3", "strip-ansi": "6.0.1", "wrap-ansi": "7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -622,7 +673,7 @@
"colors-named-hex": ["colors-named-hex@1.0.2", "", {}, "sha512-k6kq1e1pUCQvSVwIaGFq2l0LrkAPQZWyeuZn1Z8nOiYSEZiKoFj4qx690h2Kd34DFl9Me0gKS6MUwAMBJj8nuA=="],
"commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"complex.js": ["complex.js@2.4.2", "", {}, "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g=="],
@@ -664,6 +715,8 @@
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
@@ -682,10 +735,14 @@
"detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
"devalue": ["devalue@5.7.1", "", {}, "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "2.3.0", "domhandler": "5.0.3", "entities": "4.5.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
@@ -698,12 +755,14 @@
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "2.0.0", "domelementtype": "2.3.0", "domhandler": "5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-errors": "1.3.0", "gopd": "1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.211", "", {}, "sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw=="],
"elegant-spinner": ["elegant-spinner@1.0.1", "", {}, "sha512-B+ZM+RXvRqQaAmkMlO/oSe5nMUOaUnyfGYCEHoR8wrXsZR2mA0XVibsxV1bvTwxdRWah1PkQqso2EzhILGHtEQ=="],
@@ -758,7 +817,7 @@
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
"esrap": ["esrap@2.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA=="],
"esrap": ["esrap@2.2.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
@@ -772,7 +831,7 @@
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"execa": ["execa@9.6.0", "", { "dependencies": { "@sindresorhus/merge-streams": "4.0.0", "cross-spawn": "7.0.6", "figures": "6.1.0", "get-stream": "9.0.1", "human-signals": "8.0.1", "is-plain-obj": "4.1.0", "is-stream": "4.0.1", "npm-run-path": "6.0.0", "pretty-ms": "9.2.0", "signal-exit": "4.1.0", "strip-final-newline": "4.0.0", "yoctocolors": "2.1.2" } }, "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw=="],
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
@@ -838,7 +897,7 @@
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
"get-func-name": ["get-func-name@2.0.2", "", {}, "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ=="],
@@ -964,6 +1023,12 @@
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "2.0.1" }, "optionalDependencies": { "graceful-fs": "4.2.11" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
@@ -974,6 +1039,30 @@
"lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "3.0.6" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -986,7 +1075,7 @@
"listr-verbose-renderer": ["listr-verbose-renderer@0.5.0", "", { "dependencies": { "chalk": "2.4.2", "cli-cursor": "2.1.0", "date-fns": "1.30.1", "figures": "2.0.0" } }, "sha512-04PDPqSlsqIOaaaGZ+41vq5FejI9auqTInicFRndCBgE3bXG8D6W1I+mWhk+1nqbHmyhla/6BUrd5OSiHwKRXw=="],
"listr2": ["listr2@8.3.3", "", { "dependencies": { "cli-truncate": "4.0.0", "colorette": "2.0.20", "eventemitter3": "5.0.1", "log-update": "6.1.0", "rfdc": "1.4.1", "wrap-ansi": "9.0.0" } }, "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ=="],
"listr2": ["listr2@10.1.0", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^7.0.2", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-/6t2KgDYIcCjhELwvrRxi1gaJ4xCGLTjNvh6mSjYenBkrZxggek8EwCbwBU33GMUCpyyrOzz2TzylrO5mTiI1w=="],
"local-pkg": ["local-pkg@0.4.3", "", {}, "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g=="],
@@ -1002,15 +1091,29 @@
"lodash.get": ["lodash.get@4.4.2", "", {}, "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
"lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="],
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
"lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
"lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="],
"log-symbols": ["log-symbols@1.0.2", "", { "dependencies": { "chalk": "1.1.3" } }, "sha512-mmPrW0Fh2fxOzdBbFv4g1m6pR72haFLPJ2G5SJEELf1y+iaQrDG6cWCPjy54RHYbZAt7X+ls690Kw62AdWXBzQ=="],
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "7.0.0", "cli-cursor": "5.0.0", "slice-ansi": "7.1.0", "strip-ansi": "7.1.0", "wrap-ansi": "9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"log-update": ["log-update@7.1.0", "", { "dependencies": { "ansi-escapes": "^7.1.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.2", "strip-ansi": "^7.1.2", "wrap-ansi": "^9.0.2" } }, "sha512-y9pi/ZOQQVvTgfRDEHV1Cj4zQUkJZPipEUNOxhn1R6KgmdMs7LKvXWCd9eMVPGJgvYzFLCenecWr0Ps8ChVv2A=="],
"long": ["long@4.0.0", "", {}, "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="],
@@ -1090,6 +1193,8 @@
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="],
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -1108,7 +1213,9 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"ofetch": ["ofetch@1.4.1", "", { "dependencies": { "destr": "2.0.5", "node-fetch-native": "1.6.7", "ufo": "1.6.1" } }, "sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
@@ -1132,6 +1239,8 @@
"p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "3.1.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
@@ -1174,6 +1283,8 @@
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "4.2.0", "read-cache": "1.0.0", "resolve": "1.22.10" }, "peerDependencies": { "postcss": "8.5.6" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
@@ -1204,12 +1315,14 @@
"protobufjs": ["protobufjs@6.11.4", "", { "dependencies": { "@protobufjs/aspromise": "1.1.2", "@protobufjs/base64": "1.1.2", "@protobufjs/codegen": "2.0.4", "@protobufjs/eventemitter": "1.1.0", "@protobufjs/fetch": "1.1.0", "@protobufjs/float": "1.0.2", "@protobufjs/inquire": "1.1.0", "@protobufjs/path": "1.1.2", "@protobufjs/pool": "1.1.0", "@protobufjs/utf8": "1.1.0", "@types/long": "4.0.2", "@types/node": "24.3.0", "long": "4.0.0" }, "bin": { "pbjs": "bin/pbjs", "pbts": "bin/pbts" } }, "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw=="],
"publish-browser-extension": ["publish-browser-extension@4.0.0", "", { "dependencies": { "cac": "^6.7.14", "consola": "^3.4.2", "dotenv": "^17.2.3", "form-data-encoder": "^4.1.0", "formdata-node": "^6.0.3", "listr2": "^8.3.3", "ofetch": "^1.4.1", "zod": "^3.25.76 || ^4.0.0" }, "bin": { "publish-extension": "bin/publish-extension.mjs" } }, "sha512-2bsHf2m+ivNyDa67YdVhcxpF3wjWzVTqG5JWQYwi6by47XyA7jf43A9S7+UBULM/d2f82fPUGnRuecMK1CgEPA=="],
"publish-browser-extension": ["publish-browser-extension@4.0.4", "", { "dependencies": { "cac": "^6.7.14", "consola": "^3.4.2", "dotenv": "^17.2.4", "form-data-encoder": "^4.1.0", "formdata-node": "^6.0.3", "jsonwebtoken": "^9.0.3", "listr2": "^10.1.0", "ofetch": "^1.5.1", "zod": "3.25.76 || ^4.3.6" }, "bin": { "publish-extension": "bin/publish-extension.mjs" } }, "sha512-QMQbWL0FWgBfnkJ6w8HOJoIPaWLE7vTpewM4ae2vLs7SrD4eKdAk+SxOzqAICwbhEPuaLAOA+XkT9sZS5R0PmA=="],
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "1.4.5", "once": "1.4.0" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@@ -1240,6 +1353,8 @@
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "2.16.1", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@@ -1250,6 +1365,8 @@
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
"rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="],
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "2.2.0", "xml2js": "0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
@@ -1276,6 +1393,8 @@
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "4.2.3", "detect-libc": "2.0.4", "node-addon-api": "6.1.0", "prebuild-install": "7.1.3", "semver": "7.7.2", "simple-get": "4.0.1", "tar-fs": "3.1.0", "tunnel-agent": "0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -1304,7 +1423,7 @@
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "6.2.1", "is-fullwidth-code-point": "4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
"slice-ansi": ["slice-ansi@7.1.0", "", { "dependencies": { "ansi-styles": "6.2.1", "is-fullwidth-code-point": "5.0.0" } }, "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg=="],
"sortablejs": ["sortablejs@1.15.6", "", {}, "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A=="],
@@ -1352,7 +1471,7 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.38.6", "", { "dependencies": { "@jridgewell/remapping": "2.3.5", "@jridgewell/sourcemap-codec": "1.5.5", "@sveltejs/acorn-typescript": "1.0.5", "@types/estree": "1.0.8", "acorn": "8.15.0", "aria-query": "5.3.2", "axobject-query": "4.1.0", "clsx": "2.1.1", "esm-env": "1.2.2", "esrap": "2.1.0", "is-reference": "3.0.3", "locate-character": "3.0.0", "magic-string": "0.30.18", "zimmerframe": "1.1.2" } }, "sha512-ltBPlkvqk3bgCK7/N323atUpP3O3Y+DrGV4dcULrsSn4fZaaNnOmdplNznwfdWclAgvSr5rxjtzn/zJhRm6TKg=="],
"svelte": ["svelte@5.55.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ=="],
"symbol-observable": ["symbol-observable@1.2.0", "", {}, "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="],
@@ -1378,7 +1497,7 @@
"tinycolor2": ["tinycolor2@1.4.2", "", {}, "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="],
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinypool": ["tinypool@0.7.0", "", {}, "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww=="],
@@ -1432,7 +1551,7 @@
"validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="],
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "0.25.9", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.49.0", "tinyglobby": "0.2.14" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "jiti": "1.21.7", "sass": "1.91.0", "yaml": "2.8.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
"vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
"vite-node": ["vite-node@0.34.6", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.1", "mlly": "1.8.0", "pathe": "1.1.2", "picocolors": "1.1.1", "vite": "5.4.19" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA=="],
@@ -1440,7 +1559,7 @@
"vite-plugin-externalize-deps": ["vite-plugin-externalize-deps@0.7.0", "", { "peerDependencies": { "vite": "6.3.5" } }, "sha512-do2gPrR79Tm8UKcqsw3RTAtN4YO8GkVRBckWdJWINZ3Qdp3KN9S1oyUZxKszTB/iyg4zdOUweLOeBI8t86QVow=="],
"vitefu": ["vitefu@1.1.1", "", { "optionalDependencies": { "vite": "6.3.5" } }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"vitest": ["vitest@0.34.6", "", { "dependencies": { "@types/chai": "4.3.20", "@types/chai-subset": "1.3.6", "@types/node": "24.3.0", "@vitest/expect": "0.34.6", "@vitest/runner": "0.34.6", "@vitest/snapshot": "0.34.6", "@vitest/spy": "0.34.6", "@vitest/utils": "0.34.6", "acorn": "8.15.0", "acorn-walk": "8.3.4", "cac": "6.7.14", "chai": "4.5.0", "debug": "4.4.1", "local-pkg": "0.4.3", "magic-string": "0.30.18", "pathe": "1.1.2", "picocolors": "1.1.1", "std-env": "3.9.0", "strip-literal": "1.3.0", "tinybench": "2.9.0", "tinypool": "0.7.0", "vite": "5.4.19", "vite-node": "0.34.6", "why-is-node-running": "2.3.0" }, "bin": { "vitest": "vitest.mjs" } }, "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q=="],
@@ -1464,6 +1583,8 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
@@ -1478,15 +1599,15 @@
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="],
@@ -1508,7 +1629,9 @@
"@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@bedframe/core/@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.0.2", "", { "dependencies": { "@rollup/pluginutils": "4.2.1", "@webcomponents/custom-elements": "1.6.0", "acorn-walk": "8.3.4", "cheerio": "1.1.2", "convert-source-map": "1.9.0", "debug": "4.4.1", "es-module-lexer": "0.10.5", "fast-glob": "3.3.3", "fs-extra": "10.1.0", "jsesc": "3.1.0", "magic-string": "0.30.18", "pathe": "2.0.3", "picocolors": "1.1.1", "react-refresh": "0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-BeaVEkCTmna2tzl5DL9nw1kxll1IpIFZ+wbl2+iILz4fNJy1xRD6c1nF8w8/CvrWUuPYTFTpyX9K+A30ISDXHA=="],
"@bedframe/cli/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
"@bedframe/core/@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.3.0", "", { "dependencies": { "@rollup/pluginutils": "^4.1.2", "@webcomponents/custom-elements": "^1.5.0", "acorn-walk": "^8.2.0", "cheerio": "^1.0.0-rc.10", "convert-source-map": "^1.7.0", "debug": "^4.3.3", "es-module-lexer": "^0.10.0", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "jsesc": "^3.0.2", "magic-string": "^0.30.12", "pathe": "^2.0.1", "picocolors": "^1.1.1", "react-refresh": "^0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-+0CNVGS4bB30OoaF1vUsHVwWU1Lm7MxI0XWY9Fd/Ob+ZVTZgEFNqJ1ZC69IVwQsoYhY0sMQLvpLWiFIuDz8htg=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -1544,6 +1667,8 @@
"@samverschueren/stream-to-observable/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
"@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@vitest/runner/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
@@ -1564,14 +1689,18 @@
"cheerio/undici": ["undici@7.15.0", "", {}, "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ=="],
"cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "10.4.0", "get-east-asian-width": "1.3.0", "strip-ansi": "7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"cli-truncate/string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"concurrently/rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"dependency-cruiser/commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"eslint/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
@@ -1584,6 +1713,8 @@
"htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"lightningcss/detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
"listr/is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="],
"listr/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
@@ -1606,9 +1737,11 @@
"log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "5.1.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"log-update/slice-ansi": ["slice-ansi@7.1.0", "", { "dependencies": { "ansi-styles": "6.2.1", "is-fullwidth-code-point": "5.0.0" } }, "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"log-update/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "6.2.0" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"log-update/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"mathjs/fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
@@ -1650,7 +1783,7 @@
"slice-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.0.0", "", { "dependencies": { "get-east-asian-width": "1.3.0" } }, "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA=="],
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -1664,9 +1797,13 @@
"tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"vite-node/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
@@ -1674,6 +1811,8 @@
"vite-plugin-dts/@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "1.0.8", "estree-walker": "2.0.2", "picomatch": "4.0.3" }, "optionalDependencies": { "rollup": "4.49.0" } }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="],
"vite-plugin-dts/vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "0.25.9", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.49.0", "tinyglobby": "0.2.14" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "jiti": "1.21.7", "sass": "1.91.0", "yaml": "2.8.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
"vitest/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"vitest/vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.6", "rollup": "4.49.0" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "sass": "1.91.0" }, "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="],
@@ -1686,8 +1825,18 @@
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"z-schema/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"@bedframe/cli/vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"@bedframe/cli/vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"@bedframe/cli/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"@bedframe/core/@crxjs/vite-plugin/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
@@ -1714,12 +1863,16 @@
"any-observable/rxjs/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"cli-truncate/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "6.2.0" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"concurrently/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "4.2.3", "strip-ansi": "6.0.1", "wrap-ansi": "7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"concurrently/yargs/y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"concurrently/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"listr-update-renderer/chalk/ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="],
@@ -1762,6 +1915,10 @@
"log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
"log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "10.4.0", "get-east-asian-width": "1.3.0", "strip-ansi": "7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"million/@rollup/pluginutils/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -1782,6 +1939,8 @@
"sharp/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "1.1.4", "simple-swizzle": "0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -1802,6 +1961,10 @@
"vite-plugin-dts/@rollup/pluginutils/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"vite-plugin-dts/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"vite-plugin-dts/vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"vitest/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"vitest/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
@@ -1810,14 +1973,22 @@
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"wrap-ansi/string-width/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@microsoft/api-extractor/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"@rushstack/node-core-library/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
"concurrently/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"concurrently/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "4.3.0", "string-width": "4.2.3", "strip-ansi": "6.0.1" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"listr-update-renderer/cli-truncate/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@1.0.0", "", { "dependencies": { "number-is-nan": "1.0.1" } }, "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw=="],
"listr-update-renderer/log-update/wrap-ansi/string-width": ["string-width@2.1.1", "", { "dependencies": { "is-fullwidth-code-point": "2.0.0", "strip-ansi": "4.0.0" } }, "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw=="],
@@ -1830,6 +2001,14 @@
"log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "5.0.1" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"log-update/slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"log-update/wrap-ansi/string-width/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"log-update/wrap-ansi/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "6.2.0" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"pkg-install/execa/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="],
"pkg-install/execa/cross-spawn/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
@@ -1940,12 +2119,20 @@
"vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"concurrently/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"listr-update-renderer/log-update/wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
"listr-update-renderer/log-update/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@3.0.1", "", {}, "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw=="],
"listr-verbose-renderer/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"log-update/wrap-ansi/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="],
"pkg-install/execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="],
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
}
}
+3 -1
View File
@@ -1,5 +1,7 @@
# BetterSEQTA+ Architecture
**Published version:** [docs.betterseqta.org/architecture/](https://docs.betterseqta.org/architecture/)
Hey there! 👋 New to the codebase and feeling a bit lost? Don't worry - this guide will help you understand how everything fits together!
## Table of Contents
@@ -221,7 +223,7 @@ console.log(settingsState.[the setting name])
Ready to contribute? Here's what to do next:
1. **Read the code**: Start with `src/SEQTA.ts` and follow the flow
2. **Try creating a simple plugin**: Follow our [plugin guide](./plugins/README.md)
2. **Try creating a simple plugin**: Follow the [plugin documentation](https://docs.betterseqta.org/plugins/)
3. **Look at existing issues**: Check our [GitHub issues](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) for "good first issue" labels
4. **Join our Discord**: Get help from the community!
+141
View File
@@ -0,0 +1,141 @@
# BetterSEQTA Cloud — settings sync (server specification)
This document describes the HTTP API the BetterSEQTA+ extension expects for **cloud backup of extension settings**. The client is implemented in the extension repo; the accounts service (`accounts.betterseqta.org`) must implement these endpoints.
## Purpose
- Store **one JSON document per authenticated BetterSEQTA Cloud user** representing a snapshot of the extensions `chrome.storage.local` data (theme, layout, plugin settings, `plugin.*` keys, etc.).
- The extension **does not upload OAuth tokens** (`bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`). Those remain only on the client.
- **Download** replaces local storage with the stored snapshot, then the client reapplies the current devices session tokens so the user stays signed in.
## Base URL
All routes below are relative to:
`https://accounts.betterseqta.org`
## Authentication
Every request must include:
```http
Authorization: Bearer <access_token>
```
Use the same **access tokens** issued by the existing BetterSEQTA+ OAuth flows (`/api/bsplus/login`, `/api/bsplus/refresh`). Resolve the user from the token; the document is scoped to that user.
## Endpoints
### `PUT /api/bsplus/settings/sync`
Upserts the callers settings backup.
**Request body (JSON):**
```json
{
"schemaVersion": 1,
"themeId": "uuid-string-or-empty",
"data": {
"...": "flat key-value map mirroring extension storage (see Payload shape)",
"selectedTheme": "uuid-or-empty-string"
}
}
```
- **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations.
- **`themeId`**: optional but recommended duplicate of **`data.selectedTheme`**. Should be the UUID of the **installed** BetterSEQTA store theme (`selectedTheme`). This may be a normal theme id **or** a **flavour (slave) variant** id from themes with **`flavours[]`** — the extension uses it after restore to prefetch `theme.json` when missing locally (same **`GET …/themes/{id}/download`** as the store UI). Persist and return **`themeId`** in sync with **`data.selectedTheme`**.
- **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`).
**Success response:** HTTP `200` (or `201` if you prefer create semantics). Example:
```json
{
"updated_at": "2026-04-07T12:00:00.000Z"
}
```
`updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields.
**Error responses:** Standard JSON error body if applicable, e.g. `{ "error": "message" }`, with appropriate HTTP status (`401`, `413`, `422`, etc.).
---
### `GET /api/bsplus/settings/sync`
Returns the callers latest settings backup.
**Success response:** HTTP `200` with body:
```json
{
"schemaVersion": 1,
"themeId": "uuid-string-or-empty",
"data": { },
"updated_at": "2026-04-07T12:00:00.000Z"
}
```
- **`data`**: required for restore; must be the same shape as accepted in `PUT` (flat map of storage keys).
- **`themeId`**: optional; if present must match **`data.selectedTheme`** (see `PUT`).
- **`schemaVersion`**: optional but recommended; should match what was stored.
- **`updated_at`**: optional; included for UX if the client shows “last backup” time.
The extension resolves **`themeId`** (if non-empty), else **`data.selectedTheme`,** to [`resolveThemeIdForPostSyncDownload`](../src/seqta/utils/cloudSettingsSync.ts) after downloading the envelope — used only to reinstall theme assets from **`betterseqta.org`** when IndexedDB lacks that id (see **BetterSEQTA Cloud** flavour note in **[THEME_STORE_FLAVOURS_API](./THEME_STORE_FLAVOURS_API.md)** section “Cloud settings sync compatibility”).
**No backup yet:** HTTP **`404`**. The extension treats this as “nothing in the cloud” and shows an error to the user.
**Error responses:** `401` if the token is invalid, etc.
---
## Suggested database shape
Example relational layout:
| Column | Type | Notes |
|---------------|-------------|--------|
| `user_id` | FK → users | Unique per backup row (one row per user). |
| `payload` | JSON / JSONB| Store `{ "schemaVersion", "data" }` or only `data` + separate `schema_version` column. |
| `updated_at` | timestamptz | Set on each successful `PUT`. |
Unique constraint on `user_id`.
## Semantics
- **Last write wins:** each `PUT` replaces the stored backup for that user.
- **Optional later:** `If-Unmodified-Since` or a `revision` field for conflict detection (not required for v1).
## Security and privacy
- **Encryption at rest** for `payload` is recommended.
- Payload may contain **school-related UI preferences** and plugin data; treat as **user data** under your privacy policy.
- **Do not require or store** refresh/access tokens in the payload; the extension already strips them on upload.
### Never included in the sync payload (`chrome.storage.local` only)
The backup is a flat JSON map of **`chrome.storage.local`** keys. It does **not** include:
- **IndexedDB** — e.g. the Global Search index (`betterseqta-index` and related DBs) lives outside extension storage and is never serialized here.
- **OAuth / session keys** — `bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`, plus legacy `cloudAccessToken` / `cloudUsername`.
- **Assessment Averages caches** — `plugin.assessments-average.storage.assessments`, `plugin.assessments-average.storage.weightings` (school assessment data).
- **Keys under** `plugin.global-search.storage.*` — reserved so any future plugin storage cache there is not synced.
- **Grade Analytics** — keys under `bsplus.analytics.*` (synced assessment cache and per-school chart preferences).
- **`bsplus_cloud_settings_known_remote_updated_at`** — client-only watermark for auto-sync (not part of the cloud backup blob).
On restore, those keys are **not** taken from the server; the device keeps its current local values.
## Related endpoint: `GET /api/user/cloud-summary`
The extension may call **`GET /api/user/cloud-summary`** (same host, `Authorization: Bearer`) for a **small** JSON summary (e.g. whether DesQTA / BetterSEQTA+ cloud settings exist and **`bsplus.updated_at`** / **`schemaVersion`**). It does **not** return the large settings `data` blob.
- **Auto-sync flow:** compare `bsplus.updated_at` to a **client-only** watermark stored in extension storage as **`bsplus_cloud_settings_known_remote_updated_at`** (never uploaded, never applied from the server payload; preserved on restore).
- If the server timestamp is newer (and `schemaVersion` is not ahead of the client), the client then calls **`GET /api/bsplus/settings/sync`** and applies the full envelope as usual.
This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages, `storage`) and works on **Chromium and Firefox** builds (see `webextension-polyfill`).
## Client reference (extension)
- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, **`bsplus_pending_theme_ensure_after_cloud`**, and **`bsplus_cloud_settings_known_remote_updated_at`** — includes **`themeId`** aligned with **`selectedTheme`**).
- Download: resolve id via **`resolveThemeIdForPostSyncDownload`** → **`applyDownloadedEnvelope`** after `GET` → prefetch theme blobs in page context if needed (**`prepareThemeAfterCloudSync`** in **`ThemeManager`**) → reload SEQTA tabs; local auth keys, sensitive device keys, client-only watermark, and **`bsplus_pending_theme_ensure_after_cloud`** semantics preserved as documented above.
- Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`.
+3 -1
View File
@@ -1,5 +1,7 @@
# Getting Started as a Contributor
**Published version:** [docs.betterseqta.org/contributing/](https://docs.betterseqta.org/contributing/)
Welcome to BetterSEQTA+! 🎉 This guide will walk you through making your first contribution, even if you're completely new to the project.
## Table of Contents
@@ -222,7 +224,7 @@ git push origin your-branch-name
### Stuck? Here's How to Get Unstuck
1. **Check the docs** - [Architecture guide](./ARCHITECTURE.md) explains everything
1. **Check the docs** - The [architecture guide](https://docs.betterseqta.org/architecture/) explains everything
2. **Search existing issues** - Someone might have had the same problem
3. **Ask in Discord** - Our community is super helpful
4. **Create an issue** - If you found a bug or need help
+22 -21
View File
@@ -1,30 +1,37 @@
# BetterSEQTA+ Documentation
🚧 DOCS UNDER CONSTRUCTION! 🚧
Welcome to the BetterSEQTA+ documentation! This documentation will help you understand how BetterSEQTA+ works and how to extend it with plugins and new features.
**Canonical documentation lives at [docs.betterseqta.org](https://docs.betterseqta.org/).** The pages below are the same topics; use the site for search, navigation, and the latest updates.
## Table of Contents
### Getting Started
- [Project Overview](./README.md) - This file
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+
- [Getting Started Contributing](./GETTING_STARTED_CONTRIBUTING.md) - **Start here!** Complete beginner-friendly guide
- [Architecture Guide](./ARCHITECTURE.md) - How BetterSEQTA+ works under the hood
- [Contributing Guide](../CONTRIBUTING.md) - Official contribution guidelines
- [Troubleshooting](./TROUBLESHOOTING.md) - Common issues and solutions
- [Documentation home](https://docs.betterseqta.org/)
- [Installation](https://docs.betterseqta.org/install/)
- [Contributing](https://docs.betterseqta.org/contributing/)
- [GitHub releases & CI](RELEASES.md) — workflows, nightly builds, update detector
- [Architecture](https://docs.betterseqta.org/architecture/)
- [Contribution guidelines (repository)](../CONTRIBUTING.md)
- [Troubleshooting](https://docs.betterseqta.org/troubleshooting/)
### Plugin System
### Features & customization
- [Creating Your First Plugin](./plugins/README.md) - A comprehensive, beginner-friendly guide to creating plugins
- [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs
- [Features](https://docs.betterseqta.org/features/)
- [Themes & customization](https://docs.betterseqta.org/customization/)
- [Theme creation](https://docs.betterseqta.org/theme-creation/)
### Plugin system
- [Plugins overview](https://docs.betterseqta.org/plugins/)
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
- [Example plugin](https://docs.betterseqta.org/example-plugin/)
## Core Concepts
BetterSEQTA+ is built around several core concepts:
1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. Check out our [plugin guide](./plugins/README.md) to learn how to create your own!
1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. See the [plugins documentation](https://docs.betterseqta.org/plugins/).
2. **Type-Safe Settings**: Each plugin can define settings that are type-safe and automatically rendered in the settings UI. The settings system uses TypeScript decorators to make it easy to define settings with proper typing.
@@ -36,19 +43,13 @@ BetterSEQTA+ is built around several core concepts:
If you need help with BetterSEQTA+, you can:
- [Open an Issue](https://github.com/SeqtaLearning/betterseqta-plus/issues) - Report bugs or request features
- [Open an Issue](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) - Report bugs or request features
- [Join the Discord](https://discord.gg/YzmbnCDkat) - Chat with the community
- [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly
## Contributing to the Documentation
We welcome contributions to the documentation! If you find something unclear or missing, please open an issue or submit a pull request.
To contribute to the documentation:
1. Fork the repository
2. Make your changes to the documentation files
3. Submit a pull request with a clear description of your changes
We welcome contributions to the documentation. The published site is built from the documentation repository; see [Contributing](https://docs.betterseqta.org/contributing/) for how to help.
## License
+266
View File
@@ -0,0 +1,266 @@
# GitHub releases, CI, and the update detector
BetterSEQTA+ is distributed on the Chrome Web Store and Firefox Add-ons, but some users sideload builds from GitHub. This document explains how automated builds, releases, and the in-extension update badge work.
All published releases target the upstream repository: **[BetterSEQTA/BetterSEQTA-Plus](https://github.com/BetterSEQTA/BetterSEQTA-Plus)**.
---
## Overview
```mermaid
flowchart TB
subgraph ci [Continuous integration]
PrCi[pr-ci.yml — every PR]
Mvp[mvp.yml — push to main]
end
subgraph releases [GitHub releases]
Manual[release.yml — manual stable]
Nightly[nightly.yml — daily cron]
end
subgraph output [Outputs]
Zips["betterseqtaplus-VERSION-chrome.zip\nbetterseqtaplus-VERSION-firefox.zip"]
GhStable["Release tag: 3.7.0"]
GhNightly["Release tag: nightly"]
Badge[Update badge in settings]
end
PrCi -->|build only| NoRelease[No release]
Mvp -->|artifact| DistZip[dist.zip artifact]
Manual --> Zips
Manual --> GhStable
Manual --> Badge
Nightly --> Zips
Nightly --> GhNightly
Nightly --> Badge
```
| Workflow | Trigger | Creates a release? | Update detector in build? |
|----------|---------|------------------|---------------------------|
| `pr-ci.yml` | Every pull request to `main` | No | No |
| `mvp.yml` | Every push to `main` | No | No |
| `release.yml` | Manual (`workflow_dispatch`) | Yes — stable version tag | Yes — stable channel |
| `nightly.yml` | Daily at 03:00 UTC + manual | Yes — reuses `nightly` tag | Yes — nightly channel |
---
## Shared build action
All release and CI builds that need packaged extensions use the composite action at [`.github/actions/build-extension/action.yml`](../.github/actions/build-extension/action.yml).
It:
1. Installs dependencies (`npm install --legacy-peer-deps`)
2. Runs `npm run build` (Chrome then Firefox via Vite)
3. Zips each unpacked folder into sideload-ready archives:
- `dist/betterseqtaplus-{version}-chrome.zip`
- `dist/betterseqtaplus-{version}-firefox.zip`
The `{version}` comes from `package.json`.
### Build-time flags (release builds only)
Release and nightly workflows pass environment variables into Vite, which bakes them into the extension at compile time via `define` in [`vite.config.ts`](../vite.config.ts):
| Variable | Stable release | Nightly | PR / local dev |
|----------|----------------|---------|----------------|
| `GH_RELEASE_UPDATE_CHECK` | `true` | `true` | `false` / unset |
| `UPDATE_CHANNEL` | `stable` | `nightly` | `stable` (unused) |
| `GH_RELEASE_REPO` | `BetterSEQTA/BetterSEQTA-Plus` | same | same |
| `BUILD_LABEL` | empty | GitHub run number | empty |
When `GH_RELEASE_UPDATE_CHECK` is not `true`, the update-checker code is tree-shaken out of the bundle. PR CI builds and local `npm run build` do **not** include the update badge.
To test a release-style build locally:
```bash
# PowerShell
$env:GH_RELEASE_UPDATE_CHECK="true"
$env:UPDATE_CHANNEL="stable"
npm run build
# bash
GH_RELEASE_UPDATE_CHECK=true UPDATE_CHANNEL=stable npm run build
```
---
## Stable release (`release.yml`)
**Purpose:** A safe, manual way to publish versioned builds that users can download from GitHub.
**Trigger:** Manual only — Actions → **Release****Run workflow**. There is no schedule or automatic trigger.
When dispatching, check **“I have already updated the version in package.json”**. The workflow will not run without that confirmation.
### Before you run it
1. Merge your changes to `main`.
2. Bump `version` in [`package.json`](../package.json) (e.g. `3.7.0``3.8.0`).
3. Commit and push that bump.
### What the workflow does
1. Aborts if the version confirmation was not checked.
2. Reads the version from `package.json`.
3. Builds Chrome and Firefox with the update detector enabled (stable channel).
4. If no release exists for that tag: creates one (e.g. `3.8.0`) with a placeholder description.
5. If a release already exists for that tag: **only replaces the zip assets** (`--clobber`). Title and body are left unchanged.
6. Uploads `betterseqtaplus-{version}-chrome.zip` and `-firefox.zip`.
On first publish, edit the **title** and **release notes** on GitHub afterward. Re-running for the same version refreshes the files only.
### Downloading and installing
1. Open [github.com/BetterSEQTA/BetterSEQTA-Plus/releases](https://github.com/BetterSEQTA/BetterSEQTA-Plus/releases).
2. Pick the version you want.
3. Download `betterseqtaplus-{version}-chrome.zip` or `-firefox.zip`.
4. Unzip and load the unpacked folder as a temporary extension (Chrome: Extensions → Load unpacked; Firefox: `about:debugging` → Load Temporary Add-on).
**These GitHub builds are for sideloading only. Do not upload them to the Chrome Web Store or Firefox Add-ons.**
---
## Nightly release (`nightly.yml`)
**Purpose:** Continuous experimental builds from `main` so testers always have a single place to get the latest code.
**Trigger:**
- Automatically every day at **03:00 UTC**
- Manually via Actions → **Nightly Release****Run workflow**
### What the workflow does
1. Builds from the current `main` branch with the update detector enabled (nightly channel).
2. Uses a fixed release tag: **`nightly`** (marked as prerelease).
3. On first run: creates the `nightly` release with the text in [`.github/nightly-release-notes.md`](../.github/nightly-release-notes.md).
4. On every subsequent run: **replaces** the zip assets on the same release (`--clobber`). The release title and body are not rewritten.
The nightly release body warns that builds are experimental and must not be uploaded to extension stores.
---
## PR CI (`pr-ci.yml`)
**Purpose:** Verify that every pull request builds cleanly in a fresh environment.
**Trigger:** Every pull request targeting `main`.
**Steps:**
1. `npm install --legacy-peer-deps`
2. `npm run lint` (ESLint on `src/**/*.{js,ts}`)
3. Build via the shared action with **no** update detector
No release is created and no artifacts are published for end users.
---
## Push CI (`mvp.yml`)
**Purpose:** Build verification when code lands on `main`.
**Trigger:** Push to `main` only (not pull requests — those use `pr-ci.yml`).
Uploads a `dist.zip` artifact containing the full `dist/` folder for debugging. No release, no update detector.
---
## Authentication
Release workflows run **only on [BetterSEQTA/BetterSEQTA-Plus](https://github.com/BetterSEQTA/BetterSEQTA-Plus)**. They use the default `GITHUB_TOKEN` with `contents: write` — no extra secrets or PATs required.
---
## Update detector (in-extension)
GitHub release builds include a small feature that tells sideload users when a newer build is available, so they do not have to check GitHub manually.
### Where it appears
In the **settings popup** header (top-right): an amber **“Update available — X.X.X”** pill when an update exists, plus a muted line:
> GitHub release build — do not upload to extension stores.
Clicking the badge opens the relevant GitHub releases page.
### How it checks for updates
Implementation: [`src/utils/githubReleaseUpdate.ts`](../src/utils/githubReleaseUpdate.ts)
**Stable channel** (from `release.yml` builds):
1. Fetches releases from the GitHub API for `BetterSEQTA/BetterSEQTA-Plus`.
2. Ignores the `nightly` tag and any non-semver tags.
3. Finds the highest semver tag.
4. Compares it to `browser.runtime.getManifest().version`.
5. Shows the badge if the remote version is newer.
**Nightly channel** (from `nightly.yml` builds):
1. Fetches the `nightly` release.
2. Compares its `published_at` timestamp to `lastSeenNightlyPublishedAt` in extension storage.
3. On first install, records the current publish time without showing a badge.
4. Shows **“Update available — nightly #123”** (run number) when a newer nightly has been published.
Checks are throttled to once every **6 hours** per browser profile (`localStorage` key `bsplus_lastGhReleaseCheck`). Recent results are cached in memory for the session.
### Dev override (testing)
To test the badge without publishing a real release:
1. Open settings and unlock **dev mode** (click the logo, type `dev`).
2. In the developer section, set **GitHub latest version override** to a version higher than your installed one (e.g. `9.9.9`).
3. Reopen settings — the badge should appear.
This only applies when dev mode is on. Clear the field to return to normal API checks.
---
## File reference
| Path | Role |
|------|------|
| `.github/actions/build-extension/action.yml` | Shared install, build, zip |
| `.github/workflows/release.yml` | Manual stable releases |
| `.github/workflows/nightly.yml` | Nightly releases |
| `.github/workflows/pr-ci.yml` | PR lint + build |
| `.github/workflows/mvp.yml` | Push-to-main build artifact |
| `.github/nightly-release-notes.md` | Static body for the `nightly` release |
| `vite.config.ts` | Injects build-time `define` flags |
| `src/env.d.ts` | TypeScript declarations for those flags |
| `src/utils/githubReleaseUpdate.ts` | Update check logic |
| `src/interface/pages/settings.svelte` | Badge and disclaimer UI |
| `src/interface/pages/settings/general.svelte` | Dev override input |
| `src/types/storage.ts` | `devGhReleaseVersionOverride`, `lastSeenNightlyPublishedAt` |
---
## Quick reference
### Publish a stable release
```text
1. Bump version in package.json on main
2. Actions → Release → Run workflow → confirm version checkbox
3. Edit release title/notes on GitHub (first time only)
4. Re-run with same version to refresh zips without changing release text
```
### Get the latest nightly
```text
https://github.com/BetterSEQTA/BetterSEQTA-Plus/releases/tag/nightly
```
### Verify a PR locally
```bash
npm run lint
npm run build
```
+2
View File
@@ -1,5 +1,7 @@
# Theme Creation Guide
**Published version:** [docs.betterseqta.org/theme-creation/](https://docs.betterseqta.org/theme-creation/)
This guide covers everything you need to know about creating custom themes for BetterSEQTA+.
## Table of Contents
+2
View File
@@ -1,5 +1,7 @@
# Troubleshooting Guide
**Published version:** [docs.betterseqta.org/troubleshooting/](https://docs.betterseqta.org/troubleshooting/)
Having issues with BetterSEQTA+ development? This guide covers the most common problems and their solutions.
## Table of Contents
+28 -6
View File
@@ -1,5 +1,7 @@
# Contributing to BetterSEQTA+
**Published version:** [docs.betterseqta.org/contributing/](https://docs.betterseqta.org/contributing/)
Thank you for your interest in contributing to BetterSEQTA+! This document provides guidelines and instructions for contributing to the project.
## Table of Contents
@@ -57,7 +59,7 @@ Key points:
5. **Install in Chrome/Firefox**
Follow the [installation instructions](./installation.md#development-installation) to load the development version into your browser.
Follow the [installation instructions](https://docs.betterseqta.org/install/) to load the development version into your browser.
### Project Structure
@@ -117,12 +119,13 @@ git checkout -b feature/my-new-feature
If your changes require documentation updates, include them in the same PR.
4. **Run Tests**
4. **Run CI checks locally**
Make sure all tests pass before submitting your PR:
Pull requests trigger **PR CI**, which lints and builds in a clean environment:
```bash
npm test
npm run lint
npm run build
```
5. **Submit Your PR**
@@ -137,6 +140,25 @@ git checkout -b feature/my-new-feature
Once approved, a maintainer will merge your PR.
### GitHub Actions workflows
See [RELEASES.md](RELEASES.md) for a full guide (workflows, sideload installs, and update detector).
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `pr-ci.yml` | Every PR to `main` | Typecheck, lint, and build (no release) |
| `mvp.yml` | Push to `main` | Build and upload `dist.zip` artifact |
| `release.yml` | Manual (`workflow_dispatch`) | Stable release on [BetterSEQTA/BetterSEQTA-Plus](https://github.com/BetterSEQTA/BetterSEQTA-Plus) tagged with `package.json` version |
| `nightly.yml` | Daily cron + manual | Updates the fixed `nightly` prerelease with latest `main` builds |
**Releasing a stable version:** bump `version` in `package.json` on `main`, then manually run the **Release** workflow and confirm the version checkbox. Edit the release title and notes on GitHub after the first publish. Re-running for the same tag only updates the zip files. Assets are `betterseqtaplus-{version}-chrome.zip` and `-firefox.zip`.
**Nightly builds** replace assets on the same `nightly` release. The release body warns that builds are experimental and must not be uploaded to extension stores.
**GitHub release builds** include an in-extension update checker (settings header badge). **PR CI and local dev builds do not.**
Release workflows are dispatched only on the main **BetterSEQTA/BetterSEQTA-Plus** repository and use the default `GITHUB_TOKEN`.
### Coding Standards
We follow TypeScript best practices and have a consistent code style:
@@ -246,8 +268,8 @@ Join our community channels to discuss the project, get help, and connect with o
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Creating Your First Plugin](./plugins/creating-plugins.md)
- [Plugin API Reference](./advanced/plugin-api.md)
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
## Recognition
+4 -2
View File
@@ -1,5 +1,7 @@
# Installing BetterSEQTA+
**Published version:** [docs.betterseqta.org/install/](https://docs.betterseqta.org/install/)
This guide will walk you through the process of installing and setting up BetterSEQTA+ for development or usage.
## Prerequisites
@@ -178,5 +180,5 @@ bun run dev
Now that you have BetterSEQTA+ installed, you can:
- [Getting Started with Plugins](./plugins/getting-started.md)
- [Contribute to the project](../CONTRIBUTING.md)
- [Plugins](https://docs.betterseqta.org/plugins/)
- [Contribute to the project](https://docs.betterseqta.org/contributing/) · [Repository CONTRIBUTING.md](../CONTRIBUTING.md)
+4 -2
View File
@@ -1,5 +1,7 @@
# Example Plugin Template
**Published version:** [docs.betterseqta.org/example-plugin/](https://docs.betterseqta.org/example-plugin/)
This is a complete, working example of a simple BetterSEQTA+ plugin. You can copy this code and modify it to create your own plugin!
## What This Example Does
@@ -328,8 +330,8 @@ Once you've got this working:
## Need Help?
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
- 📚 Read our [Plugin Development Guide](./README.md)
- 🐛 Check the [Troubleshooting Guide](../TROUBLESHOOTING.md)
- 📚 Read the [plugin documentation](https://docs.betterseqta.org/plugins/)
- 🐛 Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
- 📝 Open an issue on GitHub
Happy coding! 🎉
+3 -1
View File
@@ -1,5 +1,7 @@
# Creating Plugins for BetterSEQTA+
**Published version:** [docs.betterseqta.org/plugins/](https://docs.betterseqta.org/plugins/) · [Plugin development](https://docs.betterseqta.org/plugin-development/) · [Plugin API](https://docs.betterseqta.org/plugin-api/)
Hey there! 👋 So you want to create a plugin for BetterSEQTA+? That's awesome! This guide will walk you through everything you need to know, from the very basics to more advanced features. Don't worry if you're new to this - we'll explain everything step by step.
## What is a Plugin?
@@ -294,4 +296,4 @@ Got stuck? No worries! Here's where you can get help:
- Check out the built-in plugins in the `src/plugins/built-in` folder
- Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues)
Happy coding and feel free to checkout the api reference [here](./api-reference.md)
Happy coding and feel free to check out the [plugin API](https://docs.betterseqta.org/plugin-api/) on the documentation site.
+3 -1
View File
@@ -1,6 +1,8 @@
# Plugin API Reference
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see [Creating Your First Plugin](./README.md).
**Published version:** [docs.betterseqta.org/plugin-api/](https://docs.betterseqta.org/plugin-api/)
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see the [plugins section](https://docs.betterseqta.org/plugins/) at [docs.betterseqta.org](https://docs.betterseqta.org/).
## Plugin Structure
+43
View File
@@ -0,0 +1,43 @@
import type { Plugin } from "vite";
/**
* Firefox extension pages forbid eval / `Function` constructor. Some deps still emit:
* - `Function(\`return this\`)()` (lodash-style global)
* - `try { return Function(\`\`) / new Function("") … }` (feature probes, e.g. PDF.js / ORT)
*/
export function firefoxStripFunctionProbe(): Plugin {
return {
name: "firefox-strip-function-probe",
apply: "build",
enforce: "post",
generateBundle(_options, bundle) {
if ((process.env.MODE || "chrome").toLowerCase() !== "firefox") return;
const literalReplacements: [string, string][] = [
['try{return new Function(""),!0}catch{return!1}', "return!1"],
["try{return new Function(''),!0}catch{return!1}", "return!1"],
['try{return new Function(""),true}catch{return false}', "return false"],
["try{return new Function(''),true}catch{return false}", "return false"],
// Empty template literal probe (minifier output)
["try{return Function(``),!0}catch{return!1}", "return!1"],
];
for (const chunk of Object.values(bundle)) {
if (chunk.type !== "chunk" || typeof chunk.code !== "string") continue;
let { code } = chunk;
code = code.replace(/Function\(`return this`\)\(\)/g, "(globalThis)");
code = code.replace(/Function\("return this"\)\(\)/g, "(globalThis)");
code = code.replace(/Function\('return this'\)\(\)/g, "(globalThis)");
for (const [from, to] of literalReplacements) {
if (code.includes(from)) {
code = code.split(from).join(to);
}
}
chunk.code = code;
}
},
};
}
+24 -12
View File
@@ -1,10 +1,11 @@
{
"name": "betterseqtaplus",
"version": "3.4.15",
"version": "3.7.0",
"type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
"browserslist": "> 0.5%, last 2 versions, not dead",
"scripts": {
"postinstall": "node scripts/copy-pdfjs-assets.mjs",
"autoaudit": "npm audit && npm audit fix && npm run build",
"dev": "cross-env MODE=chrome vite dev",
"dev:firefox": "cross-env MODE=firefox vite build --watch",
@@ -16,7 +17,8 @@
"build:dev": "cross-env MODE=chrome SOURCEMAP=true vite build && cross-env MODE=firefox SOURCEMAP=true vite build",
"convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari",
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
"lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint \"src/**/*.{js,ts}\"",
"release": "gh release create $npm_package_version --repo BetterSEQTA/BetterSEQTA-Plus ./dist/*.zip --generate-notes",
"publish": "bun lib/publish.js --b",
"zip": "bedframe zip"
},
@@ -31,25 +33,30 @@
"author": {
"name": "SethBurkart123",
"email": "betterseqta.plus@gmail.com",
"url": "https://github.com/BetterSEQTA/BetterSEQTA-plus"
"url": "https://github.com/BetterSEQTA/BetterSEQTA-Plus"
},
"license": "MIT",
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.95",
"@crxjs/vite-plugin": "^2.2.0",
"@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.4.0",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.1.8",
"@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"cross-env": "^10.0.0",
"dependency-cruiser": "^17.0.1",
"eslint": "^9.33.0",
"eslint-plugin-import": "^2.31.0",
"glob": "^11.0.1",
"mime-types": "^3.0.1",
"prettier": "^3.5.3",
"process": "^0.11.10",
"publish-browser-extension": "^4.0.0",
"publish-browser-extension": "^4.0.4",
"sass": "^1.85.1",
"sass-loader": "^16.0.5",
"semver": "^7.7.1",
@@ -57,7 +64,7 @@
"url": "^0.11.4"
},
"dependencies": {
"@bedframe/core": "^0.0.46",
"@bedframe/core": "^0.1.0",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1",
@@ -65,13 +72,14 @@
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.1.4",
"@types/color": "^4.2.0",
"@types/lodash": "^4.17.16",
"@types/node": "^24.3.0",
"@types/qrcode": "^1.5.6",
"@types/sortablejs": "^1.15.8",
"@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.3",
@@ -81,8 +89,10 @@
"canvas-confetti": "^1.9.3",
"codemirror": "^6.0.1",
"color": "^5.0.0",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"dompurify": "^3.2.4",
"embeddia": "^1.2.1",
"embeddia": "^1.3.0",
"embla-carousel-autoplay": "^8.5.2",
"embla-carousel-svelte": "^8.5.2",
"esbuild": "^0.25.3",
@@ -90,6 +100,7 @@
"flexsearch": "^0.8.147",
"fuse.js": "^7.1.0",
"idb": "^8.0.2",
"layerchart": "2.0.0-next.27",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"mathjs": "^14.4.0",
@@ -97,15 +108,16 @@
"motion": "^12.4.12",
"pdfjs-dist": "^5.4.530",
"postcss": "^8.5.3",
"qrcode": "^1.5.4",
"react": "17",
"react-best-gradient-color-picker": "3.0.11",
"react-dom": "17",
"rss-parser": "^3.13.0",
"sortablejs": "^1.15.6",
"svelte": "^5.22.6",
"svelte": "^5.46.4",
"typescript": "^5.8.2",
"uuid": "^11.1.0",
"vite": "^6.2.1",
"vite": "^8.0.5",
"webextension-polyfill": "^0.12.0"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { copyFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const pdfjsRoot = join(root, "node_modules", "pdfjs-dist");
const outDir = join(root, "src", "public", "resources", "pdfjs");
mkdirSync(outDir, { recursive: true });
copyFileSync(
join(pdfjsRoot, "build", "pdf.worker.min.mjs"),
join(outDir, "pdf.worker.min.mjs"),
);
copyFileSync(
join(pdfjsRoot, "legacy", "build", "pdf.min.mjs"),
join(outDir, "pdf.legacy.min.mjs"),
);
+36 -1
View File
@@ -11,6 +11,30 @@ import { main } from "@/seqta/main";
import { delay } from "./seqta/utils/delay";
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
function registerFetchSeqtaAppLinkListener() {
browser.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (request?.type !== "fetchSeqtaAppLink") return false;
void (async () => {
try {
const res = await fetch(`${location.origin}/seqta/student/load/profile`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({}),
});
const data = await res.json();
const statusOk = data?.status === "200" || data?.status === 200;
const raw = data?.payload?.app_link;
const appLink = typeof raw === "string" && raw.length > 0 ? raw : null;
sendResponse({ appLink: statusOk ? appLink : null });
} catch {
sendResponse({ appLink: null });
}
})();
return true;
});
}
export let MenuOptionsOpen = false;
var IsSEQTAPage = false;
@@ -26,10 +50,21 @@ if (document.childNodes[1]) {
}
async function init() {
if (hasSEQTAText && document.title.includes("SEQTA Learn") && !IsSEQTAPage) {
if (
hasSEQTAText &&
(document.title.includes("SEQTA Learn") ||
document.title.includes("SEQTA Engage")) &&
!IsSEQTAPage
) {
IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page");
if (typeof window !== "undefined" && window === window.top) {
void browser.runtime.sendMessage({ type: "cloudSettingsPoll" }).catch(() => {});
}
registerFetchSeqtaAppLinkListener();
const documentLoadStyle = document.createElement("style");
documentLoadStyle.textContent = documentLoadCSS;
document.head.appendChild(documentLoadStyle);
+579 -38
View File
@@ -1,12 +1,38 @@
import browser from "webextension-polyfill";
import semver from "semver";
import type { SettingsState } from "@/types/storage";
import { fetchNews } from "./background/news";
import {
initCloudSettingsAutoSync,
performCloudSettingsDownloadWithRetry,
performCloudSettingsUploadWithRetry,
requestCloudSettingsDebouncedUpload,
runCloudSettingsPoll,
} from "./background/cloudSettingsAutoSync";
/**
* Session-only dev-mode override of the content API base.
*
* Stored in a module-level variable (not `chrome.storage`) so it is wiped
* automatically when the browser/service-worker process restarts. Content
* scripts re-sync this on every page load via `setDevApiBase` so the value
* survives transient service-worker terminations within the same browser
* session.
*/
const DEFAULT_API_BASE = "https://betterseqta.org";
let DEV_API_BASE: string | null = null;
function apiBase(): string {
return DEV_API_BASE ?? DEFAULT_API_BASE;
}
function reloadSeqtaPages() {
const result = browser.tabs.query({});
function open(tabs: any) {
for (let tab of tabs) {
if (tab.title.includes("SEQTA Learn")) {
if (
tab.title?.includes("SEQTA Learn") ||
tab.title?.includes("SEQTA Engage")
) {
browser.tabs.reload(tab.id);
}
}
@@ -14,52 +40,439 @@ function reloadSeqtaPages() {
result.then(open, console.error);
}
// @ts-ignore
browser.runtime.onMessage.addListener(
(request: any, _: any, sendResponse: (response?: any) => void) => {
switch (request.type) {
case "reloadTabs":
reloadSeqtaPages();
break;
/** Callback for sending a response back to the message sender */
type MessageSender = { (response?: unknown): void };
case "extensionPages":
browser.tabs.query({}).then(function (tabs) {
for (let tab of tabs) {
if (tab.url?.includes("chrome-extension://")) {
browser.tabs.sendMessage(tab.id!, request);
}
}
/** Accept API + GitHub fallback shapes; always return `{ success, data?: { themes } }`. */
function normalizeFetchThemesResponse(json: unknown): {
success: boolean;
data?: { themes: unknown[] };
error?: string;
} {
if (!json || typeof json !== "object") {
return { success: false, error: "Invalid themes response" };
}
const body = json as Record<string, unknown>;
if (body.success === false) {
return {
success: false,
error: typeof body.error === "string" ? body.error : "Failed to fetch themes",
};
}
const data = body.data;
let themes: unknown[] | null = null;
if (data && typeof data === "object" && !Array.isArray(data)) {
const nested = (data as Record<string, unknown>).themes;
if (Array.isArray(nested)) themes = nested;
} else if (Array.isArray(data)) {
themes = data;
}
if (!themes && Array.isArray(body.themes)) {
themes = body.themes;
}
if (!themes) {
return { success: false, error: "Themes list missing from response" };
}
return { success: true, data: { themes } };
}
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
const { token } = request;
const apiUrl = `${apiBase()}/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(async (r) => {
const json = await r.json();
if (!r.ok) {
throw new Error(
(json && typeof json === "object" && "error" in json && typeof (json as { error?: string }).error === "string"
? (json as { error: string }).error
: null) ?? `Themes API HTTP ${r.status}`,
);
}
return normalizeFetchThemesResponse(json);
})
.then(sendResponse)
.catch((err) => {
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
fetch(githubUrl, { cache: "no-store" })
.then(async (r) => {
if (!r.ok) throw new Error(`GitHub fallback HTTP ${r.status}`);
const data = await r.json();
const themes = Array.isArray(data) ? data : (data?.themes ?? []);
return normalizeFetchThemesResponse({ success: true, data: { themes } });
})
.then(sendResponse)
.catch((fallbackErr) => {
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
sendResponse({ success: false, error: fallbackErr?.message });
});
break;
});
return true;
}
case "currentTab":
browser.tabs
.query({ active: true, currentWindow: true })
.then(function (tabs) {
browser.tabs
.sendMessage(tabs[0].id!, request)
.then(function (response) {
sendResponse(response);
function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boolean {
const { themeId, token } = request;
if (!themeId || typeof themeId !== "string") {
sendResponse({ success: false, error: "Missing themeId" });
return false;
}
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers })
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.error("[Background] fetchThemeDetails error:", err);
sendResponse({ success: false, error: err?.message });
});
return true;
}
function handleFetchFromUrl(request: any, sendResponse: MessageSender): boolean {
const { url } = request;
if (!url || typeof url !== "string") {
sendResponse({ error: "Missing url" });
return false;
}
fetch(url, { cache: "no-store" })
.then((r) => r.json())
.then((data) => sendResponse({ data }))
.catch((err) => {
console.error("[Background] fetchFromUrl error:", err);
sendResponse({ error: err?.message });
});
return true;
}
async function parseJsonResponse(r: Response): Promise<any> {
const text = await r.text();
try {
return text ? JSON.parse(text) : {};
} catch {
return {};
}
}
function handleCloudReserveClient(request: any, sendResponse: MessageSender): boolean {
const redirect_uri = request.redirect_uri ?? "https://accounts.betterseqta.org/auth/bsplus/callback";
fetch("https://accounts.betterseqta.org/api/bsplus/client/reserve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ redirect_uri }),
})
.then(async (r) => {
const data = await parseJsonResponse(r);
if (!r.ok) sendResponse({ error: data?.error ?? `Reserve failed (${r.status})` });
else sendResponse(data);
})
.catch((err) => {
console.error("[Background] cloudReserveClient error:", err);
sendResponse({ error: err?.message ?? "Network error" });
});
return true;
}
function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
const { client_id, redirect_uri, login, password } = request;
if (!client_id || !redirect_uri || !login || !password) {
sendResponse({ error: "Missing client_id, redirect_uri, login, or password" });
return false;
}
fetch("https://accounts.betterseqta.org/api/bsplus/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id, redirect_uri, login, password }),
})
.then(async (r) => {
const data = await parseJsonResponse(r);
if (!r.ok) sendResponse({ error: data?.error ?? "Login failed" });
else sendResponse(data);
})
.catch((err) => {
console.error("[Background] cloudLogin error:", err);
sendResponse({ error: err?.message ?? "Network error" });
});
return true;
}
function handleCloudStartLogin(request: any, sendResponse: MessageSender): boolean {
const { client_id, redirect_uri } = request;
if (!client_id || !redirect_uri) {
sendResponse({ error: "Missing client_id or redirect_uri" });
return true;
}
const authorizeUrl = `https://accounts.betterseqta.org/login?redirect=${encodeURIComponent(`/oauth/authorize?client_id=${client_id}&redirect_uri=${encodeURIComponent(redirect_uri)}`)}`;
browser.tabs.create({ url: authorizeUrl }).then(() => {
sendResponse({ success: true });
}).catch((err) => {
console.error("[Background] cloudStartLogin error:", err);
sendResponse({ error: err?.message ?? "Failed to open login page" });
});
return true;
}
const CALLBACK_URL_PREFIX = "https://accounts.betterseqta.org/auth/bsplus/callback";
function initCloudLoginCallbackListener() {
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.url && changeInfo.url.startsWith(CALLBACK_URL_PREFIX)) {
try {
const url = new URL(changeInfo.url);
const token = url.searchParams.get("token");
const refreshToken = url.searchParams.get("refresh_token");
const userId = url.searchParams.get("user_id");
if (token && refreshToken) {
// Store tokens
void (async () => {
try {
await browser.storage.local.set({
bsplus_token: token,
bsplus_refresh_token: refreshToken,
});
});
return true;
case "githubTab":
browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
break;
// Fetch full user info
const userRes = await fetch("https://accounts.betterseqta.org/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});
if (userRes.ok) {
const user = await userRes.json();
await browser.storage.local.set({ bsplus_user: user });
} else if (userId) {
await browser.storage.local.set({ bsplus_user: { id: userId } });
}
case "setDefaultStorage":
SetStorageValue(getDefaultValues());
break;
// Trigger cloud settings download
void performCloudSettingsDownloadWithRetry(token).catch((err) => {
console.warn("[Background] Cloud settings download after login:", err);
});
} catch (err) {
console.error("[Background] Failed to process login callback:", err);
}
})();
case "sendNews":
fetchNews(request.source ?? "australia", sendResponse);
return true;
default:
console.log("Unknown request type");
// Close the callback tab
void browser.tabs.remove(tabId);
}
} catch (err) {
console.error("[Background] Error parsing callback URL:", err);
}
}
});
}
initCloudLoginCallbackListener();
function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
const { refresh_token, client_id } = request;
if (!refresh_token || !client_id) {
sendResponse({ error: "Missing refresh_token or client_id" });
return false;
}
fetch("https://accounts.betterseqta.org/api/bsplus/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token, client_id }),
})
.then(async (r) => {
const data = await parseJsonResponse(r);
if (!r.ok) sendResponse({ error: data?.error ?? "Refresh failed" });
else sendResponse(data);
})
.catch((err) => {
console.error("[Background] cloudRefresh error:", err);
sendResponse({ error: err?.message ?? "Network error" });
});
return true;
}
function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const res = await performCloudSettingsUploadWithRetry(token);
sendResponse({
success: res.success,
error: res.error,
updated_at: res.updated_at,
});
} catch (err) {
console.error("[Background] cloudSettingsUpload error:", err);
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Upload failed",
});
}
})();
return true;
}
function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const res = await performCloudSettingsDownloadWithRetry(token);
sendResponse({
success: res.success,
notFound: res.notFound,
error: res.error,
updated_at: res.updated_at,
});
} catch (err) {
console.error("[Background] cloudSettingsDownload error:", err);
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Download failed",
});
}
})();
return true;
}
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
const { themeId, token, action } = request;
if (!themeId || !token) {
sendResponse({ success: false, error: "Theme ID and token required" });
return false;
}
const isFavorite = action === "favorite";
fetch(`${apiBase()}/api/themes/${themeId}/favorite`, {
method: isFavorite ? "POST" : "DELETE",
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.error("[Background] cloudFavorite error:", err);
sendResponse({ success: false, error: err?.message });
});
return true;
}
/** Handler for a message type; receives request, sendResponse, and optional sender (for tab routing) */
type MessageHandler = {
(request: any, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender): boolean | void;
};
function isSeqtaOrigin(origin: string): boolean {
try {
const u = new URL(origin);
return u.hostname.includes("seqta") || u.hostname.endsWith(".edu.au");
} catch {
return false;
}
}
function handleSetDevApiBase(request: any): boolean {
const url = typeof request?.url === "string" ? request.url.trim() : null;
if (url && /^https?:\/\//.test(url)) {
DEV_API_BASE = url.replace(/\/$/, "");
} else {
DEV_API_BASE = null;
}
return false;
}
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
reloadTabs: () => reloadSeqtaPages(),
setDevApiBase: handleSetDevApiBase,
extensionPages: (req) => {
browser.tabs.query({}).then((tabs) => {
for (const tab of tabs) {
if (tab.url?.includes("chrome-extension://")) browser.tabs.sendMessage(tab.id!, req);
}
});
},
currentTab: (req, sendResponse) => {
browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => {
browser.tabs.sendMessage(tabs[0].id!, req).then(sendResponse);
});
return true;
},
githubTab: () => {
void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
},
setDefaultStorage: () => SetStorageValue(getDefaultValues()),
sendNews: (req, sendResponse) => {
fetchNews(req.source ?? "australia", sendResponse);
return true;
},
fetchThemes: handleFetchThemes,
fetchThemeDetails: handleFetchThemeDetails,
fetchFromUrl: handleFetchFromUrl,
cloudReserveClient: handleCloudReserveClient,
cloudLogin: handleCloudLogin,
cloudStartLogin: handleCloudStartLogin,
cloudRefresh: handleCloudRefresh,
cloudFavorite: handleCloudFavorite,
cloudSettingsUpload: handleCloudSettingsUpload,
cloudSettingsDownload: handleCloudSettingsDownload,
cloudSettingsPoll: () => {
void runCloudSettingsPoll();
return false;
},
cloudSettingsRequestDebouncedUpload: () => {
requestCloudSettingsDebouncedUpload();
return false;
},
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
(async () => {
try {
let tabId = sender?.tab?.id;
let originForCheck: string | undefined = req.baseUrl;
if (tabId == null) {
const tabs = await browser.tabs.query({ active: true, lastFocusedWindow: true });
const tab = tabs[0];
if (!tab?.id || !tab.url) {
sendResponse({ appLink: null });
return;
}
tabId = tab.id;
if (!originForCheck) originForCheck = new URL(tab.url).origin;
} else if (!originForCheck && sender?.tab?.url) {
originForCheck = new URL(sender.tab.url).origin;
}
if (!originForCheck || !isSeqtaOrigin(originForCheck)) {
sendResponse({ appLink: null });
return;
}
const reply = (await browser.tabs.sendMessage(tabId, { type: "fetchSeqtaAppLink" })) as
| { appLink?: string | null }
| undefined;
const appLink = typeof reply?.appLink === "string" && reply.appLink.length > 0 ? reply.appLink : null;
sendResponse({ appLink });
} catch (err) {
console.error("[Background] getSeqtaSession error:", err);
sendResponse({ appLink: null });
}
})();
return true;
},
};
browser.runtime.onMessage.addListener(
// @ts-ignore - OnMessageListener expects literal true for async, we return boolean
(request: any, sender: browser.Runtime.MessageSender, sendResponse: MessageSender) => {
const handler = MESSAGE_HANDLERS[request.type];
if (handler) {
const result = handler(request, sendResponse, sender);
return result === true;
}
console.log("Unknown request type");
return false;
},
);
@@ -127,6 +540,12 @@ function getDefaultValues(): SettingsState {
customshortcuts: [],
lettergrade: false,
newsSource: "australia",
iconOnlySidebar: false,
adaptiveThemeColour: false,
adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true,
themeOfTheMonthDisabled: false,
autoCloudSettingsSync: true,
};
}
@@ -136,6 +555,120 @@ function SetStorageValue(object: any) {
}
}
/** One-time migration for 3.6.5: opt upgraders into Global Search + indexing + transparency defaults. */
const GLOBAL_SEARCH_PLUGIN_SETTINGS_KEY = "plugin.global-search.settings";
const GLOBAL_SEARCH_MIGRATION_VERSION = "3.6.5";
async function migrateGlobalSearchDefaultsFor365Upgrade(
previousVersion: string,
): Promise<void> {
try {
const currRaw = browser.runtime.getManifest().version;
const prev = semver.coerce(previousVersion);
const curr = semver.coerce(currRaw);
if (
prev == null ||
curr == null ||
semver.lt(curr, GLOBAL_SEARCH_MIGRATION_VERSION) ||
!semver.lt(prev, GLOBAL_SEARCH_MIGRATION_VERSION)
) {
return;
}
const got = await browser.storage.local.get(GLOBAL_SEARCH_PLUGIN_SETTINGS_KEY);
const existing = (got[GLOBAL_SEARCH_PLUGIN_SETTINGS_KEY] ?? {}) as Record<
string,
unknown
>;
await browser.storage.local.set({
[GLOBAL_SEARCH_PLUGIN_SETTINGS_KEY]: {
...existing,
enabled: true,
transparencyEffects: true,
runIndexingOnLoad: true,
passiveIndexing: true,
},
});
console.info(
`[BetterSEQTA+] Migration ${GLOBAL_SEARCH_MIGRATION_VERSION}: Global Search and related settings enabled (from ${previousVersion}).`,
);
} catch (e) {
console.warn("[BetterSEQTA+] Global Search 3.6.5 settings migration failed:", e);
}
}
/** One-time reset for 3.6.6: re-enable Theme of the Month for existing users. */
const THEME_OF_THE_MONTH_RESET_VERSION = "3.6.6";
async function resetThemeOfTheMonthDisabledFor366Upgrade(
previousVersion: string,
): Promise<void> {
try {
const currRaw = browser.runtime.getManifest().version;
const prev = semver.coerce(previousVersion);
const curr = semver.coerce(currRaw);
if (
prev == null ||
curr == null ||
semver.lt(curr, THEME_OF_THE_MONTH_RESET_VERSION) ||
!semver.lt(prev, THEME_OF_THE_MONTH_RESET_VERSION)
) {
return;
}
await browser.storage.local.set({
themeOfTheMonthDisabled: false,
themeOfTheMonthLastSeenId: undefined,
});
console.info(
`[BetterSEQTA+] Migration ${THEME_OF_THE_MONTH_RESET_VERSION}: Theme of the Month re-enabled (from ${previousVersion}).`,
);
} catch (e) {
console.warn(
"[BetterSEQTA+] Theme of the Month 3.6.6 reset migration failed:",
e,
);
}
}
/** 3.7.0: Close no longer marks entries seen — clear legacy dismissal keys. */
const THEME_OF_THE_MONTH_RELOAD_VERSION = "3.7.0";
async function resetThemeOfTheMonthDismissalFor370Upgrade(
previousVersion: string,
): Promise<void> {
try {
const currRaw = browser.runtime.getManifest().version;
const prev = semver.coerce(previousVersion);
const curr = semver.coerce(currRaw);
if (
prev == null ||
curr == null ||
semver.lt(curr, THEME_OF_THE_MONTH_RELOAD_VERSION) ||
!semver.lt(prev, THEME_OF_THE_MONTH_RELOAD_VERSION)
) {
return;
}
await browser.storage.local.set({
themeOfTheMonthLastSeenId: undefined,
themeOfTheMonthDismissedMonth: undefined,
});
console.info(
`[BetterSEQTA+] Migration ${THEME_OF_THE_MONTH_RELOAD_VERSION}: Theme of the Month shows again until dismissed for the month (from ${previousVersion}).`,
);
} catch (e) {
console.warn(
"[BetterSEQTA+] Theme of the Month 3.7.0 dismissal migration failed:",
e,
);
}
}
browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.remove(["justupdated"]);
browser.storage.local.remove(["data"]);
@@ -143,4 +676,12 @@ browser.runtime.onInstalled.addListener(function (event) {
if (event.reason == "install" || event.reason == "update") {
browser.storage.local.set({ justupdated: true });
}
if (event.reason === "update" && event.previousVersion) {
void migrateGlobalSearchDefaultsFor365Upgrade(event.previousVersion);
void resetThemeOfTheMonthDisabledFor366Upgrade(event.previousVersion);
void resetThemeOfTheMonthDismissalFor370Upgrade(event.previousVersion);
}
});
initCloudSettingsAutoSync({ reloadSeqtaPages });
+420
View File
@@ -0,0 +1,420 @@
import browser from "webextension-polyfill";
import {
applyDownloadedEnvelope,
buildUploadPayload,
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
isKeyIncludedInCloudUploadPayload,
resolveThemeIdForPostSyncDownload,
setKnownRemoteUpdatedAt,
} from "@/seqta/utils/cloudSettingsSync";
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
export const CLOUD_SUMMARY_URL = `${ACCOUNTS_BASE}/api/user/cloud-summary`;
const CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`;
const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`;
const UPLOAD_DEBOUNCE_MS = 2000;
const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000;
const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll";
type CloudSummaryResponse = {
desqta?: unknown;
bsplus?: { updated_at: string; schemaVersion: number } | null;
};
let reloadSeqtaPagesFn: (() => void) | null = null;
let suppressAutoUploadDuringRestore = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let pollInFlight: Promise<void> | null = null;
function isAutoCloudSyncEnabled(all: Record<string, unknown>): boolean {
return all.autoCloudSettingsSync !== false;
}
async function parseJsonResponse(r: Response): Promise<any> {
const text = await r.text();
try {
return text ? JSON.parse(text) : {};
} catch {
return {};
}
}
async function getAccessToken(): Promise<string | null> {
const { bsplus_token } = await browser.storage.local.get("bsplus_token");
return typeof bsplus_token === "string" && bsplus_token.length > 0 ? bsplus_token : null;
}
async function tryRefreshTokens(): Promise<boolean> {
const result = await browser.storage.local.get([
"bsplus_refresh_token",
"bsplus_client_id",
"bsplus_user",
]);
const refresh_token = result.bsplus_refresh_token as string | undefined;
const client_id = result.bsplus_client_id as string | undefined;
if (!refresh_token || !client_id) return false;
try {
const r = await fetch(REFRESH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token, client_id }),
});
const data = await parseJsonResponse(r);
if (!r.ok || !data.access_token || !data.refresh_token) return false;
await browser.storage.local.set({
bsplus_token: data.access_token,
bsplus_refresh_token: data.refresh_token,
bsplus_user: data.user ?? result.bsplus_user,
});
return true;
} catch {
return false;
}
}
function isServerTimestampNewer(serverIso: string, localIso: string | undefined): boolean {
const a = Date.parse(serverIso);
if (Number.isNaN(a)) return false;
if (localIso === undefined || localIso === "") return true;
const b = Date.parse(localIso);
if (Number.isNaN(b)) return true;
return a > b;
}
async function fetchCloudSummaryOnce(
token: string,
): Promise<
| { ok: true; data: CloudSummaryResponse }
| { ok: false; unauthorized: boolean; error?: string }
> {
try {
const r = await fetch(CLOUD_SUMMARY_URL, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = (await parseJsonResponse(r)) as CloudSummaryResponse;
if (r.status === 401) return { ok: false, unauthorized: true };
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: (data as { error?: string })?.error ?? `Summary failed (${r.status})`,
};
}
return { ok: true, data };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Network error",
};
}
}
async function fetchCloudSummaryWithAuthRetry(
token: string,
): Promise<CloudSummaryResponse | null> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await fetchCloudSummaryOnce(t);
if (res.ok) return res.data;
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) break;
const next = await getAccessToken();
if (!next) break;
t = next;
continue;
}
if (res.error) console.warn("[BS+ cloud sync] cloud-summary:", res.error);
break;
}
return null;
}
type PutResult =
| { ok: true; updated_at?: string }
| { ok: false; unauthorized: boolean; error?: string };
async function putSettingsOnce(token: string): Promise<PutResult> {
try {
const all = await browser.storage.local.get();
const payload = buildUploadPayload(all as Record<string, unknown>);
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await parseJsonResponse(r);
if (r.status === 401) return { ok: false, unauthorized: true };
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: data?.error ?? `Upload failed (${r.status})`,
};
}
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Upload failed",
};
}
}
export async function performCloudSettingsUploadWithRetry(
token: string,
): Promise<{ success: boolean; error?: string; updated_at?: string }> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await putSettingsOnce(t);
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
const next = await getAccessToken();
if (!next) return { success: false, error: "Not authenticated" };
t = next;
continue;
}
return { success: false, error: res.error ?? "Upload failed" };
}
return { success: false, error: "Upload failed" };
}
type GetResult =
| { ok: true; updated_at?: string }
| { ok: false; notFound?: boolean; unauthorized: boolean; error?: string };
async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
try {
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = await parseJsonResponse(r);
if (r.status === 401) return { ok: false, unauthorized: true };
if (r.status === 404) {
return {
ok: false,
notFound: true,
unauthorized: false,
error: "No settings backup found in the cloud",
};
}
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: data?.error ?? `Download failed (${r.status})`,
};
}
const themeIdToEnsure = resolveThemeIdForPostSyncDownload(data);
await applyDownloadedEnvelope(data);
if (themeIdToEnsure) {
await browser.storage.local.set({
[BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY]: themeIdToEnsure,
});
} else {
await browser.storage.local.remove(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
}
reloadSeqtaPagesFn?.();
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Download failed",
};
}
}
export async function performCloudSettingsDownloadWithRetry(
token: string,
): Promise<{ success: boolean; notFound?: boolean; error?: string; updated_at?: string }> {
suppressAutoUploadDuringRestore = true;
try {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await getSettingsAndApplyOnce(t);
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
const next = await getAccessToken();
if (!next) return { success: false, error: "Not authenticated" };
t = next;
continue;
}
return {
success: false,
notFound: res.notFound,
error: res.error ?? "Download failed",
};
}
return { success: false, error: "Download failed" };
} finally {
suppressAutoUploadDuringRestore = false;
}
}
async function maybeUploadBaseline(token: string): Promise<void> {
const res = await performCloudSettingsUploadWithRetry(token);
if (!res.success) {
console.warn("[BS+ cloud sync] Baseline upload failed:", res.error);
}
}
async function downloadIfNeeded(token: string): Promise<void> {
const res = await performCloudSettingsDownloadWithRetry(token);
if (!res.success && !res.notFound) {
console.warn("[BS+ cloud sync] Auto-download failed:", res.error);
}
}
async function runCloudSettingsPollInner(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
let token = await getAccessToken();
if (!token) return;
const summary = await fetchCloudSummaryWithAuthRetry(token);
if (!summary) return;
const bsplus = summary.bsplus;
const watermark = all[BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as string | undefined;
if (
bsplus &&
typeof bsplus.schemaVersion === "number" &&
bsplus.schemaVersion > CLOUD_SETTINGS_SYNC_SCHEMA_VERSION
) {
console.warn(
"[BS+ cloud sync] Server schemaVersion newer than client; skip auto-download",
);
return;
}
token = (await getAccessToken()) ?? token;
if (!watermark) {
if (!bsplus?.updated_at) {
await maybeUploadBaseline(token);
return;
}
await downloadIfNeeded(token);
return;
}
if (!bsplus?.updated_at) return;
if (isServerTimestampNewer(bsplus.updated_at, watermark)) {
await downloadIfNeeded(token);
}
}
export function runCloudSettingsPoll(): Promise<void> {
if (pollInFlight) return pollInFlight;
pollInFlight = (async () => {
try {
const { [POLL_THROTTLE_KEY]: last } = await browser.storage.local.get(POLL_THROTTLE_KEY);
if (Date.now() - (Number(last) || 0) < POLL_THROTTLE_MS) return;
await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() });
await runCloudSettingsPollInner();
} catch (e) {
console.error("[BS+ cloud sync] Poll error:", e);
} finally {
pollInFlight = null;
}
})();
return pollInFlight;
}
function clearUploadDebounce(): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
}
function scheduleDebouncedUpload(): void {
if (suppressAutoUploadDuringRestore) return;
clearUploadDebounce();
debounceTimer = setTimeout(() => {
debounceTimer = null;
void runDebouncedUploadJob();
}, UPLOAD_DEBOUNCE_MS);
}
/** Call after store theme install (and similar) so cloud upload runs even if storage events are flaky. */
export function requestCloudSettingsDebouncedUpload(): void {
void (async () => {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
if (suppressAutoUploadDuringRestore) return;
if (!(await getAccessToken())) return;
scheduleDebouncedUpload();
})();
}
async function runDebouncedUploadJob(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
const token = await getAccessToken();
if (!token) return;
const res = await performCloudSettingsUploadWithRetry(token);
if (!res.success) {
console.warn("[BS+ cloud sync] Auto-upload failed:", res.error);
}
}
async function syncAutoUploadWithStorage(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) {
clearUploadDebounce();
}
}
function onStorageChanged(
changes: Record<string, browser.storage.StorageChange>,
area: string,
): void {
if (area !== "local") return;
if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) {
void syncAutoUploadWithStorage();
}
const keys = Object.keys(changes);
if (!keys.some((k) => isKeyIncludedInCloudUploadPayload(k))) return;
void (async () => {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
if (suppressAutoUploadDuringRestore) return;
if (!(await getAccessToken())) return;
scheduleDebouncedUpload();
})();
}
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
browser.storage.onChanged.addListener(onStorageChanged);
}
+13 -12
View File
@@ -92,8 +92,12 @@ const rssFeedsByCountry: Record<string, string[]> = {
* used to send the fetched news data back to the caller.
* It's called with an object like `{ news: { articles: [...] } }`.
*/
export async function fetchNews(source: string, sendResponse: any) {
if (source === "australia") {
export async function fetchNews(source: string | undefined, sendResponse: any) {
const normalizedSource = typeof source === "string" && source.trim()
? source.trim()
: "australia";
if (normalizedSource === "australia") {
const date = new Date();
const from =
@@ -111,18 +115,15 @@ export async function fetchNews(source: string, sendResponse: any) {
const parser = new Parser();
let feeds: string[];
console.log("fetchNews", source);
console.log("fetchNews", normalizedSource);
if (rssFeedsByCountry[source.toLowerCase()]) {
// If the source is a country, fetch from predefined feeds
feeds = rssFeedsByCountry[source.toLowerCase()];
} else if (source.startsWith("http")) {
// If the source is a URL, use it directly
feeds = [source];
if (rssFeedsByCountry[normalizedSource.toLowerCase()]) {
feeds = rssFeedsByCountry[normalizedSource.toLowerCase()];
} else if (normalizedSource.startsWith("http")) {
feeds = [normalizedSource];
} else {
throw new Error(
"Invalid source. Provide a country code or a valid RSS feed URL.",
);
console.warn("[BetterSEQTA+] Invalid news source, falling back to Australia", normalizedSource);
return fetchNews("australia", sendResponse);
}
const articlesPromises = feeds.map(async (feedUrl) => {
+24
View File
@@ -26,6 +26,30 @@
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 {
.legacy-root,
.legacy-root * {
font-family: var(--betterseqta-font-family, Rubik), sans-serif !important;
}
.iconFamily,
.iconFamily *,
[class~="iconFamily"],
[class~="iconFamily"] * {
font-family: "IconFamily" !important;
}
}
html {
background: #161616 !important;
background-color: #161616;
+856 -32
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -35,9 +35,19 @@
}
#menu .sub {
transition: transform 0.3s ease;
transition: transform 0.3s ease, left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
#menu > ul:has(li.hasChildren.active) > li.active {
background: transparent !important;
}
/* Icon-only collapsed: submenu slides over narrow icons */
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li::before,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li::before,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li > label,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li > svg,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > label,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > svg {
transform: translateX(-70px);
}
+4
View File
@@ -0,0 +1,4 @@
declare const __ENABLE_GH_RELEASE_UPDATE_CHECK__: boolean;
declare const __GH_RELEASE_REPO__: string;
declare const __UPDATE_CHANNEL__: "stable" | "nightly";
declare const __BUILD_LABEL__: string;
+15 -2
View File
@@ -1,7 +1,20 @@
<script lang="ts">
let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>();
let {
onClick,
text,
disabled = false,
} = $props<{
onClick: () => void;
text: string;
disabled?: boolean;
}>();
</script>
<button onclick={onClick} class='px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg'>
<button
type="button"
onclick={onClick}
disabled={disabled}
class="px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
>
{text}
</button>
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from "svelte";
import { animate } from "motion";
import { delay } from "@/seqta/utils/delay.ts";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
const { hidePanel } = $props<{
hidePanel: () => void;
}>();
let cloudState = $state(cloudAuth.state);
let background = $state<HTMLDivElement | null>(null);
let content = $state<HTMLDivElement | null>(null);
let loginError = $state<string | null>(null);
onMount(() => {
const unsub = cloudAuth.subscribe((s) => {
cloudState = s;
});
if (background && content) {
animate(
background,
{ opacity: [0, 1] },
{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }
);
animate(
content,
{ scale: [0.4, 1], opacity: [0, 1] },
{ type: "spring", stiffness: 400, damping: 30 }
);
}
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closePanel();
};
document.addEventListener("keydown", handleEscapeKey);
return () => {
unsub();
document.removeEventListener("keydown", handleEscapeKey);
};
});
async function closePanel() {
if (!background || !content) return;
animate(
content,
{ scale: [1, 0.4], opacity: [1, 0] },
{ type: "spring", stiffness: 400, damping: 30 }
);
animate(
background,
{ opacity: [1, 0] },
{ ease: [0.4, 0, 0.2, 1] }
);
await delay(400);
hidePanel();
}
function handleBackgroundClick(event: MouseEvent) {
if (event.target === background) closePanel();
}
async function handleSignIn() {
loginError = null;
const result = await cloudAuth.startLogin();
if (result.success) {
closePanel();
} else {
loginError = result.error ?? "Failed to open login page";
}
}
async function handleLogout() {
await cloudAuth.logout();
}
function getInitials(): string {
const u = cloudState.user;
if (!u) return "?";
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
if (u.username) return u.username.slice(0, 2).toUpperCase();
if (u.email) return u.email.slice(0, 2).toUpperCase();
return "?";
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={background}
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50"
onclick={handleBackgroundClick}
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }}
>
<div
bind:this={content}
class="p-5 w-[320px] bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
>
<h3 class="text-lg font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
<p class="mt-0.5 text-sm text-zinc-500 dark:text-zinc-400">Account & sync</p>
<div class="mt-4">
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()}
</div>
{/if}
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-zinc-900 dark:text-white truncate">
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</p>
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
<p class="text-xs text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
{/if}
</div>
</div>
<button
type="button"
onclick={handleLogout}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Sign out
</button>
</div>
{:else}
<div class="flex flex-col gap-3">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Sign in to sync settings across devices, use your cloud profile picture, and more.
</p>
<button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
{#if loginError}
<p class="text-xs text-red-600 dark:text-red-400">{loginError}</p>
{/if}
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,120 @@
<script lang="ts">
import browser from "webextension-polyfill";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import Button from "./Button.svelte";
import Switch from "./Switch.svelte";
let { showDisclaimer } = $props<{
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
}>();
let cloudState = $state(cloudAuth.state);
let busy = $state(false);
let statusMessage = $state<string | null>(null);
let statusError = $state<string | null>(null);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => {
cloudState = s;
});
return unsub;
});
async function upload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsUpload",
token,
})) as { success?: boolean; error?: string };
if (res?.success) {
statusMessage = "Settings uploaded.";
} else {
statusError = res?.error ?? "Upload failed";
}
} catch (e) {
statusError = e instanceof Error ? e.message : "Upload failed";
} finally {
busy = false;
}
}
function promptDownload() {
showDisclaimer(confirmDownload, () => {});
}
async function confirmDownload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsDownload",
token,
})) as { success?: boolean; error?: string; notFound?: boolean };
if (res?.success) {
statusMessage = "Settings restored.";
} else {
statusError = res?.error ?? "Download failed";
}
} catch (e) {
statusError = e instanceof Error ? e.message : "Download failed";
} finally {
busy = false;
}
}
</script>
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-2.5">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-[11px] font-semibold text-zinc-800 dark:text-zinc-100">Automatic sync</p>
<p class="text-[10px] text-zinc-500 dark:text-zinc-400">Syncs settings when SEQTA loads and when you make changes</p>
</div>
<div class="shrink-0">
<Switch
state={$settingsState.autoCloudSettingsSync !== false}
onChange={(isOn: boolean) => (settingsState.autoCloudSettingsSync = isOn)}
/>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button
text={busy ? "Please wait\u2026" : "Upload"}
onClick={upload}
disabled={busy}
/>
<Button
text={busy ? "Please wait\u2026" : "Download"}
onClick={promptDownload}
disabled={busy}
/>
</div>
{#if statusMessage}
<p class="text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
{/if}
{#if statusError}
<p class="text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
{/if}
<p class="text-[10px] text-zinc-400 dark:text-zinc-500">
Passwords and tokens are never synced.
<a
href="https://betterseqta.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 rounded-sm"
>Privacy policy</a>
</p>
</div>
{/if}
@@ -0,0 +1,172 @@
<script lang="ts">
import { fade } from "svelte/transition";
import browser from "webextension-polyfill";
import QRCode from "qrcode";
import { portal } from "../utils/portal";
let showQrModal = $state(false);
let qrDataUrl = $state<string | null>(null);
let appLink = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let isLoading = $state(false);
let isStandalone = $state(false);
function isExtensionPage(): boolean {
return (
window.location.protocol === "chrome-extension:" ||
window.location.protocol === "moz-extension:"
);
}
function isSeqtaUrl(url: string): boolean {
try {
const u = new URL(url);
return u.hostname.includes("seqta") || u.hostname.endsWith(".edu.au");
} catch {
return false;
}
}
function normalizeBaseUrl(url: string): string {
try {
const u = new URL(url);
return u.origin;
} catch {
return url;
}
}
async function getAppLink(): Promise<string | null> {
let baseUrl: string | undefined;
if (isExtensionPage()) {
baseUrl = undefined;
} else {
baseUrl = normalizeBaseUrl(window.location.href);
if (!isSeqtaUrl(baseUrl)) return null;
}
const { appLink: link } = (await browser.runtime.sendMessage({
type: "getSeqtaSession",
baseUrl,
})) as { appLink: string | null };
return link ?? null;
}
async function generateQrCode() {
errorMessage = null;
qrDataUrl = null;
isLoading = true;
try {
isStandalone = isExtensionPage();
const link = await getAppLink();
if (!link) {
if (isStandalone) {
errorMessage =
"Open SEQTA Learn in a tab and log in, then open settings from that tab to generate a QR code.";
} else {
errorMessage = "Please log in to SEQTA Learn first.";
}
return;
}
const dataUrl = await QRCode.toDataURL(link, { width: 256, margin: 2 });
appLink = link;
qrDataUrl = dataUrl;
showQrModal = true;
} catch (err) {
console.error("[ConnectMobileApp] Failed to generate QR:", err);
errorMessage = "Failed to generate QR code. Please try again.";
} finally {
isLoading = false;
}
}
function closeModal() {
showQrModal = false;
qrDataUrl = null;
appLink = null;
errorMessage = null;
}
function openAppLink() {
if (appLink) window.location.href = appLink;
}
function downloadQrImage() {
if (!qrDataUrl) return;
const link = document.createElement("a");
link.href = qrDataUrl;
link.download = "desqta-login-qr.png";
link.click();
}
</script>
<div class="flex flex-col gap-1 items-end">
<button
type="button"
onclick={generateQrCode}
disabled={isLoading}
class="px-5 py-1.5 text-[0.75rem] text-nowrap shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-opacity">
{isLoading ? "Generating..." : "Generate QR"}
</button>
{#if errorMessage}
<p class="text-xs text-right text-amber-600 dark:text-amber-400">{errorMessage}</p>
{/if}
</div>
{#if showQrModal && qrDataUrl}
<div
use:portal
class="fixed cursor-auto inset-0 z-[10000] flex justify-center items-center bg-black/50 {isStandalone ? 'backdrop-blur-sm' : ''}"
role="button"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget) closeModal();
}}
onkeydown={(e) => {
if (e.key === "Escape") closeModal();
}}
transition:fade={{ duration: 150 }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="p-6 mx-4 w-full max-w-sm bg-white rounded-2xl shadow-2xl dark:bg-zinc-800"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}>
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Scan with DesQTA</h2>
<button
type="button"
onclick={closeModal}
class="p-2 rounded-lg transition-colors text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 dark:hover:text-zinc-400 dark:hover:bg-zinc-700"
aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex justify-center p-4 bg-white rounded-xl dark:bg-zinc-900">
<img src={qrDataUrl} alt="SEQTA Learn app link QR code" class="w-64 h-64" />
</div>
<div class="flex flex-col gap-2 mt-4">
<button
type="button"
onclick={openAppLink}
class="px-4 py-2.5 w-full text-sm font-medium text-white bg-indigo-600 rounded-lg transition-colors dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
Sign into DesQTA Desktop
</button>
<button
type="button"
onclick={downloadQrImage}
class="px-4 py-2 w-full text-xs font-medium rounded-lg border transition-colors text-zinc-500 dark:text-zinc-400 border-zinc-200 dark:border-zinc-600 hover:bg-zinc-50 dark:hover:bg-zinc-800/50">
Download QR as image
</button>
</div>
<p class="mt-2 text-sm text-center text-zinc-600 dark:text-zinc-400">
Or scan this QR code with DesQTA on your phone.
</p>
</div>
</div>
{/if}
@@ -0,0 +1,141 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { onMount } from "svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { FONT_PRESETS, DEFAULT_FONT_ID, getFontPreset } from "@/seqta/ui/fonts/presets";
import {
applySelectedFont,
buildFontPreviewCss,
ensureFontPickerFontsLoaded,
} from "@/seqta/ui/fonts/Manager";
import { portal } from "@/interface/utils/portal";
import { syncPageThemeToElement } from "@/interface/utils/syncPageTheme";
import fontPickerStyles from "./fontPickerModal.css?inline";
let { hidePicker } = $props<{ hidePicker: () => void }>();
let rootEl = $state<HTMLElement | null>(null);
let selectedId = $state(getFontPreset($settingsState.selectedFont).id);
let styleEl: HTMLStyleElement | null = null;
function selectFont(id: string) {
selectedId = id;
settingsState.selectedFont = id;
applySelectedFont(id);
}
function resetToDefault() {
selectFont(DEFAULT_FONT_ID);
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) hidePicker();
}
function syncTheme() {
if (rootEl) syncPageThemeToElement(rootEl);
}
onMount(() => {
void ensureFontPickerFontsLoaded();
styleEl = document.getElementById(
"betterseqta-font-picker-styles",
) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = "betterseqta-font-picker-styles";
document.head.appendChild(styleEl);
}
styleEl.textContent = `${fontPickerStyles}\n${buildFontPreviewCss()}`;
syncTheme();
const themeObserver = new MutationObserver(() => syncTheme());
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style", "class"],
});
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === "Escape") hidePicker();
};
document.addEventListener("keydown", handleEscapeKey);
return () => {
themeObserver.disconnect();
document.removeEventListener("keydown", handleEscapeKey);
};
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={rootEl}
use:portal={document.body}
class="bsplus-font-picker-overlay bsplus-font-picker-root"
onclick={handleBackdropClick}
onkeydown={(event) => {
if (event.key === "Enter" || event.key === " ") handleBackdropClick(event as unknown as MouseEvent);
}}
role="presentation"
transition:fade={{ duration: 200 }}
>
<div
class="bsplus-font-picker-dialog"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="font-picker-title"
>
<header class="bsplus-font-picker-header">
<div class="bsplus-font-picker-header-actions">
<button
type="button"
onclick={resetToDefault}
disabled={selectedId === DEFAULT_FONT_ID}
class="bsplus-font-picker-reset"
aria-label="Reset font to default"
>
Reset to default
</button>
<button
type="button"
onclick={hidePicker}
class="bsplus-font-picker-done"
aria-label="Close font picker"
>
Done
</button>
</div>
<div class="bsplus-font-picker-header-text">
<h2 id="font-picker-title" class="bsplus-font-picker-title">
Choose a font
</h2>
<p class="bsplus-font-picker-desc">
Choose a typeface for SEQTA Learn.
</p>
</div>
</header>
<div class="bsplus-font-picker-list">
{#each FONT_PRESETS as preset (preset.id)}
<button
type="button"
onclick={() => selectFont(preset.id)}
class="bsplus-font-picker-option {selectedId === preset.id ? 'is-selected' : ''}"
data-font-id={preset.id}
>
<div class="bsplus-font-picker-option-head">
<span class="bsplus-font-picker-option-name">{preset.name}</span>
{#if selectedId === preset.id}
<span class="bsplus-font-picker-badge">Selected</span>
{/if}
</div>
</button>
{/each}
</div>
</div>
</div>
+54 -12
View File
@@ -8,12 +8,12 @@
let select: HTMLSelectElement;
</script>
<div class="border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 shadow-2xl rounded-xl w-full overflow-clip">
<div class="select-wrapper relative w-full overflow-hidden rounded-2xl border shadow-2xl">
<select
bind:this={select}
value={state}
onchange={() => onChange(select.value)}
class="px-4 py-2 pr-9 text-[0.875rem] font-medium text-black dark:text-white w-full border-none bg-white/80 dark:bg-zinc-800/70 hover:bg-white/90 dark:hover:bg-zinc-800/80 focus:bg-white/90 dark:focus:bg-zinc-800/80 focus:ring-0 rounded-md appearance-none transition-colors"
class="select-input w-full appearance-none border-none bg-transparent px-4 py-2.5 pr-10 text-[0.875rem] font-medium transition-colors"
>
{#each options as option}
<option value={option.value}>
@@ -21,20 +21,62 @@
</option>
{/each}
</select>
<span class="select-icon pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd"></path>
</svg>
</span>
</div>
<style>
/* Make native dropdown list readable on Windows */
select option {
background-color: #ffffff;
color: #111827; /* zinc-900 */
}
:global(.dark) select option {
background-color: #1f2937; /* zinc-800 */
color: #ffffff;
.select-wrapper {
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 72%, transparent);
border-radius: 18px;
color: var(--text-primary);
transition:
background-color 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease,
transform 180ms ease;
}
:global(.dark) div::after {
color: rgba(255, 255, 255, 0.6);
.select-wrapper:hover {
background: color-mix(in srgb, var(--background-primary) 94%, var(--background-secondary) 6%);
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 88%, transparent);
}
.select-wrapper:focus-within {
background: color-mix(in srgb, var(--background-primary) 96%, var(--background-secondary) 4%);
border-color: color-mix(in srgb, var(--text-primary) 22%, var(--theme-offset-bg, var(--background-secondary)) 78%);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent);
}
.select-input {
color: var(--text-primary);
outline: none;
text-overflow: ellipsis;
}
.select-input:hover,
.select-input:focus {
background: transparent;
}
.select-input option {
background: var(--background-primary);
color: var(--text-primary);
}
.select-icon {
color: color-mix(in srgb, var(--text-primary) 60%, transparent);
}
.select-input {
color-scheme: light;
}
:global(.dark) .select-input {
color-scheme: dark;
}
</style>
@@ -0,0 +1,79 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { animate } from "motion";
import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let { onClose } = $props<{ onClose: () => void }>();
let modalElement: HTMLElement;
onMount(() => {
return cloudAuth.subscribe((s) => {
if (s.isLoggedIn) onClose();
});
});
$effect(() => {
if (modalElement) {
animate(
modalElement,
{ scale: [0.9, 1], opacity: [0, 1] },
{ type: "spring", stiffness: 300, damping: 25 },
);
}
});
async function handleSignIn() {
await cloudAuth.startLogin();
}
</script>
<div
class="flex fixed inset-0 z-[99999] justify-center items-center bg-black/50"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onkeydown={(e) => {
if (e.key === "Escape") onClose();
}}
role="button"
tabindex="-1"
transition:fade={{ duration: 150 }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={modalElement}
class="p-4 mx-4 w-full max-w-md max-h-[90vh] overflow-y-auto bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2 class="mb-3 text-xl font-bold text-zinc-900 dark:text-white">
Sign in to favorite themes
</h2>
<p class="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
Sign in to the Theme Store to save favorites across devices, or create an account to get started.
</p>
<button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
<p class="mt-2 text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
<div class="flex justify-end mt-4">
<button
type="button"
onclick={onClose}
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Close
</button>
</div>
</div>
</div>
+1 -1
View File
@@ -9,7 +9,7 @@
let percentage = $derived(((state - min) / (max - min)) * 100);
</script>
<div class="relative mx-auto w-full max-w-lg">
<div class="relative w-full min-w-0">
<input
type="range"
min={min}
@@ -3,8 +3,7 @@
import './TabbedContainer.css';
import { onMount } from 'svelte';
let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>();
let activeTab = $state(0);
let { tabs, activeTab = $bindable(0) } = $props<{ tabs: { title: string, Content: any, props?: any }[]; activeTab?: number }>();
let containerRef: HTMLElement | null = null;
let tabWidth = $state(0);
@@ -0,0 +1,311 @@
/* Font picker — analytics design tokens & components */
.bsplus-font-picker-overlay {
position: fixed;
inset: 0;
z-index: 50000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.25rem;
cursor: pointer;
background: color-mix(in srgb, #000 52%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.bsplus-font-picker-root {
--bsplus-analytics-radius: 16px;
--bsplus-analytics-radius-sm: 12px;
--bsplus-analytics-ease: cubic-bezier(0.4, 0, 0.2, 1);
--bsplus-analytics-surface: var(--background-primary, #ffffff);
--bsplus-analytics-surface-2: var(--background-secondary, #f8fafc);
--bsplus-analytics-text: var(--text-primary, #1a1a1a);
--bsplus-analytics-muted: color-mix(
in srgb,
var(--bsplus-analytics-text) 55%,
transparent
);
--bsplus-analytics-border: color-mix(
in srgb,
var(--theme-offset-bg, var(--background-secondary, #e2e8f0)) 78%,
transparent
);
--bsplus-analytics-shadow: 0 5px 16px 6px rgba(0, 0, 0, 0.12);
--bsplus-analytics-shadow-hover: 0 8px 24px 8px rgba(0, 0, 0, 0.16);
--bsplus-analytics-accent: var(--better-main, #007bff);
font-family: Rubik, system-ui, sans-serif;
font-size: 0.875rem;
color: var(--bsplus-analytics-text);
}
.bsplus-font-picker-root.dark {
--bsplus-analytics-shadow: 0 5px 20px 6px rgba(0, 0, 0, 0.45);
--bsplus-analytics-shadow-hover: 0 10px 28px 10px rgba(0, 0, 0, 0.55);
}
@keyframes bsplus-font-picker-in {
from {
opacity: 0;
transform: translateY(18px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.bsplus-font-picker-dialog {
width: min(100%, 22rem);
max-height: min(88vh, 820px);
display: flex;
flex-direction: column;
overflow: hidden;
cursor: auto;
border-radius: var(--bsplus-analytics-radius);
background: var(--bsplus-analytics-surface);
border: 1px solid var(--bsplus-analytics-border);
box-shadow: var(--bsplus-analytics-shadow-hover);
animation: bsplus-font-picker-in 0.45s var(--bsplus-analytics-ease) forwards;
}
@media (min-width: 640px) {
.bsplus-font-picker-dialog {
width: min(92vw, 22rem);
}
}
@media (prefers-reduced-motion: reduce) {
.bsplus-font-picker-dialog {
animation: none;
}
}
.bsplus-font-picker-header {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.85rem;
flex-shrink: 0;
padding: 1.15rem 1.25rem;
border-bottom: 1px solid var(--bsplus-analytics-border);
}
.bsplus-font-picker-header-actions {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
flex-wrap: wrap;
}
.bsplus-font-picker-header-text {
min-width: 0;
}
.bsplus-font-picker-title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
color: var(--bsplus-analytics-text);
}
.bsplus-font-picker-desc {
margin: 0.35rem 0 0;
font-size: 0.8125rem;
color: var(--bsplus-analytics-muted);
line-height: 1.5;
}
.bsplus-font-picker-reset {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.65rem 1rem;
border-radius: var(--bsplus-analytics-radius-sm);
border: 2px solid var(--bsplus-analytics-border);
background: transparent;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
color: var(--bsplus-analytics-text);
transition:
transform 0.2s var(--bsplus-analytics-ease),
background 0.2s var(--bsplus-analytics-ease),
border-color 0.2s var(--bsplus-analytics-ease),
opacity 0.2s ease;
}
.bsplus-font-picker-reset:hover:not(:disabled) {
transform: scale(1.02);
background: color-mix(
in srgb,
var(--bsplus-analytics-surface-2) 80%,
transparent
);
}
.bsplus-font-picker-reset:active:not(:disabled) {
transform: scale(0.98);
}
.bsplus-font-picker-reset:focus-visible {
outline: none;
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent);
}
.bsplus-font-picker-reset:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.bsplus-font-picker-done {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0.65rem 1.25rem;
border-radius: var(--bsplus-analytics-radius-sm);
border: none;
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
background: var(--bsplus-analytics-accent);
color: var(--text-color, #ffffff);
box-shadow: 0 2px 8px
color-mix(in srgb, var(--bsplus-analytics-accent) 40%, transparent);
transition:
transform 0.2s var(--bsplus-analytics-ease),
box-shadow 0.2s var(--bsplus-analytics-ease);
}
.bsplus-font-picker-done:hover {
transform: scale(1.03);
box-shadow: 0 4px 14px
color-mix(in srgb, var(--bsplus-analytics-accent) 45%, transparent);
}
.bsplus-font-picker-done:active {
transform: scale(0.97);
}
.bsplus-font-picker-done:focus-visible {
outline: none;
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent);
}
.bsplus-font-picker-list {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 1rem 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent)
transparent;
}
.bsplus-font-picker-list::-webkit-scrollbar {
width: 8px;
}
.bsplus-font-picker-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent);
}
.bsplus-font-picker-option {
width: 100%;
padding: 0.9rem 1rem;
text-align: left;
border-radius: var(--bsplus-analytics-radius-sm);
border: 1px solid var(--bsplus-analytics-border);
background: var(--bsplus-analytics-surface);
box-shadow: var(--bsplus-analytics-shadow);
cursor: pointer;
font-family: Rubik, system-ui, sans-serif;
flex-shrink: 0;
transition:
transform 0.25s var(--bsplus-analytics-ease),
box-shadow 0.25s var(--bsplus-analytics-ease),
border-color 0.2s ease,
background 0.2s ease;
}
.bsplus-font-picker-option:hover {
transform: translateY(-2px);
box-shadow: var(--bsplus-analytics-shadow-hover);
background: color-mix(
in srgb,
var(--bsplus-analytics-surface-2) 55%,
var(--bsplus-analytics-surface)
);
}
.bsplus-font-picker-option:focus-visible {
outline: none;
box-shadow:
var(--bsplus-analytics-shadow-hover),
0 0 0 3px color-mix(in srgb, var(--bsplus-analytics-accent) 30%, transparent);
}
.bsplus-font-picker-option.is-selected {
border-color: color-mix(
in srgb,
var(--bsplus-analytics-accent) 45%,
var(--bsplus-analytics-border)
);
background: color-mix(
in srgb,
var(--bsplus-analytics-accent) 10%,
var(--bsplus-analytics-surface)
);
box-shadow: 0 4px 16px
color-mix(in srgb, var(--bsplus-analytics-accent) 22%, transparent);
}
.bsplus-font-picker-option-head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0;
}
.bsplus-font-picker-root .bsplus-font-picker-option-name {
font-size: 1rem;
font-weight: 700;
color: var(--bsplus-analytics-text);
text-align: left;
min-width: 0;
}
.bsplus-font-picker-badge {
display: inline-flex;
align-items: center;
flex-shrink: 0;
margin-left: auto;
padding: 0.2rem 0.65rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
background: color-mix(
in srgb,
var(--bsplus-analytics-accent) 18%,
transparent
);
color: var(--bsplus-analytics-accent);
}
@@ -0,0 +1,161 @@
<script lang="ts">
import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let { alwaysShowUserName = false, onClick = undefined } = $props<{
alwaysShowUserName?: boolean;
onClick?: () => void;
}>();
let cloudState = $state(cloudAuth.state);
let open = $state(false);
let dropdownEl: HTMLElement;
onMount(() => {
const unsubscribe = cloudAuth.subscribe((state) => {
cloudState = state;
});
return unsubscribe;
});
function handleClickOutside(e: MouseEvent) {
if (dropdownEl && !dropdownEl.contains(e.target as Node)) {
open = false;
}
}
$effect(() => {
if (open) {
const timer = setTimeout(() => {
document.addEventListener("click", handleClickOutside);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener("click", handleClickOutside);
};
}
});
async function handleLogout() {
await cloudAuth.logout();
open = false;
}
async function handleSignIn() {
await cloudAuth.startLogin();
open = false;
}
function handleButtonClick() {
if (onClick) {
onClick();
} else {
open = !open;
}
}
function getInitials(): string {
const u = cloudState.user;
if (!u) return "?";
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
if (u.username) return u.username.slice(0, 2).toUpperCase();
if (u.email) return u.email.slice(0, 2).toUpperCase();
return "?";
}
</script>
<div class="relative flex items-center" bind:this={dropdownEl}>
<button
type="button"
onclick={handleButtonClick}
class="flex items-center gap-2 px-3 py-1.5 text-[0.75rem] rounded-lg shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white transition-colors duration-200"
>
{#if cloudState.isLoggedIn}
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
{getInitials()}
</div>
{/if}
<span
class={alwaysShowUserName
? "inline max-w-[10rem] truncate text-[0.75rem]"
: "hidden max-w-24 truncate sm:inline text-[0.75rem]"}
>
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</span>
{:else}
<span class="text-sm font-IconFamily" aria-hidden="true">{'\ued53'}</span>
<span class="text-[0.75rem]">Sign in</span>
{/if}
</button>
{#if !onClick && open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute right-0 top-full mt-2 w-80 rounded-xl border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-xl z-[100] overflow-hidden"
onclick={(e) => e.stopPropagation()}
>
<div class="p-4 border-b border-zinc-200 dark:border-zinc-600">
<h3 class="text-xl font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
<p class="text-base text-zinc-500 dark:text-zinc-400">Sync favorites across devices</p>
</div>
<div class="p-4">
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()}
</div>
{/if}
<div class="min-w-0 flex-1">
<p class="text-base font-medium text-zinc-900 dark:text-white truncate">
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</p>
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
<p class="text-base text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
{/if}
</div>
</div>
<button
type="button"
onclick={handleLogout}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Sign out
</button>
</div>
{:else}
<div class="flex flex-col gap-3">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Sign in to sync favorites across devices.
</p>
<button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
</div>
{/if}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,111 @@
<script lang="ts">
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let {
introText,
onSuccess,
compact = false,
} = $props<{
introText?: string;
onSuccess?: () => void;
/** Smaller padding/text for overlays (e.g. SignInToFavoriteModal) */
compact?: boolean;
}>();
let username = $state("");
let password = $state("");
let loading = $state(false);
let error = $state<string | null>(null);
const inputClass = $derived(
compact
? "w-full px-4 py-2 text-sm rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
: "w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200",
);
const btnClass = $derived(
compact
? "w-full px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200"
: "w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200",
);
const linkClass = $derived(
compact
? "inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
: "inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200",
);
async function handleLogin() {
if (loading) return;
error = null;
if (!username.trim() || !password) {
error = "Please enter username and password";
return;
}
loading = true;
try {
const result = await cloudAuth.login(username.trim(), password);
if (result.success) {
password = "";
onSuccess?.();
} else {
error = result.error ?? "Login failed";
}
} finally {
loading = false;
}
}
</script>
{#if introText}
<p
class="mb-4 text-zinc-600 dark:text-zinc-400 {compact ? 'text-sm' : 'text-base'}"
>
{introText}
</p>
{/if}
<form
class="flex flex-col gap-3"
autocomplete="off"
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
<input
type="text"
name="betterseqta-cloud-username"
autocomplete="off"
placeholder="Email or username"
bind:value={username}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute("readonly")}
class={inputClass}
/>
<input
type="password"
name="betterseqta-cloud-password"
autocomplete="new-password"
placeholder="Password"
bind:value={password}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute("readonly")}
class={inputClass}
/>
{#if error}
<p class="text-red-600 dark:text-red-400 {compact ? 'text-sm' : 'text-base'}">{error}</p>
{/if}
<button type="submit" disabled={loading} class={btnClass}>
{loading ? "Signing in..." : "Sign in"}
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class={linkClass}
>
Create account
</a>
</form>
@@ -1,20 +1,27 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import type { Theme } from '@/interface/types/Theme';
import type { ThemeCoverSlide } from '@/interface/types/Theme';
import emblaCarouselSvelte from 'embla-carousel-svelte';
import Autoplay from 'embla-carousel-autoplay';
let { coverThemes, setDisplayTheme } = $props<{ coverThemes: Theme[], setDisplayTheme: (theme: Theme) => void }>();
let { slides, setDisplayTheme } = $props<{
slides: ThemeCoverSlide[];
setDisplayTheme: (theme: import('@/interface/types/Theme').Theme) => void;
}>();
let emblaApi = $state();
const options = { loop: true };
const plugins = [
Autoplay({
delay: 5000,
stopOnInteraction: false,
stopOnMouseEnter: true
})
];
const options = $derived({ loop: slides.length > 1 });
const plugins = $derived(
slides.length > 1
? [
Autoplay({
delay: 5000,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
]
: [],
);
function onInit(event: CustomEvent) {
emblaApi = event.detail;
@@ -26,41 +33,77 @@
const slideNext = () => emblaApi?.scrollNext();
</script>
{#if coverThemes.length > 0}
{#if slides.length > 0}
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
<div
class="w-full aspect-8/3"
class="w-full aspect-[5/1] max-h-[500px]"
use:emblaCarouselSvelte={{ options, plugins }}
onemblaInit={onInit}
>
<div class="flex">
{#each coverThemes as theme}
{#each slides as slide (slide.imageUrl + slide.title + (slide.subtitle ?? ''))}
<div
class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip"
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
onclick={() => setDisplayTheme(theme)}
onkeydown={(e) => {
if (e.key === 'Enter') setDisplayTheme(slide.openTheme);
}}
onclick={() => setDisplayTheme(slide.openTheme)}
>
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-full" />
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
<p class='text-lg text-white'>{theme.description}</p>
<img src={slide.imageUrl} alt="" class="object-cover w-full h-full" />
{#if slide.badgeFeatured === true}
<div class="absolute top-4 left-4 z-[2] pointer-events-none">
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm"
aria-label="Featured theme"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path
fill-rule="evenodd"
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
clip-rule="evenodd"
/>
</svg>
Featured
</span>
</div>
{/if}
<div class="absolute bottom-0 left-0 p-8 z-[1]">
<h2 class="text-4xl font-bold text-white">{slide.title}</h2>
{#if slide.subtitle}
<p class="text-lg font-medium text-white/95 mt-1 line-clamp-2">{slide.subtitle}</p>
{/if}
{#if slide.openTheme.author && !slide.subtitle}
<p class="text-sm text-white/90 mt-1 mb-1 line-clamp-1">By {slide.openTheme.author}</p>
{/if}
{#if slide.openTheme.description && !slide.subtitle}
<p class="text-lg text-white line-clamp-3">{slide.openTheme.description}</p>
{/if}
</div>
<div class='absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80'></div>
<div class="absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80"></div>
</div>
{/each}
</div>
</div>
<!-- Navigation buttons -->
<div class='flex absolute right-2 bottom-2 z-10 gap-2'>
<button aria-label="Previous" onclick={slidePrev} class='flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800'>
<div class="flex absolute right-2 bottom-2 z-10 gap-2">
<button
aria-label="Previous"
onclick={slidePrev}
type="button"
class="flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800 transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
</svg>
</button>
<button aria-label="Next" onclick={slideNext} class='flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800'>
<button
aria-label="Next"
onclick={slideNext}
type="button"
class="flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800 transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
@@ -3,6 +3,7 @@
import logoDark from '@/resources/icons/betterseqta-light-full.png';
import { closeStore } from '@/seqta/ui/renderStore'
import browser from 'webextension-polyfill';
import CloudHeader from './CloudHeader.svelte';
// Props
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
@@ -39,6 +40,8 @@
>
Backgrounds
</button>
<CloudHeader />
</div>
<div class="flex relative gap-2">
+192 -10
View File
@@ -1,19 +1,201 @@
<script lang="ts">
import type { Theme } from '@/interface/types/Theme'
let { theme, onClick } = $props<{ theme: Theme; onClick: () => void }>();
import {
masterGridDisplayDownloadCount,
gridCardPreviewImageUrls,
} from '@/interface/utils/themeStoreFlavours'
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
import emblaCarouselSvelte from 'embla-carousel-svelte';
import Autoplay from 'embla-carousel-autoplay';
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn, allStoreThemeRows } = $props<{
theme: Theme;
onClick: () => void;
toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean;
onRequestSignIn?: () => void;
/** Raw API themes (includes hidden slaves) for aggregated master download totals */
allStoreThemeRows?: Theme[];
}>();
const displayDownloadCount = $derived(
allStoreThemeRows != null
? masterGridDisplayDownloadCount(theme, allStoreThemeRows)
: (theme.download_count ?? 0),
);
const gridRotatorUrls = $derived(gridCardPreviewImageUrls(theme, allStoreThemeRows));
/** Mirrors CoverSwiper (featured bar): horizontal slides + autoplay */
function prefersReducedMotion(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/** Read once synchronously where `window` exists so reduced-motion doesnt briefly mount carousel */
let allowSlideAutoplay = $state(!prefersReducedMotion());
const gridEmblaKey = $derived(gridRotatorUrls.join('|'));
const gridEmblaOptions = $derived({ loop: gridRotatorUrls.length > 1 });
const gridEmblaPlugins = $derived.by(() => {
if (!allowSlideAutoplay || gridRotatorUrls.length <= 1) return [];
return [
Autoplay({
delay: 2000,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
];
});
let menuOpen = $state(false);
let menuRef: HTMLDivElement;
onMount(() => {
const closeMenu = (e: MouseEvent) => {
if (menuOpen && menuRef && !menuRef.contains(e.target as Node)) {
menuOpen = false;
}
};
document.addEventListener('click', closeMenu);
return () => document.removeEventListener('click', closeMenu);
});
function handleCardClick(e: MouseEvent) {
if ((e.target as HTMLElement).closest('[data-theme-menu]')) return;
onClick();
}
function handleFavoriteClick(e: MouseEvent) {
e.stopPropagation();
if (isLoggedIn) {
toggleFavorite(theme);
} else {
onRequestSignIn?.();
}
menuOpen = false;
}
</script>
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={onClick}>
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white">
{theme.name}
<div
class="relative z-0 hover:z-20 w-full cursor-pointer"
role="button"
tabindex="-1"
onkeydown={onClick}
onclick={handleCardClick}
>
<div
class="bg-gray-50 w-full transition-all duration-500 ease-out relative group flex flex-col rounded-xl overflow-clip border hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto"
transition:fade
>
{#if theme.featured === true}
<div class="absolute top-2 left-2 z-20 pointer-events-none">
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm"
aria-label="Featured theme"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
</svg>
Featured
</span>
</div>
{/if}
<!-- Menu dropdown -->
<div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}>
<button
type="button"
class="flex justify-center items-center w-8 h-8 rounded-lg bg-black/40 hover:bg-black/60 text-white transition-all"
onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}
aria-label="Theme options"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
</button>
{#if menuOpen}
<div
class="absolute right-0 top-full mt-1 py-1 min-w-[140px] rounded-lg bg-white dark:bg-zinc-800 shadow-lg border border-zinc-200 dark:border-zinc-700"
role="menu"
>
<button
type="button"
class="flex gap-2 items-center w-full px-3 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700"
role="menuitem"
onclick={handleFavoriteClick}
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill={theme.is_favorited ? 'currentColor' : 'none'}
stroke="currentColor"
stroke-width="2"
class="w-5 h-5 {theme.is_favorited ? 'text-red-500' : ''}"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{theme.is_favorited ? 'Favorited' : 'Favorite'}
</button>
</div>
{/if}
</div>
<div class="absolute bottom-1 left-3 right-3 z-10 mb-1 flex flex-col gap-0.5">
<span class="text-xl font-bold text-white drop-shadow-md">{theme.name}</span>
{#if theme.author}
<span class="text-xs text-white/85 drop-shadow-md line-clamp-1">By {theme.author}</span>
{/if}
<div class="flex gap-3 text-xs font-medium text-white/90 drop-shadow-sm">
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{displayDownloadCount.toLocaleString()}
</span>
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{(theme.favorite_count ?? 0).toLocaleString()}
</span>
</div>
</div>
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
<div class='w-full'>
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
</div>
{#if gridRotatorUrls.length === 0}
<div class="relative w-full h-48 overflow-hidden rounded-md bg-zinc-200 dark:bg-zinc-700" aria-hidden="true"></div>
{:else if !allowSlideAutoplay || gridRotatorUrls.length === 1}
<div class="relative w-full h-48 overflow-hidden rounded-md">
<img
src={gridRotatorUrls[0] ?? theme.marqueeImage ?? theme.coverImage}
alt=""
class="object-cover w-full h-full"
draggable="false"
/>
</div>
{:else}
{#key gridEmblaKey}
<div
class="relative w-full h-48 overflow-hidden rounded-md"
use:emblaCarouselSvelte={{
options: gridEmblaOptions,
plugins: gridEmblaPlugins,
}}
>
<div class="flex h-full">
{#each gridRotatorUrls as url (url)}
<div class="relative flex-[0_0_100%] min-w-0 h-full shrink-0">
<img
src={url}
alt=""
class="object-cover w-full h-full select-none"
draggable="false"
/>
</div>
{/each}
</div>
</div>
{/key}
{/if}
</div>
</div>
@@ -2,22 +2,49 @@
import type { Theme } from '@/interface/types/Theme'
import ThemeCard from './ThemeCard.svelte';
let { themes, searchTerm, setDisplayTheme } = $props<{ themes: Theme[]; searchTerm: string, setDisplayTheme: (theme: Theme) => void }>();
let {
themes,
searchTerm,
setDisplayTheme,
toggleFavorite,
isLoggedIn,
onRequestSignIn,
allStoreThemeRows,
} = $props<{
themes: Theme[];
searchTerm: string;
setDisplayTheme: (theme: Theme) => void;
toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean;
onRequestSignIn?: () => void;
/** Raw API list (includes `slave` rows) for master download aggregation */
allStoreThemeRows?: Theme[];
}>();
let filteredThemes = $derived(themes.filter((theme: Theme) =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
));
let filteredThemes = $derived(themes.filter((theme: Theme) => {
const q = searchTerm.toLowerCase();
const name = (theme.name ?? '').toLowerCase();
const description = (theme.description ?? '').toLowerCase();
return name.includes(q) || description.includes(q);
}));
</script>
<div class="relative" >
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
{#each filteredThemes as theme (theme.id)}
<ThemeCard theme={theme} onClick={() => setDisplayTheme(theme)} />
<ThemeCard
{theme}
onClick={() => setDisplayTheme(theme)}
{toggleFavorite}
{isLoggedIn}
{onRequestSignIn}
{allStoreThemeRows}
/>
{/each}
{#if filteredThemes.length !== 0}
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='w-full cursor-pointer'>
<div class="bg-zinc-50 h-48 w-full transition-all hover:scale-105 duration-500 relative justify-center items-center group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] rounded-xl overflow-clip border">
<a href="https://docs.betterseqta.org/theme-creation/" class="block relative z-0 hover:z-20 w-full cursor-pointer">
<div class="bg-zinc-50 h-48 w-full transition-all duration-500 ease-out relative overflow-clip rounded-xl border group group/card flex flex-col justify-center items-center hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1]">
<div class="text-2xl font-IconFamily">{'\uecb3'}</div>
<div class="text-xl font-bold text-center transition-all duration-500 dark:text-white">
Got a Theme Idea?
@@ -32,7 +59,7 @@
<div class="absolute top-0 flex flex-col items-center justify-center w-full text-center h-96">
<h1 class="mt-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl">That doesn't exist! 😭😭😭</h1>
<p class="mt-6 text-lg leading-7 text-zinc-600 dark:text-zinc-300">Sorry, we couldn't find the theme you're looking for. Maybe... you could create it?</p>
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
<a href="https://docs.betterseqta.org/theme-creation/" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
Show me how!
</a>
</div>
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,14 @@
<script lang="ts">
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
import { onDestroy, onMount } from 'svelte'
import browser from 'webextension-polyfill'
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { OpenStorePage } from '@/seqta/ui/renderStore'
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte'
const themeManager = ThemeManager.getInstance();
@@ -13,6 +16,17 @@
let { isEditMode } = $props<{ isEditMode: boolean }>();
let isDragging = $state(false);
let tempTheme = $state(null);
let favoriteStatus = $state<Record<string, boolean>>({});
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
let prevLoggedIn = $state(false);
let showSignInModal = $state(false);
cloudAuth.subscribe((s) => {
const now = s.isLoggedIn;
if (now && !prevLoggedIn && themes) void fetchThemes();
prevLoggedIn = now;
cloudLoggedIn = now;
});
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
if (isEditMode) return;
@@ -71,7 +85,7 @@
try {
const result = JSON.parse(event.target?.result as string);
tempTheme = result;
await themeManager.installTheme(result);
await themeManager.installTheme(result, { fromStore: false });
await fetchThemes();
} catch (error) {
console.error('Error parsing file:', error);
@@ -87,11 +101,55 @@
themes: await themeManager.getAvailableThemes(),
selectedTheme: themeManager.getSelectedThemeId() || '',
}
if (themes && cloudLoggedIn) {
const token = await cloudAuth.getStoredToken();
if (token) {
const status: Record<string, boolean> = {};
await Promise.all(
themes.themes.map(async (t) => {
try {
const res = (await browser.runtime.sendMessage({
type: 'fetchThemeDetails',
themeId: t.id,
token,
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
if (res?.success && res?.data?.theme) {
status[t.id] = !!res.data.theme.is_favorited;
}
} catch {
// Theme may not exist on store (e.g. locally created)
}
})
);
favoriteStatus = status;
}
} else {
favoriteStatus = {};
}
}
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
e.stopPropagation();
if (!cloudLoggedIn) {
showSignInModal = true;
return;
}
const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !favoriteStatus[theme.id];
const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite',
themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean };
if (result?.success) {
favoriteStatus = { ...favoriteStatus, [theme.id]: isFavorite };
}
}
onMount(async () => {
await fetchThemes();
themeUpdates.addListener(fetchThemes);
})
@@ -144,6 +202,18 @@
{/if}
{#if !isEditMode}
<div
class="flex absolute right-24 top-1/4 z-20 place-items-center p-2 w-8 h-8 text-center rounded-full opacity-0 transition-all -translate-y-1/2 group-hover:opacity-100 group-hover:top-1/2 {(favoriteStatus[theme.id] ?? false) ? 'text-red-400' : 'text-white/80'} bg-black/50"
onclick={(event) => handleToggleFavorite(theme, event)}
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') handleToggleFavorite(theme, event as any) }}
role="button"
tabindex="-1"
title={cloudLoggedIn ? ((favoriteStatus[theme.id] ?? false) ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={(favoriteStatus[theme.id] ?? false) ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</div>
<div
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
@@ -211,3 +281,7 @@
</button>
</div>
</div>
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if}
+89 -16
View File
@@ -15,12 +15,35 @@
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
import ColourPicker from "../components/ColourPicker.svelte";
import FontPickerModal from "../components/FontPickerModal.svelte";
import CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "../hooks/SettingsPopup";
import {
checkGithubReleaseUpdate,
dismissNightlyUpdate,
isGhReleaseUpdateCheckEnabled,
type GhReleaseUpdateInfo,
} from "@/utils/githubReleaseUpdate";
let devModeSequence = "";
let settingsActiveTab = $state(0);
let showDisclaimerModal = $state(false);
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
let disclaimerTitle = $state("Confirm");
let disclaimerMessage = $state("");
const ghReleaseUpdateEnabled = isGhReleaseUpdateCheckEnabled();
let ghReleaseUpdate = $state<GhReleaseUpdateInfo | null>(null);
const openGhRelease = () => {
const url = ghReleaseUpdate?.url
?? "https://github.com/BetterSEQTA/BetterSEQTA-Plus/releases";
if (ghReleaseUpdate?.available) {
dismissNightlyUpdate();
}
window.open(url, "_blank");
closeExtensionPopup();
};
const handleDevModeToggle = () => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -43,6 +66,10 @@
showColourPicker = true;
};
const openFontPicker = () => {
showFontPicker = true;
};
const openChangelog = () => {
OpenWhatsNewPopup();
closeExtensionPopup();
@@ -65,24 +92,41 @@
let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state<boolean>(false);
let showFontPicker = $state<boolean>(false);
let showCloudPanel = $state<boolean>(false);
const showDisclaimer = (onConfirm: () => void, onCancel: () => void) => {
const openCloudPanel = () => {
showCloudPanel = true;
};
const showDisclaimer = (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => {
disclaimerCallbacks = { onConfirm, onCancel };
disclaimerTitle = title ?? "Confirm";
disclaimerMessage = message ?? "";
showDisclaimerModal = true;
};
onMount(async () => {
onMount(() => {
settingsPopup.addListener(() => {
showColourPicker = false;
showFontPicker = false;
showCloudPanel = false;
});
if (!standalone) return;
StandaloneStore.setStandalone(true);
if (standalone) {
StandaloneStore.setStandalone(true);
}
if (ghReleaseUpdateEnabled) {
void checkGithubReleaseUpdate().then((info) => {
ghReleaseUpdate = info;
});
}
});
</script>
<div
class="w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode
class="relative w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode
? 'dark'
: ''} {standalone ? 'h-[600px]' : 'h-full rounded-xl'} overflow-clip"
>
@@ -114,7 +158,25 @@
/>
{#if !standalone}
<div class="flex absolute top-1 right-1 gap-1 items-center">
<div class="flex absolute top-1 right-1 gap-1 items-start">
{#if ghReleaseUpdateEnabled}
<div class="flex flex-col items-end gap-0.5 max-w-[9rem] mr-0.5">
{#if ghReleaseUpdate?.available}
<button
type="button"
onclick={openGhRelease}
class="px-1.5 py-0.5 text-[10px] font-semibold leading-tight text-white rounded-full bg-amber-500 hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-500"
title="Open GitHub release"
>
Update available — {ghReleaseUpdate.label}
</button>
{/if}
<p class="text-[9px] leading-tight text-right text-zinc-500 dark:text-zinc-400">
GitHub release build — do not upload to extension stores.
</p>
</div>
{/if}
<div class="flex gap-1 items-center">
<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"
@@ -136,6 +198,7 @@
>
{"\uecba"}
</button>
</div>
<!-- <button
onclick={openMinecraftServer}
@@ -275,11 +338,12 @@
</div>
<TabbedContainer
bind:activeTab={settingsActiveTab}
tabs={[
{
title: "Settings",
Content: Settings,
props: { showColourPicker: openColourPicker, showDisclaimer },
props: { showColourPicker: openColourPicker, showFontPicker: openFontPicker, showDisclaimer, showCloudPanel: openCloudPanel },
},
{ title: "Shortcuts", Content: Shortcuts },
{ title: "Themes", Content: Theme },
@@ -294,19 +358,28 @@
}}
/>
{/if}
{#if showCloudPanel}
<CloudPanel
hidePanel={() => {
showCloudPanel = false;
}}
/>
{/if}
</div>
{#if showFontPicker}
<FontPickerModal
hidePicker={() => {
showFontPicker = false;
}}
/>
{/if}
{#if showDisclaimerModal && disclaimerCallbacks}
<DisclaimerModal
title="Assessment Averages Disclaimer"
message="This feature calculates a simple average of your assessment grades. It does not take into account:
• Assessment weightings
• Different grading scales
• Other factors used in official reports
The displayed average may be inaccurate compared to your actual marks found in reports.
Do you want to enable this feature?"
title={disclaimerTitle}
message={disclaimerMessage}
onConfirm={() => {
disclaimerCallbacks?.onConfirm();
showDisclaimerModal = false;
+273 -50
View File
@@ -10,10 +10,42 @@
import type { SettingsList } from "@/interface/types/SettingsProps"
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
import CloudSettingsSync from "@/interface/components/CloudSettingsSync.svelte"
import CloudHeader from "@/interface/components/store/CloudHeader.svelte"
import { cloudAuth } from "@/seqta/utils/CloudAuth"
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
import { showThemeOfTheMonthPopupNow } from "@/seqta/utils/Openers/OpenThemeOfTheMonthPopup"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase"
let devApiBaseInput = $state<string>(getStoredOverride() ?? "")
let devApiBaseActive = $state<string | null>(getStoredOverride())
function applyDevApiBase() {
const trimmed = devApiBaseInput.trim()
if (trimmed === "") {
setApiBase(null)
devApiBaseActive = null
return
}
if (!/^https?:\/\//.test(trimmed)) {
alert("Please enter a full URL starting with http:// or https://")
return
}
setApiBase(trimmed)
devApiBaseActive = trimmed.replace(/\/$/, "")
}
function clearDevApiBase() {
devApiBaseInput = ""
setApiBase(null)
devApiBaseActive = null
}
import { getAllPluginSettings } from "@/plugins"
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
// Union type representing all possible settings
@@ -48,9 +80,17 @@
settings: Record<string, SettingType>;
}
const pluginSettings = getAllPluginSettings() as Plugin[];
const pluginSettings = getAllPluginSettings().filter(
(plugin) => !(isSeqtaEngageExperience() && plugin.pluginId === "global-search"),
) as Plugin[];
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({});
let cloudState = $state(cloudAuth.state);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => { cloudState = s; });
return unsub;
});
async function loadPluginSettings() {
for (const plugin of pluginSettings) {
if (Object.keys(plugin.settings).length === 0) continue;
@@ -92,10 +132,25 @@
loadPluginSettings();
})
const { showColourPicker, showDisclaimer } = $props<{
const { showColourPicker, showFontPicker, showDisclaimer, showCloudPanel } = $props<{
showColourPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
showFontPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void;
showCloudPanel: () => void;
}>();
async function exportCloudSettingsJsonToFile() {
const payload = await getSnapshotForUpload();
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `betterseqta-plus-settings-export-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
a.click();
URL.revokeObjectURL(url);
}
</script>
{#snippet Setting({ title, description, Component, props }: SettingsList) }
@@ -113,27 +168,15 @@
<div class="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700">
{#each [
{
title: "Transparency Effects",
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)",
id: 1,
Component: Switch,
props: {
state: $settingsState.transparencyEffects,
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
}
},
{
title: "Custom Theme Colour",
description: "Customise the overall theme colour of SEQTA Learn.",
id: 4,
Component: PickerSwatch,
props: {
onClick: showColourPicker
}
title: "Connect Mobile App",
description: "Link your SEQTA session to DesQTA — the modern desktop and mobile app for SEQTA Learn",
id: 0,
Component: ConnectMobileApp,
props: {}
},
{
title: "Edit Sidebar Layout",
description: "Customise the sidebar layout.",
description: "Reorder pages on the sidebar",
id: 5,
Component: Button,
props: {
@@ -141,9 +184,38 @@
text: "Edit"
}
},
{
title: "Custom Theme Colour",
description: "Customise the overall theme colour of SEQTA Learn",
id: 4,
Component: PickerSwatch,
props: {
onClick: showColourPicker
}
},
{
title: "Interface Font",
description: "Choose the typeface used across SEQTA Learn",
id: 16,
Component: Button,
props: {
onClick: showFontPicker,
text: "Change"
}
},
{
title: "Icon Only Sidebar",
description: "Show only icons in the sidebar for a compact layout",
id: 14,
Component: Switch,
props: {
state: $settingsState.iconOnlySidebar ?? false,
onChange: (isOn: boolean) => settingsState.iconOnlySidebar = isOn
}
},
{
title: "Animations",
description: "Enables animations on certain pages.",
description: "Enable animations on certain pages",
id: 6,
Component: Switch,
props: {
@@ -162,27 +234,37 @@
}
},
{
title: "Default Page",
description: "The page to load when SEQTA Learn is opened.",
id: 10,
Component: Select,
title: "Transparency Effects",
description: "Enable transparency effects on certain elements, such as blur (May impact battery life)",
id: 1,
Component: Switch,
props: {
state: $settingsState.defaultPage,
onChange: (value: string) => settingsState.defaultPage = value,
options: [
{ value: 'home', label: 'Home' },
{ value: 'dashboard', label: 'Dashboard' },
{ value: 'timetable', label: 'Timetable' },
{ value: 'welcome', label: 'Welcome' },
{ value: 'messages', label: 'Messages' },
{ value: 'documents', label: 'Documents' },
{ value: 'reports', label: 'Reports' },
]
state: $settingsState.transparencyEffects,
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
}
},
{
title: "Default Page",
description: "Choose which page loads first when you open SEQTA",
id: 10,
Component: Select,
props: {
state: $settingsState.defaultPage ?? "home",
onChange: (value: string) => (settingsState.defaultPage = value),
options: [
{ value: "home", label: "Home" },
{ value: "dashboard", label: "Dashboard" },
{ value: "timetable", label: "Timetable" },
{ value: "welcome", label: "Welcome" },
{ value: "messages", label: "Messages" },
{ value: "documents", label: "Documents" },
{ value: "reports", label: "Reports" },
],
},
},
{
title: "News Feed Source",
description: "Choose sources of your news feed.",
description: "Choose the sources for your news feed",
id: 11,
Component: Select,
props: {
@@ -207,6 +289,49 @@
{@render Setting(option)}
{/each}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Adaptive Theme Colour</h2>
<p class="text-xs">Change the theme colour based on the current class (e.g. when viewing a course or assessments page)</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeColour ?? false}
onChange={(isOn: boolean) => settingsState.adaptiveThemeColour = isOn}
/>
</div>
</div>
{#if $settingsState.adaptiveThemeColour}
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
<div class="pr-4">
<h2 class="text-sm font-bold">Soft Gradient</h2>
<p class="text-xs">Use a soft gradient instead of a solid colour when viewing a class</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeGradient ?? false}
onChange={(isOn: boolean) => settingsState.adaptiveThemeGradient = isOn}
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
<div class="pr-4">
<h2 class="text-sm font-bold">Smooth colour transition</h2>
<p class="text-xs">Ease between class/subject colours when navigating instead of switching instantly</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeColourTransition ?? true}
onChange={(isOn: boolean) => settingsState.adaptiveThemeColourTransition = isOn}
/>
</div>
</div>
{/if}
</div>
</div>
{#each pluginSettings as plugin}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40 {!(plugin as any).disableToggle && Object.keys(plugin.settings).length === 0 ? 'hidden' : ''}">
@@ -233,9 +358,9 @@
async () => {
await updatePluginSetting(plugin.pluginId, 'enabled', true);
},
() => {
// Do nothing on cancel
}
() => {},
"Assessment Averages Disclaimer",
"This feature calculates a simple average of your assessment grades. It does not take into account:\n• Assessment weightings\n• Different grading scales\n• Other factors used in official reports\n\nThe displayed average may be inaccurate compared to your actual marks found in reports.\n\nDo you want to enable this feature?"
);
return;
}
@@ -248,8 +373,8 @@
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]}
<!-- Skip the 'enabled' setting if it's part of the settings object -->
{#if key !== 'enabled'}
<!-- Skip the 'enabled' setting and hide cloud-only settings when not signed in -->
{#if key !== 'enabled' && !(key === 'useCloudPfp' && !cloudState.isLoggedIn)}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">{setting.title || key}</h2>
@@ -262,13 +387,15 @@
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/>
{:else if setting.type === 'number'}
<Slider
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
min={setting.min}
max={setting.max}
step={setting.step}
/>
<div class="w-28 shrink-0">
<Slider
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
min={setting.min}
max={setting.max}
step={setting.step}
/>
</div>
{:else if setting.type === 'string'}
<input
type="text"
@@ -307,9 +434,40 @@
{/each}
{/if}
</div>
{#if plugin.pluginId === 'global-search'}
{@render Setting({
title: "Theme of the Month",
description: "Show the monthly featured theme popup when a new entry is available",
id: 15,
Component: Switch,
props: {
state: !($settingsState.themeOfTheMonthDisabled ?? false),
onChange: (isOn: boolean) => settingsState.themeOfTheMonthDisabled = !isOn
}
})}
{/if}
</div>
{/each}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">BetterSEQTA Cloud</h2>
<p class="text-xs">Account & sync</p>
</div>
<div>
<CloudHeader alwaysShowUserName onClick={showCloudPanel} />
</div>
</div>
{#if cloudState.isLoggedIn}
<div class="px-3 pb-3">
<CloudSettingsSync showDisclaimer={(onConfirm, onCancel) => showDisclaimer(onConfirm, onCancel, "Restore from cloud?", "This will replace your local settings with the cloud backup. Continue?")} />
</div>
{/if}
</div>
</div>
<div class="p-1 border-none"></div>
{@render Setting({
@@ -377,6 +535,71 @@
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Show Theme of the Month</h2>
<p class="text-xs">Fetch and show the current month's popup now (ignores dismissed state)</p>
</div>
<div>
<Button
onClick={async () => {
closeExtensionPopup();
await new Promise((resolve) => setTimeout(resolve, 100));
await showThemeOfTheMonthPopupNow();
}}
text="Show Now"
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
<p class="text-xs">Download the same payload as cloud sync (OAuth tokens stripped). For debugging and server testing.</p>
</div>
<div>
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
</div>
</div>
<div class="flex flex-col gap-2 px-4 py-3">
<div class="flex justify-between items-start gap-3">
<div class="pr-4">
<h2 class="text-sm font-bold">API Base URL (session only)</h2>
<p class="text-xs">Override the content API host for this browser session. Cleared on restart. Affects themes, theme of the month, and other server-driven content.</p>
{#if devApiBaseActive}
<p class="text-xs mt-1 text-amber-600 dark:text-amber-400">
Override active: <span class="font-mono">{devApiBaseActive}</span>
</p>
{/if}
</div>
</div>
<div class="flex gap-2 items-center">
<input
type="text"
placeholder="https://betterseqta.org"
bind:value={devApiBaseInput}
class="flex-1 px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
/>
<Button onClick={applyDevApiBase} text="Apply" />
{#if devApiBaseActive}
<Button onClick={clearDevApiBase} text="Clear" />
{/if}
</div>
</div>
<div class="flex flex-col gap-2 px-4 py-3">
<div>
<h2 class="text-sm font-bold">GitHub latest version override</h2>
<p class="text-xs">Pretend a newer GitHub release exists to test the update badge. Only applies when dev mode is on.</p>
</div>
<input
type="text"
placeholder="e.g. 9.9.9"
value={$settingsState.devGhReleaseVersionOverride ?? ""}
oninput={(e) => {
settingsState.devGhReleaseVersionOverride = e.currentTarget.value;
}}
class="px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
/>
</div>
</div>
{/if}
</div>
+216 -38
View File
@@ -7,6 +7,7 @@
import SkeletonLoader from '../components/SkeletonLoader.svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import type { Theme } from '../types/Theme'
import { visibleStoreThemes, buildCoverSlidesForThemes, normalizeStoreTheme } from '@/interface/utils/themeStoreFlavours'
import browser from 'webextension-polyfill'
import ThemeModal from '../components/store/ThemeModal.svelte'
import Header from '../components/store/Header.svelte'
@@ -15,13 +16,24 @@
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
import Backgrounds from '../components/store/Backgrounds.svelte'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
import { consumePendingHighlightThemeId } from '@/seqta/utils/openThemeStoreWithHighlight'
const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
// State variables
let searchTerm = $state('');
let themes = $state<Theme[]>([]);
let coverThemes = $state<Theme[]>([]);
/** Grid/search/cover: hides flat-listed slaves when API sends them */
let listThemes = $derived(visibleStoreThemes(themes));
/** Cover marquee slides (master + flavour imagery for top masters) */
let coverSlides = $derived(buildCoverSlidesForThemes(listThemes.slice(0, 3)));
let loading = $state(true);
let darkMode = $state(false);
let displayTheme = $state<Theme | null>(null);
@@ -29,7 +41,21 @@
let activeTab = $state('themes');
let error = $state<string | null>(null);
let fetchAttempt = $state(0);
let selectedBackground = $state<string | null>(null);
let showSignInOverlay = $state(false);
const MAX_FETCH_ATTEMPTS = 3;
const FETCH_MESSAGE_TIMEOUT_MS = 25_000;
function sendMessageWithTimeout<T>(message: object): Promise<T> {
return Promise.race([
browser.runtime.sendMessage(message) as Promise<T>,
new Promise<T>((_, reject) => {
setTimeout(() => reject(new Error('Theme store request timed out — reload the SEQTA page after updating the extension.')), FETCH_MESSAGE_TIMEOUT_MS);
}),
]);
}
const fetchCurrentThemes = async () => {
const themes = await themeManager.getAvailableThemes();
@@ -48,38 +74,151 @@
activeTab = tab;
};
// Fetch themes and initialize app
const fetchThemes = async () => {
try {
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${(new Date()).getTime()}`, { cache: 'no-store' });
const data = await response.json();
themes = data.themes;
/** Featured themes first; within each group, newest by `created_at` (API: Unix seconds). */
function compareStoreThemes(a: Theme, b: Theme): number {
const fa = a.featured === true ? 1 : 0;
const fb = b.featured === true ? 1 : 0;
if (fa !== fb) return fb - fa;
const ca = a.created_at ?? 0;
const cb = b.created_at ?? 0;
if (ca !== cb) return cb - ca;
return a.name.localeCompare(b.name);
}
// Shuffle for cover themes
const shuffled = [...themes].sort(() => 0.5 - Math.random());
coverThemes = shuffled.slice(0, 3);
loading = false;
} catch (error) {
console.error('Failed to fetch themes', error);
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs
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 (isRetry = false) => {
if (!isRetry) {
fetchAttempt = 0;
error = null;
}
try {
const token = await cloudAuth.getStoredToken();
const data = await sendMessageWithTimeout<{
success?: boolean;
data?: { themes: unknown[] };
error?: string;
}>({
type: 'fetchThemes',
token: token ?? undefined,
});
if (!data?.success || !Array.isArray(data?.data?.themes)) {
throw new Error(data?.error || 'Failed to fetch themes');
}
themes = data.data.themes
.map((row) => normalizeStoreTheme(row as Record<string, unknown>))
.filter((t) => t.id.length > 0)
.sort(compareStoreThemes);
error = null;
loading = false;
} catch (err) {
console.error('Failed to fetch themes', err);
fetchAttempt += 1;
if (fetchAttempt >= MAX_FETCH_ATTEMPTS) {
error =
err instanceof Error
? err.message
: 'Could not load themes. Reload the SEQTA page, then open the store again.';
loading = false;
return;
}
setTimeout(() => fetchThemes(true), 5000);
}
};
function focusThemeById(themeId: string) {
const match = themes.find((t) => t.id === themeId)
?? themes.find((t) => t.flavours?.some((f) => f.id === themeId));
if (match) {
activeTab = 'themes';
searchTerm = '';
displayTheme = match;
}
}
function onHighlightThemeEvent(e: Event) {
const detail = (e as CustomEvent).detail;
if (detail?.themeId && typeof detail.themeId === 'string') {
focusThemeById(detail.themeId);
}
}
// On mount
onMount(async () => {
window.addEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
await fetchThemes();
await fetchCurrentThemes();
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
darkMode = $settingsState.DarkMode;
const pending = consumePendingHighlightThemeId();
if (pending) focusThemeById(pending);
return () => {
window.removeEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
};
});
// Filter themes based on search term
let filteredThemes = $derived(themes.filter(theme =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
theme.description.toLowerCase().includes(searchTerm.toLowerCase())
));
// Filter themes (list is already featured-first, then newest; filter preserves order)
let filteredThemes = $derived(
listThemes.filter((theme) => {
const q = searchTerm.toLowerCase();
const name = (theme.name ?? '').toLowerCase();
const description = (theme.description ?? '').toLowerCase();
return name.includes(q) || description.includes(q);
}),
);
async function installThemeFromStore(themeId: string, meta: Theme) {
const fullRow = themes.find((x) => x.id === themeId);
if (fullRow) {
await themeManager.downloadTheme(fullRow);
} else {
const flavour = meta.flavours?.find((f) => f.id === themeId);
await themeManager.downloadTheme({
id: themeId,
name: flavour?.name ?? meta.name,
} as Theme);
}
await themeManager.setTheme(themeId);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
void browser.runtime.sendMessage({ type: 'cloudSettingsRequestDebouncedUpload' }).catch(() => {});
}
async function removeThemeFromStore(themeId: string) {
await themeManager.deleteTheme(themeId);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
$effect(() => {
loadBackground();
@@ -91,6 +230,17 @@
console.error(error);
}
});
// Refetch themes when user logs in (from another tab) to get is_favorited
let lastLoggedIn = $state(false);
$effect(() => {
if (cloudLoggedIn && !lastLoggedIn) {
lastLoggedIn = true;
fetchThemes();
} else if (!cloudLoggedIn) {
lastLoggedIn = false;
}
});
</script>
<div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}">
@@ -101,40 +251,64 @@
<!-- Loading State -->
{#if loading}
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
<SkeletonLoader width="100%" height="200px" />
{#each Array(6) as _, i (i)}
<SkeletonLoader width="100%" height="200px" />
{/each}
</div>
{:else if error}
<div class="flex flex-col items-center justify-center py-24 text-center max-w-lg mx-auto">
<h2 class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">Couldn&apos;t load themes</h2>
<p class="mt-3 text-zinc-600 dark:text-zinc-300">{error}</p>
<p class="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
After an extension update, reload your SEQTA tab so the new version can talk to the browser.
</p>
<button
type="button"
class="mt-6 px-4 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700"
onclick={() => {
loading = true;
error = null;
void fetchThemes();
}}
>
Try again
</button>
</div>
{:else}
<!-- Themes Tab Content -->
{#if activeTab === 'themes'}
{#if searchTerm === ''}
<CoverSwiper {coverThemes} {setDisplayTheme} />
<CoverSwiper slides={coverSlides} {setDisplayTheme} />
{/if}
<!-- ThemeGrid to display filtered themes -->
<ThemeGrid themes={filteredThemes} {searchTerm} {setDisplayTheme} />
<ThemeGrid
themes={filteredThemes}
allStoreThemeRows={themes}
{searchTerm}
{setDisplayTheme}
{toggleFavorite}
isLoggedIn={cloudLoggedIn}
onRequestSignIn={() => (showSignInOverlay = true)}
/>
{#if displayTheme}
<ThemeModal
currentThemes={currentThemes}
allThemes={themes}
allThemes={listThemes}
allStoreThemeRows={themes}
theme={displayTheme}
{displayTheme}
{setDisplayTheme}
onInstall={async () => {
if (displayTheme) {
await themeManager.downloadTheme(displayTheme);
await themeManager.setTheme(displayTheme.id);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
{toggleFavorite}
isLoggedIn={cloudLoggedIn}
onRequestSignIn={() => (showSignInOverlay = true)}
onInstall={async (themeId: string) => {
if (displayTheme) await installThemeFromStore(themeId, displayTheme);
}}
onRemove={async () => {
if (displayTheme?.id) {
console.debug('deleting theme', displayTheme.id);
await themeManager.deleteTheme(displayTheme.id);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
onRemove={async (themeId: string) => {
console.debug('deleting theme', themeId);
await removeThemeFromStore(themeId);
}}
/>
{/if}
@@ -144,4 +318,8 @@
{/if}
</div>
</div>
{#if showSignInOverlay}
<SignInToFavoriteModal onClose={() => (showSignInOverlay = false)} />
{/if}
</div>
+57 -10
View File
@@ -4,7 +4,10 @@
import { slide } from 'svelte/transition';
import { fade } from 'svelte/transition';
import { type LoadedCustomTheme } from '@/types/CustomThemes'
import {
type LoadedCustomTheme,
shouldForceThemeAppearance,
} from '@/types/CustomThemes'
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
@@ -21,9 +24,9 @@
handleImageVariableChange,
handleCoverImageUpload
} from '../utils/themeImageHandlers';
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
const { themeID } = $props<{ themeID: string }>()
const themeManager = ThemeManager.getInstance();
@@ -40,7 +43,9 @@
coverImage: null,
isEditable: true,
hideThemeName: false,
forceDark: undefined
forceTheme: undefined,
forceDark: undefined,
adaptiveCssVariables: [],
})
let closedAccordions = $state<string[]>([])
let themeLoaded = $state(false);
@@ -80,7 +85,13 @@
}))
}
theme = loadedTheme
theme = {
...loadedTheme,
adaptiveCssVariables: loadedTheme.adaptiveCssVariables ?? [],
forceTheme:
loadedTheme.forceTheme ??
(loadedTheme.forceDark !== undefined ? true : undefined),
}
themeLoaded = true
} else {
themeLoaded = true
@@ -114,6 +125,14 @@
blob: image.blob
}))
themeClone.coverImage = theme.coverImage
themeClone.userEdited = true
if (shouldForceThemeAppearance(themeClone)) {
themeClone.forceTheme = true;
} else {
themeClone.forceTheme = false;
themeClone.forceDark = undefined;
}
themeManager.clearPreview();
await themeManager.saveTheme(themeClone);
@@ -270,7 +289,7 @@
<h1 class='text-xl font-semibold'>Theme Creator</h1>
<a href='https://betterseqta.gitbook.io/betterseqta-docs' target='_blank' class='text-sm font-light text-zinc-500 dark:text-zinc-400'>
<a href='https://docs.betterseqta.org/theme-creation/' target='_blank' rel='noopener noreferrer' class='text-sm font-light text-zinc-500 dark:text-zinc-400'>
<span class='pr-0.5 no-underline font-IconFamily'>{'\ueb44'}</span>
<span class='underline'>
Need help? Check out the docs!
@@ -317,6 +336,27 @@
<Divider />
<div class="py-3">
<h2 class="text-sm font-bold">Adaptive CSS variables</h2>
<p class="text-xs text-zinc-600 dark:text-zinc-400">
One per line, each must start with <code class="text-xs">--</code>. These receive the same colour as the adaptive accent when &quot;Adaptive theme colour&quot; is enabled in general settings. Use them in Custom CSS, e.g. <code class="text-xs">border-color: var(--my-accent);</code>
</p>
<textarea
placeholder="--my-accent&#10;--class-banner"
value={theme.adaptiveCssVariables?.join('\n') ?? ''}
oninput={(e) => {
const lines = e.currentTarget.value
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
theme = { ...theme, adaptiveCssVariables: lines };
}}
class="p-2 mt-2 w-full min-h-[5rem] font-mono text-sm rounded-lg border-0 transition dark:placeholder-zinc-400 bg-zinc-200 dark:bg-zinc-700 focus:outline-none focus:ring-1 focus:ring-zinc-100 dark:focus:ring-zinc-700 focus:bg-zinc-300/50 dark:focus:bg-zinc-600"
></textarea>
</div>
<Divider />
{#each [
{
type: 'switch',
@@ -332,21 +372,28 @@
title: 'Force Theme',
description: 'Force users to use either dark or light mode',
props: {
state: theme.forceDark !== undefined,
onChange: (value: boolean) => theme = { ...theme, forceDark: value ? false : undefined }
state: shouldForceThemeAppearance(theme),
onChange: (value: boolean) => {
if (value) {
theme = { ...theme, forceTheme: true, forceDark: false };
} else {
theme = { ...theme, forceTheme: false, forceDark: undefined };
}
}
}
},
{
type: 'conditional',
props: {
condition: theme.forceDark !== undefined,
condition: shouldForceThemeAppearance(theme),
children: {
type: 'lightDarkToggle',
title: 'Mode',
description: 'Choose whether to force light or dark mode',
props: {
state: theme.forceDark === true,
onChange: (value: boolean) => theme = { ...theme, forceDark: value }
onChange: (value: boolean) =>
(theme = { ...theme, forceDark: value, forceTheme: true })
}
}
}
+45 -2
View File
@@ -1,7 +1,50 @@
export type ThemeRole = "standard" | "master" | "slave";
/** List/detail metadata for variants of a master theme (full theme.json fetched at install by id). */
export type ThemeFlavour = {
id: string;
name: string;
/** Mirrors theme.json accent (e.g. defaultColour); used for install picker buttons */
accent_color: string;
cover_image: string;
marquee_image?: string;
/** Per-variant installs when slaves are not returned as flat `theme_role` rows */
download_count?: number;
};
export type Theme = {
id: string;
name: string;
description: string;
coverImage: string;
marqueeImage: string;
id: string;
marqueeImage?: string;
theme_json_url?: string;
is_favorited?: boolean;
favorite_count?: number;
download_count?: number;
author?: string;
featured?: boolean;
tags?: string[];
/** Unix time in seconds (API list/detail). */
created_at?: number;
/** Unix seconds — last server update (GET /api/themes). */
updated_at?: number;
/** Omitted / `standard` — show in grid. `slave` hides from grid. `master` can list `flavours`. */
theme_role?: ThemeRole;
/** Present when `theme_role === "slave"` and API returns a flat list during migration */
master_id?: string;
/** Variants nested on master rows; installs use flavour `id` */
flavours?: ThemeFlavour[];
};
/** One marquee slide (cover hero or modal carousel). */
export type ThemeCoverSlide = {
imageUrl: string;
/** Main line — usually master name */
title: string;
/** Subline — flavour name when applicable */
subtitle?: string;
/** Opening the modal uses this theme (always the grid row / master object) */
openTheme: Theme;
badgeFeatured?: boolean;
};
+27
View File
@@ -0,0 +1,27 @@
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.
* Pass `document.body` to escape transformed/contained settings popups entirely.
*/
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) {
const nextDest =
newTarget ?? (root instanceof ShadowRoot ? root : document.body);
nextDest.appendChild(node);
},
destroy() {
node.remove();
},
};
};
+74
View File
@@ -0,0 +1,74 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
const THEME_CSS_VARS = [
"--better-main",
"--better-pale",
"--better-light",
"--text-color",
"--background-primary",
"--background-secondary",
"--text-primary",
"--theme-offset-bg",
"--better-sub",
] as const;
const ACCENT_CSS_VARS = [
"--better-main",
"--accent-color-value",
"--accentColor",
"--colour-betterseqta-blue",
] as const;
function extractSolidColor(value: string): string | null {
const trimmed = value.trim();
if (!trimmed || trimmed === "initial") return null;
if (
trimmed.startsWith("#") ||
trimmed.startsWith("rgb") ||
trimmed.startsWith("hsl")
) {
return trimmed;
}
if (trimmed.includes("gradient")) {
const match = trimmed.match(
/#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/i,
);
return match?.[0] ?? null;
}
return null;
}
function resolvePageAccentColor(): string {
const computed = getComputedStyle(document.documentElement);
for (const name of ACCENT_CSS_VARS) {
const solid = extractSolidColor(computed.getPropertyValue(name));
if (solid) return solid;
}
const fromSettings = settingsState.selectedColor?.trim();
if (fromSettings) {
const solid = extractSolidColor(fromSettings);
if (solid) return solid;
}
return "#007bff";
}
/** Copy SEQTA page theme tokens onto a portaled UI root (matches analytics sync). */
export function syncPageThemeToElement(target: HTMLElement): void {
const computed = getComputedStyle(document.documentElement);
for (const name of THEME_CSS_VARS) {
const value = computed.getPropertyValue(name).trim();
if (value) {
target.style.setProperty(name, value);
}
}
const accent = resolvePageAccentColor();
target.style.setProperty("--bsplus-analytics-accent", accent);
target.style.setProperty("--better-main", accent);
target.classList.toggle(
"dark",
document.documentElement.classList.contains("dark"),
);
}
+246
View File
@@ -0,0 +1,246 @@
import type { Theme, ThemeCoverSlide, ThemeFlavour } from "@/interface/types/Theme";
export function isHiddenStoreTheme(theme: Theme): boolean {
return theme.theme_role === "slave";
}
/** Coerce API / fallback rows into the store `Theme` shape (camelCase images, safe strings). */
export function normalizeStoreTheme(raw: Record<string, unknown>): Theme {
const flavours = Array.isArray(raw.flavours)
? (raw.flavours as Record<string, unknown>[]).map(
(f): ThemeFlavour => ({
id: String(f.id ?? ""),
name: String(f.name ?? ""),
accent_color: String(f.accent_color ?? f.accentColor ?? ""),
cover_image: String(f.cover_image ?? f.coverImage ?? ""),
marquee_image:
typeof (f.marquee_image ?? f.marqueeImage) === "string"
? String(f.marquee_image ?? f.marqueeImage)
: undefined,
download_count:
typeof f.download_count === "number"
? f.download_count
: typeof f.downloadCount === "number"
? f.downloadCount
: undefined,
}),
)
: undefined;
return {
id: String(raw.id ?? ""),
name: String(raw.name ?? "Untitled"),
description: String(raw.description ?? ""),
coverImage: String(raw.coverImage ?? raw.cover_image ?? ""),
marqueeImage:
typeof (raw.marqueeImage ?? raw.marquee_image) === "string"
? String(raw.marqueeImage ?? raw.marquee_image)
: undefined,
theme_json_url:
typeof (raw.theme_json_url ?? raw.themeJsonUrl) === "string"
? String(raw.theme_json_url ?? raw.themeJsonUrl)
: undefined,
is_favorited: raw.is_favorited === true || raw.isFavorited === true,
favorite_count:
typeof raw.favorite_count === "number"
? raw.favorite_count
: typeof raw.favoriteCount === "number"
? raw.favoriteCount
: undefined,
download_count:
typeof raw.download_count === "number"
? raw.download_count
: typeof raw.downloadCount === "number"
? raw.downloadCount
: undefined,
author: typeof raw.author === "string" ? raw.author : undefined,
featured: raw.featured === true,
tags: Array.isArray(raw.tags) ? (raw.tags as string[]) : undefined,
created_at:
typeof raw.created_at === "number"
? raw.created_at
: typeof raw.createdAt === "number"
? raw.createdAt
: undefined,
updated_at:
typeof raw.updated_at === "number"
? raw.updated_at
: typeof raw.updatedAt === "number"
? raw.updatedAt
: undefined,
theme_role:
raw.theme_role === "master" || raw.theme_role === "slave" || raw.theme_role === "standard"
? raw.theme_role
: undefined,
master_id:
typeof (raw.master_id ?? raw.masterId) === "string"
? String(raw.master_id ?? raw.masterId)
: undefined,
flavours,
};
}
/** Grid and search: omit slave rows (when API sends a flattened list). */
export function visibleStoreThemes(themes: Theme[]): Theme[] {
const visible = themes.filter((t) => !isHiddenStoreTheme(t));
// If every row is a slave (bad/migration payload), avoid an empty grid.
if (visible.length === 0 && themes.length > 0) {
return themes;
}
return visible;
}
function marqueeOrCoverUrl(t: { marqueeImage?: string; coverImage: string }): string {
return t.marqueeImage || t.coverImage;
}
/**
* Builds slides for CoverSwiper: for each top-N master, first master image then each flavour image.
*/
export function buildCoverSlidesForThemes(topThemes: Theme[]): ThemeCoverSlide[] {
const slides: ThemeCoverSlide[] = [];
for (const theme of topThemes) {
const flavours = theme.flavours ?? [];
if (flavours.length === 0) {
slides.push({
imageUrl: marqueeOrCoverUrl(theme),
title: theme.name,
subtitle: theme.author ? `By ${theme.author}` : undefined,
openTheme: theme,
badgeFeatured: theme.featured === true,
});
continue;
}
slides.push({
imageUrl: marqueeOrCoverUrl(theme),
title: theme.name,
subtitle: theme.author ? `By ${theme.author}` : undefined,
openTheme: theme,
badgeFeatured: theme.featured === true,
});
for (const f of flavours) {
slides.push({
imageUrl: f.marquee_image || f.cover_image,
title: theme.name,
subtitle: f.name,
openTheme: theme,
badgeFeatured: theme.featured === true,
});
}
}
return slides;
}
export type ModalHeroSlide = { imageUrl: string; caption: string };
/** Preview image for carousel + flavour picker (matches hero slide order after master slide). */
export function flavourCarouselImageUrl(f: ThemeFlavour): string {
const u = (f.marquee_image || f.cover_image || "").trim();
return u;
}
/** Preview image for master variant tile (modal hero slide 0). */
export function masterCarouselImageUrl(t: Theme): string {
return (marqueeOrCoverUrl(t) || "").trim();
}
/**
* Ordered preview URLs for the store grid card rotator: master first, then each variant.
* Uses nested `flavours` when present; otherwise flat `slave` rows (same order as modal when possible).
*/
export function gridCardPreviewImageUrls(theme: Theme, allStoreRows?: Theme[]): string[] {
const urls: string[] = [];
const push = (raw: string) => {
const u = raw.trim();
if (u && !urls.includes(u)) urls.push(u);
};
push(marqueeOrCoverUrl(theme) || "");
const flavours = theme.flavours ?? [];
if (flavours.length > 0) {
for (const f of flavours) {
push(flavourCarouselImageUrl(f));
}
return urls.length > 0 ? urls : [(theme.coverImage || "").trim()].filter(Boolean);
}
if (allStoreRows) {
const slaves = allStoreRows
.filter((t) => t.theme_role === "slave" && t.master_id === theme.id)
.sort((a, b) => a.id.localeCompare(b.id));
for (const s of slaves) {
push((marqueeOrCoverUrl(s) || "").trim());
}
}
if (urls.length > 0) return urls;
const fallback = (theme.coverImage || "").trim();
return fallback ? [fallback] : [];
}
/**
* Downloads shown on the grid card for a master row: master's count plus each slave
* (flat `theme_role === "slave"` with `master_id`) and any flavour-only `download_count`
* when there is no matching flat slave id (nested-only API shape).
*/
export function masterGridDisplayDownloadCount(master: Theme, allStoreRows: Theme[]): number {
let total = master.download_count ?? 0;
const slaveRows = allStoreRows.filter(
(t) => t.theme_role === "slave" && t.master_id === master.id,
);
const countedIds = new Set(slaveRows.map((r) => r.id));
for (const r of slaveRows) {
total += r.download_count ?? 0;
}
for (const f of master.flavours ?? []) {
if (countedIds.has(f.id)) continue;
total += f.download_count ?? 0;
}
return total;
}
/** Modal hero: master first, then each flavour (plan order). */
export function buildModalHeroSlides(theme: Theme): ModalHeroSlide[] {
const slides: ModalHeroSlide[] = [];
slides.push({
imageUrl: marqueeOrCoverUrl(theme),
caption: theme.name,
});
const flavours = theme.flavours ?? [];
for (const f of flavours) {
slides.push({
imageUrl: f.marquee_image || f.cover_image,
caption: `${theme.name}${f.name}`,
});
}
return slides;
}
/**
* Relative luminance 01 for rgba/rgb/hex-ish strings; fallback 0.5 white text
*/
export function pickContrastingTextColor(backgroundCss: string): "#ffffff" | "#0a0a0a" {
const s = backgroundCss.trim();
const rgba = s.match(
/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i,
);
if (rgba) {
const r = Number(rgba[1]) / 255;
const g = Number(rgba[2]) / 255;
const b = Number(rgba[3]) / 255;
const a = rgba[4] !== undefined ? Number(rgba[4]) : 1;
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const effective = lum * a + 0.05 * (1 - a);
return effective > 0.45 ? "#0a0a0a" : "#ffffff";
}
const hex = s.match(/^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
if (hex) {
const r = parseInt(hex[1], 16) / 255;
const g = parseInt(hex[2], 16) / 255;
const b = parseInt(hex[3], 16) / 255;
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return lum > 0.45 ? "#0a0a0a" : "#ffffff";
}
return "#ffffff";
}
+27
View File
@@ -0,0 +1,27 @@
import * as pdfjs from "pdfjs-dist";
import browser from "webextension-polyfill";
/** Static copies in `src/public` (see `scripts/copy-pdfjs-assets.mjs`, manifest web_accessible_resources). */
const PDF_WORKER_RESOURCE = "resources/pdfjs/pdf.worker.min.mjs";
const PDF_LEGACY_RESOURCE = "resources/pdfjs/pdf.legacy.min.mjs";
function extensionAssetUrl(relativePath: string): string {
return browser.runtime.getURL(relativePath.replace(/^\/+/, ""));
}
let workerConfigured = false;
/** Required before pdfjs spawns a worker (content-script / extension isolate). */
export function ensurePdfjsWorker(): void {
if (workerConfigured) return;
pdfjs.GlobalWorkerOptions.workerSrc = extensionAssetUrl(PDF_WORKER_RESOURCE);
workerConfigured = true;
}
/** Page-context script on Firefox must load these chrome-extension:// URLs (see web_accessible_resources). */
export function getPdfjsPageContextUrls(): { lib: string; worker: string } {
return {
lib: extensionAssetUrl(PDF_LEGACY_RESOURCE),
worker: extensionAssetUrl(PDF_WORKER_RESOURCE),
};
}
+8 -3
View File
@@ -16,12 +16,12 @@
}
},
"permissions": ["tabs", "notifications", "storage"],
"host_permissions": ["https://newsapi.org/", "*://*/*"],
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
"background": {
"service_worker": "background.ts"
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org"
},
"content_scripts": [
{
@@ -32,7 +32,12 @@
],
"web_accessible_resources": [
{
"resources": ["resources/icons/*", "resources/update-image.webp"],
"resources": [
"resources/icons/*",
"resources/update-image.webp",
"resources/pdfjs/pdf.worker.min.mjs",
"resources/pdfjs/pdf.legacy.min.mjs"
],
"matches": ["*://*/*"]
}
]
@@ -6,6 +6,7 @@ import {
Setting,
} from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline";
import { waitForElm } from "@/seqta/utils/waitForElm";
const settings = defineSettings({
speed: numberSetting({
@@ -35,13 +36,10 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
settings: instance.settings,
run: async (api) => {
// Create the background elements
const container = document.getElementById("container");
const menu = document.getElementById("menu");
if (!container || !menu) {
return () => {};
}
const [container, menu] = await Promise.all([
waitForElm("#container", true),
waitForElm("#menu", true),
]);
const backgrounds = [
{ classes: ["bg"] },
@@ -0,0 +1,46 @@
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
function randomEngagePdfFileName(): string {
const token = Math.random().toString(36).slice(2, 10);
return `${token}.pdf`;
}
export async function requestEngageAssessmentPdf(params: {
assessmentID: string | number;
metaclassID: string | number;
studentID: string | number;
}): Promise<string> {
const fileName = randomEngagePdfFileName();
const cacheBuster = Math.random().toString(36).slice(2, 10);
const response = await fetch(
`${location.origin}/seqta/parent/print/assessment?${cacheBuster}`,
{
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({
id: params.assessmentID,
metaclass: params.metaclassID,
student: Number(params.studentID),
fileName,
}),
},
);
if (!response.ok) {
throw new Error(
`Failed to generate PDF: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as {
payload?: { file?: string };
};
return data.payload?.file ?? fileName;
}
export function getEngageAssessmentReportUrl(fileName: string): string {
return `${location.origin}/seqta/parent/report/get?file=${encodeURIComponent(fileName)}`;
}
+176 -121
View File
@@ -7,19 +7,21 @@ import {
import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import {
clearStuck,
getClassByPattern,
initStorage,
injectWeightingsTab,
letterToNumber,
parseAssessments,
processAssessments,
} from "./utils.ts";
import { injectRubricCopyButtons } from "./rubricCopy.ts";
interface weightingsStorage {
weightings: Record<string, string>;
assessments: Record<string, string>;
weightingOverrides: Record<string, string>;
}
const settings = defineSettings({
@@ -37,6 +39,8 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
const instance = new AssessmentsAveragePluginClass();
let overrideListenerController: AbortController | null = null;
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
id: "assessments-average",
name: "Assessment Averages",
@@ -58,130 +62,181 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
);
await parseAssessments(api);
const sampleAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___']",
await renderSubjectAverage(api);
overrideListenerController?.abort();
overrideListenerController = new AbortController();
document.addEventListener(
"betterseqta:overrideChanged",
() => renderSubjectAverage(api),
{ signal: overrideListenerController.signal },
);
if (!sampleAssessmentItem) return;
const assessmentItemClass =
Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"),
) || "";
const metaContainerClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__metaContainer___",
);
const metaClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__meta___",
);
const simpleResultClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__simpleResult___",
);
const titleClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__title___",
);
const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
if (!thermoscoreElement) return;
const thermoscoreClass =
Array.from(thermoscoreElement.classList).find((c) =>
c.startsWith("Thermoscore__Thermoscore___"),
) || "";
const fillClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__fill___",
);
const textClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__text___",
);
const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
if (!assessmentsList) return;
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
if (!marks || !marks.length) return;
const assessmentItems = Array.from(
assessmentsList.querySelectorAll(
`[class*='AssessmentItem__AssessmentItem___']`,
),
).filter(
(item) =>
!item
.querySelector(`[class*='AssessmentItem__title___']`)
?.textContent?.includes("Subject Average"),
);
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
await processAssessments(api, assessmentItems);
if (!count || totalWeight === 0) return;
const avg = weightedTotal / totalWeight;
const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce(
(acc, [k, v]) => {
acc[v] = k;
return acc;
},
{} as Record<number, string>,
);
const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade
? letterAvg
: `${avg.toFixed(2)}%`;
const existing = assessmentsList.querySelector(
`[class*='AssessmentItem__title___']`,
);
if (existing?.textContent === "Subject Average") return;
let warningHTML = "";
if (hasInaccurateWeighting) {
warningHTML = /* html */ `
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
Some weightings unavailable
</div>
`;
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
const observer = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
}
assessmentsList.insertBefore(
stringToHTML(/* html */ `
<div class="${assessmentItemClass}">
<div class="${metaContainerClass}">
<div class="${metaClass}">
<div class="${simpleResultClass}">
<div class="${titleClass}">Subject Average</div>
${warningHTML}
</div>
</div>
</div>
<div class="${thermoscoreClass}">
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
</div>
</div>
</div>
`).firstChild!,
assessmentsList.firstChild,
);
});
api.seqta.onMount("[class*='SelectedAssessment__']", () => {
injectWeightingsTab(api);
injectRubricCopyButtons();
});
},
};
let renderInFlight = false;
async function renderSubjectAverage(api: any) {
if (renderInFlight) return;
renderInFlight = true;
try {
const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
if (!assessmentsList) return;
// Remove existing subject average before re-rendering
Array.from(
assessmentsList.querySelectorAll(`[class*='AssessmentItem__title___']`),
)
.find((el) => el.textContent === "Subject Average")
?.closest("[class*='AssessmentItem__AssessmentItem___']")
?.remove();
const sampleAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___']",
);
if (!sampleAssessmentItem) return;
const assessmentItemClass =
Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"),
) || "";
const metaContainerClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__metaContainer___",
);
const metaClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__meta___",
);
const simpleResultClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__simpleResult___",
);
const titleClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__title___",
);
const assessmentItems = Array.from(
assessmentsList.querySelectorAll(
`[class*='AssessmentItem__AssessmentItem___']`,
),
).filter(
(item) =>
!item
.querySelector(`[class*='AssessmentItem__title___']`)
?.textContent?.includes("Subject Average"),
);
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
await processAssessments(api, assessmentItems);
if (!count || totalWeight === 0) return;
const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
if (!thermoscoreElement) return;
const thermoscoreClass =
Array.from(thermoscoreElement.classList).find((c) =>
c.startsWith("Thermoscore__Thermoscore___"),
) || "";
const fillClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__fill___",
);
const textClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__text___",
);
const avg = weightedTotal / totalWeight;
const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce(
(acc, [k, v]) => {
acc[v] = k;
return acc;
},
{} as Record<number, string>,
);
const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
let warningHTML = "";
if (hasInaccurateWeighting) {
warningHTML = /* html */ `
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
Some weightings unavailable
</div>
`;
}
assessmentsList.insertBefore(
stringToHTML(/* html */ `
<div class="${assessmentItemClass}">
<div class="${metaContainerClass}">
<div class="${metaClass}">
<div class="${simpleResultClass}">
<div class="${titleClass}">Subject Average</div>
${warningHTML}
</div>
</div>
</div>
<div class="${thermoscoreClass}">
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
</div>
</div>
</div>
`).firstChild!,
assessmentsList.firstChild,
);
applySubjectColourToOverallResult();
} finally {
renderInFlight = false;
}
}
function applySubjectColourToOverallResult() {
const selectedAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
) || document.querySelector(
"[class*='Collapsible__content___'] [class*='AssessmentItem__AssessmentItem___']",
);
const assessmentThermoscore = selectedAssessmentItem?.querySelector(
"[class*='Thermoscore__Thermoscore___']",
) as HTMLElement | null;
const overallResult = document.querySelector(
"[class*='OverallResult__OverallResult___']",
) as HTMLElement | null;
const assessableCriterionHeaders = document.querySelectorAll(
"[class*='AssessableCriterion__header___']",
);
if (assessmentThermoscore && (overallResult || assessableCriterionHeaders.length > 0)) {
const accentColour =
getComputedStyle(assessmentThermoscore).getPropertyValue("--assessment-accent-colour").trim() ||
getComputedStyle(assessmentThermoscore).getPropertyValue("--fill-colour").trim() ||
getComputedStyle(assessmentThermoscore.closest("[class*='Collapsible__Collapsible___']") || assessmentThermoscore).getPropertyValue("--assessment-accent-colour").trim() ||
getComputedStyle(assessmentThermoscore.closest("[class*='Collapsible__Collapsible___']") || assessmentThermoscore).getPropertyValue("--item-colour").trim();
if (accentColour) {
overallResult?.style.setProperty("--assessment-accent-colour", accentColour);
overallResult?.style.setProperty("--fill-colour", accentColour);
assessableCriterionHeaders.forEach((el) => {
(el as HTMLElement).style.setProperty("--assessment-accent-colour", accentColour);
(el as HTMLElement).style.setProperty("--fill-colour", accentColour);
});
}
}
}
export default assessmentsAveragePlugin;
@@ -0,0 +1,388 @@
const RUBRIC_SELECTOR =
"[class*='AssessableCriterion__rubric___'][class*='Rubric__Rubric___'], [class*='Rubric__Rubric___'][class*='AssessableCriterion__rubric___']";
const ENHANCED_ATTR = "data-betterseqta-rubric-copy";
const STYLE_ID = "betterseqta-rubric-copy-styles-v2";
let observer: MutationObserver | null = null;
function ensureStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.betterseqta-rubric-copy-host {
position: relative;
}
.betterseqta-rubric-copy-overlay {
position: absolute;
inset: auto 0 0 0;
display: flex;
justify-content: flex-end;
align-items: flex-end;
padding: 0.75rem 0.85rem;
pointer-events: none;
opacity: 0;
transform: translateY(10px);
transition:
opacity 0.35s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.72) 0%,
rgba(0, 0, 0, 0.42) 42%,
rgba(0, 0, 0, 0.08) 72%,
transparent 100%
);
border-radius: 0 0 8px 8px;
z-index: 5;
}
.betterseqta-rubric-copy-host:hover .betterseqta-rubric-copy-overlay,
.betterseqta-rubric-copy-host:focus-within .betterseqta-rubric-copy-overlay {
opacity: 1;
transform: translateY(0);
}
.betterseqta-rubric-copy-btn {
pointer-events: auto;
display: inline-flex !important;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.75rem !important;
margin: 0 !important;
border: 1px solid rgba(15, 23, 42, 0.12) !important;
border-radius: 8px !important;
background: rgba(255, 255, 255, 0.96) !important;
color: #0f172a !important;
font-family: Rubik, system-ui, sans-serif !important;
font-size: 0.8125rem !important;
font-weight: 600 !important;
line-height: 1 !important;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.28);
transform: translateY(0) scale(1);
transition:
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
background 0.28s ease,
color 0.28s ease,
box-shadow 0.28s ease,
border-color 0.28s ease;
}
.betterseqta-rubric-copy-btn:hover {
transform: translateY(-1px) scale(1.04) !important;
background: #f1f5f9 !important;
color: #0f172a !important;
border-color: rgba(15, 23, 42, 0.18) !important;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.32);
}
.betterseqta-rubric-copy-btn:active {
transform: translateY(0) scale(0.98) !important;
background: #e2e8f0 !important;
color: #0f172a !important;
}
.betterseqta-rubric-copy-btn:focus-visible {
outline: none !important;
box-shadow:
0 0 0 2px rgba(255, 255, 255, 0.95),
0 0 0 4px rgba(59, 130, 246, 0.85) !important;
}
.betterseqta-rubric-copy-btn svg {
width: 1rem !important;
height: 1rem !important;
flex-shrink: 0;
stroke: currentColor !important;
fill: none !important;
}
.betterseqta-rubric-copy-btn.is-copied {
background: #ecfdf5 !important;
color: #047857 !important;
border-color: rgba(4, 120, 87, 0.25) !important;
}
.betterseqta-rubric-copy-btn.is-copied:hover {
background: #d1fae5 !important;
color: #065f46 !important;
}
@media (prefers-reduced-motion: reduce) {
.betterseqta-rubric-copy-overlay,
.betterseqta-rubric-copy-btn {
transition: none;
}
}
`;
document.head.appendChild(style);
}
function cellText(element: Element | null | undefined): string {
return element?.textContent?.replace(/\s+/g, " ").trim() ?? "";
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
interface RubricCell {
text: string;
selected: boolean;
}
interface RubricTableData {
header: string[];
rows: RubricCell[][];
}
function parseRubricTable(rubric: Element): RubricTableData | null {
const lines = rubric.querySelectorAll("[class*='Rubric__line___']");
const rows: RubricCell[][] = [];
lines.forEach((line) => {
const meta = line.querySelector("[class*='Rubric__meta___']");
const label = cellText(meta?.querySelector("[class*='Rubric__label___']"));
const criterion = cellText(
meta?.querySelector("[class*='Rubric__description___']"),
);
const row: RubricCell[] = [
{ text: label, selected: false },
{ text: criterion, selected: false },
];
line.querySelectorAll("[class*='Rubric__descriptor___']").forEach((descriptor) => {
const text = cellText(
descriptor.querySelector("[class*='Rubric__description___']"),
);
const selected = Array.from(descriptor.classList).some((cls) =>
cls.startsWith("Rubric__selected___"),
);
row.push({ text, selected });
});
if (row.some((cell) => cell.text)) rows.push(row);
});
if (!rows.length) return null;
const maxCols = Math.max(...rows.map((row) => row.length));
const normalized = rows.map((row) => {
const copy = [...row];
while (copy.length < maxCols) {
copy.push({ text: "", selected: false });
}
return copy;
});
const header = [
"Category",
"Criterion",
...Array.from({ length: maxCols - 2 }, (_, i) => `Band ${i + 1}`),
].slice(0, maxCols);
return { header, rows: normalized };
}
function rubricToPlainText(table: RubricTableData): string {
const formatCell = (cell: RubricCell) =>
cell.selected && cell.text ? `${cell.text} (selected)` : cell.text;
return [
table.header.join("\t"),
...table.rows.map((row) => row.map(formatCell).join("\t")),
].join("\n");
}
const RUBRIC_PASTE_FONT_PT = 7;
function rubricPasteFontStyle(): string {
return [
`font-size:${RUBRIC_PASTE_FONT_PT}pt`,
"mso-ansi-font-size:8.0pt",
"mso-bidi-font-size:8.0pt",
"font-family:Calibri,Arial,sans-serif",
"line-height:1.2",
].join(";");
}
function rubricPasteCellContent(text: string): string {
return `<span style="${rubricPasteFontStyle()}">${escapeHtml(text)}</span>`;
}
function rubricToHtmlTable(table: RubricTableData): string {
const baseFont = rubricPasteFontStyle();
const cellStyle =
`border:1px solid #000000;border-collapse:collapse;padding:4px;vertical-align:top;${baseFont}`;
const headerStyle = `${cellStyle}background:#f3f4f6;font-weight:700;`;
const selectedStyle = `${cellStyle}background:#dbeafe;font-weight:600;`;
const headerRow = table.header
.map(
(heading) =>
`<th style="${headerStyle}">${rubricPasteCellContent(heading)}</th>`,
)
.join("");
const bodyRows = table.rows
.map((row) => {
const cells = row
.map((cell) => {
const style = cell.selected ? selectedStyle : cellStyle;
return `<td style="${style}">${rubricPasteCellContent(cell.text)}</td>`;
})
.join("");
return `<tr>${cells}</tr>`;
})
.join("");
return [
`<table border="1" cellpadding="0" cellspacing="0" style="border-collapse:collapse;width:100%;${baseFont}">`,
`<thead><tr>${headerRow}</tr></thead>`,
`<tbody>${bodyRows}</tbody>`,
"</table>",
].join("");
}
function rubricToHtmlDocument(table: RubricTableData): string {
return [
"<!DOCTYPE html>",
"<html>",
"<head><meta charset=\"utf-8\"></head>",
`<body style="${rubricPasteFontStyle()}">`,
rubricToHtmlTable(table),
"</body>",
"</html>",
].join("");
}
async function copyRubricTable(rubric: Element, button: HTMLButtonElement) {
const table = parseRubricTable(rubric);
if (!table) return;
const plain = rubricToPlainText(table);
const htmlTable = rubricToHtmlTable(table);
const htmlDocument = rubricToHtmlDocument(table);
let copied = false;
if (navigator.clipboard?.write && typeof ClipboardItem !== "undefined") {
try {
await navigator.clipboard.write([
new ClipboardItem({
"text/plain": new Blob([plain], { type: "text/plain" }),
"text/html": new Blob([htmlDocument], { type: "text/html" }),
}),
]);
copied = true;
} catch {
// Fall through to legacy rich-text copy.
}
}
if (!copied) {
const host = document.createElement("div");
host.contentEditable = "true";
host.innerHTML = htmlTable;
host.style.position = "fixed";
host.style.left = "-9999px";
host.style.top = "0";
document.body.appendChild(host);
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(host);
selection?.removeAllRanges();
selection?.addRange(range);
copied = document.execCommand("copy");
selection?.removeAllRanges();
host.remove();
if (!copied) {
await navigator.clipboard.writeText(plain);
}
}
const label = button.querySelector(".betterseqta-rubric-copy-label");
const original = label?.textContent ?? "Copy rubric";
button.classList.add("is-copied");
if (label) label.textContent = "Copied!";
window.setTimeout(() => {
button.classList.remove("is-copied");
if (label) label.textContent = original;
}, 1800);
}
function createCopyButton(rubric: Element): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "betterseqta-rubric-copy-btn";
button.setAttribute("aria-label", "Copy rubric");
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
<span class="betterseqta-rubric-copy-label">Copy rubric</span>
`;
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
void copyRubricTable(rubric, button);
});
return button;
}
function enhanceRubric(rubric: HTMLElement) {
if (rubric.hasAttribute(ENHANCED_ATTR)) return;
const host = document.createElement("div");
host.className = "betterseqta-rubric-copy-host";
rubric.parentElement?.insertBefore(host, rubric);
host.appendChild(rubric);
const overlay = document.createElement("div");
overlay.className = "betterseqta-rubric-copy-overlay";
overlay.appendChild(createCopyButton(rubric));
host.appendChild(overlay);
rubric.setAttribute(ENHANCED_ATTR, "true");
}
function enhanceRubrics(root: ParentNode = document) {
ensureStyles();
root.querySelectorAll<HTMLElement>(RUBRIC_SELECTOR).forEach(enhanceRubric);
}
function watchRubrics(root: ParentNode) {
observer?.disconnect();
enhanceRubrics(root);
observer = new MutationObserver(() => {
enhanceRubrics(root);
});
observer.observe(root, { childList: true, subtree: true });
}
export function injectRubricCopyButtons() {
const root =
document.querySelector("[class*='SelectedAssessment__']") ?? document;
watchRubrics(root);
}
export function teardownRubricCopyButtons() {
observer?.disconnect();
observer = null;
}
+515 -82
View File
@@ -1,8 +1,18 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
import {
getEngageAssessmentReportUrl,
requestEngageAssessmentPdf,
} from "./engage.ts";
import {
ensurePdfjsWorker,
getPdfjsPageContextUrls,
} from "@/lib/pdfjsExtension.ts";
import * as pdfjs from "pdfjs-dist";
pdfjs.GlobalWorkerOptions.workerSrc =
"https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.worker.min.mjs";
ensurePdfjsWorker();
export async function initStorage(api: any) {
await api.storage.loaded;
@@ -13,6 +23,9 @@ export async function initStorage(api: any) {
if (!api.storage.assessments) {
api.storage.assessments = {};
}
if (!api.storage.weightingOverrides) {
api.storage.weightingOverrides = {};
}
}
export function clearStuck(api: any) {
@@ -71,51 +84,179 @@ function parseGrade(text: string): number {
return letterToNumber[str] ?? 0;
}
function formatWeightDisplay(weighting: string): string {
return `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`;
}
function saveWeightingOverride(
api: any,
assessmentID: string,
raw: string,
): { ok: boolean; error?: string } {
const trimmed = raw.trim();
if (trimmed === "") {
const { [assessmentID]: _, ...rest } = api.storage.weightingOverrides;
api.storage.weightingOverrides = rest;
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
return { ok: true };
}
const val = parseFloat(trimmed);
if (isNaN(val) || val < 0) {
return { ok: false, error: "Invalid. Must be 0 or greater" };
}
api.storage.weightingOverrides = {
...api.storage.weightingOverrides,
[assessmentID]: String(val),
};
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
return { ok: true };
}
function attachWeightInputListeners(
input: HTMLInputElement,
api: any,
assessmentID: string,
) {
const save = () => {
const result = saveWeightingOverride(api, assessmentID, input.value);
input.style.borderColor = result.ok
? "rgba(128,128,128,0.35)"
: "rgba(255,80,80,0.6)";
};
input.addEventListener("blur", save);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") input.blur();
});
}
function updateWeightLabelContent(
weightLabel: HTMLElement,
weighting: string | undefined,
assessmentID: string | undefined,
api: any,
) {
const existingInput = weightLabel.querySelector(
".betterseqta-weight-input",
) as HTMLInputElement | null;
if (existingInput && document.activeElement === existingInput) return;
weightLabel.querySelector(".betterseqta-weight-value")?.remove();
weightLabel.querySelector(".betterseqta-weight-input")?.remove();
Array.from(weightLabel.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim())
.forEach((node) => node.remove());
weightLabel.title = "";
if (weighting === "processing") {
const span = document.createElement("span");
span.className = "betterseqta-weight-value";
span.textContent = "...";
span.style.opacity = "0.5";
weightLabel.appendChild(span);
return;
}
if (weighting === "N/A" && assessmentID) {
const input = document.createElement("input");
input.type = "number";
input.min = "0";
input.step = "5";
input.className = "betterseqta-weight-input";
input.placeholder = "Set %";
input.setAttribute("aria-label", "Assessment weighting percentage");
input.style.cssText =
"width:52px;padding:1px 4px;border-radius:4px;border:1px solid rgba(128,128,128,0.35);background:rgba(128,128,128,0.08);color:inherit;font-size:inherit;outline:none;";
attachWeightInputListeners(input, api, assessmentID);
weightLabel.appendChild(input);
weightLabel.title = "Enter assessment weighting %";
return;
}
const span = document.createElement("span");
span.className = "betterseqta-weight-value";
span.textContent =
weighting && weighting !== "N/A"
? formatWeightDisplay(weighting)
: "N/A";
weightLabel.appendChild(span);
}
function createWeightLabel(
assessmentItem: Element,
weighting: string | undefined,
api: any,
) {
const statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___']`,
) as HTMLElement;
let statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
) as HTMLElement | null;
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";
if (!statsContainer) {
const statsClass = getClassByPattern(document, "AssessmentItem__stats___");
statsContainer = document.createElement("div");
statsContainer.className = statsClass;
statsContainer.classList.add("betterseqta-stats-container");
const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`);
if (thermoscore) {
thermoscore.insertAdjacentElement("afterend", statsContainer);
} else {
assessmentItem.appendChild(statsContainer);
}
}
statsContainer.style.position = "relative";
weightLabel.style.position = "absolute";
weightLabel.style.right = "0";
weightLabel.style.top = "50%";
weightLabel.style.transform = "translateY(-50%)";
const hasNativeLabel = !!statsContainer.querySelector(
`[class*='Label__Label___']:not(.betterseqta-weight-label)`,
);
statsContainer.style.justifyContent = hasNativeLabel
? "space-between"
: "flex-end";
const title = assessmentItem
.querySelector(`[class*='AssessmentItem__title___']`)
?.textContent?.trim();
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
const existingLabel = statsContainer.querySelector(
".betterseqta-weight-label",
) as HTMLElement | null;
if (existingLabel) {
updateWeightLabelContent(existingLabel, weighting, assessmentID, api);
return;
}
statsContainer.style.display = "flex";
statsContainer.style.alignItems = "center";
statsContainer.style.width = "100%";
// Try to clone an existing label from the stats container first,
// fall back to building from scratch if none exists
const existingNativeLabel = statsContainer.querySelector(
`[class*='Label__Label___']`,
) as HTMLElement | null;
const weightLabel = existingNativeLabel
? (existingNativeLabel.cloneNode(true) as HTMLElement)
: (() => {
const labelClass = getClassByPattern(document, "Label__Label___");
const innerTextClass = getClassByPattern(document, "Label__innerText___");
const el = document.createElement("label");
el.className = labelClass;
el.innerHTML = `<div class="${innerTextClass}">Weight</div>`;
return el;
})();
weightLabel.classList.add("betterseqta-weight-label");
weightLabel.style.flex = "none";
weightLabel.style.width = "fit-content";
const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`);
if (innerTextDiv) innerTextDiv.textContent = "Weight";
updateWeightLabelContent(weightLabel, weighting, assessmentID, api);
statsContainer.appendChild(weightLabel);
}
@@ -219,6 +360,13 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
export async function extractPDFText(url: string): Promise<string> {
try {
if (isFirefox) {
const { lib: pdfLibUrl, worker: pdfWorkerUrl } =
getPdfjsPageContextUrls();
const escJsSingleQuoted = (s: string) =>
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
@@ -232,13 +380,15 @@ export async function extractPDFText(url: string): Promise<string> {
(function() {
const requestId = '${requestId}';
const url = '${escapedUrl}';
const pdfLibSrc = '${pdfLibInj}';
const pdfWorkerSrc = '${pdfWorkerInj}';
if (window.pdfjsLib) {
extractPDF();
} else {
const pdfjsScript = document.createElement('script');
pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.min.js';
pdfjsScript.type = 'text/javascript';
pdfjsScript.src = pdfLibSrc;
pdfjsScript.type = 'module';
pdfjsScript.onload = function() {
extractPDF();
@@ -256,7 +406,7 @@ export async function extractPDFText(url: string): Promise<string> {
function extractPDF() {
try {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = '';
window.pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
@@ -411,8 +561,6 @@ export async function extractPDFText(url: string): Promise<string> {
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 (
@@ -433,35 +581,55 @@ async function handleWeightings(mark: any, api: any) {
};
try {
const filename =
"BetterSEQTA-" +
String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
let pdfUrl: string;
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 (isSeqtaEngageExperience()) {
const studentID = getEngageAssessmentStudentId();
if (!studentID) {
throw new Error("Could not resolve Engage student ID from URL or storage");
}
if (!printResponse.ok) {
throw new Error(
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
const reportFile = await requestEngageAssessmentPdf({
assessmentID,
metaclassID,
studentID,
});
await new Promise((resolve) => setTimeout(resolve, 1000));
pdfUrl = getEngageAssessmentReportUrl(reportFile);
} else {
const userInfo = await getUserInfo();
const userID = userInfo.id;
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));
pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
}
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}`);
}
@@ -502,7 +670,11 @@ export async function parseAssessments(api: any) {
"[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
const marks = [
...(state["marks"] ?? []),
...(state["upcoming"] ?? []),
...(state["pending"] ?? []),
];
if (!marks) return;
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
@@ -515,15 +687,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
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___']`,
);
@@ -533,11 +696,22 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
if (!title) continue;
const assessmentID = api.storage.assessments?.[title];
const weighting = assessmentID
const autoWeighting = assessmentID
? api.storage.weightings?.[assessmentID]
: undefined;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
const weighting = override ?? autoWeighting;
createWeightLabel(assessmentItem, weighting);
createWeightLabel(assessmentItem, weighting, api);
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
if (
weighting === null ||
@@ -546,8 +720,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
weighting === "processing"
) {
hasInaccurateWeighting = true;
weightedTotal += grade;
totalWeight += 1;
continue
} else {
const weight = parseFloat(weighting);
@@ -570,3 +743,263 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
count,
};
}
function resolveTabSetClasses(): Record<string, string> {
const patterns = [
"TabSet__tabsheet___",
"TabSet__hidden___",
"TabSet__selected___",
"TabSet__disappearToLeft___",
"TabSet__disappearToRight___",
"TabSet__appearFromRight___",
"TabSet__appearFromLeft___",
];
const resolved: Record<string, string> = {};
// First pass: scan live DOM elements (fast, covers currently-applied classes)
const allClasses = Array.from(
document.querySelectorAll('[class*="TabSet__"]'),
).flatMap((el) => Array.from(el.classList));
for (const pattern of patterns) {
const found = allClasses.find((c) => c.startsWith(pattern));
if (found) resolved[pattern] = found;
}
// Second pass: scan stylesheets for any classes not yet in the DOM
// (e.g. animation classes that haven't been applied yet)
const missing = patterns.filter((p) => !resolved[p]);
if (missing.length > 0) {
try {
for (const sheet of Array.from(document.styleSheets)) {
if (missing.every((p) => resolved[p])) break;
try {
for (const rule of Array.from(sheet.cssRules ?? [])) {
if (!(rule instanceof CSSStyleRule)) continue;
const selectorClasses =
rule.selectorText.match(/\.([\w-]+)/g) ?? [];
for (const pattern of missing) {
if (!resolved[pattern]) {
const match = selectorClasses.find((c) =>
c.slice(1).startsWith(pattern),
);
if (match) resolved[pattern] = match.slice(1);
}
}
}
} catch {
// Cross-origin stylesheet
}
}
} catch {}
}
// Fallback: use the base pattern as-is so the function doesn't crash,
// though styles won't apply if the hash is truly unknown.
for (const pattern of patterns) {
if (!resolved[pattern]) resolved[pattern] = pattern;
}
return resolved;
}
function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
const titleEl = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']",
);
const title = titleEl?.textContent?.trim();
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
const rawWeight = assessmentID
? api.storage.weightings?.[assessmentID]
: undefined;
const weightingUnavailable = rawWeight === "N/A";
const autoWeight =
rawWeight && rawWeight !== "processing" && rawWeight !== "N/A"
? rawWeight
: undefined;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
const statusNote = !assessmentID
? ""
: rawWeight === "processing"
? "Weighting is still being detected."
: weightingUnavailable
? "No weighting was found in the marksheet. Set one manually."
: "Overrides the auto-detected value.";
sheet.innerHTML = `
<style>
#betterseqta-weight-override::placeholder {
opacity: 0.4;
}
</style>
<div style="padding:16px;max-width:360px">
<h2 style="margin:0 0 4px;font-size:15px;font-weight:600">Weighting Override</h2>
<p style="margin:0 0 16px;font-size:12px;opacity:0.6">
Set the weighting for this assessment.
${statusNote}
</p>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<label style="font-size:13px;opacity:0.7;flex-shrink:0">Auto-detected</label>
<span style="font-size:13px;opacity:${autoWeight != null ? "1" : "0.4"}">${autoWeight != null ? `${autoWeight}%` : "none"}</span>
</div>
<div style="display:flex;align-items:center;gap:12px">
<label for="betterseqta-weight-override" style="font-size:13px;opacity:0.7;flex-shrink:0">${weightingUnavailable ? "Weight %" : "Override %"}</label>
<input
id="betterseqta-weight-override"
type="number"
min="0"
step="5"
placeholder="${weightingUnavailable ? "Enter weight" : autoWeight ?? ""}"
value="${override ?? ""}"
${!assessmentID ? "disabled" : ""}
style="
width:90px;
padding:5px 8px;
border-radius:6px;
border:1px solid rgba(128,128,128,0.3);
background:rgba(128,128,128,0.08);
color:inherit;
font-size:13px;
outline:none;
"
/>
</div>
<div style="margin-top:10px;min-height:18px">
<span class="betterseqta-save-status" style="font-size:12px;opacity:0.5"></span>
</div>
${!assessmentID ? `<p style="font-size:12px;color:rgba(255,80,80,0.8);margin-top:8px">Assessment not yet indexed — try refreshing.</p>` : ""}
</div>
`;
if (!assessmentID) return;
const input = sheet.querySelector(
"#betterseqta-weight-override",
) as HTMLInputElement;
const statusEl = sheet.querySelector(
".betterseqta-save-status",
) as HTMLElement;
const save = () => {
const raw = input.value.trim();
if (raw === "") {
const result = saveWeightingOverride(api, assessmentID, "");
if (!result.ok) return;
input.style.borderColor = "rgba(128,128,128,0.3)";
} else {
const result = saveWeightingOverride(api, assessmentID, raw);
if (!result.ok) {
input.style.borderColor = "rgba(255,80,80,0.6)";
statusEl.textContent = result.error ?? "Invalid. Must be 0 or greater";
statusEl.style.color = "rgba(255,80,80,0.8)";
return;
}
input.style.borderColor = "rgba(128,128,128,0.3)";
}
statusEl.textContent = "Saved";
statusEl.style.color = "";
setTimeout(() => (statusEl.textContent = ""), 2000);
};
input.addEventListener("blur", save);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
input.blur();
save();
}
});
input.addEventListener("input", () => {
input.style.borderColor = "rgba(128,128,128,0.3)";
if (statusEl.textContent === "Invalid. Must be 0 or greater.")
statusEl.textContent = "";
});
}
export function injectWeightingsTab(api: any) {
const tabList = document.querySelector(
'[class*="TabSet__tabs___"]',
) as HTMLElement;
const container = document.querySelector(
'[class*="TabSet__tabContainer___"]',
) as HTMLElement;
if (!tabList || !container) return;
if (tabList.querySelector(".betterseqta-weightings-tab")) return;
const cls = resolveTabSetClasses();
const prefix = (tabList.querySelector("li") as HTMLElement).id.replace(
/-tab-\d+$/,
"",
);
const newIndex = tabList.querySelectorAll("li").length;
const newTab = document.createElement("li");
newTab.id = `${prefix}-tab-${newIndex}`;
newTab.className = "";
newTab.setAttribute("aria-selected", "false");
newTab.setAttribute("aria-controls", `${prefix}-tabsheet-${newIndex}`);
newTab.classList.add("betterseqta-weightings-tab");
newTab.textContent = "Weightings";
tabList.appendChild(newTab);
const newSheet = document.createElement("div");
newSheet.id = `${prefix}-tabsheet-${newIndex}`;
newSheet.setAttribute("aria-labelledby", `${prefix}-tab-${newIndex}`);
newSheet.className = [
cls["TabSet__tabsheet___"],
cls["TabSet__hidden___"],
cls["TabSet__disappearToRight___"],
].join(" ");
container.appendChild(newSheet);
newTab.addEventListener("click", () => {
buildWeightingsTabContent(api, newSheet);
});
const allTabs = Array.from(tabList.querySelectorAll("li"));
const allSheets = Array.from(
container.querySelectorAll('[class*="tabsheet"]'),
);
allTabs.forEach((tab, i) => {
tab.addEventListener("click", () => {
const currentIndex = allTabs.findIndex((t) =>
t.className.includes("TabSet__selected___"),
);
if (i === currentIndex) return;
const goingRight = i > currentIndex;
allTabs.forEach((t) => {
t.className = "";
t.setAttribute("aria-selected", "false");
});
allSheets[currentIndex].className = [
cls["TabSet__tabsheet___"],
cls["TabSet__hidden___"],
goingRight
? cls["TabSet__disappearToLeft___"]
: cls["TabSet__disappearToRight___"],
].join(" ");
allSheets[i].className = [
cls["TabSet__tabsheet___"],
cls["TabSet__selected___"],
goingRight
? cls["TabSet__appearFromRight___"]
: cls["TabSet__appearFromLeft___"],
].join(" ");
tab.className = cls["TabSet__selected___"];
tab.setAttribute("aria-selected", "true");
});
});
}
@@ -1,15 +1,26 @@
<script lang="ts">
import { determineStatus, formatDate, getGradeValue } from "./utils";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { buildEngageAssessmentPagePath } from "@/seqta/utils/engageAssessmentStudent";
import OverviewIcon from "./OverviewIcon.svelte";
import {
GROUP_SORT_ICONS,
STATUS_COLUMN_ICONS,
type OverviewIconName,
} from "./icons";
import confetti from "canvas-confetti";
export let data: any;
interface FilterOptions {
subject: string;
sortBy: "due" | "grade" | "subject" | "title";
student: string;
sortBy: "due" | "grade" | "subject" | "title" | "year";
}
const HIDDEN_ASSESSMENTS_KEY = "betterseqta-hidden-assessments";
function percentageToLetter(percentage: number): string {
const letterMap: Record<number, string> = {
100: "A+",
@@ -36,53 +47,142 @@
let currentFilters: FilterOptions = {
subject: "all",
student: "all",
sortBy: "due",
};
const isEngage = isSeqtaEngageExperience();
$: showStudentFilter = isEngage && (data?.students?.length ?? 0) > 1;
let filteredAssessments: any[] = [];
let statusGroups: Record<string, any[]> = {};
let columns: {
key: string;
title: string;
className: string;
icon: OverviewIconName;
}[] = [];
function getAssessmentYear(a: any): number {
const dateStr = a.due || a.date || a.dueDate || a.created;
return dateStr ? new Date(dateStr).getFullYear() : 0;
}
function getAssessmentType(a: any): string {
return (a.type || a.assessmentType || a.taskType || "Other").toString();
}
function getAssessmentGrade(a: any): string {
const val = getGradeValue(a);
if (val === null) return "No grade";
return percentageToLetter(val);
}
function getGroupKey(assessment: any): string {
switch (currentFilters.sortBy) {
case "due":
return determineStatus(assessment);
case "year":
return String(getAssessmentYear(assessment) || "Unknown");
case "subject":
return assessment.code || "Unknown";
case "grade":
return getAssessmentGrade(assessment);
case "title":
const first = (assessment.title || "?")[0].toUpperCase();
return /[A-Z0-9]/.test(first) ? first : "#";
default:
return determineStatus(assessment);
}
}
function sortCompare(a: any, b: any): number {
return new Date(a.due || a.date || 0).getTime() - new Date(b.due || b.date || 0).getTime();
}
const STATUS_COLUMNS: {
key: string;
title: string;
className: string;
icon: OverviewIconName;
}[] = [
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "calendar-days" },
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "clock" },
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "exclamation-triangle" },
{ key: "SUBMITTED", title: "Submitted", className: "column-submitted", icon: "document-check" },
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "check-circle" },
];
function groupSortIcon(): OverviewIconName {
return GROUP_SORT_ICONS[currentFilters.sortBy] ?? "queue-list";
}
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;
if (currentFilters.subject !== "all" && a.code !== currentFilters.subject) {
return false;
}
if (
isEngage &&
currentFilters.student !== "all" &&
String(a.studentId) !== currentFilters.student
) {
return false;
}
return true;
});
const groups: Record<string, any[]> = {};
filtered.forEach((assessment) => {
const key = getGroupKey(assessment);
if (!groups[key]) groups[key] = [];
groups[key].push(assessment);
});
Object.keys(groups).forEach((key) => {
groups[key].sort(sortCompare);
});
let cols: { key: string; title: string; className: string; icon: OverviewIconName }[];
if (currentFilters.sortBy === "due") {
cols = STATUS_COLUMNS;
} else {
const keys = Object.keys(groups).filter((k) => groups[k]?.length > 0);
const sortIcon = groupSortIcon();
if (currentFilters.sortBy === "year") {
cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: sortIcon }));
} 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: sortIcon }));
} else {
cols = keys.sort().map((k) => ({ key: k, title: k, className: "column-custom", icon: sortIcon }));
}
}
return { filteredAssessments: filtered, statusGroups: groups, columns: cols };
}
$: if (data) {
const _ = currentFilters.sortBy && currentFilters.subject;
const result = buildGroupsAndColumns();
filteredAssessments = result.filteredAssessments;
statusGroups = result.statusGroups;
columns = result.columns;
}
function updateAssessments() {
filteredAssessments = data.assessments.filter((a: any) => {
const subjectMatch =
currentFilters.subject === "all" || a.code === currentFilters.subject;
return subjectMatch;
});
filteredAssessments.sort((a: any, b: any) => {
switch (currentFilters.sortBy) {
case "due":
return new Date(a.due).getTime() - new Date(b.due).getTime();
case "grade":
const gradeA = getGradeValue(a);
const gradeB = getGradeValue(b);
if (gradeA === null && gradeB === null) return 0;
if (gradeA === null) return 1;
if (gradeB === null) return -1;
return gradeB - gradeA;
case "subject":
return a.code.localeCompare(b.code);
case "title":
return a.title.localeCompare(b.title);
default:
return 0;
}
});
statusGroups = {
UPCOMING: [],
DUE_SOON: [],
OVERDUE: [],
SUBMITTED: [],
MARKS_RELEASED: [],
};
filteredAssessments.forEach((assessment) => {
const status = determineStatus(assessment);
if (statusGroups[status]) {
statusGroups[status].push(assessment);
}
});
const result = buildGroupsAndColumns();
filteredAssessments = result.filteredAssessments;
statusGroups = result.statusGroups;
columns = result.columns;
}
function getDueDateClass(assessment: any): string {
@@ -123,6 +223,56 @@
}
}
function hideAssessment(assessment: any) {
const hidden = JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]");
const id = String(assessment.id);
if (!hidden.includes(id)) {
hidden.push(id);
localStorage.setItem(HIDDEN_ASSESSMENTS_KEY, JSON.stringify(hidden));
visibilityRefresh++;
closeAllMenus();
updateAssessments();
}
}
function hideSubject(subjectCode: string) {
const filters = { ...(settingsState.subjectfilters || {}) };
filters[subjectCode] = false;
settingsState.subjectfilters = filters;
closeAllMenus();
updateAssessments();
}
function unhideSubject(subjectCode: string) {
const filters = { ...(settingsState.subjectfilters || {}) };
filters[subjectCode] = true;
settingsState.subjectfilters = filters;
updateAssessments();
}
function unhideAssessment(assessmentId: string) {
const hidden = JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]");
const idStr = String(assessmentId);
const filtered = hidden.filter((id: string) => id !== idStr);
localStorage.setItem(HIDDEN_ASSESSMENTS_KEY, JSON.stringify(filtered));
visibilityRefresh++;
updateAssessments();
}
function initSubjectFilters() {
const filters = settingsState.subjectfilters || {};
let updated = false;
data.subjects.forEach((s: any) => {
if (!Object.prototype.hasOwnProperty.call(filters, s.code)) {
filters[s.code] = true;
updated = true;
}
});
if (updated) {
settingsState.subjectfilters = filters;
}
}
function checkForCelebration() {
const overdueCount = statusGroups.OVERDUE?.length || 0;
const dueSoonCount = statusGroups.DUE_SOON?.length || 0;
@@ -197,10 +347,37 @@
if ((event.target as HTMLElement).closest(".card-menu")) {
return;
}
if (isSeqtaEngageExperience()) {
const studentId = assessment.studentId ?? data?.studentId;
if (!studentId) return;
window.location.hash = buildEngageAssessmentPagePath(
studentId,
assessment.programmeID,
assessment.metaclassID,
assessment.id,
);
return;
}
window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`;
}
let openMenuId: string | null = null;
let showVisibilityPanel = false;
let visibilityRefresh = 0;
$: hiddenSubjects = data?.subjects?.filter(
(s: any) => (settingsState.subjectfilters || {})[s.code] === false
) || [];
$: hiddenAssessmentIds = (() => {
visibilityRefresh; // Dependency for reactivity
return new Set((JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]")).map(String));
})();
$: hiddenAssessmentsWithInfo = data?.assessments?.filter(
(a: any) => hiddenAssessmentIds.has(String(a.id))
) || [];
$: hasHiddenItems = hiddenSubjects.length > 0 || hiddenAssessmentsWithInfo.length > 0;
function toggleMenu(assessmentId: string, event: Event) {
event.stopPropagation();
@@ -211,71 +388,96 @@
openMenuId = null;
}
$: {
if (data) {
updateAssessments();
}
$: if (data) {
initSubjectFilters();
updateAssessments();
void currentFilters.sortBy;
void currentFilters.subject;
void currentFilters.student;
}
const columns = [
{
key: "UPCOMING",
title: "Upcoming",
className: "column-upcoming",
icon: "📅",
},
{
key: "DUE_SOON",
title: "Due Soon",
className: "column-due-soon",
icon: "⏰",
},
{
key: "OVERDUE",
title: "Overdue",
className: "column-overdue",
icon: "🚨",
},
{
key: "SUBMITTED",
title: "Submitted",
className: "column-submitted",
icon: "📝",
},
{
key: "MARKS_RELEASED",
title: "Marked",
className: "column-marked",
icon: "✅",
},
];
</script>
<svelte:window on:click={closeAllMenus} />
<div id="grid-view-container">
<div class="grid-view-header">
<h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters">
<div class="bsplus-overview-page">
<header class="grid-view-header bsplus-overview-animate">
<div class="grid-view-header-text">
<h1 class="grid-view-title">Assessments</h1>
<p class="grid-view-subtitle">Track upcoming tasks, submissions, and released marks</p>
</div>
<div class="grid-view-filters bsplus-overview-toolbar">
{#if showStudentFilter}
<select class="filter-select" bind:value={currentFilters.student}>
<option value="all">All Students</option>
{#each data.students as student}
<option value={String(student.id)}>{student.name}</option>
{/each}
</select>
{/if}
<select class="filter-select" bind:value={currentFilters.subject}>
<option value="all">All Subjects</option>
{#each data.subjects as subject}
<option value={subject.code}>{subject.code} - {subject.title}</option>
{/each}
</select>
<select class="filter-select" bind:value={currentFilters.sortBy}>
<option value="due">Sort by Due Date</option>
<option value="grade">Sort by Grade</option>
<option value="subject">Sort by Subject</option>
<option value="title">Sort by Title</option>
<select class="filter-select" bind:value={currentFilters.sortBy} title="Group by - columns change based on this">
<option value="due">Group: Status</option>
<option value="year">Group: Year</option>
<option value="subject">Group: Subject</option>
<option value="grade">Group: Grade</option>
<option value="title">Group: Title (A-Z)</option>
</select>
{#if hasHiddenItems}
<button
class="visibility-toggle"
class:active={showVisibilityPanel}
on:click={() => (showVisibilityPanel = !showVisibilityPanel)}
title="Manage hidden subjects and assessments"
>
<OverviewIcon name="eye" size={18} />
<span>Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})</span>
</button>
{/if}
</div>
</div>
</header>
<div id="main-grid-content">
{#if showVisibilityPanel && hasHiddenItems}
<div class="visibility-panel bsplus-overview-animate">
<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" class="bsplus-overview-animate bsplus-overview-delay-1">
{#if filteredAssessments.length === 0}
<div class="empty-state">
<div class="empty-icon">📋</div>
<OverviewIcon name="clipboard-document-list" size={40} class="empty-icon" />
<p>No assessments found matching your filters</p>
</div>
{:else}
@@ -286,9 +488,15 @@
<div class="kanban-column {column.className}">
<div class="column-header">
<div class="column-title">
{column.icon} {column.title}
<span class="column-count">{statusGroups[column.key].length}</span>
</div>
<span class="column-title-main">
<OverviewIcon
name={column.icon ?? STATUS_COLUMN_ICONS[column.key] ?? "queue-list"}
size={18}
/>
{column.title}
</span>
<span class="column-count">{statusGroups[column.key].length}</span>
</div>
</div>
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
{#each statusGroups[column.key] as assessment}
@@ -307,6 +515,9 @@
on:keydown={(e) => e.key === 'Enter' && handleCardClick(assessment, e)}
>
<div class="card-labels">
{#if isEngage && assessment.studentName}
<span class="card-label label-student">{assessment.studentName}</span>
{/if}
<span class="card-label label-subject">{assessment.code}</span>
{#if assessment.submitted}
<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>
@@ -324,11 +535,7 @@
on:click={(e) => toggleMenu(assessment.id, e)}
aria-label="Open menu"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/>
</svg>
<OverviewIcon name="ellipsis-vertical" size={16} />
</button>
<div class="menu-dropdown" style="display: {openMenuId === assessment.id ? 'block' : 'none'};">
{#if status !== "MARKS_RELEASED"}
@@ -340,6 +547,12 @@
Mark as Not Complete
</button>
{/if}
<button class="menu-item menu-item-hide" on:click={() => hideAssessment(assessment)}>
Hide assessment
</button>
<button class="menu-item menu-item-hide" on:click={() => hideSubject(assessment.code)}>
Hide subject ({assessment.code})
</button>
</div>
</div>
{/if}
@@ -349,7 +562,8 @@
{#if !assessment.results && !isCompleted}
<div class="assessment-meta">
<div class="due-date {dueDateClass}">
📅 {formatDate(assessment.due, assessment.submitted)}
<OverviewIcon name="calendar-days" size={14} />
{formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)}
</div>
</div>
{/if}
@@ -1,8 +1,11 @@
<script lang="ts">
import OverviewIcon from "./OverviewIcon.svelte";
export let error: string;
</script>
<div class="error-container">
<div class="error-container bsplus-overview-animate">
<OverviewIcon name="exclamation-circle" size={40} class="error-icon" />
<p class="error-text">Failed to load assessments</p>
<p style="color: #94a3b8; font-size: 0.875rem;">{error}</p>
<p class="error-detail">{error}</p>
</div>
@@ -0,0 +1,32 @@
<script lang="ts">
import { OVERVIEW_ICON_PATHS, type OverviewIconName } from "./icons";
interface Props {
name: OverviewIconName;
class?: string;
size?: number;
}
let { name, class: className = "", size = 20 }: Props = $props();
const paths = $derived.by(() => {
const raw = OVERVIEW_ICON_PATHS[name];
return Array.isArray(raw) ? raw : [raw];
});
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
width={size}
height={size}
class="bsplus-overview-icon {className}"
aria-hidden="true"
>
{#each paths as path, index (index)}
<path stroke-linecap="round" stroke-linejoin="round" d={path} />
{/each}
</svg>
@@ -1,7 +1,28 @@
<div id="grid-view-container">
<div class="grid-view-header">
<h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters">
<script lang="ts">
import OverviewIcon from "./OverviewIcon.svelte";
import type { OverviewIconName } from "./icons";
const columns: {
key: string;
title: string;
className: string;
icon: OverviewIconName;
skeletonCount: number;
}[] = [
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "calendar-days", skeletonCount: 3 },
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "clock", skeletonCount: 2 },
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "exclamation-triangle", skeletonCount: 1 },
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "check-circle", skeletonCount: 4 },
];
</script>
<div class="bsplus-overview-page">
<header class="grid-view-header bsplus-overview-animate">
<div class="grid-view-header-text">
<h1 class="grid-view-title">Assessments</h1>
<p class="grid-view-subtitle">Loading your assessment overview…</p>
</div>
<div class="grid-view-filters bsplus-overview-toolbar">
<select class="filter-select" disabled>
<option value="all">Loading subjects...</option>
</select>
@@ -9,17 +30,20 @@
<option value="due">Sort by Due Date</option>
</select>
</div>
</div>
</header>
<div id="main-grid-content">
<div id="main-grid-content" class="bsplus-overview-animate bsplus-overview-delay-1">
<div class="kanban-board">
{#each columns as column}
<div class="kanban-column-parent">
<div class="kanban-column {column.className}">
<div class="column-header">
<div class="column-title">
{column.icon} {column.title}
<span class="column-count">...</span>
<span class="column-title-main">
<OverviewIcon name={column.icon} size={18} />
{column.title}
</span>
<span class="column-count"></span>
</div>
</div>
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
@@ -43,36 +67,3 @@
</div>
</div>
</div>
<script lang="ts">
const columns = [
{
key: "UPCOMING",
title: "Upcoming",
className: "column-upcoming",
icon: "📅",
skeletonCount: 3,
},
{
key: "DUE_SOON",
title: "Due Soon",
className: "column-due-soon",
icon: "⏰",
skeletonCount: 2,
},
{
key: "OVERDUE",
title: "Overdue",
className: "column-overdue",
icon: "🚨",
skeletonCount: 1,
},
{
key: "MARKS_RELEASED",
title: "Marked",
className: "column-marked",
icon: "✅",
skeletonCount: 4,
},
];
</script>
+88 -31
View File
@@ -1,20 +1,26 @@
interface Subject {
code: string;
programme: number;
metaclass: number;
title: string;
}
import {
activeSubjectsFromLearnPayload,
assessmentBelongsToActiveSubjects,
filterAssessmentsForActiveSubjects,
type OverviewSubject,
} from "./utils";
interface PrefItem {
name: string;
value: string;
}
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { getMockAssessmentsData } from "@/seqta/ui/dev/hideSensitiveContent";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import {
getEngageAssessmentsData,
} from "./engageApi";
let cache: { time: number; data: any } | null = null;
let cache: { time: number; engageAll?: boolean; studentId: number; data: any } | null =
null;
const CACHE_MS = 10 * 60 * 1000;
const student = 69;
async function fetchJSON(url: string, body: any) {
const res = await fetch(`${location.origin}${url}`, {
@@ -26,11 +32,9 @@ async function fetchJSON(url: string, body: any) {
return res.json();
}
async function loadSubjects() {
async function loadSubjects(): Promise<OverviewSubject[]> {
const res = await fetchJSON("/seqta/student/load/subjects?", {});
return res.payload
.filter((s: any) => s.active === 1)
.flatMap((s: any) => s.subjects);
return activeSubjectsFromLearnPayload(res.payload);
}
async function loadPrefs(student: number) {
@@ -56,7 +60,18 @@ async function loadUpcoming(student: number) {
return res.payload;
}
async function loadPast(student: number, subjects: Subject[]) {
function normalizeAssessmentDates(t: any, subject: OverviewSubject): any {
const normalized = { ...t };
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: OverviewSubject[]) {
const map: Record<number, any> = {};
await Promise.all(
subjects.map(async (s) => {
@@ -65,10 +80,22 @@ async function loadPast(student: number, subjects: Subject[]) {
metaclass: s.metaclass,
student,
});
if (res.payload.tasks) {
res.payload.tasks.forEach((t: any) => {
map[t.id] = t;
});
const processAssessment = (t: any) => {
if (t && t.id) {
const merged = {
...t,
programmeID: t.programmeID || t.programme || s.programme,
metaclassID: t.metaclassID || t.metaclass || s.metaclass,
code: t.code || t.subject || s.code,
};
map[t.id] = normalizeAssessmentDates(merged, s);
}
};
if (res.payload?.pending && Array.isArray(res.payload.pending)) {
res.payload.pending.forEach(processAssessment);
}
if (res.payload?.tasks && Array.isArray(res.payload.tasks)) {
res.payload.tasks.forEach(processAssessment);
}
}),
);
@@ -104,35 +131,65 @@ async function loadSubmissions(student: number, assessments: any[]) {
return submissionMap;
}
export async function getAssessmentsData() {
if (settingsState.mockNotices) {
return getMockAssessmentsData();
}
if (cache && Date.now() - cache.time < CACHE_MS) return cache.data;
async function getLearnAssessmentsData(studentId: number) {
const [subjects, colors, upcoming] = await Promise.all([
loadSubjects(),
loadPrefs(student),
loadUpcoming(student),
loadPrefs(studentId),
loadUpcoming(studentId),
]);
const pastMap = await loadPast(student, subjects);
const pastMap = await loadPast(studentId, subjects);
const map: Record<number, any> = {};
upcoming.forEach((a: any) => {
map[a.id] = { ...a };
if (assessmentBelongsToActiveSubjects(a, subjects)) {
map[a.id] = { ...a };
}
});
Object.values(pastMap).forEach((t: any) => {
if (!assessmentBelongsToActiveSubjects(t, subjects)) return;
if (map[t.id]) Object.assign(map[t.id], t);
else map[t.id] = t;
});
const allAssessments = Object.values(map);
const submissions = await loadSubmissions(student, allAssessments);
const allAssessments = filterAssessmentsForActiveSubjects(
Object.values(map),
subjects,
);
const submissions = await loadSubmissions(studentId, allAssessments);
allAssessments.forEach((assessment: any) => {
assessment.submitted = submissions[assessment.id] || false;
});
const data = { assessments: allAssessments, subjects, colors };
cache = { time: Date.now(), data };
return { assessments: allAssessments, subjects, colors, studentId };
}
export async function getAssessmentsData() {
if (settingsState.mockNotices) {
return getMockAssessmentsData();
}
if (isSeqtaEngageExperience()) {
if (cache && Date.now() - cache.time < CACHE_MS && cache.engageAll) {
return cache.data;
}
const data = await getEngageAssessmentsData();
cache = { time: Date.now(), studentId: 0, engageAll: true, data };
return data;
}
const studentId = (await getUserInfo()).id;
if (
cache &&
Date.now() - cache.time < CACHE_MS &&
cache.studentId === studentId
) {
return cache.data;
}
const data = await getLearnAssessmentsData(studentId);
cache = { time: Date.now(), studentId, data };
return data;
}
@@ -0,0 +1,235 @@
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
import {
activeSubjectsFromEngageChild,
assessmentBelongsToActiveSubjects,
filterAssessmentsForActiveSubjects,
type OverviewSubject,
} from "./utils";
interface PrefItem {
name: string;
value: string;
}
export interface EngageStudent {
id: number;
name: string;
}
interface EngageChildPayload {
id?: number;
name?: string;
terms?: {
active?: number;
subjects?: {
code?: string;
programme?: number;
metaclass?: number;
title?: string;
description?: string;
}[];
}[];
}
async function fetchJSON(url: string, body: unknown) {
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();
}
async function loadEngageChildrenPayload(): Promise<EngageChildPayload[]> {
const res = await fetchJSON("/seqta/parent/load/subjects", {});
return Array.isArray(res.payload) ? res.payload : [];
}
export async function resolveEngageStudentId(): Promise<number> {
const fromUrlOrStorage = getEngageAssessmentStudentId();
if (fromUrlOrStorage) return Number(fromUrlOrStorage);
const children = await loadEngageChildrenPayload();
const firstChild = children[0];
if (firstChild?.id != null) return Number(firstChild.id);
throw new Error("Could not resolve Engage student ID");
}
function subjectsFromChild(child: EngageChildPayload): OverviewSubject[] {
return activeSubjectsFromEngageChild(child);
}
async function loadEngagePrefs(): Promise<Record<string, string>> {
const res = await fetchJSON("/seqta/parent/load/prefs?", {
request: "userPrefs",
asArray: true,
});
const colors: Record<string, string> = {};
(res.payload ?? []).forEach((pref: PrefItem) => {
if (pref.name.startsWith("timetable.subject.colour.")) {
const code = pref.name.replace("timetable.subject.colour.", "");
colors[code] = pref.value;
}
});
return colors;
}
async function loadEngageUpcoming(studentId: number) {
const res = await fetchJSON("/seqta/parent/assessment/list/upcoming?", {
student: studentId,
});
return res.payload ?? [];
}
function normalizeAssessmentDates(t: any, subject: OverviewSubject): any {
const normalized = { ...t };
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 loadEngagePast(studentId: number, subjects: OverviewSubject[]) {
const map: Record<number, any> = {};
await Promise.all(
subjects.map(async (subject) => {
const res = await fetchJSON("/seqta/parent/assessment/list/past?", {
programme: subject.programme,
metaclass: subject.metaclass,
student: studentId,
});
const processAssessment = (task: any) => {
if (task?.id) {
const merged = {
...task,
programmeID: task.programmeID || task.programme || subject.programme,
metaclassID: task.metaclassID || task.metaclass || subject.metaclass,
code: task.code || task.subject || subject.code,
};
map[task.id] = normalizeAssessmentDates(merged, subject);
}
};
if (Array.isArray(res.payload?.pending)) {
res.payload.pending.forEach(processAssessment);
}
if (Array.isArray(res.payload?.tasks)) {
res.payload.tasks.forEach(processAssessment);
}
}),
);
return map;
}
async function loadEngageSubmissions(studentId: number, assessments: any[]) {
const submissionMap: Record<number, boolean> = {};
await Promise.all(
assessments.map(async (assessment) => {
try {
const res = await fetchJSON("/seqta/parent/assessment/submissions/get", {
assessment: assessment.id,
metaclass: assessment.metaclassID,
student: studentId,
});
submissionMap[assessment.id] =
Array.isArray(res.payload) && res.payload.length > 0;
} catch (error) {
console.warn(
`[BetterSEQTA+] Failed to fetch Engage submission for assessment ${assessment.id}:`,
error,
);
submissionMap[assessment.id] = false;
}
}),
);
return submissionMap;
}
async function loadEngageAssessmentsForStudent(
child: EngageChildPayload,
): Promise<any[]> {
const studentId = Number(child.id);
const studentName = child.name ?? "Student";
const subjects = subjectsFromChild(child);
const [upcoming, pastMap] = await Promise.all([
loadEngageUpcoming(studentId),
loadEngagePast(studentId, subjects),
]);
const map: Record<number, any> = {};
upcoming.forEach((assessment: any) => {
if (assessmentBelongsToActiveSubjects(assessment, subjects)) {
map[assessment.id] = { ...assessment };
}
});
Object.values(pastMap).forEach((task: any) => {
if (!assessmentBelongsToActiveSubjects(task, subjects)) return;
if (map[task.id]) Object.assign(map[task.id], task);
else map[task.id] = task;
});
const assessments = filterAssessmentsForActiveSubjects(
Object.values(map),
subjects,
).map((assessment) => ({
...assessment,
studentId,
studentName,
}));
const submissions = await loadEngageSubmissions(studentId, assessments);
assessments.forEach((assessment) => {
assessment.submitted = submissions[assessment.id] || false;
});
return assessments;
}
export async function getEngageAssessmentsData() {
const childrenPayload = await loadEngageChildrenPayload();
const students: EngageStudent[] = childrenPayload
.filter((child) => child.id != null)
.map((child) => ({
id: Number(child.id),
name: child.name ?? "Student",
}));
if (!students.length) {
throw new Error("No Engage students found");
}
const [colors, assessmentsByChild] = await Promise.all([
loadEngagePrefs(),
Promise.all(childrenPayload.map((child) => loadEngageAssessmentsForStudent(child))),
]);
const subjectsMap = new Map<string, OverviewSubject>();
childrenPayload.forEach((child) => {
subjectsFromChild(child).forEach((subject) => {
if (!subjectsMap.has(subject.code)) {
subjectsMap.set(subject.code, subject);
}
});
});
const defaultStudentId = await resolveEngageStudentId();
return {
assessments: assessmentsByChild.flat(),
subjects: Array.from(subjectsMap.values()),
colors,
students,
studentId: defaultStudentId,
};
}
@@ -0,0 +1,65 @@
/** Heroicons v2 outline paths (https://heroicons.com) */
export type OverviewIconName =
| "calendar-days"
| "clock"
| "exclamation-triangle"
| "document-check"
| "check-circle"
| "book-open"
| "calendar"
| "chart-bar"
| "queue-list"
| "eye"
| "clipboard-document-list"
| "ellipsis-vertical"
| "exclamation-circle";
export const OVERVIEW_ICON_PATHS: Record<
OverviewIconName,
string | string[]
> = {
"calendar-days":
"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5",
clock: "M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z",
"exclamation-triangle":
"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z",
"document-check":
"M10.125 2.25h-4.5c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 8.625a2.625 2.625 0 100-5.25 2.625 2.625 0 000 5.25zm0 0l-3 3m3-3l3 3",
"check-circle":
"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
"book-open":
"M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25",
calendar:
"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5",
"chart-bar":
"M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z",
"queue-list":
"M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z",
eye: [
"M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z",
"M15 12a3 3 0 11-6 0 3 3 0 016 0z",
],
"clipboard-document-list": [
"M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 17.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z",
"M8.25 6.75V4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V6.75H8.25z",
],
"ellipsis-vertical":
"M12 6.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 12.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 18.75a.75.75 0 110-1.5.75.75 0 010 1.5z",
"exclamation-circle":
"M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z",
};
export const STATUS_COLUMN_ICONS: Record<string, OverviewIconName> = {
UPCOMING: "calendar-days",
DUE_SOON: "clock",
OVERDUE: "exclamation-triangle",
SUBMITTED: "document-check",
MARKS_RELEASED: "check-circle",
};
export const GROUP_SORT_ICONS: Record<string, OverviewIconName> = {
year: "calendar",
subject: "book-open",
grade: "chart-bar",
title: "queue-list",
};
@@ -1,9 +1,50 @@
import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api";
import { renderErrorState, renderSkeletonLoader } from "./ui";
import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import {
isEngageAssessmentOverviewRoute,
} from "@/seqta/utils/engageAssessmentStudent";
import { resolveEngageStudentId } from "./engageApi";
const OVERVIEW_MENU_CLASS = "betterseqta-assessments-overview-item";
function ensureOverviewMenuPosition(
menu: HTMLElement,
gridItem: HTMLElement,
) {
if (menu.firstElementChild !== gridItem) {
menu.insertBefore(gridItem, menu.firstChild);
}
}
function isOverviewRoute() {
if (isSeqtaEngageExperience()) {
return isEngageAssessmentOverviewRoute();
}
return window.location.hash.includes("/assessments/overview");
}
async function waitForAssessmentsSubmenu(): Promise<HTMLElement> {
if (!isSeqtaEngageExperience()) {
return (await waitForElm(
'[data-key="assessments"] > .sub > ul',
true,
100,
60,
)) as HTMLElement;
}
return (await waitForElm(
'[data-key="assessments"] .sub ul, [data-key="assessments"] ul',
true,
100,
350,
)) as HTMLElement;
}
const assessmentsOverviewPlugin: Plugin<{}> = {
id: "assessments-overview",
@@ -16,33 +57,46 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
styles,
run: async () => {
const menu = (await waitForElm(
'[data-key="assessments"] > .sub > ul',
true,
100,
60,
)) as HTMLElement;
const menu = await waitForAssessmentsSubmenu();
const gridItem = document.createElement("li");
gridItem.className = "item";
gridItem.classList.add(OVERVIEW_MENU_CLASS);
const label = document.createElement("label");
label.textContent = "Overview";
gridItem.appendChild(label);
menu.insertBefore(gridItem, menu.children[1] || null);
menu.insertBefore(gridItem, menu.firstChild);
if (window.location.hash.includes("/assessments/overview")) {
loadGridView();
const menuObserver = new MutationObserver(() => {
ensureOverviewMenuPosition(menu, gridItem);
});
menuObserver.observe(menu, { childList: true });
if (isOverviewRoute()) {
void loadGridView();
}
const clickHandler = (e: Event) => {
e.preventDefault();
loadGridView();
void loadGridView();
};
gridItem.addEventListener("click", clickHandler);
async function loadGridView() {
await delay(1);
window.history.pushState({}, "", "/#?page=/assessments/overview");
document.title = "Overview ― SEQTA Learn";
if (isSeqtaEngageExperience()) {
const studentId = await resolveEngageStudentId();
window.history.pushState(
{},
"",
`/#?page=/assessments/${studentId}/overview`,
);
document.title = "Overview ― SEQTA Engage";
} else {
window.history.pushState({}, "", "/#?page=/assessments/overview");
document.title = "Overview ― SEQTA Learn";
}
const main = document.getElementById("main");
if (!main) return;
@@ -56,7 +110,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
.querySelector('[data-key="assessments"]')
?.classList.add("active");
main.innerHTML = '<div id="grid-view-container"></div>';
main.innerHTML = '<div id="grid-view-container" class="bsplus-overview-host"></div>';
const container = document.getElementById(
"grid-view-container",
) as HTMLElement;
@@ -65,7 +119,6 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
try {
const data = await getAssessmentsData();
const { renderGrid } = await import("./ui");
renderGrid(container, data);
} catch (err) {
console.error("Failed to load assessments:", err);
@@ -77,6 +130,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
}
return () => {
menuObserver.disconnect();
gridItem.removeEventListener("click", clickHandler);
gridItem.remove();
};
@@ -1,84 +1,191 @@
#grid-view-container {
/* ─── Design tokens (aligned with Grade Analytics) ─── */
.bsplus-overview-host,
.bsplus-overview-root,
#grid-view-container.bsplus-overview-root {
--bsplus-overview-radius: 16px;
--bsplus-overview-radius-sm: 12px;
--bsplus-overview-ease: cubic-bezier(0.4, 0, 0.2, 1);
--bsplus-overview-surface: var(--background-primary, #ffffff);
--bsplus-overview-surface-2: var(--background-secondary, #f8fafc);
--bsplus-overview-text: var(--text-primary, #1a1a1a);
--bsplus-overview-muted: color-mix(
in srgb,
var(--bsplus-overview-text) 55%,
transparent
);
--bsplus-overview-border: color-mix(
in srgb,
var(--theme-offset-bg, var(--background-secondary, #e2e8f0)) 78%,
transparent
);
--bsplus-overview-shadow: 0 5px 16px 6px rgba(0, 0, 0, 0.08);
--bsplus-overview-shadow-hover: 0 8px 24px 8px rgba(0, 0, 0, 0.12);
--bsplus-overview-accent: var(--better-main, #007bff);
}
.bsplus-overview-root.dark {
--bsplus-overview-shadow: 0 5px 20px 6px rgba(0, 0, 0, 0.35);
--bsplus-overview-shadow-hover: 0 10px 28px 10px rgba(0, 0, 0, 0.45);
}
@keyframes bsplus-overview-fade-in-up {
from {
opacity: 0;
transform: translateY(14px) scale(0.99);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.bsplus-overview-animate {
animation: bsplus-overview-fade-in-up 0.5s var(--bsplus-overview-ease) forwards;
}
@media (prefers-reduced-motion: reduce) {
.bsplus-overview-animate {
animation: none;
}
}
.bsplus-overview-delay-1 {
animation-delay: 80ms;
}
.bsplus-overview-icon {
flex-shrink: 0;
}
#grid-view-container,
.bsplus-overview-root {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
min-height: min(100%, calc(100vh - 6rem));
box-sizing: border-box;
padding: 1.5rem 1.25rem 2rem;
font-family: Rubik, system-ui, sans-serif;
font-size: 0.875rem;
color: var(--bsplus-overview-text);
gap: 1.25rem;
}
.grid-view-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
padding: 1rem;
align-items: flex-start;
gap: 1.25rem;
flex-shrink: 0;
padding: 0;
}
.grid-view-header-text {
min-width: 0;
}
.grid-view-title {
font-size: 1.875rem !important;
font-weight: 700;
color: #1a1a1a;
letter-spacing: -0.02em;
line-height: 1.2;
color: var(--bsplus-overview-text);
margin: 0 0 0.35rem;
}
.grid-view-subtitle {
margin: 0;
font-size: 0.9375rem;
line-height: 1.5;
color: var(--bsplus-overview-muted);
}
/* Dark mode support */
.dark .grid-view-title {
color: #f8fafc;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.grid-view-filters {
.grid-view-filters,
.bsplus-overview-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
padding: 0.85rem 1rem;
border-radius: var(--bsplus-overview-radius);
background: var(--bsplus-overview-surface);
border: 1px solid var(--bsplus-overview-border);
box-shadow: var(--bsplus-overview-shadow);
}
.filter-select {
background: #ffffff !important;
border: 2px solid #e2e8f0;
border-radius: 8px;
color: #1a1a1a;
padding: 0.75rem 1rem;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background: var(--bsplus-overview-surface-2) !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.75rem center !important;
background-repeat: no-repeat !important;
background-size: 1rem !important;
border: 2px solid var(--bsplus-overview-border);
border-radius: var(--bsplus-overview-radius-sm);
color: var(--bsplus-overview-text);
color-scheme: light;
padding: 0.65rem 2.25rem 0.65rem 0.9rem;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
font-family: inherit;
line-height: 1.2;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s var(--bsplus-overview-ease);
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
min-width: 180px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
min-width: 11rem;
min-height: 2.75rem;
}
.filter-select::-ms-expand {
display: none;
}
.filter-select option {
background: var(--bsplus-overview-surface);
color: var(--bsplus-overview-text);
}
.filter-select:focus {
outline: none;
border-color: #d41e3a;
box-shadow: 0 0 0 3px rgba(212, 30, 58, 0.1);
border-color: var(--bsplus-overview-accent);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--bsplus-overview-accent) 22%, transparent);
}
.filter-select:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border-color: color-mix(
in srgb,
var(--bsplus-overview-accent) 35%,
var(--bsplus-overview-border)
);
}
/* Dark mode dropdowns */
.dark .filter-select {
background: var(--background-primary) !important;
border-color: var(--background-secondary);
color: var(--text-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dark .filter-select:focus {
border-color: #d41e3a;
box-shadow: 0 0 0 3px rgba(212, 30, 58, 0.2);
}
.dark .filter-select:hover {
border-color: var(--background-secondary);
background: var(--background-secondary);
background: var(--bsplus-overview-surface-2) !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;
color-scheme: dark;
}
.dark .filter-select option {
background: var(--background-primary);
color: var(--text-primary);
background: var(--bsplus-overview-surface);
color: var(--bsplus-overview-text);
}
.bsplus-overview-page {
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
min-height: 0;
}
#main-grid-content {
@@ -99,67 +206,62 @@
.kanban-column-parent {
flex: 0 0 320px;
}
.kanban-column {
max-height: 100%;
background: #f8fafc;
border-radius: 12px;
background: color-mix(in srgb, var(--bsplus-overview-surface-2) 88%, transparent);
border-radius: var(--bsplus-overview-radius);
display: flex;
flex-direction: column;
min-height: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid var(--bsplus-overview-border);
box-shadow: var(--bsplus-overview-shadow);
overflow: hidden;
transition:
box-shadow 0.25s var(--bsplus-overview-ease),
transform 0.25s var(--bsplus-overview-ease);
}
/* Dark mode columns */
.dark .kanban-column {
background: var(--background-primary);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
.kanban-column:hover {
box-shadow: var(--bsplus-overview-shadow-hover);
}
.column-header {
padding: 1rem 1.25rem;
border-bottom: 2px solid #e2e8f0;
background: #ffffff;
border-radius: 12px 12px 0 0;
padding: 1rem 1.15rem;
border-bottom: 1px solid var(--bsplus-overview-border);
border-radius: var(--bsplus-overview-radius) var(--bsplus-overview-radius) 0 0;
position: sticky;
top: 0;
z-index: 10;
}
/* Dark mode column headers */
.dark .column-header {
background: var(--background-secondary);
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.column-title {
font-size: 1rem;
font-weight: 600;
color: #1a1a1a;
font-size: 0.9375rem;
font-weight: 700;
color: var(--bsplus-overview-text);
margin: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.dark .column-title {
color: var(--text-primary);
.column-title-main {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.column-count {
background: #e2e8f0;
color: #64748b;
padding: 0.25rem 0.5rem;
border-radius: 6px;
background: color-mix(in srgb, var(--bsplus-overview-muted) 18%, transparent);
color: var(--bsplus-overview-muted);
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.dark .column-count {
background: var(--background-secondary);
color: var(--text-primary);
flex-shrink: 0;
}
.column-cards {
@@ -173,33 +275,28 @@
/* Assessment Cards */
.assessment-card {
background: #ffffff;
border-radius: 8px;
background: var(--bsplus-overview-surface);
border-radius: var(--bsplus-overview-radius-sm);
padding: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition:
transform 0.2s var(--bsplus-overview-ease),
box-shadow 0.2s var(--bsplus-overview-ease),
border-color 0.2s ease;
cursor: pointer;
position: relative;
border-left: 4px solid var(--subject-color, #d41e3a);
border: 1px solid #e2e8f0;
border: 1px solid var(--bsplus-overview-border);
border-left: 4px solid var(--subject-color, var(--bsplus-overview-accent));
}
.assessment-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
border-color: #cbd5e1;
}
/* Dark mode cards */
.dark .assessment-card {
background: var(--background-secondary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.dark .assessment-card:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.15);
box-shadow: var(--bsplus-overview-shadow-hover);
transform: translateY(-2px);
border-color: color-mix(
in srgb,
var(--bsplus-overview-accent) 22%,
var(--bsplus-overview-border)
);
}
.card-labels {
@@ -220,7 +317,16 @@
}
.label-subject {
background: var(--subject-color, #d41e3a);
background: color-mix(in srgb, var(--subject-color, var(--bsplus-overview-accent)) 88%, transparent);
}
.label-student {
background: rgba(99, 102, 241, 0.9);
text-transform: none;
}
.dark .label-student {
background: rgba(99, 102, 241, 0.75);
}
.card-menu {
@@ -235,14 +341,17 @@
border: none !important;
padding: 0.25rem !important;
cursor: pointer;
border-radius: 4px;
color: #64748b;
transition: all 0.2s ease;
border-radius: 8px;
color: var(--bsplus-overview-muted);
transition:
background 0.2s ease,
color 0.2s ease,
transform 0.2s var(--bsplus-overview-ease);
display: flex !important;
align-items: center;
justify-content: center;
width: 24px !important;
height: 24px !important;
width: 28px !important;
height: 28px !important;
margin: 0 !important;
position: static !important;
transform: none !important;
@@ -251,44 +360,32 @@
}
.menu-button:hover {
background: #f1f5f9 !important;
color: #1a1a1a;
background: color-mix(
in srgb,
var(--bsplus-overview-surface-2) 90%,
transparent
) !important;
color: var(--bsplus-overview-text);
transform: scale(1.05) !important;
}
.menu-button svg {
width: 16px !important;
height: 16px !important;
fill: currentColor !important;
display: block !important;
}
.dark .menu-button {
color: var(--text-primary);
opacity: 0.7;
}
.dark .menu-button:hover {
background: rgba(255, 255, 255, 0.1) !important;
opacity: 1;
.menu-button:focus-visible {
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--bsplus-overview-accent) 35%, transparent) !important;
}
.menu-dropdown {
position: absolute;
top: 100%;
right: 0;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 140px;
background: var(--bsplus-overview-surface);
border: 1px solid var(--bsplus-overview-border);
border-radius: var(--bsplus-overview-radius-sm);
box-shadow: var(--bsplus-overview-shadow-hover);
min-width: 160px;
z-index: 30;
margin-top: 4px;
}
.dark .menu-dropdown {
background: var(--background-secondary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
padding: 0.25rem;
}
.menu-item {
@@ -299,22 +396,19 @@
border: none;
text-align: left;
cursor: pointer;
font-size: 0.875rem;
color: #1a1a1a;
transition: background-color 0.2s ease;
font-size: 0.8125rem;
color: var(--bsplus-overview-text);
transition: background-color 0.15s ease;
white-space: nowrap;
border-radius: 8px;
}
.menu-item:hover {
background: #f8fafc;
}
.dark .menu-item {
color: var(--text-primary);
}
.dark .menu-item:hover {
background: rgba(255, 255, 255, 0.05);
background: color-mix(
in srgb,
var(--bsplus-overview-surface-2) 90%,
transparent
);
}
.menu-item.mark-completed {
@@ -335,17 +429,138 @@
color: #ef4444;
}
.menu-item.menu-item-hide {
color: #64748b;
}
.dark .menu-item.menu-item-hide {
color: var(--text-primary);
opacity: 0.8;
}
.visibility-toggle {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.65rem 0.9rem;
font-size: 0.875rem;
font-weight: 600;
border-radius: var(--bsplus-overview-radius-sm);
border: 2px solid var(--bsplus-overview-border);
background: var(--bsplus-overview-surface-2);
color: var(--bsplus-overview-muted);
cursor: pointer;
transition:
border-color 0.2s ease,
background 0.2s ease,
color 0.2s ease,
transform 0.2s var(--bsplus-overview-ease);
min-height: 2.75rem;
}
.visibility-toggle:hover {
border-color: color-mix(
in srgb,
var(--bsplus-overview-accent) 35%,
var(--bsplus-overview-border)
);
color: var(--bsplus-overview-text);
transform: scale(1.02);
}
.visibility-toggle.active {
border-color: color-mix(
in srgb,
var(--bsplus-overview-accent) 45%,
var(--bsplus-overview-border)
);
background: color-mix(
in srgb,
var(--bsplus-overview-accent) 10%,
var(--bsplus-overview-surface-2)
);
color: var(--bsplus-overview-accent);
}
.visibility-panel {
padding: 1rem 1.15rem;
margin: 0;
background: var(--bsplus-overview-surface);
border-radius: var(--bsplus-overview-radius);
border: 1px solid var(--bsplus-overview-border);
box-shadow: var(--bsplus-overview-shadow);
}
.visibility-panel-title {
font-size: 0.875rem;
font-weight: 700;
color: var(--bsplus-overview-text);
margin: 0 0 0.75rem;
}
.visibility-section {
margin-bottom: 0.5rem;
}
.visibility-section:last-child {
margin-bottom: 0;
}
.visibility-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bsplus-overview-muted);
display: block;
margin-bottom: 0.35rem;
}
.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: color-mix(in srgb, var(--bsplus-overview-surface-2) 92%, transparent);
border: 1px solid var(--bsplus-overview-border);
border-radius: 999px;
font-size: 0.8125rem;
color: var(--bsplus-overview-text);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.visibility-unhide {
padding: 0.125rem 0.55rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 999px;
border: none;
background: var(--bsplus-overview-accent);
color: var(--text-color, #fff);
cursor: pointer;
transition: transform 0.2s var(--bsplus-overview-ease);
flex-shrink: 0;
}
.visibility-unhide:hover {
transform: scale(1.05);
}
.assessment-title {
font-size: 0.875rem;
font-weight: 600;
color: #1a1a1a;
color: var(--bsplus-overview-text);
margin: 0 0 0.75rem;
line-height: 1.4;
padding-right: 2rem; /* Make room for menu button */
}
.dark .assessment-title {
color: var(--text-primary);
line-height: 1.45;
padding-right: 2rem;
}
.assessment-meta {
@@ -354,12 +569,7 @@
justify-content: space-between;
margin-top: 0.75rem;
font-size: 0.75rem;
color: #64748b;
}
.dark .assessment-meta {
color: var(--text-primary);
opacity: 0.7;
color: var(--bsplus-overview-muted);
}
.due-date {
@@ -387,11 +597,7 @@
justify-content: flex-start;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #e5e7eb;
}
.dark .card-footer {
border-top-color: rgba(255, 255, 255, 0.1);
border-top: 1px solid var(--bsplus-overview-border);
}
.grade-display {
@@ -434,46 +640,101 @@
border-color: var(--background-secondary);
}
/* Column-specific styling */
/* Column header gradients — softer tints, same status colours */
.column-upcoming .column-header {
background: linear-gradient(135deg, #ffffff 0%, #f0f9ff 100%);
background: linear-gradient(
135deg,
var(--bsplus-overview-surface) 0%,
color-mix(in srgb, #38bdf8 9%, var(--bsplus-overview-surface)) 100%
);
}
.column-due-soon .column-header {
background: linear-gradient(135deg, #ffffff 0%, #fffbeb 100%);
background: linear-gradient(
135deg,
var(--bsplus-overview-surface) 0%,
color-mix(in srgb, #fbbf24 10%, var(--bsplus-overview-surface)) 100%
);
}
.column-overdue .column-header {
background: linear-gradient(135deg, #ffffff 0%, #fef2f2 100%);
background: linear-gradient(
135deg,
var(--bsplus-overview-surface) 0%,
color-mix(in srgb, #f87171 10%, var(--bsplus-overview-surface)) 100%
);
}
.column-submitted .column-header {
background: linear-gradient(135deg, #ffffff 0%, #fef3c7 100%);
background: linear-gradient(
135deg,
var(--bsplus-overview-surface) 0%,
color-mix(in srgb, #f59e0b 9%, var(--bsplus-overview-surface)) 100%
);
}
.column-marked .column-header {
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
background: linear-gradient(
135deg,
var(--bsplus-overview-surface) 0%,
color-mix(in srgb, #34d399 10%, var(--bsplus-overview-surface)) 100%
);
}
.column-custom .column-header {
background: linear-gradient(
135deg,
var(--bsplus-overview-surface) 0%,
color-mix(in srgb, var(--bsplus-overview-accent) 8%, var(--bsplus-overview-surface)) 100%
);
}
/* Dark mode column headers */
.dark .column-upcoming .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%);
background: linear-gradient(
135deg,
var(--bsplus-overview-surface) 0%,
color-mix(in srgb, #3b82f6 14%, var(--bsplus-overview-surface-2)) 100%
);
}
.dark .column-due-soon .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #92400e 100%);
background: linear-gradient(
135deg,
var(--bsplus-overview-surface) 0%,
color-mix(in srgb, #f59e0b 14%, var(--bsplus-overview-surface-2)) 100%
);
}
.dark .column-overdue .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #991b1b 100%);
background: linear-gradient(
135deg,
var(--bsplus-overview-surface) 0%,
color-mix(in srgb, #ef4444 13%, var(--bsplus-overview-surface-2)) 100%
);
}
.dark .column-submitted .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #92400e 100%);
background: linear-gradient(
135deg,
var(--bsplus-overview-surface) 0%,
color-mix(in srgb, #d97706 12%, var(--bsplus-overview-surface-2)) 100%
);
}
.dark .column-marked .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%);
background: linear-gradient(
135deg,
var(--bsplus-overview-surface) 0%,
color-mix(in srgb, #10b981 13%, var(--bsplus-overview-surface-2)) 100%
);
}
.dark .column-custom .column-header {
background: linear-gradient(
135deg,
var(--bsplus-overview-surface) 0%,
color-mix(in srgb, var(--bsplus-overview-accent) 12%, var(--bsplus-overview-surface-2)) 100%
);
}
/* Subject filter view */
@@ -529,73 +790,54 @@
}
/* Loading and error states */
.loading-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 4rem 0;
background: #ffffff;
border-radius: 12px;
border: 2px solid #e2e8f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dark .loading-container {
background: var(--background-primary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.loading-spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid #e2e8f0;
border-top: 3px solid #d41e3a;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.dark .loading-spinner {
border-color: rgba(255, 255, 255, 0.1);
border-top-color: #d41e3a;
}
.loading-text {
margin-top: 1rem;
color: #64748b;
font-size: 0.875rem;
font-weight: 500;
}
.dark .loading-text {
color: var(--text-primary);
opacity: 0.7;
}
.loading-container,
.error-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 4rem 0;
background: #ffffff;
border-radius: 12px;
border: 2px solid #fecaca;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 3rem 2rem;
background: var(--bsplus-overview-surface);
border-radius: var(--bsplus-overview-radius);
border: 1px solid var(--bsplus-overview-border);
box-shadow: var(--bsplus-overview-shadow);
}
.dark .error-container {
background: var(--background-primary);
border-color: #991b1b;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
.loading-spinner {
width: 3rem;
height: 3rem;
border: 4px solid var(--bsplus-overview-border);
border-top-color: var(--bsplus-overview-accent);
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
.loading-text {
margin-top: 1rem;
color: var(--bsplus-overview-muted);
font-size: 0.875rem;
font-weight: 500;
}
.error-icon {
color: #ef4444;
margin-bottom: 0.75rem;
opacity: 0.85;
}
.error-text {
color: #ef4444;
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
font-weight: 700;
margin: 0 0 0.35rem;
}
.error-detail {
color: var(--bsplus-overview-muted);
font-size: 0.875rem;
margin: 0;
text-align: center;
max-width: 28rem;
}
.empty-state {
@@ -603,15 +845,20 @@
flex-direction: column;
justify-content: center;
align-items: center;
padding: 2rem;
color: #64748b;
font-size: 0.875rem;
gap: 0.75rem;
padding: 3rem 2rem;
color: var(--bsplus-overview-muted);
font-size: 0.9375rem;
text-align: center;
border-radius: var(--bsplus-overview-radius);
background: var(--bsplus-overview-surface);
border: 1px solid var(--bsplus-overview-border);
box-shadow: var(--bsplus-overview-shadow);
}
.dark .empty-state {
color: var(--text-primary);
opacity: 0.7;
.empty-icon {
color: var(--bsplus-overview-muted);
opacity: 0.55;
}
.empty-column {
@@ -631,12 +878,6 @@
opacity: 0.7;
}
.empty-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.5;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
@@ -712,8 +953,10 @@
/* Responsive design */
@media (max-width: 768px) {
#grid-view-container {
padding: 1rem;
#grid-view-container,
.bsplus-overview-root {
padding: 1.25rem 1rem 1.5rem;
gap: 1rem;
}
.grid-view-header {
@@ -722,23 +965,29 @@
align-items: stretch;
}
.grid-view-filters {
justify-content: center;
flex-wrap: wrap;
.grid-view-filters,
.bsplus-overview-toolbar {
justify-content: stretch;
}
.kanban-board {
flex-direction: column;
gap: 1rem;
padding: 0;
}
.kanban-column-parent {
flex: none;
width: 100%;
}
.kanban-column {
flex: none;
max-height: none;
}
.filter-select {
min-width: 140px;
min-width: 0;
flex: 1 1 140px;
}
}
+130 -20
View File
@@ -1,45 +1,155 @@
import renderSvelte from "@/interface/main";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import AssessmentsOverview from "./AssessmentsOverview.svelte";
import SkeletonLoader from "./SkeletonLoader.svelte";
import ErrorState from "./ErrorState.svelte";
import { unmount } from "svelte";
let currentApp: any = null;
let themeObserver: MutationObserver | null = null;
type ThemeSettingKey =
| "selectedColor"
| "DarkMode"
| "adaptiveThemeColour"
| "adaptiveThemeGradient"
| "selectedTheme";
export function renderGrid(container: HTMLElement, data: any) {
if (currentApp) {
unmount(currentApp);
let themeListeners: Array<{ key: ThemeSettingKey; listener: () => void }> = [];
const THEME_CSS_VARS = [
"--better-main",
"--better-pale",
"--better-light",
"--text-color",
"--background-primary",
"--background-secondary",
"--text-primary",
"--theme-offset-bg",
"--better-sub",
] as const;
const ACCENT_CSS_VARS = [
"--better-main",
"--accent-color-value",
"--accentColor",
"--colour-betterseqta-blue",
] as const;
function extractSolidColor(value: string): string | null {
const trimmed = value.trim();
if (!trimmed || trimmed === "initial") return null;
if (
trimmed.startsWith("#") ||
trimmed.startsWith("rgb") ||
trimmed.startsWith("hsl")
) {
return trimmed;
}
if (trimmed.includes("gradient")) {
const match = trimmed.match(
/#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/i,
);
return match?.[0] ?? null;
}
return null;
}
function resolvePageAccentColor(): string {
const computed = getComputedStyle(document.documentElement);
for (const name of ACCENT_CSS_VARS) {
const solid = extractSolidColor(computed.getPropertyValue(name));
if (solid) return solid;
}
const fromSettings = settingsState.selectedColor?.trim();
if (fromSettings) {
const solid = extractSolidColor(fromSettings);
if (solid) return solid;
}
return "#007bff";
}
function syncOverviewTheme(target: HTMLElement) {
const computed = getComputedStyle(document.documentElement);
for (const name of THEME_CSS_VARS) {
const value = document.documentElement.style.getPropertyValue(name).trim()
|| computed.getPropertyValue(name).trim();
if (value) target.style.setProperty(name, value);
}
container.innerHTML = "";
container.className = "";
const accent = resolvePageAccentColor();
target.style.setProperty("--bsplus-overview-accent", accent);
target.style.setProperty("--better-main", accent);
target.classList.toggle(
"dark",
document.documentElement.classList.contains("dark"),
);
}
function watchOverviewTheme(root: HTMLElement) {
for (const { key, listener } of themeListeners) {
settingsState.unregister(key, listener);
}
themeListeners = [];
const listener = () => syncOverviewTheme(root);
for (const key of [
"selectedColor",
"DarkMode",
"adaptiveThemeColour",
"adaptiveThemeGradient",
"selectedTheme",
] satisfies ThemeSettingKey[]) {
settingsState.register(key, listener);
themeListeners.push({ key, listener });
}
themeObserver?.disconnect();
themeObserver = new MutationObserver(() => syncOverviewTheme(root));
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style", "class"],
});
}
function prepareContainer(container: HTMLElement) {
container.innerHTML = "";
container.className = "bsplus-overview-host";
container.classList.add("bsplus-overview-root");
syncOverviewTheme(container);
watchOverviewTheme(container);
}
export function renderGrid(container: HTMLElement, data: any) {
if (currentApp) unmount(currentApp);
prepareContainer(container);
currentApp = renderSvelte(AssessmentsOverview, container, { data });
}
export function renderSkeletonLoader(container: HTMLElement) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
if (currentApp) unmount(currentApp);
prepareContainer(container);
currentApp = renderSvelte(SkeletonLoader, container);
}
export function renderLoadingState(container: HTMLElement) {
renderSkeletonLoader(container);
}
export function renderErrorState(container: HTMLElement, error: string) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
if (currentApp) unmount(currentApp);
prepareContainer(container);
currentApp = renderSvelte(ErrorState, container, { error });
}
export function teardownOverviewUi() {
for (const { key, listener } of themeListeners) {
settingsState.unregister(key, listener);
}
themeListeners = [];
themeObserver?.disconnect();
themeObserver = null;
if (currentApp) {
unmount(currentApp);
currentApp = null;
}
}
@@ -1,3 +1,115 @@
export interface OverviewSubject {
code: string;
programme: number;
metaclass: number;
title: string;
}
function isActiveTermFlag(active: unknown): boolean {
return active === 1 || active === true;
}
export function normalizeOverviewSubject(raw: unknown): OverviewSubject | null {
if (!raw || typeof raw !== "object") return null;
const subject = raw as Record<string, unknown>;
const programme = Number(subject.programme ?? subject.programmeID);
const metaclass = Number(subject.metaclass ?? subject.metaclassID);
if (!programme || !metaclass || Number.isNaN(programme) || Number.isNaN(metaclass)) {
return null;
}
const code = String(subject.code ?? subject.subject ?? "").trim();
if (!code) return null;
return {
code,
programme,
metaclass,
title: String(subject.title ?? subject.description ?? code),
};
}
/** Subjects from the active programme-year folder(s) in `/seqta/student/load/subjects`. */
export function activeSubjectsFromLearnPayload(payload: unknown): OverviewSubject[] {
if (!Array.isArray(payload)) return [];
const subjects: OverviewSubject[] = [];
const seen = new Set<string>();
for (const folder of payload) {
if (!folder || typeof folder !== "object") continue;
const term = folder as { active?: unknown; subjects?: unknown[] };
if (!isActiveTermFlag(term.active) || !Array.isArray(term.subjects)) continue;
for (const raw of term.subjects) {
const subject = normalizeOverviewSubject(raw);
if (!subject) continue;
const key = `${subject.programme}-${subject.metaclass}`;
if (seen.has(key)) continue;
seen.add(key);
subjects.push(subject);
}
}
return subjects;
}
export function activeSubjectsFromEngageChild(child: {
terms?: { active?: number; subjects?: unknown[] }[];
}): OverviewSubject[] {
const subjects: OverviewSubject[] = [];
const seen = new Set<string>();
for (const term of child.terms ?? []) {
if (term.active !== 1) continue;
for (const raw of term.subjects ?? []) {
const subject = normalizeOverviewSubject(raw);
if (!subject) continue;
const key = `${subject.programme}-${subject.metaclass}`;
if (seen.has(key)) continue;
seen.add(key);
subjects.push(subject);
}
}
return subjects;
}
export function assessmentBelongsToActiveSubjects(
assessment: Record<string, unknown>,
activeSubjects: OverviewSubject[],
): boolean {
if (!activeSubjects.length) return false;
const programme = Number(
assessment.programmeID ?? assessment.programme,
);
const metaclass = Number(
assessment.metaclassID ?? assessment.metaclass,
);
if (programme && metaclass && !Number.isNaN(programme) && !Number.isNaN(metaclass)) {
return activeSubjects.some(
(subject) =>
subject.programme === programme && subject.metaclass === metaclass,
);
}
const code = String(assessment.code ?? assessment.subject ?? "").trim();
if (!code) return false;
return activeSubjects.some((subject) => subject.code === code);
}
export function filterAssessmentsForActiveSubjects<T extends Record<string, unknown>>(
assessments: T[],
activeSubjects: OverviewSubject[],
): T[] {
return assessments.filter((assessment) =>
assessmentBelongsToActiveSubjects(assessment, activeSubjects),
);
}
export function formatDate(dateStr: string, submitted?: boolean): string {
const d = new Date(dateStr);
const now = new Date();
@@ -7,7 +7,7 @@ import localforage from "localforage";
const settings = defineSettings({
uploader: componentSetting({
title: "Background Music",
description: "Upload a .wav or .mp3 audio file to play in the background.",
description: "Upload a .wav or .mp3 audio file to play in the background",
component: BackgroundMusicSetting,
}),
volume: numberSetting({
@@ -99,7 +99,7 @@ async function startPlayback(volume: number): Promise<void> {
const backgroundMusicPlugin: Plugin<typeof settings> = {
id: "background-music",
name: "Background Music",
description: "Play your own music in the background while SEQTA is open.",
description: "Play your own music in the background while SEQTA is open",
version: "1.0.0",
settings,
styles,
@@ -142,7 +142,6 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
if (!currentAudio) return;
const pauseOnHidden = (api.settings as any).pauseOnHidden ?? true;
if (!pauseOnHidden) return;
if (document.visibilityState === "hidden") {
if (visibilityResumeTimeout !== null) {
clearTimeout(visibilityResumeTimeout);
@@ -183,5 +182,3 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
};
export default backgroundMusicPlugin;
+39 -69
View File
@@ -5,7 +5,9 @@ import {
defineSettings,
hotkeySetting,
} from "../../core/settingsHelpers";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import styles from "./src/core/styles.css?inline";
import { resetSearchIndexes } from "./src/indexing/resetIndexes";
// Platform-aware default hotkey
const getDefaultHotkey = () => {
@@ -34,85 +36,42 @@ const settings = defineSettings({
title: "Index on Page Load",
description: "Run content indexing when SEQTA loads",
}),
passiveIndexing: booleanSetting({
default: true,
title: "Index Browsed Content",
description:
"Capture safe text from SEQTA pages you visit so they're searchable. Sensitive routes (settings, files, login) are always excluded.",
}),
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?");
const confirmed = confirm(
"Reset the search index and all stored Global Search data?\n\nAfter this, reload this SEQTA tab so indexing can run again and rebuild the index.",
);
if (!confirmed) return;
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));
}
try {
// `resetSearchIndexes` is a tiny statically-imported helper: no
// dynamic chunks to chase, so the button keeps working even when
// the settings page has been open across an extension update.
await resetSearchIndexes();
alert(
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
);
} catch (e) {
alert(
"Failed to reset index: " +
String(e) +
"\n\nTry closing other browser tabs and try again.",
);
}
},
}),
});
// Create the lazy plugin definition - this loads immediately but doesn't import heavy dependencies
export default defineLazyPlugin({
const globalSearchPlugin = defineLazyPlugin({
id: "global-search",
name: "Global Search",
description: "Quick search for everything in SEQTA",
@@ -120,9 +79,20 @@ export default defineLazyPlugin({
settings,
disableToggle: true,
defaultEnabled: false,
beta: true,
styles: styles,
// Lazy loader - only imports the heavy plugin when actually needed
loader: () => import("./src/core/index")
});
const runGlobalSearch = globalSearchPlugin.run!;
globalSearchPlugin.run = async (api) => {
if (isSeqtaEngageExperience()) {
return () => {};
}
return runGlobalSearch(api);
};
export default globalSearchPlugin;
@@ -48,6 +48,13 @@
let calculatorResult = $state<string | null>(null);
let resultsList = $state<HTMLUListElement>();
// Monotonic counter so a slow async search (vector reranking) cannot
// overwrite results from a newer keystroke. Without this guard, the user
// observes results "flickering" — e.g. typing `world w` finds the assessment
// but `world wa` triggers a new search whose vector pass returns later than
// the `world w` pass and clobbers the more relevant matches.
let searchRequestId = 0;
const updateCalculatorState = (hasResult: string | null) => {
calculatorResult = hasResult;
};
@@ -166,9 +173,10 @@
});
const term = searchTerm.trim().toLowerCase();
const requestId = ++searchRequestId;
if (commandsFuse && dynamicContentFuse) {
combinedResults = await doSearch(
const results = await doSearch(
term,
commandsFuse,
commandIdToItemMap,
@@ -176,7 +184,16 @@
dynamicIdToItemMap,
true, // sortByRecent
);
// Drop the result if the user has typed since this search started, or
// if the current term no longer matches what we searched for. This
// keeps the visible list anchored to the latest query.
if (requestId !== searchRequestId) return;
if (searchTerm.trim().toLowerCase() !== term) return;
combinedResults = results;
} else {
if (requestId !== searchRequestId) return;
combinedResults = [];
}
@@ -0,0 +1,89 @@
<script lang="ts">
import HighlightedText from '../../utils/HighlightedText.svelte';
import type { DynamicContentItem } from '../../utils/dynamicItems';
import type { FuseResultMatch } from '../../core/types';
const { item, isSelected, searchTerm, matches, onclick } = $props<{
item: DynamicContentItem;
isSelected: boolean;
searchTerm: string;
matches?: readonly FuseResultMatch[];
onclick: () => void;
}>();
const categoryLabel = (category: string): string => {
if (!category) return '';
return category.charAt(0).toUpperCase() + category.slice(1);
};
const gradientForCategory = (category: string): string => {
switch (category) {
case 'courses':
return 'from-[#7c5fe0] to-[#4d2bb8]';
case 'notices':
return 'from-[#f6c453] to-[#d39007]';
case 'documents':
return 'from-[#4FBBFE] to-[#2090F3]';
case 'folio':
return 'from-[#22c55e] to-[#0f9b3a]';
case 'portals':
return 'from-[#22d3ee] to-[#0e7490]';
case 'reports':
return 'from-[#f97316] to-[#c2410c]';
case 'goals':
return 'from-[#10b981] to-[#047857]';
case 'passive':
return 'from-[#6b7280] to-[#374151]';
default:
return 'from-[#4FBBFE] to-[#2090F3]';
}
};
const fallbackIcon = (category: string): string => {
switch (category) {
case 'courses':
return '\ueb4d';
case 'notices':
return '\ueb24';
case 'documents':
return '\ueb6f';
case 'folio':
return '\ueb16';
case 'portals':
return '\ueb01';
case 'reports':
return '\ueb70';
case 'goals':
return '\uea15';
case 'passive':
return '\ueb71';
default:
return '\ue924';
}
};
</script>
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group transition-colors duration-100 ring-0 dark:ring-zinc-600/50
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white dark:ring-[1px] dark:shadow' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={onclick}
>
<div class="flex items-center w-full">
<div
class="flex-none scale-90 w-8 h-8 text-xl font-IconFamily flex items-center justify-center text-white rounded-md bg-gradient-to-br {gradientForCategory(item.category)}"
>
{item.metadata?.icon || fallbackIcon(item.category)}
</div>
<span class="ml-4 text-lg truncate">
<HighlightedText text={item.text} term={searchTerm} matches={matches} />
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
{item.metadata?.subjectCode || categoryLabel(item.category)}
</span>
</div>
{#if item.content}
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
<HighlightedText text={item.content} term={searchTerm} matches={matches} />
</div>
{/if}
</button>
@@ -25,11 +25,11 @@ async function getCurrentLesson() {
try {
const response = await fetch(`${location.origin}/seqta/student/load/timetable?`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from: todayFormatted,
until: todayFormatted,
student: 69,
}),
});
@@ -15,6 +15,10 @@ import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
import { IndexedDbManager } from "embeddia";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { checkAndHandleUpdate } from "../utils/versionCheck";
import {
getStoredPassiveItems,
installPassiveObserver,
} from "../indexing/passiveObserver";
// Platform-aware default hotkey
const getDefaultHotkey = () => {
@@ -43,11 +47,19 @@ const settings = defineSettings({
title: "Index on Page Load",
description: "Run content indexing when SEQTA loads",
}),
passiveIndexing: booleanSetting({
default: true,
title: "Index Browsed Content",
description:
"Capture safe text from SEQTA pages you visit so they're searchable. Sensitive routes (settings, files, login) are always excluded.",
}),
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?");
const confirmed = confirm(
"Reset the search index and all stored Global Search data?\n\nAfter this, reload this SEQTA tab so indexing can run again and rebuild the index.",
);
if (confirmed) {
try {
@@ -106,7 +118,9 @@ const settings = defineSettings({
try {
await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index");
alert("Search index and storage have been reset successfully.");
alert(
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
);
} catch (e) {
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
}
@@ -131,6 +145,9 @@ class GlobalSearchPlugin extends BasePlugin<typeof settings> {
@Setting(settings.runIndexingOnLoad)
runIndexingOnLoad!: boolean;
@Setting(settings.passiveIndexing)
passiveIndexing!: boolean;
@Setting(settings.resetIndex)
resetIndex!: () => void;
}
@@ -145,32 +162,40 @@ const globalSearchPlugin: Plugin<typeof settings> = {
settings: settingsInstance.settings,
disableToggle: true,
defaultEnabled: false,
beta: true,
styles: styles,
run: async (api) => {
const appRef = { current: null };
// Check for extension updates and clear caches if needed
// Use a timeout to avoid blocking initialization
setTimeout(async () => {
try {
const wasUpdated = await checkAndHandleUpdate();
if (wasUpdated) {
console.log("[Global Search] Extension updated - caches cleared");
}
} catch (error: any) {
// Handle CSS preload errors and other failures gracefully
// These can happen in Firefox or when assets aren't available
if (error?.message?.includes("preload CSS") ||
error?.message?.includes("MIME type") ||
error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")) {
console.debug("[Global Search] Version check skipped due to asset loading restrictions:", error.message);
} else {
console.warn("[Global Search] Failed to check for updates:", error);
}
// Run the version check BEFORE we open any IndexedDB connections.
// On a normal load (no version change) this is just a string compare
// and a manifest read, so the cost is negligible. On a real update,
// we want the database wipe to complete before `IndexedDbManager`
// grabs a handle on `embeddiaDB`, otherwise the delete request comes
// back blocked.
try {
const wasUpdated = await checkAndHandleUpdate();
if (wasUpdated) {
console.log(
"[Global Search] Extension updated — search index reset; the next indexing pass will repopulate.",
);
}
}, 100);
} catch (error: any) {
// Firefox sometimes refuses CSS preloads or asset reads; we never
// want this path to take the whole plugin down.
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);
}
}
try {
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
@@ -211,6 +236,17 @@ const globalSearchPlugin: Plugin<typeof settings> = {
const workerManager = VectorWorkerManager.getInstance();
console.log("Streaming active:", workerManager.isStreamingActive());
},
passiveItems: async () => {
const items = await getStoredPassiveItems();
console.log(`Captured ${items.length} passive items`);
return items;
},
runSelfTests: async () => {
const { runGlobalSearchSelfTests } = await import(
"../indexing/selfTests"
);
return runGlobalSearchSelfTests();
},
checkIndexedDBSize: async () => {
try {
const estimate = await navigator.storage.estimate();
@@ -233,6 +269,14 @@ const globalSearchPlugin: Plugin<typeof settings> = {
}
};
if (api.settings.passiveIndexing) {
try {
installPassiveObserver();
} catch (error) {
console.warn("[Global Search] Passive observer install failed:", error);
}
}
if (api.settings.runIndexingOnLoad) {
setTimeout(async () => {
await runIndexing();
@@ -8,7 +8,12 @@ import browser from "webextension-polyfill";
export function mountSearchBar(
titleElement: Element,
api: any,
appRef: { current: any; storageChangeHandler?: any; progressHandler?: any },
appRef: {
current: any;
storageChangeHandler?: any;
progressHandler?: any;
clearDoneFlashTimer?: () => void;
},
) {
if (titleElement.querySelector(".search-trigger")) {
return;
@@ -18,74 +23,215 @@ export function mountSearchBar(
let currentHotkey = isValidHotkey(api.settings.searchHotkey) ? api.settings.searchHotkey : "ctrl+k";
let hotkeyDisplay = formatHotkeyForDisplay(currentHotkey);
// Search trigger + progress UI live in one wrapper so the auto-margin
// pushes the whole group to the left edge of the topbar instead of
// stranding the progress text on the far right of the screen.
const searchWrapper = document.createElement("div");
searchWrapper.className = "search-trigger-wrapper";
// Anchor stacks button + slim progress strip in one rounded chip (see
// `.search-trigger-anchor` in styles.css).
const searchAnchor = document.createElement("div");
searchAnchor.className = "search-trigger-anchor";
const searchButton = document.createElement("div");
searchButton.className = "search-trigger";
// Create progress indicator container
const progressContainer = document.createElement("div");
progressContainer.className = "search-progress-container";
progressContainer.style.cssText = "display: flex; align-items: center; gap: 8px; margin-left: 8px; min-width: 120px;";
// Create progress bar
const progressBarWrapper = document.createElement("div");
progressBarWrapper.className = "search-progress-bar-wrapper";
progressBarWrapper.style.cssText = "flex: 1; height: 4px; background: rgba(0, 0, 0, 0.1); border-radius: 2px; overflow: hidden; display: none;";
const progressTrack = document.createElement("div");
progressTrack.className = "search-progress-track";
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;";
progressTrack.appendChild(progressBar);
progressBarWrapper.appendChild(progressTrack);
// 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");
// Use a block-level <div> so the label reliably participates in flex
// layout. A <span> defaults to `display: inline`, which silently ignores
// `max-width`, `overflow`, and `text-overflow: ellipsis`, and was the
// reason the label appeared blank when the bar was visible.
const progressText = document.createElement("div");
progressText.className = "search-progress-text";
progressText.style.cssText = "font-size: 11px; color: #666; white-space: nowrap; display: none;";
progressText.setAttribute("aria-live", "polite");
progressContainer.appendChild(progressBarWrapper);
progressContainer.appendChild(progressText);
searchAnchor.appendChild(searchButton);
searchAnchor.appendChild(progressBarWrapper);
searchWrapper.appendChild(searchAnchor);
searchWrapper.appendChild(progressText);
// Indexing state
let isIndexing = false;
/** True while indexing has run until it finishes/fails — used for Done! flash only */
let ranIndexingCycle = false;
let completedJobs = 0;
let totalJobs = 0;
let indexingStatus: string | null = null;
let doneFlashTimer: ReturnType<typeof setTimeout> | null = null;
let doneFadeTimer: ReturnType<typeof setTimeout> | null = null;
/** Captures `wasIndexing && !indexing` for the current dispatcher tick */
let indexingJustStoppedFlag = false;
const DONE_HOLD_MS = 5000;
const DONE_FADE_MS = 550;
/** Treat as failure copy — plain “Done!” would be misleading */
const statusLooksRough = (s: string) =>
/\b(fail|error|cancel)\b/i.test(s);
const truncateStatus = (s: string, max = 44) =>
s.length > max ? s.slice(0, max - 1) + "…" : s;
const clearDoneFlashTimer = () => {
if (doneFlashTimer) {
clearTimeout(doneFlashTimer);
doneFlashTimer = null;
}
if (doneFadeTimer) {
clearTimeout(doneFadeTimer);
doneFadeTimer = null;
}
};
const updateProgressDisplay = () => {
if (isIndexing && totalJobs > 0) {
const indexingStoppedThisTick = indexingJustStoppedFlag;
indexingJustStoppedFlag = false;
const active = isIndexing && totalJobs > 0;
// Stray pulses (missing total, 0 completed, etc.) used to hit the idle
// branch and call clearDoneFlashTimer(), killing the Done! hold/fade.
if (doneFlashTimer !== null || doneFadeTimer !== null) {
if (!active) {
return;
}
clearDoneFlashTimer();
}
const completionEligible =
ranIndexingCycle &&
!active &&
totalJobs > 0 &&
(completedJobs >= totalJobs || indexingStoppedThisTick);
if (active) {
clearDoneFlashTimer();
progressBarWrapper.classList.remove("is-rough-complete");
progressText.classList.remove(
"is-rough",
"is-fading-done",
"is-done-message",
);
const percentage = Math.round((completedJobs / totalJobs) * 100);
progressBar.style.width = `${Math.max(2, percentage)}%`;
progressBarWrapper.style.display = "block";
progressBarWrapper.classList.add("is-active");
searchAnchor.classList.add("is-indexing");
searchButton.classList.add("is-indexing");
if (indexingStatus) {
progressText.textContent = indexingStatus.length > 20 ? indexingStatus.substring(0, 20) + "..." : indexingStatus;
progressText.style.display = "block";
progressText.textContent = `${truncateStatus(indexingStatus)} · ${percentage}%`;
} else {
progressText.textContent = `${completedJobs}/${totalJobs} (${percentage}%)`;
progressText.style.display = "block";
progressText.textContent = `Indexing ${completedJobs}/${totalJobs} (${percentage}%)`;
}
} else {
progressBarWrapper.style.display = "none";
progressText.style.display = "none";
progressText.classList.add("is-active");
return;
}
if (completionEligible) {
// Duplicate end-of-run ticks must not reschedule hold/fade timers
if (doneFlashTimer !== null || doneFadeTimer !== null) {
return;
}
const rough =
indexingStatus != null && statusLooksRough(indexingStatus);
progressBar.style.width = "0%";
progressBarWrapper.classList.remove("is-active");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove("is-fading-done");
progressText.textContent = rough ? truncateStatus(indexingStatus!, 52) : "Done!";
if (rough) {
progressText.classList.add("is-rough");
progressBarWrapper.classList.add("is-rough-complete");
} else {
progressText.classList.remove("is-rough");
progressBarWrapper.classList.remove("is-rough-complete");
}
progressText.classList.add("is-active", "is-done-message");
doneFlashTimer = setTimeout(() => {
doneFlashTimer = null;
progressText.classList.add("is-fading-done");
doneFadeTimer = setTimeout(() => {
doneFadeTimer = null;
ranIndexingCycle = false;
indexingStatus = null;
progressBar.style.width = "0%";
progressBarWrapper.classList.remove("is-active");
progressBarWrapper.classList.remove("is-rough-complete");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove(
"is-active",
"is-rough",
"is-fading-done",
"is-done-message",
);
progressText.textContent = "";
}, DONE_FADE_MS);
}, DONE_HOLD_MS);
return;
}
clearDoneFlashTimer();
progressBarWrapper.classList.remove("is-active");
progressBarWrapper.classList.remove("is-rough-complete");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove(
"is-active",
"is-rough",
"is-fading-done",
"is-done-message",
);
progressBar.style.width = "0%";
progressText.textContent = "";
ranIndexingCycle = false;
indexingStatus = null;
};
// 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;
const { completed, total, indexing, status } = event.detail as {
completed?: number;
total?: number;
indexing?: boolean;
status?: string;
};
const wasIndexing = isIndexing;
completedJobs = completed ?? 0;
totalJobs = total ?? 0;
isIndexing = Boolean(indexing);
indexingStatus = status ?? null;
indexingJustStoppedFlag = wasIndexing && !isIndexing;
if (!wasIndexing && isIndexing) ranIndexingCycle = true;
if (wasIndexing && !isIndexing) ranIndexingCycle = true;
if (totalJobs > 0 && completedJobs >= totalJobs && !isIndexing) {
ranIndexingCycle = true;
}
updateProgressDisplay();
};
window.addEventListener('indexing-progress', progressHandler as EventListener);
appRef.progressHandler = progressHandler;
appRef.clearDoneFlashTimer = clearDoneFlashTimer;
const updateSearchButtonDisplay = () => {
searchButton.innerHTML = /* html */ `
@@ -99,8 +245,7 @@ export function mountSearchBar(
};
updateSearchButtonDisplay();
titleElement.appendChild(searchButton);
titleElement.appendChild(progressContainer);
titleElement.appendChild(searchWrapper);
// Listen for hotkey setting changes
const handleStorageChange = (changes: any, area: string) => {
@@ -139,7 +284,12 @@ export function mountSearchBar(
}
}
export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any; progressHandler?: any }) {
export function cleanupSearchBar(appRef: {
current: any;
storageChangeHandler?: any;
progressHandler?: any;
clearDoneFlashTimer?: () => void;
}) {
if (appRef.current) {
try {
unmount(appRef.current);
@@ -149,23 +299,29 @@ export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?:
}
}
try {
appRef.clearDoneFlashTimer?.();
} catch {
/* ignore */
}
appRef.clearDoneFlashTimer = undefined;
// Remove progress event listener
if (appRef.progressHandler) {
window.removeEventListener('indexing-progress', appRef.progressHandler as EventListener);
appRef.progressHandler = null;
}
// Remove search trigger button
const searchTrigger = document.querySelector(".search-trigger");
if (searchTrigger) {
searchTrigger.remove();
// Remove search trigger wrapper (which contains the button and progress UI)
const searchWrapper = document.querySelector(".search-trigger-wrapper");
if (searchWrapper) {
searchWrapper.remove();
}
// Remove progress container
const progressContainer = document.querySelector(".search-progress-container");
if (progressContainer) {
progressContainer.remove();
}
// Defensive cleanup for older mounts that may have left the trigger or
// progress container as direct children of the topbar.
document.querySelector(".search-trigger")?.remove();
document.querySelector(".search-progress-container")?.remove();
// Remove search root
const searchRoot = document.querySelector("div[data-search-root]");
@@ -1,15 +1,72 @@
/*
* Wrapper that owns the auto-margin so the whole search-trigger-and-progress
* group sits at the left of the SEQTA topbar. Previously, only the
* `.search-trigger` had `margin-right: auto`, which pushed the progress text
* all the way to the far right of the screen.
*/
.search-trigger-wrapper {
display: flex !important;
align-items: center;
gap: 12px;
margin-left: 10px;
margin-right: auto !important;
/* Allow the bar's bottom portion to peek out below the wrapper without
getting clipped by the topbar's flex line. */
overflow: visible;
}
/*
* Stacks the clickable row and the progress strip as one visual chip
* so the bar is flush under the button (no floating gap).
*/
.search-trigger-anchor {
display: inline-flex;
flex-direction: column;
align-items: stretch;
vertical-align: middle;
border-radius: 8px;
overflow: hidden;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.06) inset,
0 3px 8px rgba(0, 0, 0, 0.12);
}
.dark .search-trigger-anchor {
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 3px 10px rgba(0, 0, 0, 0.45);
}
.search-trigger-anchor.is-indexing {
/* Very soft “rear card” edge — tweak opacity if SEQTA chrome is noisy */
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.06) inset,
0 3px 8px rgba(0, 0, 0, 0.14),
1px 3px 0 rgba(139, 92, 246, 0.14),
0 2px 6px rgba(0, 0, 0, 0.08);
}
.dark .search-trigger-anchor.is-indexing {
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.05) inset,
0 4px 12px rgba(0, 0, 0, 0.5),
1px 3px 0 rgba(167, 139, 250, 0.12),
0 2px 8px rgba(0, 0, 0, 0.25);
}
.search-trigger {
display: flex;
align-items: center;
justify-content: center;
flex: none;
height: 32px;
margin-left: 10px;
border-radius: 8px;
border-radius: 0;
cursor: pointer;
transition: all 0.2s ease;
margin-right: auto !important;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
padding: 3px 12px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
box-shadow: none;
backdrop-filter: blur(4px);
user-select: none;
@@ -28,10 +85,12 @@
}
}
/* Light mode styles */
/* Light mode chip */
.search-trigger {
background-color: rgba(248, 250, 252, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
border-bottom: none;
border-radius: 8px 8px 0 0;
background-color: rgba(248, 250, 252, 0.94) !important;
color: #555 !important;
p {
@@ -44,8 +103,10 @@
}
.dark .search-trigger {
background-color: rgba(0, 0, 0, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-bottom: none;
border-radius: 8px 8px 0 0;
background-color: rgba(24, 24, 27, 0.92) !important;
color: #aaa !important;
p {
@@ -57,7 +118,17 @@
}
}
/*
* Idle: full pill rounding + closed bottom border on the anchor chip.
*/
.search-trigger-anchor:not(.is-indexing) .search-trigger {
border-radius: 8px !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.1) !important;
}
.dark .search-trigger-anchor:not(.is-indexing) .search-trigger {
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
}
.highlight {
background-color: rgba(255, 213, 0, 0.3);
font-weight: 500;
@@ -83,57 +154,139 @@
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;
}
/*
* Thin track flush under `.search-trigger` same width as chip, shared
* `overflow:hidden` rounding on `.search-trigger-anchor`.
*/
.search-progress-bar-wrapper {
flex: 1;
height: 4px;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
flex: none;
height: 0;
min-height: 0;
border: none;
background: transparent;
border-radius: 0;
overflow: hidden;
display: none;
min-width: 60px;
opacity: 1;
transform: none;
pointer-events: none;
transition: height 0.22s cubic-bezier(0.2, 0.7, 0.3, 1);
}
.dark .search-progress-bar-wrapper {
background: rgba(255, 255, 255, 0.1);
.search-progress-bar-wrapper.is-active {
height: 4px;
}
.search-progress-track {
box-sizing: border-box;
height: 100%;
width: 100%;
position: relative;
overflow: hidden;
background: rgba(15, 23, 42, 0.08);
}
.dark .search-progress-track {
background: rgba(248, 250, 252, 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;
height: 100%;
width: 0%;
background: linear-gradient(90deg, #38bdf8, #2563eb);
transition:
width 0.35s cubic-bezier(0.2, 0.7, 0.35, 1),
background 0.25s ease;
}
.search-progress-bar-wrapper.is-rough-complete .search-progress-track {
background: rgba(185, 28, 28, 0.12);
}
.dark .search-progress-bar-wrapper.is-rough-complete .search-progress-track {
background: rgba(248, 113, 113, 0.12);
}
.search-progress-bar-wrapper.is-rough-complete .search-progress-bar {
background: linear-gradient(90deg, #f87171, #dc2626);
}
.search-progress-bar::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.28),
transparent
);
animation: shimmer 2s infinite;
border-radius: 2px;
}
/*
* Progress label sits as a flex child immediately to the right of the
* search button (gap is provided by .search-trigger-wrapper). It's hidden
* by default and fades in once an indexing pass is active.
*/
.search-progress-text {
display: block;
font-size: 12px;
color: #475569;
white-space: nowrap;
font-weight: 500;
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.2s ease, transform 0.2s ease, color 0.25s ease;
pointer-events: none;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.35;
letter-spacing: 0.01em;
flex: 0 0 auto;
align-self: center;
}
.search-progress-text {
font-size: 11px;
color: #666;
white-space: nowrap;
display: none;
font-weight: 500;
/* While indexing: same neutral label colour as default (only “Done!” is green). */
.search-progress-text.is-active {
opacity: 1;
transform: translateX(0);
color: #475569;
}
/* Completed pass — green text only here, not on the strip or chip */
.search-progress-text.is-active.is-done-message {
font-weight: 600;
letter-spacing: 0.02em;
color: #15803d !important;
}
.dark .search-progress-text.is-active.is-done-message {
color: #4ade80 !important;
}
/* After DONE_HOLD_MS, fade out before DOM teardown */
.search-progress-text.is-active.is-fading-done {
opacity: 0;
transform: translateX(-4px);
transition:
opacity 0.5s ease,
transform 0.45s ease,
color 0.25s ease;
}
.dark .search-progress-text {
color: #999;
color: #cbd5e1;
}
.dark .search-progress-text.is-active {
color: #cbd5e1;
}
.search-progress-text.is-active.is-rough {
color: #b91c1c;
}
.dark .search-progress-text.is-active.is-rough {
color: #fca5a5;
}
@@ -0,0 +1,161 @@
/**
* Representative SEQTA response shapes captured from a real `/seqta/student/`
* session via the websiteskimmer recorder. These are static fixtures used
* by `selfTests.ts` to verify our extractors and the passive observer
* remain compatible with the upstream API as it evolves.
*
* NOTE: These fixtures are scrubbed of any secrets and reduced in size; the
* structure (keys, types, nesting) faithfully matches what SEQTA returns
* but the values are illustrative rather than real student data.
*/
export const subjectsListPayload = [
{
code: "2026S1",
description: "Sample Semester 1 timetable",
active: 1,
id: 77,
subjects: [
{
code: "ENGG1",
classunit: 29248,
description: "English GEN 1",
metaclass: 29611,
title: "English GEN 1",
programme: 3830,
marksbook_type: "numeric",
},
{
code: "MASA1",
classunit: 29247,
description: "Mathematics Specialist 1",
metaclass: 29610,
title: "Mathematics Specialist 1",
programme: 3831,
marksbook_type: "numeric",
},
],
},
];
export const coursesPayload = {
c: "ENGG1#1",
t: "English GEN 1",
i: 3830,
m: 29611,
document:
'{"document":{"modules":[{"uuid":"1641cf87-ae08-4bcb-832d-d5709d84d0c5"}]}}',
w: [
[
{ t: "", h: "", i: 248293, l: "", n: 0, o: "" },
{
t: "",
i: 248316,
l: '<p><a href="http://ed.ted.com/on/r80lnJL0#watch">http://ed.ted.com/on/r80lnJL0#watch</a></p>',
n: 1,
o: "",
},
],
[{ t: "Lesson 2", h: "<h1>Module 2</h1>", i: 248294, l: "", n: 0, o: "" }],
],
};
export const messagesListPayload = {
hasMore: false,
messages: [
{
date: "2026-04-29 04:26:25.075868+00",
attachments: false,
read: 1,
sender: "Jacob Johannesburg",
subject: "test",
sender_type: "student",
attachmentCount: 0,
id: 81469,
sender_id: 3111,
},
],
ts: "2026-04-30 03:25:02.27900",
};
export const documentsPayload = [
{
docs: [
{
file: 49555,
filename: "School Glossary.docx",
size: "14931",
context_uuid: "3162189c-2052-4f83-ad83-a66c57460ea2",
mimetype:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
created_date: "2021-08-04 12:55:55.102653+00",
title: "School Glossary",
uuid: "3162189c-2052-4f83-ad83-a66c57460ea2",
created_by: "537",
},
],
id: 9,
category: "Document repository",
},
];
export const noticesPayload = [
{
id: 12345,
title: "Lunchtime sport tomorrow",
contents: "<p>Bring shoes.</p>",
staff: "Mr Coach",
staff_id: 246,
label: 9,
label_title: "All Students",
colour: "#ff5722",
},
];
export const portalsPayload = [
{
is_power_portal: false,
inherit_styles: true,
icon: "colour-cerulean",
id: 328,
label: "Mathletics",
priority: 20,
uuid: "9d20f40c-fdc9-4aa3-91f1-905d86e240c4",
url: "www.mathletics.com/",
},
];
export const folioListPayload = {
me: "Jacob Johannesburg",
list: [
{
student: "Jacob Johannesburg",
id: 203,
published: "2026-04-14 20:02:50",
title: "My folio",
},
],
};
export const folioEntryPayload = {
forum: 478,
contents:
'[[embed:raw|<p>Some <strong>reflection</strong> text.</p>]] Plain trailing text.',
created: "2026-04-14 10:32:34.264641+00",
allow_comments: true,
author: { year: "Year 10", name: "Jacob Johannesburg", id: 3111 },
files: [],
id: 203,
published: "2026-04-14 20:02:50",
title: "My folio",
updated: "2026-04-14 10:32:50.696678+00",
};
/**
* Settings payload contains tenant-wide configuration including third-party
* URLs and API keys. The passive observer must NEVER index this route.
*/
export const settingsPayload = {
"global.dropbox.api.key": { value: "xxx-do-not-index" },
"global.ai.api.baseurl": { value: "https://example.com" },
};
@@ -28,6 +28,40 @@ interface AssessmentMetadata {
type ActionHandler<T = any> = (item: IndexItem & { metadata: T }) => void;
/**
* Navigate to a SEQTA SPA hash route in the most reliable way available.
*
* Setting `location.hash` works when the destination module is already
* registered with SEQTA's hashchange router (as is the case for the
* existing `message`/`assessment` actions, which then poke at the live
* DOM). For navigations that switch to a module the SPA may not have
* loaded yet (courses, forums, folios, portals, documents, reports,
* goals, notices, ...) we instead assign through `location.href` against
* the canonical `${origin}/` base. The path stays `/`, so the browser
* still treats this as a hash-only change in practice but if anything
* went sideways with the path, we get a clean reload that bootstraps the
* SPA fresh, which is far less surprising than a blank screen.
*/
function navigateToHashRoute(routeWithLeadingSlash: string): void {
const target = `${location.origin}/#?page=${routeWithLeadingSlash}`;
window.location.href = target;
}
function navigateInCurrentSeqtaApp(routeWithLeadingSlash: string): void {
window.location.hash = `#?page=${routeWithLeadingSlash}`;
}
/**
* Final-fallback hub when an item has no usable deep-link metadata.
*
* `/dashboard` is the standard SEQTA Learn landing page and is the
* destination the websiteskimmer recording captured for unknown routes.
* `/home` is BetterSEQTA-Plus's custom replacement which only renders
* after our content script has hooked the SPA using it as a fallback
* from a fresh nav can produce a blank frame.
*/
const FALLBACK_ROUTE = "/dashboard";
export const actionMap: Record<string, ActionHandler<any>> = {
message: (async (item: IndexItem & { metadata: MessageMetadata }) => {
window.location.hash = `#?page=/messages`;
@@ -81,32 +115,34 @@ export const actionMap: Record<string, ActionHandler<any>> = {
}
}
// Try to extract metadata values using multiple methods to handle XrayWrapper
// Try to extract metadata values using multiple methods to handle XrayWrapper.
// The metadata bag is intentionally typed loosely here because Firefox's
// XrayWrapper occasionally surfaces extra/casing-variant keys we still
// want to read defensively.
const getMetadataValue = (key: string, altKey?: string): any => {
const bag = metadata as unknown as Record<string, any>;
try {
// Try direct access first
const value = metadata[key];
const value = bag[key];
if (value !== undefined && value !== null) {
return value;
}
if (altKey) {
const altValue = metadata[altKey];
const altValue = bag[altKey];
if (altValue !== undefined && altValue !== null) {
return altValue;
}
}
// Try accessing via Object.keys iteration (works around XrayWrapper)
try {
const keys = Object.keys(metadata);
const keys = Object.keys(bag);
for (const k of keys) {
if (k === key || k === altKey) {
const val = metadata[k];
const val = bag[k];
if (val !== undefined && val !== null) {
return val;
}
}
}
} catch (e) {
} catch {
// Object.keys might fail on XrayWrapper, that's okay
}
return undefined;
@@ -189,14 +225,218 @@ export const actionMap: Record<string, ActionHandler<any>> = {
}) as ActionHandler<any>,
subjectassessment: ((item: IndexItem) => {
window.location.href = `/#?page=/assessments/${item.metadata.programme}:${item.metadata.subjectId}`;
navigateToHashRoute(
`/assessments/${item.metadata.programme}:${item.metadata.subjectId}`,
);
}) as ActionHandler<any>,
subjectcourse: ((item: IndexItem) => {
window.location.href = `/#?page=/courses/${item.metadata.programme}:${item.metadata.subjectId}`;
navigateToHashRoute(
`/courses/${item.metadata.programme}:${item.metadata.subjectId}`,
);
}) as ActionHandler<any>,
forum: ((item: IndexItem) => {
window.location.href = `/#?page=/forums/${item.metadata.forumId}`;
navigateToHashRoute(`/forums/${item.metadata.forumId}`);
}) as ActionHandler<any>,
course: ((item: IndexItem) => {
const programme = item.metadata?.programme;
const metaclass = item.metadata?.metaclass ?? item.metadata?.subjectId;
if (programme !== undefined && metaclass !== undefined) {
navigateToHashRoute(`/courses/${programme}:${metaclass}`);
return;
}
if (item.metadata?.route) {
navigateToHashRoute(String(item.metadata.route));
return;
}
navigateToHashRoute(FALLBACK_ROUTE);
}) as ActionHandler<any>,
notice: ((_item: IndexItem) => {
// SEQTA's notices route doesn't honour `&date=` from the hash, so just
// open the listing.
navigateToHashRoute("/notices");
}) as ActionHandler<any>,
document: ((_item: IndexItem) => {
// We don't trigger downloads automatically: opening the documents page
// gives users full SEQTA controls (preview, download, share) without
// needing the JWT-stamped streaming URL we deliberately avoid storing.
navigateToHashRoute("/documents");
}) as ActionHandler<any>,
folio: ((_item: IndexItem) => {
// SEQTA's folio SPA does not expose a per-id route; the previous
// `?page=/folios/read?id=N` shape contained a literal `?` inside the
// `page` query value and was unmatchable, which sent users to the
// dashboard. Always land on the read view and let the user pick.
navigateToHashRoute("/folios/read");
}) as ActionHandler<any>,
portal: ((item: IndexItem) => {
// SEQTA renders portals via the in-app viewer at `?page=/portals/<uuid>`
// (verified via the websiteskimmer capture). Prefer that so SSO/headers
// are preserved; only pop the external URL as a fallback if we don't
// have a UUID; final fallback to the dashboard rather than blanking.
const uuid = item.metadata?.portalUuid;
if (typeof uuid === "string" && uuid) {
navigateToHashRoute(`/portals/${uuid}`);
return;
}
const url = item.metadata?.url;
if (typeof url === "string" && url) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
navigateToHashRoute(FALLBACK_ROUTE);
}) as ActionHandler<any>,
report: ((_item: IndexItem) => {
navigateToHashRoute("/reports");
}) as ActionHandler<any>,
goal: ((item: IndexItem) => {
const year = item.metadata?.year;
if (year !== undefined) {
navigateToHashRoute(`/goals/${year}`);
} else {
navigateToHashRoute("/goals");
}
}) as ActionHandler<any>,
/**
* Routes for passively-captured items.
*
* The passive observer captures whatever `/seqta/student/...` JSON the
* page is fetching, so we can't trust a single category to imply a
* single SEQTA SPA route. Instead, derive the destination from the API
* route the entity came from, augmented with entity-shaped hints
* (programme/metaclass/year/uuid/...) that the observer hoists into
* metadata. We never replay the original POST: actions are user-driven
* and must stay safe even though the observer's own denylist excludes
* `save/*` and friends.
*/
passive: ((item: IndexItem) => {
const md = (item.metadata ?? {}) as Record<string, unknown>;
const route = typeof md.route === "string" ? (md.route as string) : "";
const sourcePage =
typeof md.sourcePage === "string" ? (md.sourcePage as string) : "";
const routeParts = route
.replace(/^\/seqta\/student\/?/, "")
.replace(/^load\//, "")
.split("/")
.filter(Boolean)
.map((part) => part.toLowerCase());
const tail = routeParts[0] ?? "";
const child = routeParts[1] ?? "";
const num = (key: string): number | undefined => {
const value = md[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value && Number.isFinite(Number(value))) {
return Number(value);
}
return undefined;
};
const str = (key: string): string | undefined => {
const value = md[key];
return typeof value === "string" && value ? value : undefined;
};
const programme = num("programme") ?? num("programmeId") ?? num("programmeID");
const metaclass =
num("metaclass") ?? num("metaclassId") ?? num("metaclassID");
const portalUuid = str("portalUuid") ?? str("uuid");
const forumId = num("forumId") ?? num("forum");
const year = num("year");
const assessmentId =
num("assessmentId") ?? num("assessmentID") ?? num("id");
const messageId = num("messageId");
if (sourcePage === "/messages") {
navigateInCurrentSeqtaApp("/messages");
return;
}
switch (tail) {
case "courses":
if (programme !== undefined && metaclass !== undefined) {
navigateToHashRoute(`/courses/${programme}:${metaclass}`);
return;
}
break;
case "assessments":
if (programme !== undefined && metaclass !== undefined) {
const itemSuffix =
assessmentId !== undefined ? `&item=${assessmentId}` : "";
navigateToHashRoute(
`/assessments/${programme}:${metaclass}${itemSuffix}`,
);
return;
}
if (assessmentId !== undefined) {
navigateToHashRoute(`/assessments/upcoming&item=${assessmentId}`);
return;
}
navigateToHashRoute("/assessments/upcoming");
return;
case "forums":
case "forum":
if (forumId !== undefined) {
navigateToHashRoute(`/forums/${forumId}`);
return;
}
break;
case "portals":
case "portal":
if (portalUuid) {
navigateToHashRoute(`/portals/${portalUuid}`);
return;
}
break;
case "goals":
case "goal":
navigateToHashRoute(year !== undefined ? `/goals/${year}` : "/goals");
return;
case "folio":
case "folios":
navigateToHashRoute("/folios/read");
return;
case "notices":
case "notice":
navigateToHashRoute("/notices");
return;
case "documents":
case "document":
navigateToHashRoute("/documents");
return;
case "reports":
case "report":
navigateToHashRoute("/reports");
return;
case "messages":
case "message":
// `/seqta/student/load/message/people` and related endpoints are
// only meaningful while SEQTA's message module is mounted. Use the
// same live hash navigation as the real message action instead of
// forcing a fresh bootstrap, which can drop back to dashboard for
// context-only endpoints.
void messageId; // noqa — preserved for future deep-select work
navigateInCurrentSeqtaApp("/messages");
return;
case "people":
if (route.includes("/load/message/people") || child === "people") {
navigateInCurrentSeqtaApp("/messages");
return;
}
break;
case "timetable":
navigateToHashRoute("/timetable");
return;
}
navigateToHashRoute(FALLBACK_ROUTE);
}) as ActionHandler<any>,
};
@@ -0,0 +1,386 @@
import { delay } from "@/seqta/utils/delay";
/**
* Shared SEQTA HTTP layer used by every indexing job.
*
* - All requests are same-origin POSTs against `/seqta/student/...` with
* `credentials: "include"` so they inherit the user's existing session.
* - Responses are parsed as JSON and lightly validated (status === "200" and
* payload present, mirroring the SEQTA convention).
* - Failures are retried with exponential backoff up to a configurable limit.
* - A simple per-route concurrency / spacing limiter prevents heavy jobs (e.g.
* per-subject course crawls) from hammering SEQTA.
*/
export interface SeqtaResponse<T = any> {
payload: T;
status: string;
}
export interface SeqtaFetchOptions {
/** Defaults to "POST". */
method?: "POST" | "GET";
/** Maximum number of retries for transient failures (default 2). */
retries?: number;
/** Initial backoff delay in ms (default 200). */
baseDelayMs?: number;
/** Hard cap on total request time in ms (default 20s). */
timeoutMs?: number;
/** AbortSignal for cancellation. */
signal?: AbortSignal;
/** Skip the routing limiter (rare; only for already-throttled callers). */
skipLimiter?: boolean;
}
const DEFAULT_RETRIES = 2;
const DEFAULT_BASE_DELAY = 200;
const DEFAULT_TIMEOUT = 20_000;
/* ------------------------------------------------------------------ */
/* limiter */
/* ------------------------------------------------------------------ */
/**
* Caps concurrent in-flight requests per normalized SEQTA route. Indexing
* jobs often fan out (e.g. one /load/courses per subject); we don't want them
* sending dozens of requests in parallel.
*/
class RouteLimiter {
private inFlight = new Map<string, number>();
private waiters = new Map<string, Array<() => void>>();
private readonly maxConcurrent: number;
constructor(maxConcurrent = 4) {
this.maxConcurrent = maxConcurrent;
}
async acquire(route: string): Promise<() => void> {
const current = this.inFlight.get(route) ?? 0;
if (current < this.maxConcurrent) {
this.inFlight.set(route, current + 1);
return () => this.release(route);
}
return new Promise((resolve) => {
const queue = this.waiters.get(route) ?? [];
queue.push(() => {
this.inFlight.set(route, (this.inFlight.get(route) ?? 0) + 1);
resolve(() => this.release(route));
});
this.waiters.set(route, queue);
});
}
private release(route: string) {
const next = (this.inFlight.get(route) ?? 1) - 1;
if (next <= 0) {
this.inFlight.delete(route);
} else {
this.inFlight.set(route, next);
}
const queue = this.waiters.get(route);
if (queue && queue.length > 0) {
const wake = queue.shift()!;
if (queue.length === 0) this.waiters.delete(route);
wake();
}
}
}
const routeLimiter = new RouteLimiter(4);
/* ------------------------------------------------------------------ */
/* route normalization */
/* ------------------------------------------------------------------ */
/**
* Strips the volatile anti-replay query token (e.g. `?mokx3qef`) so we can
* key caches and limiters off the canonical route.
*/
export function normalizeSeqtaPath(url: string): string {
try {
const parsed = new URL(url, location.origin);
// SEQTA appends a single random query token like `?mokx3qef`. Drop the
// entire query string so canonicalization is robust.
return parsed.pathname;
} catch {
// Fallback for already-relative URLs.
return url.split("?")[0];
}
}
/* ------------------------------------------------------------------ */
/* sensitive routes */
/* ------------------------------------------------------------------ */
/**
* Routes whose responses must never be indexed because they contain
* credentials, secrets, JWTs, or arbitrary configuration blobs.
*/
const SENSITIVE_PATH_PATTERNS: RegExp[] = [
/\/seqta\/student\/login(\b|\/)/i,
/\/seqta\/student\/save\//i,
/\/seqta\/student\/load\/settings(\b|\/)/i,
/\/seqta\/student\/load\/prefs(\b|\/)/i,
/\/seqta\/student\/heartbeat(\b|\/)/i,
/\/seqta\/student\/storage(\b|\/)/i,
/\/seqta\/student\/themes\//i,
/\/seqta\/student\/branding\//i,
/\/seqta\/student\/releasealert\//i,
/\/seqta\/student\/files\/stream(\b|\/)/i,
/\/seqta\/student\/load\/file(\b|\/)/i,
/\/seqta\/ta\/masquerade(\b|\/)/i,
];
export function isSensitiveSeqtaPath(path: string): boolean {
const normalized = normalizeSeqtaPath(path);
return SENSITIVE_PATH_PATTERNS.some((re) => re.test(normalized));
}
/* ------------------------------------------------------------------ */
/* student / user identity */
/* ------------------------------------------------------------------ */
interface SeqtaUserInfo {
id?: number;
personUUID?: string;
username?: string;
[key: string]: unknown;
}
let cachedUserInfo: SeqtaUserInfo | null = null;
let inflightUserInfo: Promise<SeqtaUserInfo | null> | null = null;
/**
* Resolves the current SEQTA user identity by re-using the same `login`
* handshake that the host page performs. This is the canonical way to
* discover the active student id and avoids the historical hard-coded
* `student: 69` placeholder that was incorrect on every real instance.
*
* Failures are intentionally NOT cached a transient login glitch on the
* very first call must not poison the cache for the lifetime of the page,
* because every subsequent indexing pass that needs the student id (e.g.
* the assignments job) would skip silently.
*/
export async function getCurrentUserInfo(): Promise<SeqtaUserInfo | null> {
if (cachedUserInfo) return cachedUserInfo;
if (inflightUserInfo) return inflightUserInfo;
inflightUserInfo = (async () => {
try {
const res = await fetch(`${location.origin}/seqta/student/login`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
mode: "normal",
query: null,
redirect_url: location.origin,
}),
});
if (!res.ok) return null;
const json = (await res.json()) as { payload?: SeqtaUserInfo };
const payload = json?.payload ?? null;
if (payload && typeof payload === "object") {
cachedUserInfo = payload;
return payload;
}
return null;
} catch (e) {
console.warn(
"[Global Search API] Failed to resolve current user info:",
e,
);
return null;
} finally {
inflightUserInfo = null;
}
})();
return inflightUserInfo;
}
/**
* Best-effort lookup of the active student id. Returns `undefined` when the
* value cannot be discovered (jobs should fall back gracefully rather than
* fabricating an id).
*/
export async function getCurrentStudentId(): Promise<number | undefined> {
const info = await getCurrentUserInfo();
const id = info?.id;
if (typeof id === "number" && Number.isFinite(id)) return id;
return undefined;
}
/* ------------------------------------------------------------------ */
/* core fetch */
/* ------------------------------------------------------------------ */
class SeqtaApiError extends Error {
status: number;
route: string;
constructor(message: string, status: number, route: string) {
super(message);
this.name = "SeqtaApiError";
this.status = status;
this.route = route;
}
}
function isTransientError(err: unknown): boolean {
if (err instanceof SeqtaApiError) {
if (err.status === 0 || err.status >= 500) return true;
if (err.status === 429) return true;
return false;
}
if (err instanceof TypeError) return true;
if ((err as any)?.name === "AbortError") return false;
return true;
}
/**
* Sends a JSON POST against a SEQTA route and returns the parsed envelope.
*
* - Adds `credentials: "include"` so requests reuse the active session.
* - Sets `X-Requested-With: XMLHttpRequest` so SEQTA classifies the request
* the same way as the first-party SPA (some routes 4xx without it).
* - Retries transient network/server errors with exponential backoff.
* - Validates that the response is JSON and has `status === "200"` (matches
* the SEQTA convention; jobs that need raw payloads can pass `path` but
* call `seqtaFetch` directly via the underlying API if they need to).
*/
export async function seqtaFetchJson<T = any>(
path: string,
body: Record<string, unknown> | undefined = {},
options: SeqtaFetchOptions = {},
): Promise<SeqtaResponse<T>> {
const route = normalizeSeqtaPath(path);
const retries = Math.max(0, options.retries ?? DEFAULT_RETRIES);
const baseDelay = Math.max(50, options.baseDelayMs ?? DEFAULT_BASE_DELAY);
const timeoutMs = Math.max(1_000, options.timeoutMs ?? DEFAULT_TIMEOUT);
let release: (() => void) | null = null;
if (!options.skipLimiter) {
release = await routeLimiter.acquire(route);
}
try {
let attempt = 0;
let lastError: unknown = null;
while (attempt <= retries) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const onAbort = () => controller.abort();
if (options.signal) {
if (options.signal.aborted) controller.abort();
else options.signal.addEventListener("abort", onAbort, { once: true });
}
try {
const res = await fetch(`${location.origin}${route}`, {
method: options.method ?? "POST",
credentials: "include",
headers: {
"Content-Type": "application/json; charset=utf-8",
"X-Requested-With": "XMLHttpRequest",
Accept: "text/javascript, text/html, application/xml, text/xml, */*",
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
throw new SeqtaApiError(
`HTTP ${res.status} ${res.statusText} for ${route}`,
res.status,
route,
);
}
const rawJson = (await res.json()) as unknown;
if (!rawJson || typeof rawJson !== "object") {
throw new SeqtaApiError(
`Invalid SEQTA response (not a JSON object) for ${route}`,
res.status,
route,
);
}
// SEQTA's "envelope" convention is `{ status, payload }`, but in
// practice some endpoints — notably `/seqta/student/load/subjects`
// and `/seqta/student/assessment/list/*` — occasionally return
// either a bare array or an envelope with a non-"200" status.
// Strict validation here was historically silently killing the
// assignments + courses indexing pipelines when those endpoints
// returned a quirky shape, so we normalize permissively and let
// callers handle missing/empty payloads.
let json: SeqtaResponse<T>;
if (Array.isArray(rawJson)) {
json = { payload: rawJson as unknown as T, status: "200" };
} else {
const obj = rawJson as Record<string, unknown>;
const hasEnvelopeKey = "payload" in obj || "status" in obj;
if (hasEnvelopeKey) {
json = {
payload: ("payload" in obj ? obj.payload : undefined) as T,
status:
typeof obj.status === "string"
? obj.status
: typeof obj.status === "number"
? String(obj.status)
: "200",
};
} else {
json = { payload: rawJson as unknown as T, status: "200" };
}
}
if (json.status && json.status !== "200") {
console.warn(
`[Global Search API] Non-200 SEQTA status "${json.status}" for ${route} — returning payload anyway`,
);
}
return json;
} catch (err) {
lastError = err;
if (!isTransientError(err) || attempt === retries) {
throw err;
}
const wait = Math.min(5_000, baseDelay * Math.pow(2, attempt));
await delay(wait);
attempt++;
} finally {
clearTimeout(timer);
if (options.signal) options.signal.removeEventListener("abort", onAbort);
}
}
throw lastError ?? new Error(`seqtaFetchJson exhausted retries for ${route}`);
} finally {
if (release) release();
}
}
/**
* Convenience helper: fetch and unwrap `.payload` directly. Returns `null`
* on failure rather than throwing, so jobs can use the value optionally.
*/
export async function seqtaFetchPayload<T = any>(
path: string,
body: Record<string, unknown> | undefined = {},
options: SeqtaFetchOptions = {},
): Promise<T | null> {
try {
const res = await seqtaFetchJson<T>(path, body, options);
return res.payload ?? null;
} catch (e) {
console.warn(
`[Global Search API] Request to ${normalizeSeqtaPath(path)} failed:`,
e,
);
return null;
}
}
@@ -0,0 +1,303 @@
import { htmlToPlainText } from "./utils";
import type { IndexItem } from "./types";
/**
* Safe extraction helpers used by both active SEQTA jobs and the passive
* network observer.
*
* The goal is to take arbitrary SEQTA JSON / embedded HTML fragments and
* derive concise, redacted, search-friendly text without ever indexing
* obvious credentials, tokens, JWTs, or large binary blobs.
*/
/* ------------------------------------------------------------------ */
/* sensitive keys */
/* ------------------------------------------------------------------ */
/**
* Field names whose values should never be indexed regardless of context.
* Matches SEQTA's frequently-used credential / config keys plus generic
* security-related names. Comparison is case-insensitive and matches both
* the full key and any sub-string fragments (so `client_secret`,
* `apiKey`, `dropboxKey` all hit).
*/
const SENSITIVE_KEY_FRAGMENTS: readonly string[] = [
"password",
"passwd",
"pwd",
"secret",
"token",
"jwt",
"session",
"cookie",
"auth",
"apikey",
"api_key",
"clientid",
"client_id",
"clientsecret",
"client_secret",
"credential",
"private",
"salt",
"hash",
"csrf",
"x-api",
"bearer",
"dropbox",
"oauth",
"signature",
];
export function isSensitiveKey(key: string): boolean {
if (!key) return false;
const lower = key.toLowerCase();
return SENSITIVE_KEY_FRAGMENTS.some((frag) => lower.includes(frag));
}
/**
* Returns true if the supplied scalar value looks credential-shaped: a long
* hex/base64-like blob that doesn't decode to readable text. This catches
* arbitrary tokens that don't have a clear field-name signal.
*/
export function looksLikeSecretValue(value: unknown): boolean {
if (typeof value !== "string") return false;
const trimmed = value.trim();
if (trimmed.length < 32) return false;
// Long contiguous base64 / hex with no whitespace and no humanish punctuation.
if (/\s/.test(trimmed)) return false;
if (/^[A-Za-z0-9+/=._-]{32,}$/.test(trimmed) && !/[.,!?]/.test(trimmed)) {
// Reject obvious URLs and UUIDs (they're useful and not secret).
if (/^https?:\/\//i.test(trimmed)) return false;
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
trimmed,
)
) {
return false;
}
return true;
}
// JWT detection: three base64url segments separated by dots.
if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(trimmed)) {
return true;
}
return false;
}
/* ------------------------------------------------------------------ */
/* text extraction */
/* ------------------------------------------------------------------ */
/**
* Recursively pulls human-readable text out of an arbitrary JSON value.
*
* - HTML strings are passed through `htmlToPlainText`.
* - Sensitive keys and secret-shaped values are skipped.
* - Long blobs are truncated to keep the index lean.
* - Arrays and objects are walked; depth is bounded to avoid pathological
* structures.
*/
export interface ExtractTextOptions {
/** Hard cap on combined characters across the walk (default 4000). */
maxChars?: number;
/** Maximum recursion depth (default 6). */
maxDepth?: number;
/** Maximum array length to traverse (default 200). */
maxArrayItems?: number;
/** Skip individual string values longer than this (default 8000). */
maxStringLength?: number;
}
const DEFAULT_EXTRACT_OPTIONS: Required<ExtractTextOptions> = {
maxChars: 4000,
maxDepth: 6,
maxArrayItems: 200,
maxStringLength: 8000,
};
export function extractTextFromValue(
value: unknown,
options: ExtractTextOptions = {},
): string {
const opts = { ...DEFAULT_EXTRACT_OPTIONS, ...options };
const parts: string[] = [];
let remaining = opts.maxChars;
const push = (text: string) => {
if (!text || remaining <= 0) return;
const trimmed = text.trim();
if (!trimmed) return;
const slice = trimmed.length > remaining ? trimmed.slice(0, remaining) : trimmed;
parts.push(slice);
remaining -= slice.length + 1;
};
const walk = (node: unknown, depth: number, parentKey: string | null) => {
if (remaining <= 0) return;
if (node === null || node === undefined) return;
if (parentKey && isSensitiveKey(parentKey)) return;
if (typeof node === "string") {
if (node.length > opts.maxStringLength) return;
if (looksLikeSecretValue(node)) return;
if (node.includes("<") && node.includes(">")) {
push(htmlToPlainText(node));
} else {
push(node);
}
return;
}
if (typeof node === "number" || typeof node === "boolean") {
// Numbers/booleans rarely contribute to search recall; skip to keep
// the index focused on text.
return;
}
if (depth >= opts.maxDepth) return;
if (Array.isArray(node)) {
const limit = Math.min(node.length, opts.maxArrayItems);
for (let i = 0; i < limit; i++) {
walk(node[i], depth + 1, parentKey);
if (remaining <= 0) return;
}
return;
}
if (typeof node === "object") {
for (const [key, child] of Object.entries(node as Record<string, unknown>)) {
if (remaining <= 0) return;
if (isSensitiveKey(key)) continue;
walk(child, depth + 1, key);
}
}
};
walk(value, 0, null);
return parts.join("\n").trim();
}
/* ------------------------------------------------------------------ */
/* redacted clones */
/* ------------------------------------------------------------------ */
/**
* Returns a deep clone of `value` with sensitive keys/values stripped. The
* passive observer uses this when persisting metadata so we never store
* raw tokens or settings blobs in IndexedDB.
*/
export function redactSensitive<T>(value: T, depth = 0): T {
if (value === null || value === undefined) return value;
if (depth >= 8) return value;
if (Array.isArray(value)) {
return value
.slice(0, 200)
.map((v) => redactSensitive(v, depth + 1)) as unknown as T;
}
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
if (isSensitiveKey(key)) continue;
if (typeof child === "string" && looksLikeSecretValue(child)) continue;
out[key] = redactSensitive(child, depth + 1);
}
return out as T;
}
if (typeof value === "string" && looksLikeSecretValue(value)) {
return "" as unknown as T;
}
return value;
}
/* ------------------------------------------------------------------ */
/* title / id heuristics */
/* ------------------------------------------------------------------ */
const TITLE_KEYS = [
"title",
"subject",
"name",
"label",
"heading",
"displayName",
"filename",
"code",
];
const ID_KEYS = ["id", "uuid", "messageID", "assessmentID", "notificationID"];
/**
* Best-effort title extraction: returns the first sensible string-valued
* field commonly used by SEQTA payloads. Falls back to an empty string when
* none are present.
*/
export function pickTitle(node: unknown, fallback = ""): string {
if (!node || typeof node !== "object") return fallback;
const obj = node as Record<string, unknown>;
for (const key of TITLE_KEYS) {
const v = obj[key];
if (typeof v === "string" && v.trim()) return v.trim();
}
return fallback;
}
export function pickId(node: unknown, fallback = ""): string {
if (!node || typeof node !== "object") return fallback;
const obj = node as Record<string, unknown>;
for (const key of ID_KEYS) {
const v = obj[key];
if (typeof v === "string" && v.trim()) return v.trim();
if (typeof v === "number" && Number.isFinite(v)) return String(v);
}
return fallback;
}
/* ------------------------------------------------------------------ */
/* IndexItem builders */
/* ------------------------------------------------------------------ */
/**
* Constructs an `IndexItem` from a raw entity, applying our standard
* extraction rules. Callers fill in the things that need domain knowledge
* (`category`, `actionId`, `metadata`, deep-link route hints) and we handle
* the boring text + redaction work.
*/
export function buildIndexItem(input: {
id: string;
text: string;
category: string;
rawForContent?: unknown;
contentOverride?: string;
metadata?: Record<string, unknown>;
actionId: string;
renderComponentId: string;
dateAdded?: number;
contentMaxChars?: number;
}): IndexItem {
const content =
input.contentOverride !== undefined
? input.contentOverride
: extractTextFromValue(input.rawForContent, {
maxChars: input.contentMaxChars ?? 1500,
});
const metadata = input.metadata ? redactSensitive(input.metadata) : {};
return {
id: input.id,
text: input.text,
category: input.category,
content,
dateAdded: input.dateAdded ?? Date.now(),
metadata,
actionId: input.actionId,
renderComponentId: input.renderComponentId,
};
}
@@ -1,10 +1,11 @@
import { clear, get, getAll, put, remove } from "./db";
import { clear, get, getAll, put, remove, resetDatabase } from "./db";
import { jobs } from "./jobs";
import { renderComponentMap } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types";
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
import { loadDynamicItems } from "../utils/dynamicItems";
import { getVectorizedItemIds } from "./utils";
import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion";
const META_STORE = "meta";
const LOCK_KEY = "bsq-indexer-lock";
@@ -12,6 +13,50 @@ const HEARTBEAT_INTERVAL = 10000;
const LOCK_TIMEOUT = 20000;
const LOCK_ACQUIRE_TIMEOUT = 5000;
let schemaCheckPromise: Promise<void> | null = null;
async function ensureSchemaCurrent(): Promise<void> {
if (schemaCheckPromise) return schemaCheckPromise;
schemaCheckPromise = (async () => {
let storedRaw: string | null = null;
try {
storedRaw = localStorage.getItem(SCHEMA_VERSION_KEY);
} catch {
return;
}
const stored = storedRaw ? parseInt(storedRaw, 10) : 0;
if (stored === INDEX_SCHEMA_VERSION) return;
console.warn(
`[Indexer] Schema version changed (${stored} -> ${INDEX_SCHEMA_VERSION}); resetting structured + vector indexes.`,
);
try {
await resetDatabase();
} catch (e) {
console.warn("[Indexer] Failed to reset structured database:", e);
}
try {
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase("embeddiaDB");
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
} catch (e) {
console.warn("[Indexer] Failed to reset embeddiaDB:", e);
}
try {
localStorage.setItem(SCHEMA_VERSION_KEY, String(INDEX_SCHEMA_VERSION));
} catch {
/* ignore */
}
})();
return schemaCheckPromise;
}
/* ─────────── Progressmeta helpers ─────────── */
async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
const rec = await get(META_STORE, `progress:${jobId}`);
@@ -162,6 +207,8 @@ export async function loadAllStoredItems(): Promise<IndexItem[]> {
}
export async function runIndexing(): Promise<void> {
await ensureSchemaCurrent();
if (!(await acquireLock())) {
console.debug(
"%c[Indexer] Could not acquire lock - another tab is indexing or this tab is already indexing",
@@ -178,8 +225,6 @@ export async function runIndexing(): Promise<void> {
const totalSteps = jobIds.length + 1;
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs");
let hasStreamingJobs = false;
for (const jobId of jobIds) {
dispatchProgress(
completedJobs,
@@ -255,10 +300,6 @@ export async function runIndexing(): Promise<void> {
await setStoredItems(merged);
await updateLastRunMeta(jobId);
if (jobId === 'messages' || jobId === 'notifications') {
hasStreamingJobs = true;
}
console.debug(
`%c[Indexer] ${job.label}: ${newItemsRaw.length} new items reported by run, ${merged.length} total items now in '${jobId}' store.`,
"color: #00c46f",
@@ -4,6 +4,14 @@ import { notificationsJob } from "./jobs/notifications";
import { forumsJob } from "./jobs/forums";
import { subjectsJob } from "./jobs/subjects";
import { assignmentsJob } from "./jobs/assignments";
import { coursesJob } from "./jobs/courses";
import { noticesJob } from "./jobs/notices";
import { documentsJob } from "./jobs/documents";
import { folioJob } from "./jobs/folio";
import { portalsJob } from "./jobs/portals";
import { reportsJob } from "./jobs/reports";
import { goalsJob } from "./jobs/goals";
import { passiveJob } from "./jobs/passive";
export const jobs: Record<string, Job> = {
messages: messagesJob,
@@ -11,4 +19,12 @@ export const jobs: Record<string, Job> = {
forums: forumsJob,
subjects: subjectsJob,
assignments: assignmentsJob,
courses: coursesJob,
notices: noticesJob,
documents: documentsJob,
folio: folioJob,
portals: portalsJob,
reports: reportsJob,
goals: goalsJob,
passive: passiveJob,
};
@@ -0,0 +1,179 @@
import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api";
import { buildIndexItem } from "../extract";
import { htmlToPlainText } from "../utils";
/**
* Indexes per-subject course content from `/seqta/student/load/courses`.
*
* The course payload contains the lesson grid in `w[][]` where each cell's
* `l` field is a (possibly empty) HTML snippet authored by teachers. We
* concatenate these into searchable text per course, plus the course title
* and code from `t` / `c`. Embedded files referenced via TED/SEQTA URLs are
* preserved as plain-text links so users can find them by URL fragment.
*/
interface SubjectsListPayload {
code: string;
description?: string;
active: number;
subjects: Array<{
code: string;
title?: string;
description?: string;
metaclass: number;
programme: number;
}>;
}
interface CoursePayload {
c?: string;
t?: string;
i?: number;
m?: number;
w?: Array<Array<{ l?: string; h?: string; t?: string; o?: string; i?: number }>>;
document?: string;
}
const fetchActiveSubjects = async (): Promise<
SubjectsListPayload["subjects"]
> => {
const payload = await seqtaFetchPayload<SubjectsListPayload[]>(
"/seqta/student/load/subjects",
{},
);
if (!Array.isArray(payload)) return [];
const out: SubjectsListPayload["subjects"] = [];
for (const semester of payload) {
if (!semester || !Array.isArray(semester.subjects)) continue;
if (semester.active !== 1) continue;
for (const subject of semester.subjects) {
if (
subject &&
Number.isFinite(subject.programme) &&
Number.isFinite(subject.metaclass)
) {
out.push(subject);
}
}
}
return out;
};
function flattenLessonHtml(payload: CoursePayload): string {
if (!Array.isArray(payload.w)) return "";
const fragments: string[] = [];
for (const row of payload.w) {
if (!Array.isArray(row)) continue;
for (const cell of row) {
if (!cell) continue;
if (typeof cell.l === "string" && cell.l.trim()) {
fragments.push(cell.l);
}
if (typeof cell.h === "string" && cell.h.trim()) {
fragments.push(cell.h);
}
if (typeof cell.t === "string" && cell.t.trim()) {
fragments.push(cell.t);
}
if (typeof cell.o === "string" && cell.o.trim()) {
fragments.push(cell.o);
}
}
}
if (fragments.length === 0) return "";
return htmlToPlainText(fragments.join("\n"));
}
export const coursesJob: Job = {
id: "courses",
label: "Courses",
renderComponentId: "course",
// Course content rarely changes minute-to-minute but does evolve per term.
// Refresh once per day (after pageLoad cool-down) to keep new lessons
// discoverable without hammering SEQTA.
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 },
boostCriteria: (item, searchTerm) => {
if (!searchTerm) return -50;
let score = 0;
if (item.metadata?.subjectCode) score += 0.05;
if (item.metadata?.isActive) score += 0.02;
return score;
},
run: async (_ctx) => {
const subjects = await fetchActiveSubjects();
if (subjects.length === 0) {
console.debug("[Courses job] No active subjects discovered.");
return [];
}
const items: IndexItem[] = [];
const seenIds = new Set<string>();
// Sequential per-subject fetch keeps load on SEQTA bounded; the shared
// API layer also limits concurrency per route as a defense in depth.
for (const subject of subjects) {
const id = `course-${subject.programme}-${subject.metaclass}`;
if (seenIds.has(id)) continue;
seenIds.add(id);
const payload = await seqtaFetchPayload<CoursePayload>(
"/seqta/student/load/courses",
{
programme: String(subject.programme),
metaclass: String(subject.metaclass),
},
);
if (!payload) continue;
const title =
(typeof payload.t === "string" && payload.t.trim()) ||
subject.title ||
subject.description ||
subject.code ||
"Course";
const lessonText = flattenLessonHtml(payload);
const courseCode =
(typeof payload.c === "string" && payload.c.trim()) || subject.code;
const summary = [courseCode, lessonText]
.filter((s) => s && s.length > 0)
.join("\n")
.slice(0, 4000);
items.push(
buildIndexItem({
id,
text: title,
category: "courses",
contentOverride: summary || `Course content for ${title}`,
metadata: {
subjectCode: subject.code,
subjectName: subject.title ?? title,
programme: subject.programme,
metaclass: subject.metaclass,
courseCode,
isActive: true,
route: `/courses/${subject.programme}:${subject.metaclass}`,
entityType: "course",
icon: "\ueb4d",
},
actionId: "course",
renderComponentId: "course",
}),
);
}
console.debug(
`[Courses job] Indexed ${items.length} courses across ${subjects.length} subjects.`,
);
return items;
},
purge: (items) => items,
};
@@ -0,0 +1,139 @@
import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api";
/**
* Indexes file metadata from `/seqta/student/load/documents`.
*
* Each top-level entry is a category containing one or more documents
* (`docs[]`). We capture the human-readable title, filename, mimetype, and
* stable UUID/category for every doc, but never download or index the
* binary content itself - the document streaming endpoint uses one-time
* JWTs that are unsafe to persist or replay.
*/
interface DocumentEntry {
file?: number | string;
filename?: string;
size?: string | number;
context_uuid?: string;
mimetype?: string;
created_date?: string;
title?: string;
uuid?: string;
created_by?: string;
}
interface DocumentCategory {
id: number | string;
category: string;
colour?: string;
docs: DocumentEntry[];
}
function prettySize(size: string | number | undefined): string | null {
if (size === undefined || size === null) return null;
const bytes = typeof size === "string" ? parseInt(size, 10) : size;
if (!Number.isFinite(bytes) || bytes <= 0) return null;
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return `${value.toFixed(value < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
function describeMime(mime: string | undefined): string | null {
if (!mime) return null;
if (mime.startsWith("application/pdf")) return "PDF";
if (mime.includes("officedocument.wordprocessingml")) return "Word";
if (mime.includes("officedocument.spreadsheetml")) return "Excel";
if (mime.includes("officedocument.presentationml")) return "PowerPoint";
if (mime.startsWith("image/")) return "Image";
if (mime.startsWith("video/")) return "Video";
if (mime.startsWith("audio/")) return "Audio";
return null;
}
export const documentsJob: Job = {
id: "documents",
label: "Documents",
renderComponentId: "document",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 12 }, // 12 hours
boostCriteria: (_item, searchTerm) => {
if (!searchTerm) return -20;
return 0;
},
run: async (_ctx) => {
const payload = await seqtaFetchPayload<DocumentCategory[] | null>(
"/seqta/student/load/documents",
{},
);
if (!Array.isArray(payload)) return [];
const items: IndexItem[] = [];
const seen = new Set<string>();
for (const category of payload) {
if (!category || !Array.isArray(category.docs)) continue;
for (const doc of category.docs) {
const uuid = doc.uuid || doc.context_uuid;
if (!uuid && !doc.file) continue;
const id = `document-${uuid ?? doc.file}`;
if (seen.has(id)) continue;
seen.add(id);
const title =
doc.title?.trim() ||
doc.filename?.trim() ||
`Document ${doc.file ?? uuid}`;
const sizeText = prettySize(doc.size);
const mimeLabel = describeMime(doc.mimetype);
const contentParts: string[] = [];
if (doc.filename && doc.filename !== title) contentParts.push(doc.filename);
if (category.category) contentParts.push(`Category: ${category.category}`);
if (mimeLabel) contentParts.push(mimeLabel);
if (sizeText) contentParts.push(sizeText);
if (doc.created_date) contentParts.push(`Added ${doc.created_date}`);
const dateAdded = doc.created_date
? new Date(doc.created_date).getTime() || Date.now()
: Date.now();
items.push({
id,
text: title,
category: "documents",
content: contentParts.join(" \u2022 "),
dateAdded,
metadata: {
documentUuid: uuid,
fileId: doc.file,
filename: doc.filename,
mimetype: doc.mimetype,
sizeBytes:
typeof doc.size === "string" ? parseInt(doc.size, 10) : doc.size,
categoryId: category.id,
categoryName: category.category,
createdDate: doc.created_date,
entityType: "document",
route: "/documents",
icon: "\ueb6f",
},
actionId: "document",
renderComponentId: "document",
});
}
}
console.debug(`[Documents job] Indexed ${items.length} document entries.`);
return items;
},
purge: (items) => items,
};
@@ -0,0 +1,134 @@
import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api";
import { htmlToPlainText } from "../utils";
import { delay } from "@/seqta/utils/delay";
/**
* Indexes student folio entries from `/seqta/student/folio`.
*
* The list mode returns `{ me, list: [{ id, title, published, student }] }`,
* and the load mode returns the full body via `{ contents, files, ... }`.
* Folio bodies frequently contain `[[embed:raw|<html>]]` blocks which we
* normalize to plain text before indexing - the htmlToPlainText sanitizer
* never executes scripts because it parses into an inert document.
*/
interface FolioListPayload {
me?: string;
list?: Array<{
id: number | string;
title?: string;
published?: string;
student?: string;
}>;
}
interface FolioEntryPayload {
forum?: number;
contents?: string;
created?: string;
allow_comments?: boolean;
author?: { name?: string; year?: string; id?: number };
files?: unknown[];
id?: number | string;
published?: string;
title?: string;
updated?: string;
}
const PER_ITEM_DELAY_MS = 80;
function stripEmbedRaw(text: string): string {
if (!text) return "";
return text.replace(/\[\[embed:raw\|([\s\S]*?)\]\]/g, (_match, inner) => {
return htmlToPlainText(typeof inner === "string" ? inner : "");
});
}
export const folioJob: Job = {
id: "folio",
label: "Folio",
renderComponentId: "folio",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 },
boostCriteria: (_item, searchTerm) => {
if (!searchTerm) return -30;
return 0;
},
run: async (ctx) => {
const stored = await ctx.getStoredItems("folio");
const existing = new Map(stored.map((i) => [i.id, i]));
const list = await seqtaFetchPayload<FolioListPayload | null>(
"/seqta/student/folio",
{ mode: "list", page: 0, filters: {} },
);
if (!list || !Array.isArray(list.list)) return [];
const items: IndexItem[] = [];
for (const entry of list.list) {
if (!entry || entry.id === undefined) continue;
const id = `folio-${entry.id}`;
const dateAdded = entry.published
? new Date(entry.published).getTime() || Date.now()
: Date.now();
// If we already have this folio and the title hasn't changed, reuse
// the stored content instead of paying for another /folio?mode=load.
const existingItem = existing.get(id);
const titleChanged = existingItem && existingItem.text !== (entry.title ?? "");
if (existingItem && !titleChanged) {
items.push({
...existingItem,
dateAdded,
});
continue;
}
try {
const detail = await seqtaFetchPayload<FolioEntryPayload | null>(
"/seqta/student/folio",
{ mode: "load", id: entry.id },
);
const rawContents = detail?.contents ?? "";
const flattened = stripEmbedRaw(rawContents);
const content = flattened.slice(0, 4000);
items.push({
id,
text: entry.title?.trim() || `Folio ${entry.id}`,
category: "folio",
content,
dateAdded,
metadata: {
folioId: entry.id,
student: list.me ?? entry.student,
publishedAt: entry.published,
updatedAt: detail?.updated,
createdAt: detail?.created,
authorName: detail?.author?.name,
authorId: detail?.author?.id,
forumId: detail?.forum,
allowComments: detail?.allow_comments,
fileCount: Array.isArray(detail?.files) ? detail!.files!.length : 0,
entityType: "folio",
route: "/folios/read",
icon: "\ueb16",
},
actionId: "folio",
renderComponentId: "folio",
});
} catch (e) {
console.warn(`[Folio job] Failed to load folio ${entry.id}:`, e);
}
await delay(PER_ITEM_DELAY_MS);
}
console.debug(`[Folio job] Indexed ${items.length} folio entries.`);
return items;
},
purge: (items) => items,
};
@@ -0,0 +1,109 @@
import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api";
import { extractTextFromValue } from "../extract";
import { delay } from "@/seqta/utils/delay";
/**
* Indexes student goals from `/seqta/student/load/goals`.
*
* The endpoint exposes `mode: "years"` which returns the list of available
* years and `mode: "list"` (per-year) which returns the actual goals. We
* gracefully degrade if the school has goals disabled (the years payload
* is empty in that case).
*/
interface GoalEntry {
id?: number | string;
uuid?: string;
title?: string;
description?: string;
status?: string;
year?: number | string;
created?: string;
updated?: string;
}
const PER_YEAR_DELAY_MS = 80;
export const goalsJob: Job = {
id: "goals",
label: "Goals",
renderComponentId: "goal",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 * 3 }, // every 3 days
boostCriteria: (_item, searchTerm) => {
if (!searchTerm) return -40;
return 0;
},
run: async (_ctx) => {
const years = await seqtaFetchPayload<Array<string | number> | null>(
"/seqta/student/load/goals",
{ mode: "years" },
);
if (!Array.isArray(years) || years.length === 0) {
console.debug("[Goals job] No goal years available; skipping.");
return [];
}
const items: IndexItem[] = [];
const seen = new Set<string>();
for (const year of years) {
try {
const yearGoals = await seqtaFetchPayload<GoalEntry[] | null>(
"/seqta/student/load/goals",
{ mode: "list", year },
);
if (!Array.isArray(yearGoals)) continue;
for (const goal of yearGoals) {
if (!goal) continue;
const stableId = goal.uuid ?? goal.id;
if (stableId === undefined || stableId === null) continue;
const id = `goal-${stableId}`;
if (seen.has(id)) continue;
seen.add(id);
const title =
goal.title?.trim() || goal.description?.slice(0, 80) || `Goal ${stableId}`;
const dateAdded = goal.updated || goal.created
? new Date(goal.updated ?? goal.created!).getTime() || Date.now()
: Date.now();
items.push({
id,
text: title,
category: "goals",
content: extractTextFromValue(
{ description: goal.description, status: goal.status },
{ maxChars: 1000 },
),
dateAdded,
metadata: {
goalId: goal.id,
goalUuid: goal.uuid,
status: goal.status,
year: goal.year ?? year,
createdAt: goal.created,
updatedAt: goal.updated,
entityType: "goal",
route: `/goals/${year}`,
icon: "\uea15",
},
actionId: "goal",
renderComponentId: "goal",
});
}
} catch (e) {
console.warn(`[Goals job] Failed to fetch goals for year ${year}:`, e);
}
await delay(PER_YEAR_DELAY_MS);
}
console.debug(`[Goals job] Indexed ${items.length} goal entries.`);
return items;
},
purge: (items) => items,
};
@@ -0,0 +1,218 @@
import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api";
import { htmlToPlainText } from "../utils";
import { delay } from "@/seqta/utils/delay";
/**
* Indexes daily notices from `/seqta/student/load/notices`.
*
* SEQTA returns notices keyed by date, so we sweep a sliding window
* (default: 14 days back) the first time we run, then incrementally pull
* the most recent days on subsequent runs. Sensitive routes are excluded
* because notices are surfaced for the active student already.
*/
interface NoticeRecord {
id?: number | string;
title?: string;
contents?: string;
staff?: string;
staff_id?: number;
date?: string;
label?: number;
label_title?: string;
colour?: string;
}
interface NoticesProgress {
earliestDate: string | null;
lastSweepBackTo: string | null;
}
const SWEEP_DAYS = 14;
const MAX_HISTORY_DAYS = 365;
const FETCH_DELAY_MS = 60;
function formatYmd(date: Date): string {
const y = date.getFullYear();
const m = (date.getMonth() + 1).toString().padStart(2, "0");
const d = date.getDate().toString().padStart(2, "0");
return `${y}-${m}-${d}`;
}
function parseYmd(value: string | null | undefined): Date | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return null;
const [, y, m, d] = match;
return new Date(Number(y), Number(m) - 1, Number(d));
}
const fetchNoticesForDate = async (date: string): Promise<NoticeRecord[]> => {
const payload = await seqtaFetchPayload<NoticeRecord[] | { notices?: NoticeRecord[] } | null>(
"/seqta/student/load/notices",
{ date },
);
if (!payload) return [];
if (Array.isArray(payload)) return payload;
if (Array.isArray((payload as any).notices)) return (payload as any).notices;
return [];
};
const fetchLabelLookup = async (): Promise<Map<number, string>> => {
const payload = await seqtaFetchPayload<
Array<{ id: number; title?: string }>
>("/seqta/student/load/notices", { mode: "labels" });
const map = new Map<number, string>();
if (Array.isArray(payload)) {
for (const entry of payload) {
if (entry && typeof entry.id === "number" && entry.title) {
map.set(entry.id, entry.title);
}
}
}
return map;
};
export const noticesJob: Job = {
id: "notices",
label: "Notices",
renderComponentId: "notice",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 6 }, // 6 hours
boostCriteria: (item, searchTerm) => {
if (!searchTerm) return -10;
let score = 0;
const ts = item.metadata?.timestamp;
if (typeof ts === "string") {
const ageDays =
(Date.now() - new Date(ts).getTime()) / (1000 * 60 * 60 * 24);
if (ageDays >= 0 && ageDays <= 7) score += 0.05;
}
return score;
},
run: async (ctx) => {
const stored = await ctx.getStoredItems("notices");
const existingIds = new Set(stored.map((i) => i.id));
const progress = (await ctx.getProgress<NoticesProgress>()) ?? {
earliestDate: null,
lastSweepBackTo: null,
};
const labelLookup = await fetchLabelLookup();
const today = new Date();
today.setHours(0, 0, 0, 0);
// Sweep window: always the most recent SWEEP_DAYS, plus extend further
// back the first time we run until we hit MAX_HISTORY_DAYS.
const earliestEverIso = formatYmd(
new Date(today.getTime() - MAX_HISTORY_DAYS * 86_400_000),
);
const dates: string[] = [];
for (let offset = 0; offset < SWEEP_DAYS; offset++) {
const day = new Date(today.getTime() - offset * 86_400_000);
dates.push(formatYmd(day));
}
if (
!progress.lastSweepBackTo ||
progress.lastSweepBackTo > earliestEverIso
) {
// Walk backwards in batches of ~30 days per run so we don't blow up
// a single indexing pass.
const startBack = parseYmd(progress.lastSweepBackTo) ?? today;
const targetBack = new Date(startBack.getTime() - 30 * 86_400_000);
const minBack = parseYmd(earliestEverIso) ?? targetBack;
const stopBack = targetBack < minBack ? minBack : targetBack;
for (
let cursor = new Date(startBack.getTime() - SWEEP_DAYS * 86_400_000);
cursor >= stopBack;
cursor = new Date(cursor.getTime() - 86_400_000)
) {
dates.push(formatYmd(cursor));
}
progress.lastSweepBackTo = formatYmd(stopBack);
}
const items: IndexItem[] = [];
const seen = new Set<string>();
for (const date of dates) {
try {
const notices = await fetchNoticesForDate(date);
for (const notice of notices) {
if (!notice || (notice.id === undefined && !notice.title)) continue;
const id = `notice-${date}-${notice.id ?? notice.title}`;
if (seen.has(id)) continue;
seen.add(id);
const labelTitle =
notice.label_title ??
(typeof notice.label === "number"
? labelLookup.get(notice.label) ?? null
: null);
const bodyText = notice.contents
? htmlToPlainText(notice.contents)
: "";
items.push({
id,
text: notice.title?.trim() || `Notice ${notice.id ?? date}`,
category: "notices",
content: bodyText.slice(0, 4000),
dateAdded: new Date(date).getTime(),
metadata: {
noticeId: notice.id,
date,
author: notice.staff,
authorId: notice.staff_id,
label: labelTitle,
labelId: notice.label,
colour: notice.colour,
timestamp: date,
entityType: "notice",
route: "/notices",
icon: "\ueb24",
},
actionId: "notice",
renderComponentId: "notice",
});
}
} catch (e) {
console.warn(`[Notices job] Failed to fetch notices for ${date}:`, e);
}
await delay(FETCH_DELAY_MS);
}
if (items.length > 0) {
const dateStrings = items
.map((i) => i.metadata?.date as string | undefined)
.filter((d): d is string => !!d);
if (dateStrings.length > 0) {
const earliest = dateStrings.sort()[0];
if (
!progress.earliestDate ||
earliest < progress.earliestDate
) {
progress.earliestDate = earliest;
}
}
}
await ctx.setProgress(progress);
const newCount = items.filter((i) => !existingIds.has(i.id)).length;
console.debug(
`[Notices job] Indexed ${items.length} notices across ${dates.length} dates (${newCount} new).`,
);
return items;
},
purge: (items) => {
const oneYearAgo = Date.now() - 365 * 24 * 60 * 60 * 1000;
return items.filter((i) => i.dateAdded >= oneYearAgo);
},
};
@@ -0,0 +1,49 @@
import type { Job } from "../types";
/**
* Stub job for the passive-observer store.
*
* The passive observer (see `passiveObserver.ts`) writes captured items
* directly into IndexedDB via `getAll`/`put`. We still register a job here
* so the indexer:
* - Creates the `passive` object store on first use.
* - Picks up the right `renderComponentId` when materializing in-memory
* items in `loadAllStoredItems()`.
* - Applies a deterministic boost / purge policy to passive results.
*
* `run()` is a no-op: the passive observer has its own write path so it
* works whether or not an active indexing pass is running.
*/
export const passiveJob: Job = {
id: "passive",
label: "Recently viewed",
renderComponentId: "passive",
// Run frequently so any newly captured items are merged into the
// dynamic-items cache on the next indexing tick. The actual capture is
// continuous; this is only the synchronization cadence.
frequency: { type: "interval", ms: 1000 * 60 * 5 },
boostCriteria: (item, searchTerm) => {
// Passive items are noisier than curated ones, so penalize them
// slightly when there's no query and only modestly help on matches.
if (!searchTerm) return -60;
let score = 0;
if (item.metadata?.entityType) score += 0.02;
return score;
},
run: async () => {
return [];
},
purge: (items) => {
// Keep the most recent ~500 passive entries and anything newer than
// 30 days. This caps storage growth from heavy browsing sessions.
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
const recent = items
.filter((i) => i.dateAdded >= cutoff)
.sort((a, b) => b.dateAdded - a.dateAdded)
.slice(0, 500);
return recent;
},
};
@@ -0,0 +1,90 @@
import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api";
/**
* Indexes the user's external portal entries from `/seqta/student/load/portals`.
*
* Portals are user-facing tiles linking to third-party tools (Mathletics,
* Seesaw, Google Classroom, ...). We index their labels and external URLs
* so users can jump to them via the global search palette without scrolling
* the dashboard.
*/
interface PortalPayload {
id: number | string;
label?: string;
url?: string;
uuid?: string;
icon?: string;
priority?: number;
is_power_portal?: boolean;
contents?: string;
inherit_styles?: boolean;
}
function normalizePortalUrl(raw: string | undefined): string | undefined {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed.replace(/^\/+/, "")}`;
}
export const portalsJob: Job = {
id: "portals",
label: "Portals",
renderComponentId: "portal",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 * 7 }, // weekly
boostCriteria: (_item, searchTerm) => {
if (!searchTerm) return -50;
return 0;
},
run: async (_ctx) => {
const payload = await seqtaFetchPayload<PortalPayload[] | null>(
"/seqta/student/load/portals",
{},
);
if (!Array.isArray(payload)) return [];
const items: IndexItem[] = [];
const seen = new Set<string>();
for (const portal of payload) {
if (!portal || (portal.id === undefined && !portal.uuid)) continue;
const id = `portal-${portal.uuid ?? portal.id}`;
if (seen.has(id)) continue;
seen.add(id);
const url = normalizePortalUrl(portal.url);
const label = portal.label?.trim() || `Portal ${portal.id}`;
const contentParts: string[] = [];
if (url) contentParts.push(url);
if (portal.is_power_portal) contentParts.push("Power Portal");
items.push({
id,
text: label,
category: "portals",
content: contentParts.join(" \u2022 "),
dateAdded: Date.now(),
metadata: {
portalId: portal.id,
portalUuid: portal.uuid,
url,
isPowerPortal: !!portal.is_power_portal,
entityType: "portal",
icon: "\ueb01",
},
actionId: "portal",
renderComponentId: "portal",
});
}
console.debug(`[Portals job] Indexed ${items.length} portal entries.`);
return items;
},
purge: (items) => items,
};
@@ -0,0 +1,97 @@
import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api";
/**
* Indexes report metadata from `/seqta/student/load/reports`.
*
* Reports are PDFs gated behind SEQTA's authenticated download endpoint, so
* we only index the human-readable metadata (year, term, title, file UUID)
* and a stable hash route so the search palette can deep-link straight
* into the reports page.
*/
interface ReportEntry {
id?: number | string;
uuid?: string;
title?: string;
description?: string;
date_published?: string;
date_created?: string;
year?: number | string;
term?: number | string;
metaclass?: number;
programme?: number;
filename?: string;
}
export const reportsJob: Job = {
id: "reports",
label: "Reports",
renderComponentId: "report",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 }, // daily
boostCriteria: (_item, searchTerm) => {
if (!searchTerm) return -25;
return 0;
},
run: async (_ctx) => {
const payload = await seqtaFetchPayload<ReportEntry[] | null>(
"/seqta/student/load/reports",
{},
);
if (!Array.isArray(payload)) return [];
const items: IndexItem[] = [];
const seen = new Set<string>();
for (const report of payload) {
if (!report) continue;
const stableId = report.uuid ?? report.id;
if (stableId === undefined || stableId === null) continue;
const id = `report-${stableId}`;
if (seen.has(id)) continue;
seen.add(id);
const title = report.title?.trim() || `Report ${stableId}`;
const dateAdded = report.date_published
? new Date(report.date_published).getTime() || Date.now()
: Date.now();
const contentParts: string[] = [];
if (report.description) contentParts.push(report.description);
if (report.year) contentParts.push(`Year ${report.year}`);
if (report.term) contentParts.push(`Term ${report.term}`);
if (report.date_published) contentParts.push(report.date_published);
items.push({
id,
text: title,
category: "reports",
content: contentParts.join(" \u2022 "),
dateAdded,
metadata: {
reportId: report.id,
reportUuid: report.uuid,
year: report.year,
term: report.term,
metaclass: report.metaclass,
programme: report.programme,
publishedAt: report.date_published,
createdAt: report.date_created,
filename: report.filename,
entityType: "report",
route: "/reports",
icon: "\ueb70",
},
actionId: "report",
renderComponentId: "report",
});
}
console.debug(`[Reports job] Indexed ${items.length} reports.`);
return items;
},
purge: (items) => items,
};

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