Compare commits

...

168 Commits

Author SHA1 Message Date
AdenMGB 6c08bea5f7 feat: start secuirity thingo 2026-05-08 17:18:23 +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
SethBurkart123 23ccac4836 update bun.lock 2026-05-01 12:15:53 +10:00
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
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
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
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
111 changed files with 12241 additions and 992 deletions
+9 -6
View File
@@ -4,12 +4,11 @@ package-lock.json
bun.lockb bun.lockb
pnpm-lock.yaml pnpm-lock.yaml
yarn.lock yarn.lock
bun.lock
.parcel-cache # PDF.js extension assets (copied by postinstall from pdfjs-dist)
.env src/public/resources/pdfjs/pdf.worker.min.mjs
.env.submit src/public/resources/pdfjs/pdf.legacy.min.mjs
dependency-graph.svg
# Build # Build
extension.zip extension.zip
@@ -19,5 +18,9 @@ betterseqtaplus-safari/
.million/ .million/
.vscode/ .vscode/
**/.DS_Store
.parcel-cache
.env
.env.submit
dependency-graph.svg
**/.DS_Store
+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: **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. - **📖 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 our [Architecture Guide](./docs/ARCHITECTURE.md) - **🏗️ Understand the codebase** with the [architecture guide](https://docs.betterseqta.org/architecture/)
- **🔧 Having issues?** Check our [Troubleshooting Guide](./docs/TROUBLESHOOTING.md) - **🔧 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! 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: 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 development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API Reference](./docs/advanced/plugin-api.md) - [Plugin API](https://docs.betterseqta.org/plugin-api/)
## Pull Request Process ## Pull Request Process
+25 -56
View File
@@ -16,17 +16,15 @@
<img src="https://img.shields.io/chrome-web-store/rating/afdgaoaclhkhemfkkkonemoapeinchel" /> <img src="https://img.shields.io/chrome-web-store/rating/afdgaoaclhkhemfkkkonemoapeinchel" />
</div> </div>
## Table of contents ## 📚 Documentation
All documentation has been moved to the [official docs site](https://docs.betterseqta.org):
- [Features](#features) Includes:
- [Creating Custom Themes](#creating-custom-themes) - Getting started
- [Getting Started](#getting-started) - Development setup
- [Running Development](#running-development) - Architecture
- [Building for production](#building-for-production) - Plugin system
- [Folder Structure](#folder-structure) - Theme creation
- [Contributors](#contributors)
- [Credits](#credits)
- [Star History](#star-history)
## Features ## Features
@@ -50,64 +48,32 @@
## Creating Custom Themes ## Creating Custom Themes
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://betterseqta.gitbook.io/betterseqta-docs). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator). If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://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 :) 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) ## ⚡ Quick Start
- **🏗️ Want to understand the code?** Check out our [Architecture Guide](./docs/ARCHITECTURE.md)
- **🧩 Interested in plugins?** Read our [Plugin Development Guide](./docs/plugins/README.md)
- **🐛 Found a bug?** Open an [issue](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) or fix it yourself!
- **💬 Need help?** Join our [Discord community](https://discord.gg/YzmbnCDkat)
We have lots of https://github.com/BetterSEQTA/BetterSEQTA-Plus/labels/good%20first%20issue labels perfect for beginners!
## Quick Development Setup
&nbsp;&nbsp;&nbsp; **1. Fork & Clone**
```bash ```bash
git clone https://github.com/YOUR_USERNAME/BetterSEQTA-Plus git clone https://github.com/YOUR_USERNAME_FORKED_WITH/BetterSEQTA-Plus
cd BetterSEQTA-Plus cd BetterSEQTA-Plus
```
&nbsp;&nbsp;&nbsp; **2. Install & Run**
```bash
npm install --legacy-peer-deps npm install --legacy-peer-deps
npm run dev npm run dev
``` ````
&nbsp;&nbsp;&nbsp; **3. Load in Browser** Then load `dist` in `chrome://extensions` (Developer Mode → Load unpacked).
1. Go to `chrome://extensions`
2. Enable "Developer mode"
3. Click "Load unpacked" → Select `dist` folder
4. Visit a SEQTA page to see it work! 🎉
> [!WARNING]
> Whenever you update the extension while not in dev mode, you will need to use the reload button on the extension page.
📚 **Need more details?** Check our [detailed setup guide](./docs/GETTING_STARTED_CONTRIBUTING.md#your-first-30-minutes)
### Building for Production 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)
```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.
## Contributors ## Contributors
@@ -115,11 +81,14 @@ The folder structure is as follows:
<img src="https://contrib.rocks/image?repo=betterseqta/betterseqta-plus" /> <img src="https://contrib.rocks/image?repo=betterseqta/betterseqta-plus" />
</a> </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 ## Credits
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers. This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers.
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 ## Star History
+231 -44
View File
@@ -1,10 +1,11 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"dependencies": { "dependencies": {
"@bedframe/core": "^0.0.46", "@bedframe/core": "^0.1.0",
"@codemirror/autocomplete": "^6.18.6", "@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0", "@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
@@ -12,13 +13,14 @@
"@codemirror/search": "^6.5.10", "@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4", "@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4", "@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.1.4", "@types/chrome": "^0.1.4",
"@types/color": "^4.2.0", "@types/color": "^4.2.0",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"@types/qrcode": "^1.5.6",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.3", "@types/webextension-polyfill": "^0.12.3",
@@ -44,22 +46,23 @@
"motion": "^12.4.12", "motion": "^12.4.12",
"pdfjs-dist": "^5.4.530", "pdfjs-dist": "^5.4.530",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"qrcode": "^1.5.4",
"react": "17", "react": "17",
"react-best-gradient-color-picker": "3.0.11", "react-best-gradient-color-picker": "3.0.11",
"react-dom": "17", "react-dom": "17",
"rss-parser": "^3.13.0", "rss-parser": "^3.13.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"svelte": "^5.22.6", "svelte": "^5.46.4",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vite": "^6.2.1", "vite": "^8.0.5",
"webextension-polyfill": "^0.12.0", "webextension-polyfill": "^0.12.0",
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9", "@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9", "@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.95", "@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.2.0", "@crxjs/vite-plugin": "^2.4.0",
"@types/mime-types": "^3.0.1", "@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@@ -70,7 +73,7 @@
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"process": "^0.11.10", "process": "^0.11.10",
"publish-browser-extension": "^4.0.0", "publish-browser-extension": "^4.0.4",
"sass": "^1.85.1", "sass": "^1.85.1",
"sass-loader": "^16.0.5", "sass-loader": "^16.0.5",
"semver": "^7.7.1", "semver": "^7.7.1",
@@ -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=="], "@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=="], "@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=="], "@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=="], "@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/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.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.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=="], "@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": ["@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=="], "@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=="], "@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/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=="], "@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/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": ["@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=="],
"@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=="],
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.10", "", { "dependencies": { "mini-svg-data-uri": "1.4.4" }, "peerDependencies": { "tailwindcss": "3.4.17" } }, "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw=="], "@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=="], "@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/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="],
"@types/chai": ["@types/chai@4.3.20", "", {}, "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ=="], "@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/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": ["@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=="], "@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=="], "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=="], "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=="], "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=="], "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": ["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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -622,7 +673,7 @@
"colors-named-hex": ["colors-named-hex@1.0.2", "", {}, "sha512-k6kq1e1pUCQvSVwIaGFq2l0LrkAPQZWyeuZn1Z8nOiYSEZiKoFj4qx690h2Kd34DFl9Me0gKS6MUwAMBJj8nuA=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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.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.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.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=="], "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-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=="], "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-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=="], "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -1108,7 +1213,9 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "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=="], "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-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=="], "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=="], "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=="], "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": ["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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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": ["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=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@@ -1250,6 +1365,8 @@
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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": ["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=="], "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=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
@@ -1478,15 +1599,15 @@
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], "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=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], "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=="], "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=="], "@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=="], "@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=="], "@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/runner/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"@vitest/snapshot/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=="], "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/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/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=="], "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=="], "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=="], "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/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=="], "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/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=="], "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/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=="], "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=="], "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=="], "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=="], "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/@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/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=="], "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=="], "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=="], "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=="], "@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=="], "@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=="], "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.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "6.2.0" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "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=="], "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=="], "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/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=="], "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=="], "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=="], "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-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=="], "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/@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/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=="], "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/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=="], "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=="], "@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=="], "@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=="], "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/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=="], "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/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/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=="], "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=="], "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/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-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=="], "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=="], "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 # 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! 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 ## Table of Contents
@@ -221,7 +223,7 @@ console.log(settingsState.[the setting name])
Ready to contribute? Here's what to do next: Ready to contribute? Here's what to do next:
1. **Read the code**: Start with `src/SEQTA.ts` and follow the flow 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 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! 4. **Join our Discord**: Get help from the community!
+140
View File
@@ -0,0 +1,140 @@
# 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.
- **`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 # 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. 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 ## Table of Contents
@@ -222,7 +224,7 @@ git push origin your-branch-name
### Stuck? Here's How to Get Unstuck ### 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 2. **Search existing issues** - Someone might have had the same problem
3. **Ask in Discord** - Our community is super helpful 3. **Ask in Discord** - Our community is super helpful
4. **Create an issue** - If you found a bug or need help 4. **Create an issue** - If you found a bug or need help
+21 -21
View File
@@ -1,30 +1,36 @@
# BetterSEQTA+ Documentation # BetterSEQTA+ Documentation
🚧 DOCS UNDER CONSTRUCTION! 🚧 **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.
Welcome to the BetterSEQTA+ documentation! This documentation will help you understand how BetterSEQTA+ works and how to extend it with plugins and new features.
## Table of Contents ## Table of Contents
### Getting Started ### Getting Started
- [Project Overview](./README.md) - This file - [Documentation home](https://docs.betterseqta.org/)
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+ - [Installation](https://docs.betterseqta.org/install/)
- [Getting Started Contributing](./GETTING_STARTED_CONTRIBUTING.md) - **Start here!** Complete beginner-friendly guide - [Contributing](https://docs.betterseqta.org/contributing/)
- [Architecture Guide](./ARCHITECTURE.md) - How BetterSEQTA+ works under the hood - [Architecture](https://docs.betterseqta.org/architecture/)
- [Contributing Guide](../CONTRIBUTING.md) - Official contribution guidelines - [Contribution guidelines (repository)](../CONTRIBUTING.md)
- [Troubleshooting](./TROUBLESHOOTING.md) - Common issues and solutions - [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 - [Features](https://docs.betterseqta.org/features/)
- [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs - [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 ## Core Concepts
BetterSEQTA+ is built around several 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. 2. **Type-Safe Settings**: Each plugin can define settings that are type-safe and automatically rendered in the settings UI. The settings system uses TypeScript decorators to make it easy to define settings with proper typing.
@@ -36,19 +42,13 @@ BetterSEQTA+ is built around several core concepts:
If you need help with BetterSEQTA+, you can: 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 - [Join the Discord](https://discord.gg/YzmbnCDkat) - Chat with the community
- [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly - [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly
## Contributing to the Documentation ## 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. 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.
To contribute to the documentation:
1. Fork the repository
2. Make your changes to the documentation files
3. Submit a pull request with a clear description of your changes
## License ## License
+2
View File
@@ -1,5 +1,7 @@
# Theme Creation Guide # 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+. This guide covers everything you need to know about creating custom themes for BetterSEQTA+.
## Table of Contents ## Table of Contents
+2
View File
@@ -1,5 +1,7 @@
# Troubleshooting Guide # 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. Having issues with BetterSEQTA+ development? This guide covers the most common problems and their solutions.
## Table of Contents ## Table of Contents
+5 -3
View File
@@ -1,5 +1,7 @@
# Contributing to BetterSEQTA+ # 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. Thank you for your interest in contributing to BetterSEQTA+! This document provides guidelines and instructions for contributing to the project.
## Table of Contents ## Table of Contents
@@ -57,7 +59,7 @@ Key points:
5. **Install in Chrome/Firefox** 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 ### Project Structure
@@ -246,8 +248,8 @@ Join our community channels to discuss the project, get help, and connect with o
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides: If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Creating Your First Plugin](./plugins/creating-plugins.md) - [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API Reference](./advanced/plugin-api.md) - [Plugin API](https://docs.betterseqta.org/plugin-api/)
## Recognition ## Recognition
+4 -2
View File
@@ -1,5 +1,7 @@
# Installing BetterSEQTA+ # 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. This guide will walk you through the process of installing and setting up BetterSEQTA+ for development or usage.
## Prerequisites ## Prerequisites
@@ -178,5 +180,5 @@ bun run dev
Now that you have BetterSEQTA+ installed, you can: Now that you have BetterSEQTA+ installed, you can:
- [Getting Started with Plugins](./plugins/getting-started.md) - [Plugins](https://docs.betterseqta.org/plugins/)
- [Contribute to the project](../CONTRIBUTING.md) - [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 # 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! 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 ## What This Example Does
@@ -328,8 +330,8 @@ Once you've got this working:
## Need Help? ## Need Help?
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat) - 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
- 📚 Read our [Plugin Development Guide](./README.md) - 📚 Read the [plugin documentation](https://docs.betterseqta.org/plugins/)
- 🐛 Check the [Troubleshooting Guide](../TROUBLESHOOTING.md) - 🐛 Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
- 📝 Open an issue on GitHub - 📝 Open an issue on GitHub
Happy coding! 🎉 Happy coding! 🎉
+3 -1
View File
@@ -1,5 +1,7 @@
# Creating Plugins for BetterSEQTA+ # 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. 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? ## 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 - 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) - 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 # 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 ## 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;
}
},
};
}
+20 -12
View File
@@ -1,10 +1,11 @@
{ {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"version": "3.4.15", "version": "3.6.4",
"type": "module", "type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add add heaps more features!", "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
"browserslist": "> 0.5%, last 2 versions, not dead", "browserslist": "> 0.5%, last 2 versions, not dead",
"scripts": { "scripts": {
"postinstall": "node scripts/copy-pdfjs-assets.mjs",
"autoaudit": "npm audit && npm audit fix && npm run build", "autoaudit": "npm audit && npm audit fix && npm run build",
"dev": "cross-env MODE=chrome vite dev", "dev": "cross-env MODE=chrome vite dev",
"dev:firefox": "cross-env MODE=firefox vite build --watch", "dev:firefox": "cross-env MODE=firefox vite build --watch",
@@ -18,7 +19,8 @@
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg", "dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes", "release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
"publish": "bun lib/publish.js --b", "publish": "bun lib/publish.js --b",
"zip": "bedframe zip" "zip": "bedframe zip",
"test": "vitest run"
}, },
"targets": { "targets": {
"prod": { "prod": {
@@ -31,14 +33,15 @@
"author": { "author": {
"name": "SethBurkart123", "name": "SethBurkart123",
"email": "betterseqta.plus@gmail.com", "email": "betterseqta.plus@gmail.com",
"url": "https://github.com/BetterSEQTA/BetterSEQTA-plus" "url": "https://github.com/BetterSEQTA/BetterSEQTA-Plus"
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9", "@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9", "@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.95", "@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.2.0", "@crxjs/vite-plugin": "^2.4.0",
"@types/jsdom": "^28.0.1",
"@types/mime-types": "^3.0.1", "@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@@ -46,18 +49,20 @@
"dependency-cruiser": "^17.0.1", "dependency-cruiser": "^17.0.1",
"eslint": "^9.33.0", "eslint": "^9.33.0",
"glob": "^11.0.1", "glob": "^11.0.1",
"jsdom": "^29.1.1",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"process": "^0.11.10", "process": "^0.11.10",
"publish-browser-extension": "^4.0.0", "publish-browser-extension": "^4.0.4",
"sass": "^1.85.1", "sass": "^1.85.1",
"sass-loader": "^16.0.5", "sass-loader": "^16.0.5",
"semver": "^7.7.1", "semver": "^7.7.1",
"tailwindcss": "3", "tailwindcss": "3",
"url": "^0.11.4" "url": "^0.11.4",
"vitest": "^4.1.5"
}, },
"dependencies": { "dependencies": {
"@bedframe/core": "^0.0.46", "@bedframe/core": "^0.1.0",
"@codemirror/autocomplete": "^6.18.6", "@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0", "@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
@@ -65,13 +70,14 @@
"@codemirror/search": "^6.5.10", "@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4", "@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4", "@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.1.4", "@types/chrome": "^0.1.4",
"@types/color": "^4.2.0", "@types/color": "^4.2.0",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"@types/qrcode": "^1.5.6",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.3", "@types/webextension-polyfill": "^0.12.3",
@@ -90,6 +96,7 @@
"flexsearch": "^0.8.147", "flexsearch": "^0.8.147",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"idb": "^8.0.2", "idb": "^8.0.2",
"jspdf": "^4.2.1",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mathjs": "^14.4.0", "mathjs": "^14.4.0",
@@ -97,15 +104,16 @@
"motion": "^12.4.12", "motion": "^12.4.12",
"pdfjs-dist": "^5.4.530", "pdfjs-dist": "^5.4.530",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"qrcode": "^1.5.4",
"react": "17", "react": "17",
"react-best-gradient-color-picker": "3.0.11", "react-best-gradient-color-picker": "3.0.11",
"react-dom": "17", "react-dom": "17",
"rss-parser": "^3.13.0", "rss-parser": "^3.13.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"svelte": "^5.22.6", "svelte": "^5.46.4",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vite": "^6.2.1", "vite": "^8.0.5",
"webextension-polyfill": "^0.12.0" "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 { delay } from "./seqta/utils/delay";
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle"; import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
function registerFetchSeqtaAppLinkListener() {
browser.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (request?.type !== "fetchSeqtaAppLink") return false;
void (async () => {
try {
const res = await fetch(`${location.origin}/seqta/student/load/profile`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({}),
});
const data = await res.json();
const statusOk = data?.status === "200" || data?.status === 200;
const raw = data?.payload?.app_link;
const appLink = typeof raw === "string" && raw.length > 0 ? raw : null;
sendResponse({ appLink: statusOk ? appLink : null });
} catch {
sendResponse({ appLink: null });
}
})();
return true;
});
}
export let MenuOptionsOpen = false; export let MenuOptionsOpen = false;
var IsSEQTAPage = false; var IsSEQTAPage = false;
@@ -26,10 +50,21 @@ if (document.childNodes[1]) {
} }
async function init() { 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; IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page"); 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"); const documentLoadStyle = document.createElement("style");
documentLoadStyle.textContent = documentLoadCSS; documentLoadStyle.textContent = documentLoadCSS;
document.head.appendChild(documentLoadStyle); document.head.appendChild(documentLoadStyle);
+383 -38
View File
@@ -1,12 +1,22 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import type { SettingsState } from "@/types/storage"; import type { SettingsState } from "@/types/storage";
import { fetchNews } from "./background/news"; import { fetchNews } from "./background/news";
import {
initCloudSettingsAutoSync,
performCloudSettingsDownloadWithRetry,
performCloudSettingsUploadWithRetry,
requestCloudSettingsDebouncedUpload,
runCloudSettingsPoll,
} from "./background/cloudSettingsAutoSync";
function reloadSeqtaPages() { function reloadSeqtaPages() {
const result = browser.tabs.query({}); const result = browser.tabs.query({});
function open(tabs: any) { function open(tabs: any) {
for (let tab of tabs) { for (let tab of tabs) {
if (tab.title.includes("SEQTA Learn")) { if (
tab.title?.includes("SEQTA Learn") ||
tab.title?.includes("SEQTA Engage")
) {
browser.tabs.reload(tab.id); browser.tabs.reload(tab.id);
} }
} }
@@ -14,52 +24,380 @@ function reloadSeqtaPages() {
result.then(open, console.error); result.then(open, console.error);
} }
// @ts-ignore /** Callback for sending a response back to the message sender */
browser.runtime.onMessage.addListener( type MessageSender = { (response?: unknown): void };
(request: any, _: any, sendResponse: (response?: any) => void) => {
switch (request.type) {
case "reloadTabs":
reloadSeqtaPages();
break;
case "extensionPages": function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
browser.tabs.query({}).then(function (tabs) { const { token } = request;
for (let tab of tabs) { const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
if (tab.url?.includes("chrome-extension://")) { const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
browser.tabs.sendMessage(tab.id!, request); const headers: Record<string, string> = {};
} if (token) headers["Authorization"] = `Bearer ${token}`;
} fetch(apiUrl, { cache: "no-store", headers })
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
fetch(githubUrl, { cache: "no-store" })
.then((r) => r.json())
.then((data) => sendResponse({ success: true, data: { themes: data.themes ?? [] } }))
.catch((fallbackErr) => {
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
sendResponse({ success: false, error: fallbackErr?.message });
}); });
break; });
return true;
}
case "currentTab": function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boolean {
browser.tabs const { themeId, token } = request;
.query({ active: true, currentWindow: true }) if (!themeId || typeof themeId !== "string") {
.then(function (tabs) { sendResponse({ success: false, error: "Missing themeId" });
browser.tabs return false;
.sendMessage(tabs[0].id!, request) }
.then(function (response) { const headers: Record<string, string> = {};
sendResponse(response); if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers })
.then((r) => r.json())
.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": // Fetch full user info
browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" }); const userRes = await fetch("https://accounts.betterseqta.org/api/auth/me", {
break; 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": // Trigger cloud settings download
SetStorageValue(getDefaultValues()); void performCloudSettingsDownloadWithRetry(token).catch((err) => {
break; console.warn("[Background] Cloud settings download after login:", err);
});
} catch (err) {
console.error("[Background] Failed to process login callback:", err);
}
})();
case "sendNews": // Close the callback tab
fetchNews(request.source ?? "australia", sendResponse); void browser.tabs.remove(tabId);
return true; }
} catch (err) {
default: console.error("[Background] Error parsing callback URL:", err);
console.log("Unknown request type"); }
} }
});
}
initCloudLoginCallbackListener();
function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
const { refresh_token, client_id } = request;
if (!refresh_token || !client_id) {
sendResponse({ error: "Missing refresh_token or client_id" });
return false;
}
fetch("https://accounts.betterseqta.org/api/bsplus/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token, client_id }),
})
.then(async (r) => {
const data = await parseJsonResponse(r);
if (!r.ok) sendResponse({ error: data?.error ?? "Refresh failed" });
else sendResponse(data);
})
.catch((err) => {
console.error("[Background] cloudRefresh error:", err);
sendResponse({ error: err?.message ?? "Network error" });
});
return true;
}
function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const res = await performCloudSettingsUploadWithRetry(token);
sendResponse({
success: res.success,
error: res.error,
updated_at: res.updated_at,
});
} catch (err) {
console.error("[Background] cloudSettingsUpload error:", err);
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Upload failed",
});
}
})();
return true;
}
function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const res = await performCloudSettingsDownloadWithRetry(token);
sendResponse({
success: res.success,
notFound: res.notFound,
error: res.error,
updated_at: res.updated_at,
});
} catch (err) {
console.error("[Background] cloudSettingsDownload error:", err);
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Download failed",
});
}
})();
return true;
}
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
const { themeId, token, action } = request;
if (!themeId || !token) {
sendResponse({ success: false, error: "Theme ID and token required" });
return false;
}
const isFavorite = action === "favorite";
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, {
method: isFavorite ? "POST" : "DELETE",
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.error("[Background] cloudFavorite error:", err);
sendResponse({ success: false, error: err?.message });
});
return true;
}
/** Handler for a message type; receives request, sendResponse, and optional sender (for tab routing) */
type MessageHandler = {
(request: any, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender): boolean | void;
};
function isSeqtaOrigin(origin: string): boolean {
try {
const u = new URL(origin);
return u.hostname.includes("seqta") || u.hostname.endsWith(".edu.au");
} catch {
return false;
}
}
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
reloadTabs: () => reloadSeqtaPages(),
extensionPages: (req) => {
browser.tabs.query({}).then((tabs) => {
for (const tab of tabs) {
if (tab.url?.includes("chrome-extension://")) browser.tabs.sendMessage(tab.id!, req);
}
});
},
currentTab: (req, sendResponse) => {
browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => {
browser.tabs.sendMessage(tabs[0].id!, req).then(sendResponse);
});
return true;
},
githubTab: () => {
void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
},
setDefaultStorage: () => SetStorageValue(getDefaultValues()),
sendNews: (req, sendResponse) => {
fetchNews(req.source ?? "australia", sendResponse);
return true;
},
fetchThemes: handleFetchThemes,
fetchThemeDetails: handleFetchThemeDetails,
fetchFromUrl: handleFetchFromUrl,
cloudReserveClient: handleCloudReserveClient,
cloudLogin: handleCloudLogin,
cloudStartLogin: handleCloudStartLogin,
cloudRefresh: handleCloudRefresh,
cloudFavorite: handleCloudFavorite,
cloudSettingsUpload: handleCloudSettingsUpload,
cloudSettingsDownload: handleCloudSettingsDownload,
cloudSettingsPoll: () => {
void runCloudSettingsPoll();
return false;
},
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; return false;
}, },
); );
@@ -127,6 +465,11 @@ function getDefaultValues(): SettingsState {
customshortcuts: [], customshortcuts: [],
lettergrade: false, lettergrade: false,
newsSource: "australia", newsSource: "australia",
iconOnlySidebar: false,
adaptiveThemeColour: false,
adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true,
autoCloudSettingsSync: true,
}; };
} }
@@ -144,3 +487,5 @@ browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.set({ justupdated: true }); browser.storage.local.set({ justupdated: true });
} }
}); });
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. * used to send the fetched news data back to the caller.
* It's called with an object like `{ news: { articles: [...] } }`. * It's called with an object like `{ news: { articles: [...] } }`.
*/ */
export async function fetchNews(source: string, sendResponse: any) { export async function fetchNews(source: string | undefined, sendResponse: any) {
if (source === "australia") { const normalizedSource = typeof source === "string" && source.trim()
? source.trim()
: "australia";
if (normalizedSource === "australia") {
const date = new Date(); const date = new Date();
const from = const from =
@@ -111,18 +115,15 @@ export async function fetchNews(source: string, sendResponse: any) {
const parser = new Parser(); const parser = new Parser();
let feeds: string[]; let feeds: string[];
console.log("fetchNews", source); console.log("fetchNews", normalizedSource);
if (rssFeedsByCountry[source.toLowerCase()]) { if (rssFeedsByCountry[normalizedSource.toLowerCase()]) {
// If the source is a country, fetch from predefined feeds feeds = rssFeedsByCountry[normalizedSource.toLowerCase()];
feeds = rssFeedsByCountry[source.toLowerCase()]; } else if (normalizedSource.startsWith("http")) {
} else if (source.startsWith("http")) { feeds = [normalizedSource];
// If the source is a URL, use it directly
feeds = [source];
} else { } else {
throw new Error( console.warn("[BetterSEQTA+] Invalid news source, falling back to Australia", normalizedSource);
"Invalid source. Provide a country code or a valid RSS feed URL.", return fetchNews("australia", sendResponse);
);
} }
const articlesPromises = feeds.map(async (feedUrl) => { const articlesPromises = feeds.map(async (feedUrl) => {
+23
View File
@@ -26,6 +26,29 @@
font-display: swap; font-display: swap;
} }
@font-face {
font-family: "IconFamily";
src: url("@/resources/fonts/IconFamily.woff") format("woff");
font-weight: normal;
font-style: normal;
font-display: block;
}
@layer base, override;
@layer override {
* {
font-family: Rubik, sans-serif !important;
}
.iconFamily,
.iconFamily *,
[class~="iconFamily"],
[class~="iconFamily"] * {
font-family: "IconFamily" !important;
}
}
html { html {
background: #161616 !important; background: #161616 !important;
background-color: #161616; background-color: #161616;
+497 -31
View File
@@ -1,3 +1,4 @@
@use "sass:meta"; @use "sass:meta";
@import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,600,700"); @import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,600,700");
@@ -22,10 +23,6 @@
font-family: Rubik, sans-serif !important; font-family: Rubik, sans-serif !important;
} }
*:not([class^="Canvas__canvas___"]):not([class^="Canvas__canvas___"] *):not([class^="ThemeCard__"]):not([class^="ThemeCard__"] *):not([class^="ThemePreview__"]):not([class^="ThemePreview__"] *):not([class^="academicReportsWrapper"]):not([class^="academicReportsWrapper"] *):not(textarea):not(.doesntexist) {
font-family: Rubik, sans-serif !important;
}
::view-transition-old(root), ::view-transition-old(root),
::view-transition-new(root) { ::view-transition-new(root) {
animation: none; animation: none;
@@ -64,20 +61,62 @@ body {
font-family: Rubik, sans-serif !important; font-family: Rubik, sans-serif !important;
} }
select option { select {
background-color: #ffffff !important; border-radius: 16px !important;
color: #111827 !important; border: 1px solid color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 78%, transparent) !important;
} background: color-mix(in srgb, var(--background-primary) 90%, transparent) !important;
.dark select option { color: var(--text-primary) !important;
background-color: #1f2937 !important; transition:
color: #ffffff !important; background-color 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease !important;
} }
select { select:hover {
border-radius: 8px !important; background: color-mix(in srgb, var(--background-primary) 94%, var(--background-secondary) 6%) !important;
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 92%, transparent) !important;
}
select:focus {
outline: none !important;
background: color-mix(in srgb, var(--background-primary) 96%, var(--background-secondary) 4%) !important;
border-color: color-mix(in srgb, var(--text-primary) 18%, var(--theme-offset-bg, var(--background-secondary)) 82%) !important;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent) !important;
}
select:not([multiple]):not([size]),
select[size="1"] {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23999'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
background-position: right 0.9rem center !important;
background-repeat: no-repeat !important;
background-size: 1rem !important;
padding-right: 2.6rem !important;
color-scheme: light;
}
select::-ms-expand {
display: none;
}
select option {
background: var(--background-primary) !important;
color: var(--text-primary) !important;
}
.dark select:not([multiple]):not([size]),
.dark select[size="1"] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23c9c9c9'%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 select option {
background: var(--background-primary) !important;
color: var(--text-primary) !important;
} }
#container { #container {
transition: 200ms;
background: var(--auto-background) !important; background: var(--auto-background) !important;
} }
:root * { :root * {
@@ -290,8 +329,13 @@ select {
} }
} }
.timetable-zoom { .timetable-zoom,
.timetable-hide {
font-size: 14px !important; font-size: 14px !important;
line-height: 1 !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
} }
#main > .dashboard { #main > .dashboard {
@@ -448,6 +492,7 @@ ul.magicDelete > li.deleting {
background: var(--better-main) !important; background: var(--better-main) !important;
color: var(--text-color); color: var(--text-color);
border-right: none; border-right: none;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
} }
#menu li > label > svg, #menu li > label > svg,
#menu section > label > svg { #menu section > label > svg {
@@ -455,6 +500,88 @@ ul.magicDelete > li.deleting {
width: 28px !important; width: 28px !important;
height: 28px !important; height: 28px !important;
} }
/* Icon Only Sidebar - compact mode. When submenu is open, all styles are disabled so it behaves as normal sidebar. */
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) {
#menu {
width: 70px !important;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
overflow: visible !important;
}
#menu > ul {
min-width: 0 !important;
overflow-x: visible !important;
}
#content {
left: 70px !important;
transition: left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
#menu .sub {
left: 70px !important;
transition: left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
#menu li > label span,
#menu section > label span {
display: none !important;
}
#menu ul > li > svg {
display: none !important;
}
#menu ul li,
#menu ul section {
min-width: 0 !important;
max-width: 100% !important;
padding: 8px !important;
justify-content: center !important;
box-sizing: border-box !important;
}
#menu li > label,
#menu section > label {
flex: 0 0 auto !important;
min-width: 0 !important;
justify-content: center !important;
overflow: visible !important;
}
#menu li > label > svg,
#menu section > label > svg {
margin: 0 auto !important;
flex-shrink: 0 !important;
}
/* Engage: hide text nodes in labels via font-size trick, restore SVG size */
#menu .logo-link li > label,
#menu .logo-link section > label {
font-size: 0 !important;
justify-content: center !important;
svg {
width: 24px !important;
height: 24px !important;
font-size: initial !important;
flex-shrink: 0 !important;
margin: 0 auto !important;
}
}
/* Engage: hide chevron arrows on hasChildren items */
#menu .logo-link li > svg {
display: none !important;
}
/* Engage: hide the name/details in #userActions */
#menu #userActions .details {
display: none !important;
}
}
[class*="notifications__items___"] { [class*="notifications__items___"] {
-ms-overflow-style: none !important; -ms-overflow-style: none !important;
scrollbar-width: none !important; scrollbar-width: none !important;
@@ -873,12 +1000,20 @@ html.transparencyEffects
#menu li:hover { #menu li:hover {
background: rgba(0, 0, 0, 0.15) !important; background: rgba(0, 0, 0, 0.15) !important;
} }
#menu li:focus-visible,
#menu section:focus-visible {
outline: 1px solid rgb(from currentColor r g b / 0.55);
outline-offset: -1px;
background: rgba(0, 0, 0, 0.2) !important;
}
#main > .timetablepage > .container { #main > .timetablepage > .container {
background: var(--background-primary); background: var(--background-primary);
} }
#content { #content {
transition: 0.4s; transition: left 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s ease;
left: 270px; left: 270px;
background: unset; background: unset;
} }
@@ -921,6 +1056,17 @@ html.transparencyEffects
-webkit-transform: translatex(270px); -webkit-transform: translatex(270px);
transform: translatex(270px); transform: translatex(270px);
} }
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) {
#menu {
-webkit-transform: translatex(-70px);
transform: translatex(-70px);
}
.menuShown #content {
-webkit-transform: translatex(70px);
transform: translatex(70px);
}
}
} }
@media (max-width: 1145px) { @media (max-width: 1145px) {
@@ -1186,7 +1332,7 @@ html.transparencyEffects
height: 15em; height: 15em;
font-family: Rubik, sans-serif !important; font-family: Rubik, sans-serif !important;
font-size: 14px !important; font-size: 14px !important;
font-weight: 700 !important; font-weight: 500 !important;
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
grid-auto-columns: minmax(100px, 1fr); grid-auto-columns: minmax(100px, 1fr);
@@ -1508,6 +1654,13 @@ html.transparencyEffects
box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.4); box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.4);
} }
/* Smoothed by attachNotificationsPanelAnimation (matches #ExtensionPopup spring) */
.bsplus-notifications-panel {
transform-origin: top right;
will-change: opacity, transform;
filter: drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.35));
}
#menu li.active { #menu li.active {
color: #ffffff !important; color: #ffffff !important;
background: rgba(0, 0, 0, 0.35); background: rgba(0, 0, 0, 0.35);
@@ -1677,6 +1830,22 @@ iframe.userHTML {
background-image: unset; background-image: unset;
background-color: var(--auto-background); background-color: var(--auto-background);
} }
[class*="OverallResult__OverallResult___"] {
--fill-colour: var(--assessment-accent-colour, var(--item-colour, var(--container-accent, rgb(var(--theme-sel-bg-parts)))));
}
[class*="OverallResult__OverallResult___"] [class*="Thermoscore__Thermoscore___"],
[class*="OverallResult__OverallResult___"] [class*="Thermoscore__fill___"],
[class*="OverallResult__OverallResult___"] > div > div {
--fill-colour: var(--assessment-accent-colour, var(--item-colour, var(--container-accent, rgb(var(--theme-sel-bg-parts)))));
}
[class*="AssessableCriterion__header___"] {
--fill-colour: var(--assessment-accent-colour, var(--item-colour, var(--container-accent, rgb(var(--theme-sel-bg-parts)))));
}
[class*="AssessableCriterion__header___"] [class*="Thermoscore__Thermoscore___"],
[class*="AssessableCriterion__header___"] [class*="Thermoscore__fill___"],
[class*="AssessableCriterion__header___"] > div > div > div {
--fill-colour: var(--assessment-accent-colour, var(--item-colour, var(--container-accent, rgb(var(--theme-sel-bg-parts)))));
}
.dark [class*="Thermoscore__Thermoscore___"] { .dark [class*="Thermoscore__Thermoscore___"] {
border: 2px solid rgba(255, 255, 255, 0.3); border: 2px solid rgba(255, 255, 255, 0.3);
} }
@@ -2290,7 +2459,7 @@ div.bar.flat {
padding: 0 !important; padding: 0 !important;
padding-left: 8px !important; padding-left: 8px !important;
gap: 0 8px; gap: 0 8px;
background: var(--better-main); background: transparent !important;
} }
.cke_toolbar:has(.cke_toolgroup) { .cke_toolbar:has(.cke_toolgroup) {
.cke_combo { .cke_combo {
@@ -2441,7 +2610,7 @@ body {
[class*="MessageList__unread___"] { [class*="MessageList__unread___"] {
position: relative; position: relative;
background: rgb(228 225 225); background: var(--background-secondary, rgb(228 225 225));
} }
.dark [class*="MessageList__unread___"] { .dark [class*="MessageList__unread___"] {
@@ -2567,7 +2736,7 @@ body {
[class*="MessageList__MessageList___"] [class*="MessageList__MessageList___"]
> ol > ol
> li[class*="MessageList__selected___"] { > li[class*="MessageList__selected___"] {
background: rgb(228 225 225); background: var(--background-secondary, rgb(228 225 225));
color: var(--text-primary); color: var(--text-primary);
box-shadow: none !important; box-shadow: none !important;
position: relative; position: relative;
@@ -2689,20 +2858,25 @@ body {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
will-change: transform;
} }
.logo { .logo {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
.big-circle { .big-circle {
margin: -88px; margin: -88px;
animation: spin 3s ease infinite; will-change: transform;
-moz-animation: spin 3s ease infinite; animation-timing-function: linear;
animation: spin 3s linear infinite;
-moz-animation: spin 3s linear infinite;
} }
.small-circle { .small-circle {
margin: -66px; margin: -66px;
animation: spin 3s ease infinite; will-change: transform;
-moz-animation: spin 3s ease infinite; animation-timing-function: linear;
animation: spin 3s linear infinite;
-moz-animation: spin 3s linear infinite;
} }
.dark [class*="LabelList__name___"] { .dark [class*="LabelList__name___"] {
@@ -2746,8 +2920,10 @@ body {
} }
.outer-circle { .outer-circle {
margin: -108px; margin: -108px;
animation: spinback 1s linear infinite alternate-reverse; will-change: transform;
-moz-animation: spinback 1s linear infinite alternate-reverse; animation-direction: alternate-reverse;
animation: spinback 1s linear infinite;
-moz-animation: spinback 1s linear infinite;
} }
@-moz-keyframes spin { @-moz-keyframes spin {
100% { 100% {
@@ -2855,8 +3031,14 @@ div.day-empty {
p { p {
margin: 0; margin: 0;
font-size: 30px; font-size: 30px;
font-weight: 400;
} }
} }
#notice-container.day-empty,
#notice-container .day-empty {
font-size: 14px !important;
}
.upcoming-submittedtext { .upcoming-submittedtext {
align-self: center; align-self: center;
padding: 8px 25px; padding: 8px 25px;
@@ -3036,7 +3218,7 @@ div.day-empty {
border-radius: 16px; border-radius: 16px;
font-family: Rubik, sans-serif !important; font-family: Rubik, sans-serif !important;
font-size: 20px !important; font-size: 20px !important;
font-weight: 700 !important; font-weight: 500 !important;
} }
.dark .upcoming-items { .dark .upcoming-items {
box-shadow: inset 0px 40px 80px -40px rgba(0, 0, 0, 0.6); box-shadow: inset 0px 40px 80px -40px rgba(0, 0, 0, 0.6);
@@ -3147,6 +3329,88 @@ div.day-empty {
position: absolute; position: absolute;
right: 250px; right: 250px;
} }
.engage-titlebar {
right: 250px;
top: 0;
}
/* Engage parent home: same timetable DOM as Learn; title+student replace the lone h2 — give the cluster Learns h2 margin/inset. */
.timetable-container .home-subtitle > .engage-timetable-title-cluster {
align-items: center;
box-sizing: border-box;
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 0.75rem 1rem;
margin: 20px;
min-width: 0;
}
.timetable-container .engage-timetable-title-cluster > h2 {
font-size: 20px;
font-weight: 400;
margin: 0 !important;
}
#engage-home-root.home-root {
box-sizing: border-box;
min-height: 100%;
}
.engage-child-select {
background: var(--background-primary);
border: 1px solid var(--border-primary, rgba(128, 128, 128, 0.35));
border-radius: 0.5rem;
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25;
max-width: 16rem;
min-width: 10rem;
padding: 0.35rem 0.6rem;
transition: border-color 0.2s ease-in-out, color 0.2s ease-in-out;
}
.engage-child-select:focus {
outline: none;
box-shadow: 0 0 0 2px var(--background-primary), 0 0 0 4px rgba(59, 130, 246, 0.45);
}
#engage-day-container:has(> .day-empty) {
align-content: center;
display: flex;
grid-auto-columns: unset;
grid-auto-flow: unset;
justify-content: center;
min-height: 12rem;
padding: 1.5rem;
}
#engage-day-container .day-empty {
text-align: center;
}
#engage-logouttooltip {
width: 50px !important;
margin-left: -28px !important;
top: 105% !important;
.engage-logout {
background: none !important;
border: none;
cursor: pointer;
color: var(--background-primary) !important;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
svg {
fill: var(--background-primary) !important;
}
}
}
.pagename { .pagename {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -3268,6 +3532,26 @@ div.day-empty {
font-size: 1em; font-size: 1em;
color: var(--text-primary); color: var(--text-primary);
} }
.whatsnewHeader.engageParentsAnnouncementHeader {
height: auto;
min-height: unset;
}
.whatsnewHeader.engageParentsAnnouncementHeader h1 {
line-height: 1.2;
}
.whatsnewHeader.engageParentsAnnouncementHeader .engageParentsSubheading {
margin-top: 0.35rem;
font-size: 1.05rem;
font-weight: 600;
opacity: 0.92;
}
.seqtaEngageAccent {
color: #ea580c;
font-weight: 700;
}
.dark .seqtaEngageAccent {
color: #fb923c;
}
.whatsnewBackground { .whatsnewBackground {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -3288,7 +3572,7 @@ div.day-empty {
.whatsnewImg { .whatsnewImg {
margin: 0 auto; margin: 0 auto;
width: 90%; width: 90%;
aspect-ratio: 16 / 10; aspect-ratio: 16 / 9;
border-radius: 16px; border-radius: 16px;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.3); box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.3);
} }
@@ -3396,6 +3680,138 @@ div.day-empty {
object-fit: cover; object-fit: cover;
margin-bottom: 12px; margin-bottom: 12px;
} }
.whatsnewTextContainer .engageParentsPromoWrap {
width: 100%;
margin-bottom: 12px;
border-radius: 16px;
overflow: hidden;
aspect-ratio: 16 / 9;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.28);
background: color-mix(in srgb, var(--background-secondary) 88%, var(--text-primary) 12%);
}
.whatsnewTextContainer .engageParentsPromoWrap .engageParentsPromoImg {
display: block;
width: 100%;
height: 100%;
margin: 0;
border-radius: 0;
aspect-ratio: unset;
object-fit: contain;
object-position: center;
}
.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader {
height: auto;
min-height: unset;
}
.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader h1 {
line-height: 1.2;
}
.bsCloudAccent {
color: #059669;
font-weight: 700;
}
.dark .bsCloudAccent {
color: #34d399;
}
.whatsnewTextContainer .bsCloudAutoSyncSignupCallout {
margin: 1.5rem 0 0;
padding: 1.25rem 1rem 0;
border-top: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
font-size: clamp(1.35rem, 3.8vw, 1.85rem);
font-weight: 800;
line-height: 1.35;
letter-spacing: -0.02em;
text-align: center;
color: var(--text-primary);
}
.popup-media-fullscreenable {
cursor: pointer;
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
}
.popup-media-fullscreenable:hover {
opacity: 0.95;
}
.popup-media-fullscreenable:focus {
outline: none;
}
.popup-media-fullscreenable:focus-visible {
outline: 2px solid color-mix(in srgb, var(--text-primary) 70%, transparent);
outline-offset: 4px;
}
.bsplus-popup-media-overlay-backdrop {
position: fixed;
inset: 0;
z-index: 2147483646;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(20px, 4vw, 48px);
box-sizing: border-box;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
opacity: 0;
transition: opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1);
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible {
opacity: 1;
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant {
transition: none;
}
.bsplus-popup-media-overlay-inner {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
max-width: min(96vw, 1320px);
max-height: calc(100vh - clamp(40px, 10vw, 96px));
border-radius: 20px;
overflow: hidden;
background: var(--background-primary);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
opacity: 0;
transform: scale(0.94) translateY(12px);
transition:
opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1),
transform 0.28s cubic-bezier(0.22, 0.03, 0.26, 1);
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible
.bsplus-popup-media-overlay-inner {
opacity: 1;
transform: scale(1) translateY(0);
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant
.bsplus-popup-media-overlay-inner {
transition: none;
}
.bsplus-popup-media-overlay-slot {
width: 100%;
max-height: inherit;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(16px, 3vw, 28px);
box-sizing: border-box;
}
.bsplus-popup-media-overlay-media {
max-width: 100%;
max-height: calc(100vh - clamp(120px, 22vh, 200px));
width: auto;
height: auto;
object-fit: contain;
border-radius: 12px;
}
@keyframes shimmer { @keyframes shimmer {
0% { 0% {
@@ -3550,6 +3966,17 @@ div.day-empty {
scrollbar-width: none !important; scrollbar-width: none !important;
} }
/* Fix SEQTA update: ul.logo-link covers sidebar and causes page reload on empty space clicks */
#menu ul.logo-link {
pointer-events: none;
/* Re-enable clicks on menu items while sidebar editing swaps built-in items to <section> */
li,
section {
pointer-events: auto;
}
}
.notice-unified-content.notice-modal-state { .notice-unified-content.notice-modal-state {
border: none !important; border: none !important;
} }
@@ -3619,7 +4046,7 @@ div.day-empty {
background: var(--background-primary); background: var(--background-primary);
border-radius: 16px; border-radius: 16px;
max-width: 600px; max-width: 600px;
max-height: 80vh; max-height: 90vh;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
@@ -3685,7 +4112,7 @@ div.day-empty {
margin: 0 !important; margin: 0 !important;
flex: 1; flex: 1;
display: block; display: block;
min-width: 600px; min-width: 0;
width: 100%; width: 100%;
} }
@@ -3702,7 +4129,8 @@ div.day-empty {
} }
.notice-content-body { .notice-content-body {
overflow-y: hidden; overflow-y: auto;
min-height: 0;
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 6px;
@@ -3944,3 +4372,41 @@ h2.home-subtitle {
font-size: 20px; font-size: 20px;
font-weight: 400; font-weight: 400;
} }
.bsplus-toast {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 10000;
display: flex;
align-items: flex-start;
gap: 12px;
max-width: 380px;
padding: 16px 18px;
border-radius: 12px;
background: var(--background-secondary, #fff);
color: var(--text-primary, #1a1a1a);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
font-size: 0.9rem;
line-height: 1.5;
}
.bsplus-toast-content p {
margin: 6px 0 0;
opacity: 0.8;
font-size: 0.85rem;
}
.bsplus-toast-close {
flex-shrink: 0;
background: none;
border: none;
color: var(--text-primary, #1a1a1a);
font-size: 1.3rem;
cursor: pointer;
padding: 0 2px;
line-height: 1;
opacity: 0.5;
transition: opacity 0.15s;
}
.bsplus-toast-close:hover {
opacity: 1;
}
+11 -1
View File
@@ -35,9 +35,19 @@
} }
#menu .sub { #menu .sub {
transition: transform 0.3s ease; transition: transform 0.3s ease, left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
} }
#menu > ul:has(li.hasChildren.active) > li.active { #menu > ul:has(li.hasChildren.active) > li.active {
background: transparent !important; background: transparent !important;
} }
/* Icon-only collapsed: submenu slides over narrow icons */
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li::before,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li::before,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li > label,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul:has(li.hasChildren.active) > li > svg,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > label,
body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul ul:has(li.hasChildren.active) > li > svg {
transform: translateX(-70px);
}
+15 -2
View File
@@ -1,7 +1,20 @@
<script lang="ts"> <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> </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} {text}
</button> </button>
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from "svelte";
import { animate } from "motion";
import { delay } from "@/seqta/utils/delay.ts";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
const { hidePanel } = $props<{
hidePanel: () => void;
}>();
let cloudState = $state(cloudAuth.state);
let background = $state<HTMLDivElement | null>(null);
let content = $state<HTMLDivElement | null>(null);
let loginError = $state<string | null>(null);
onMount(() => {
const unsub = cloudAuth.subscribe((s) => {
cloudState = s;
});
if (background && content) {
animate(
background,
{ opacity: [0, 1] },
{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }
);
animate(
content,
{ scale: [0.4, 1], opacity: [0, 1] },
{ type: "spring", stiffness: 400, damping: 30 }
);
}
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closePanel();
};
document.addEventListener("keydown", handleEscapeKey);
return () => {
unsub();
document.removeEventListener("keydown", handleEscapeKey);
};
});
async function closePanel() {
if (!background || !content) return;
animate(
content,
{ scale: [1, 0.4], opacity: [1, 0] },
{ type: "spring", stiffness: 400, damping: 30 }
);
animate(
background,
{ opacity: [1, 0] },
{ ease: [0.4, 0, 0.2, 1] }
);
await delay(400);
hidePanel();
}
function handleBackgroundClick(event: MouseEvent) {
if (event.target === background) closePanel();
}
async function handleSignIn() {
loginError = null;
const result = await cloudAuth.startLogin();
if (result.success) {
closePanel();
} else {
loginError = result.error ?? "Failed to open login page";
}
}
async function handleLogout() {
await cloudAuth.logout();
}
function getInitials(): string {
const u = cloudState.user;
if (!u) return "?";
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
if (u.username) return u.username.slice(0, 2).toUpperCase();
if (u.email) return u.email.slice(0, 2).toUpperCase();
return "?";
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={background}
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50"
onclick={handleBackgroundClick}
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }}
>
<div
bind:this={content}
class="p-5 w-[320px] bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
>
<h3 class="text-lg font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
<p class="mt-0.5 text-sm text-zinc-500 dark:text-zinc-400">Account & sync</p>
<div class="mt-4">
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()}
</div>
{/if}
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-zinc-900 dark:text-white truncate">
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</p>
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
<p class="text-xs text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
{/if}
</div>
</div>
<button
type="button"
onclick={handleLogout}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Sign out
</button>
</div>
{:else}
<div class="flex flex-col gap-3">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Sign in to sync settings across devices, use your cloud profile picture, and more.
</p>
<button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
{#if loginError}
<p class="text-xs text-red-600 dark:text-red-400">{loginError}</p>
{/if}
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,120 @@
<script lang="ts">
import browser from "webextension-polyfill";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import Button from "./Button.svelte";
import Switch from "./Switch.svelte";
let { showDisclaimer } = $props<{
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
}>();
let cloudState = $state(cloudAuth.state);
let busy = $state(false);
let statusMessage = $state<string | null>(null);
let statusError = $state<string | null>(null);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => {
cloudState = s;
});
return unsub;
});
async function upload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsUpload",
token,
})) as { success?: boolean; error?: string };
if (res?.success) {
statusMessage = "Settings uploaded.";
} else {
statusError = res?.error ?? "Upload failed";
}
} catch (e) {
statusError = e instanceof Error ? e.message : "Upload failed";
} finally {
busy = false;
}
}
function promptDownload() {
showDisclaimer(confirmDownload, () => {});
}
async function confirmDownload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsDownload",
token,
})) as { success?: boolean; error?: string; notFound?: boolean };
if (res?.success) {
statusMessage = "Settings restored.";
} else {
statusError = res?.error ?? "Download failed";
}
} catch (e) {
statusError = e instanceof Error ? e.message : "Download failed";
} finally {
busy = false;
}
}
</script>
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-2.5">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-[11px] font-semibold text-zinc-800 dark:text-zinc-100">Automatic sync</p>
<p class="text-[10px] text-zinc-500 dark:text-zinc-400">Syncs settings when SEQTA loads and when you make changes</p>
</div>
<div class="shrink-0">
<Switch
state={$settingsState.autoCloudSettingsSync !== false}
onChange={(isOn: boolean) => (settingsState.autoCloudSettingsSync = isOn)}
/>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button
text={busy ? "Please wait\u2026" : "Upload"}
onClick={upload}
disabled={busy}
/>
<Button
text={busy ? "Please wait\u2026" : "Download"}
onClick={promptDownload}
disabled={busy}
/>
</div>
{#if statusMessage}
<p class="text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
{/if}
{#if statusError}
<p class="text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
{/if}
<p class="text-[10px] text-zinc-400 dark:text-zinc-500">
Passwords and tokens are never synced.
<a
href="https://betterseqta.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 rounded-sm"
>Privacy policy</a>
</p>
</div>
{/if}
@@ -0,0 +1,172 @@
<script lang="ts">
import { fade } from "svelte/transition";
import browser from "webextension-polyfill";
import QRCode from "qrcode";
import { portal } from "../utils/portal";
let showQrModal = $state(false);
let qrDataUrl = $state<string | null>(null);
let appLink = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let isLoading = $state(false);
let isStandalone = $state(false);
function isExtensionPage(): boolean {
return (
window.location.protocol === "chrome-extension:" ||
window.location.protocol === "moz-extension:"
);
}
function isSeqtaUrl(url: string): boolean {
try {
const u = new URL(url);
return u.hostname.includes("seqta") || u.hostname.endsWith(".edu.au");
} catch {
return false;
}
}
function normalizeBaseUrl(url: string): string {
try {
const u = new URL(url);
return u.origin;
} catch {
return url;
}
}
async function getAppLink(): Promise<string | null> {
let baseUrl: string | undefined;
if (isExtensionPage()) {
baseUrl = undefined;
} else {
baseUrl = normalizeBaseUrl(window.location.href);
if (!isSeqtaUrl(baseUrl)) return null;
}
const { appLink: link } = (await browser.runtime.sendMessage({
type: "getSeqtaSession",
baseUrl,
})) as { appLink: string | null };
return link ?? null;
}
async function generateQrCode() {
errorMessage = null;
qrDataUrl = null;
isLoading = true;
try {
isStandalone = isExtensionPage();
const link = await getAppLink();
if (!link) {
if (isStandalone) {
errorMessage =
"Open SEQTA Learn in a tab and log in, then open settings from that tab to generate a QR code.";
} else {
errorMessage = "Please log in to SEQTA Learn first.";
}
return;
}
const dataUrl = await QRCode.toDataURL(link, { width: 256, margin: 2 });
appLink = link;
qrDataUrl = dataUrl;
showQrModal = true;
} catch (err) {
console.error("[ConnectMobileApp] Failed to generate QR:", err);
errorMessage = "Failed to generate QR code. Please try again.";
} finally {
isLoading = false;
}
}
function closeModal() {
showQrModal = false;
qrDataUrl = null;
appLink = null;
errorMessage = null;
}
function openAppLink() {
if (appLink) window.location.href = appLink;
}
function downloadQrImage() {
if (!qrDataUrl) return;
const link = document.createElement("a");
link.href = qrDataUrl;
link.download = "desqta-login-qr.png";
link.click();
}
</script>
<div class="flex flex-col gap-1 items-end">
<button
type="button"
onclick={generateQrCode}
disabled={isLoading}
class="px-5 py-1.5 text-[0.75rem] text-nowrap shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-opacity">
{isLoading ? "Generating..." : "Generate QR"}
</button>
{#if errorMessage}
<p class="text-xs text-right text-amber-600 dark:text-amber-400">{errorMessage}</p>
{/if}
</div>
{#if showQrModal && qrDataUrl}
<div
use:portal
class="fixed cursor-auto inset-0 z-[10000] flex justify-center items-center bg-black/50 {isStandalone ? 'backdrop-blur-sm' : ''}"
role="button"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget) closeModal();
}}
onkeydown={(e) => {
if (e.key === "Escape") closeModal();
}}
transition:fade={{ duration: 150 }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="p-6 mx-4 w-full max-w-sm bg-white rounded-2xl shadow-2xl dark:bg-zinc-800"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}>
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Scan with DesQTA</h2>
<button
type="button"
onclick={closeModal}
class="p-2 rounded-lg transition-colors text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 dark:hover:text-zinc-400 dark:hover:bg-zinc-700"
aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex justify-center p-4 bg-white rounded-xl dark:bg-zinc-900">
<img src={qrDataUrl} alt="SEQTA Learn app link QR code" class="w-64 h-64" />
</div>
<div class="flex flex-col gap-2 mt-4">
<button
type="button"
onclick={openAppLink}
class="px-4 py-2.5 w-full text-sm font-medium text-white bg-indigo-600 rounded-lg transition-colors dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
Sign into DesQTA Desktop
</button>
<button
type="button"
onclick={downloadQrImage}
class="px-4 py-2 w-full text-xs font-medium rounded-lg border transition-colors text-zinc-500 dark:text-zinc-400 border-zinc-200 dark:border-zinc-600 hover:bg-zinc-50 dark:hover:bg-zinc-800/50">
Download QR as image
</button>
</div>
<p class="mt-2 text-sm text-center text-zinc-600 dark:text-zinc-400">
Or scan this QR code with DesQTA on your phone.
</p>
</div>
</div>
{/if}
+54 -12
View File
@@ -8,12 +8,12 @@
let select: HTMLSelectElement; let select: HTMLSelectElement;
</script> </script>
<div class="border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 shadow-2xl rounded-xl w-full overflow-clip"> <div class="select-wrapper relative w-full overflow-hidden rounded-2xl border shadow-2xl">
<select <select
bind:this={select} bind:this={select}
value={state} value={state}
onchange={() => onChange(select.value)} onchange={() => onChange(select.value)}
class="px-4 py-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} {#each options as option}
<option value={option.value}> <option value={option.value}>
@@ -21,20 +21,62 @@
</option> </option>
{/each} {/each}
</select> </select>
<span class="select-icon pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd"></path>
</svg>
</span>
</div> </div>
<style> <style>
/* Make native dropdown list readable on Windows */ .select-wrapper {
select option { background: color-mix(in srgb, var(--background-primary) 88%, transparent);
background-color: #ffffff; border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 72%, transparent);
color: #111827; /* zinc-900 */ border-radius: 18px;
} color: var(--text-primary);
:global(.dark) select option { transition:
background-color: #1f2937; /* zinc-800 */ background-color 180ms ease,
color: #ffffff; border-color 180ms ease,
box-shadow 180ms ease,
transform 180ms ease;
} }
:global(.dark) div::after { .select-wrapper:hover {
color: rgba(255, 255, 255, 0.6); 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> </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); let percentage = $derived(((state - min) / (max - min)) * 100);
</script> </script>
<div class="relative mx-auto w-full max-w-lg"> <div class="relative w-full min-w-0">
<input <input
type="range" type="range"
min={min} min={min}
@@ -3,8 +3,7 @@
import './TabbedContainer.css'; import './TabbedContainer.css';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>(); let { tabs, activeTab = $bindable(0) } = $props<{ tabs: { title: string, Content: any, props?: any }[]; activeTab?: number }>();
let activeTab = $state(0);
let containerRef: HTMLElement | null = null; let containerRef: HTMLElement | null = null;
let tabWidth = $state(0); let tabWidth = $state(0);
@@ -0,0 +1,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,10 +1,13 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition'; 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 emblaCarouselSvelte from 'embla-carousel-svelte';
import Autoplay from 'embla-carousel-autoplay'; 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(); let emblaApi = $state();
const options = { loop: true }; const options = { loop: true };
@@ -12,8 +15,8 @@
Autoplay({ Autoplay({
delay: 5000, delay: 5000,
stopOnInteraction: false, stopOnInteraction: false,
stopOnMouseEnter: true stopOnMouseEnter: true,
}) }),
]; ];
function onInit(event: CustomEvent) { function onInit(event: CustomEvent) {
@@ -26,41 +29,77 @@
const slideNext = () => emblaApi?.scrollNext(); const slideNext = () => emblaApi?.scrollNext();
</script> </script>
{#if coverThemes.length > 0} {#if slides.length > 0}
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade> <div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
<div <div
class="w-full aspect-8/3" class="w-full aspect-[5/1] max-h-[500px]"
use:emblaCarouselSvelte={{ options, plugins }} use:emblaCarouselSvelte={{ options, plugins }}
onemblaInit={onInit} onemblaInit={onInit}
> >
<div class="flex"> <div class="flex">
{#each coverThemes as theme} {#each slides as slide (slide.imageUrl + slide.title + (slide.subtitle ?? ''))}
<div <div
class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip" class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip"
role="button" role="button"
tabindex="0" tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }} onkeydown={(e) => {
onclick={() => setDisplayTheme(theme)} 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" /> <img src={slide.imageUrl} alt="" class="object-cover w-full h-full" />
<div class='absolute bottom-0 left-0 p-8 z-[1]'> {#if slide.badgeFeatured === true}
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2> <div class="absolute top-4 left-4 z-[2] pointer-events-none">
<p class='text-lg text-white'>{theme.description}</p> <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>
<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> </div>
{/each} {/each}
</div> </div>
</div> </div>
<!-- Navigation buttons --> <div class="flex absolute right-2 bottom-2 z-10 gap-2">
<div class='flex absolute right-2 bottom-2 z-10 gap-2'> <button
<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'> 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"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
</svg> </svg>
</button> </button>
<button aria-label="Next" onclick={slideNext} class='flex 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"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg> </svg>
@@ -3,6 +3,7 @@
import logoDark from '@/resources/icons/betterseqta-light-full.png'; import logoDark from '@/resources/icons/betterseqta-light-full.png';
import { closeStore } from '@/seqta/ui/renderStore' import { closeStore } from '@/seqta/ui/renderStore'
import browser from 'webextension-polyfill'; import browser from 'webextension-polyfill';
import CloudHeader from './CloudHeader.svelte';
// Props // Props
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{ let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
@@ -39,6 +40,8 @@
> >
Backgrounds Backgrounds
</button> </button>
<CloudHeader />
</div> </div>
<div class="flex relative gap-2"> <div class="flex relative gap-2">
+192 -10
View File
@@ -1,19 +1,201 @@
<script lang="ts"> <script lang="ts">
import type { Theme } from '@/interface/types/Theme' import type { Theme } from '@/interface/types/Theme'
import {
let { theme, onClick } = $props<{ theme: Theme; onClick: () => void }>(); masterGridDisplayDownloadCount,
gridCardPreviewImageUrls,
} from '@/interface/utils/themeStoreFlavours'
import { fade } from 'svelte/transition'; 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> </script>
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={onClick}> <div
<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> class="relative z-0 hover:z-20 w-full cursor-pointer"
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white"> role="button"
{theme.name} 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>
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div> <div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
<div class='w-full'> {#if gridRotatorUrls.length === 0}
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" /> <div class="relative w-full h-48 overflow-hidden rounded-md bg-zinc-200 dark:bg-zinc-700" aria-hidden="true"></div>
</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>
</div> </div>
@@ -2,7 +2,24 @@
import type { Theme } from '@/interface/types/Theme' import type { Theme } from '@/interface/types/Theme'
import ThemeCard from './ThemeCard.svelte'; import ThemeCard from './ThemeCard.svelte';
let { themes, searchTerm, setDisplayTheme } = $props<{ themes: Theme[]; searchTerm: string, setDisplayTheme: (theme: Theme) => void }>(); let {
themes,
searchTerm,
setDisplayTheme,
toggleFavorite,
isLoggedIn,
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) => let filteredThemes = $derived(themes.filter((theme: Theme) =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase()) theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
@@ -12,12 +29,19 @@
<div class="relative" > <div class="relative" >
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
{#each filteredThemes as theme (theme.id)} {#each filteredThemes as theme (theme.id)}
<ThemeCard theme={theme} onClick={() => setDisplayTheme(theme)} /> <ThemeCard
{theme}
onClick={() => setDisplayTheme(theme)}
{toggleFavorite}
{isLoggedIn}
{onRequestSignIn}
{allStoreThemeRows}
/>
{/each} {/each}
{#if filteredThemes.length !== 0} {#if filteredThemes.length !== 0}
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='w-full cursor-pointer'> <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 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"> <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-2xl font-IconFamily">{'\uecb3'}</div>
<div class="text-xl font-bold text-center transition-all duration-500 dark:text-white"> <div class="text-xl font-bold text-center transition-all duration-500 dark:text-white">
Got a Theme Idea? Got a Theme Idea?
@@ -32,7 +56,7 @@
<div class="absolute top-0 flex flex-col items-center justify-center w-full text-center h-96"> <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> <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> <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! Show me how!
</a> </a>
</div> </div>
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,14 @@
<script lang="ts"> <script lang="ts">
import type { CustomTheme, ThemeList } from '@/types/CustomThemes' import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import browser from 'webextension-polyfill'
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator' import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { OpenStorePage } from '@/seqta/ui/renderStore' import { OpenStorePage } from '@/seqta/ui/renderStore'
import { themeUpdates } from '@/interface/hooks/ThemeUpdates' import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup' import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager' import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte'
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
@@ -13,6 +16,17 @@
let { isEditMode } = $props<{ isEditMode: boolean }>(); let { isEditMode } = $props<{ isEditMode: boolean }>();
let isDragging = $state(false); let isDragging = $state(false);
let tempTheme = $state(null); let tempTheme = $state(null);
let favoriteStatus = $state<Record<string, boolean>>({});
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
let prevLoggedIn = $state(false);
let showSignInModal = $state(false);
cloudAuth.subscribe((s) => {
const now = s.isLoggedIn;
if (now && !prevLoggedIn && themes) void fetchThemes();
prevLoggedIn = now;
cloudLoggedIn = now;
});
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => { const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
if (isEditMode) return; if (isEditMode) return;
@@ -71,7 +85,7 @@
try { try {
const result = JSON.parse(event.target?.result as string); const result = JSON.parse(event.target?.result as string);
tempTheme = result; tempTheme = result;
await themeManager.installTheme(result); await themeManager.installTheme(result, { fromStore: false });
await fetchThemes(); await fetchThemes();
} catch (error) { } catch (error) {
console.error('Error parsing file:', error); console.error('Error parsing file:', error);
@@ -87,11 +101,55 @@
themes: await themeManager.getAvailableThemes(), themes: await themeManager.getAvailableThemes(),
selectedTheme: themeManager.getSelectedThemeId() || '', selectedTheme: themeManager.getSelectedThemeId() || '',
} }
if (themes && cloudLoggedIn) {
const token = await cloudAuth.getStoredToken();
if (token) {
const status: Record<string, boolean> = {};
await Promise.all(
themes.themes.map(async (t) => {
try {
const res = (await browser.runtime.sendMessage({
type: 'fetchThemeDetails',
themeId: t.id,
token,
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
if (res?.success && res?.data?.theme) {
status[t.id] = !!res.data.theme.is_favorited;
}
} catch {
// Theme may not exist on store (e.g. locally created)
}
})
);
favoriteStatus = status;
}
} else {
favoriteStatus = {};
}
}
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
e.stopPropagation();
if (!cloudLoggedIn) {
showSignInModal = true;
return;
}
const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !favoriteStatus[theme.id];
const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite',
themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean };
if (result?.success) {
favoriteStatus = { ...favoriteStatus, [theme.id]: isFavorite };
}
} }
onMount(async () => { onMount(async () => {
await fetchThemes(); await fetchThemes();
themeUpdates.addListener(fetchThemes); themeUpdates.addListener(fetchThemes);
}) })
@@ -144,6 +202,18 @@
{/if} {/if}
{#if !isEditMode} {#if !isEditMode}
<div
class="flex absolute right-24 top-1/4 z-20 place-items-center p-2 w-8 h-8 text-center rounded-full opacity-0 transition-all -translate-y-1/2 group-hover:opacity-100 group-hover:top-1/2 {(favoriteStatus[theme.id] ?? false) ? 'text-red-400' : 'text-white/80'} bg-black/50"
onclick={(event) => handleToggleFavorite(theme, event)}
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') handleToggleFavorite(theme, event as any) }}
role="button"
tabindex="-1"
title={cloudLoggedIn ? ((favoriteStatus[theme.id] ?? false) ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={(favoriteStatus[theme.id] ?? false) ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</div>
<div <div
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2" class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }} onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
@@ -211,3 +281,7 @@
</button> </button>
</div> </div>
</div> </div>
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if}
+29 -14
View File
@@ -15,12 +15,16 @@
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup"; //import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
import ColourPicker from "../components/ColourPicker.svelte"; import ColourPicker from "../components/ColourPicker.svelte";
import CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte"; import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "../hooks/SettingsPopup"; import { settingsPopup } from "../hooks/SettingsPopup";
let devModeSequence = ""; let devModeSequence = "";
let settingsActiveTab = $state(0);
let showDisclaimerModal = $state(false); let showDisclaimerModal = $state(false);
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null); let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
let disclaimerTitle = $state("Confirm");
let disclaimerMessage = $state("");
const handleDevModeToggle = () => { const handleDevModeToggle = () => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -65,19 +69,28 @@
let { standalone } = $props<{ standalone?: boolean }>(); let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state<boolean>(false); let showColourPicker = $state<boolean>(false);
let showCloudPanel = $state<boolean>(false);
const showDisclaimer = (onConfirm: () => void, onCancel: () => void) => { const openCloudPanel = () => {
showCloudPanel = true;
};
const showDisclaimer = (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => {
disclaimerCallbacks = { onConfirm, onCancel }; disclaimerCallbacks = { onConfirm, onCancel };
disclaimerTitle = title ?? "Confirm";
disclaimerMessage = message ?? "";
showDisclaimerModal = true; showDisclaimerModal = true;
}; };
onMount(async () => { onMount(() => {
settingsPopup.addListener(() => { settingsPopup.addListener(() => {
showColourPicker = false; showColourPicker = false;
showCloudPanel = false;
}); });
if (!standalone) return; if (standalone) {
StandaloneStore.setStandalone(true); StandaloneStore.setStandalone(true);
}
}); });
</script> </script>
@@ -275,11 +288,12 @@
</div> </div>
<TabbedContainer <TabbedContainer
bind:activeTab={settingsActiveTab}
tabs={[ tabs={[
{ {
title: "Settings", title: "Settings",
Content: Settings, Content: Settings,
props: { showColourPicker: openColourPicker, showDisclaimer }, props: { showColourPicker: openColourPicker, showDisclaimer, showCloudPanel: openCloudPanel },
}, },
{ title: "Shortcuts", Content: Shortcuts }, { title: "Shortcuts", Content: Shortcuts },
{ title: "Themes", Content: Theme }, { title: "Themes", Content: Theme },
@@ -294,19 +308,20 @@
}} }}
/> />
{/if} {/if}
{#if showCloudPanel}
<CloudPanel
hidePanel={() => {
showCloudPanel = false;
}}
/>
{/if}
</div> </div>
{#if showDisclaimerModal && disclaimerCallbacks} {#if showDisclaimerModal && disclaimerCallbacks}
<DisclaimerModal <DisclaimerModal
title="Assessment Averages Disclaimer" title={disclaimerTitle}
message="This feature calculates a simple average of your assessment grades. It does not take into account: message={disclaimerMessage}
• Assessment weightings
• Different grading scales
• Other factors used in official reports
The displayed average may be inaccurate compared to your actual marks found in reports.
Do you want to enable this feature?"
onConfirm={() => { onConfirm={() => {
disclaimerCallbacks?.onConfirm(); disclaimerCallbacks?.onConfirm();
showDisclaimerModal = false; showDisclaimerModal = false;
+165 -50
View File
@@ -10,8 +10,13 @@
import type { SettingsList } from "@/interface/types/SettingsProps" import type { SettingsList } from "@/interface/types/SettingsProps"
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts" import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import PickerSwatch from "@/interface/components/PickerSwatch.svelte" import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
import 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 { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup" import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
import { getAllPluginSettings } from "@/plugins" import { getAllPluginSettings } from "@/plugins"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types" import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
@@ -50,6 +55,12 @@
const pluginSettings = getAllPluginSettings() as Plugin[]; const pluginSettings = getAllPluginSettings() as Plugin[];
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({}); 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() { async function loadPluginSettings() {
for (const plugin of pluginSettings) { for (const plugin of pluginSettings) {
@@ -92,10 +103,24 @@
loadPluginSettings(); loadPluginSettings();
}) })
const { showColourPicker, showDisclaimer } = $props<{ const { showColourPicker, showDisclaimer, showCloudPanel } = $props<{
showColourPicker: () => void; showColourPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void; showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void;
showCloudPanel: () => void;
}>(); }>();
async function exportCloudSettingsJsonToFile() {
const payload = await getSnapshotForUpload();
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `betterseqta-plus-settings-export-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
a.click();
URL.revokeObjectURL(url);
}
</script> </script>
{#snippet Setting({ title, description, Component, props }: SettingsList) } {#snippet Setting({ title, description, Component, props }: SettingsList) }
@@ -113,27 +138,15 @@
<div class="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700"> <div class="flex flex-col divide-y divide-zinc-100 dark:divide-zinc-700">
{#each [ {#each [
{ {
title: "Transparency Effects", title: "Connect Mobile App",
description: "Enables transparency effects on certain elements such as blur. (May impact battery life)", description: "Link your SEQTA session to DesQTA — the modern desktop and mobile app for SEQTA Learn",
id: 1, id: 0,
Component: Switch, Component: ConnectMobileApp,
props: { props: {}
state: $settingsState.transparencyEffects,
onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
}
},
{
title: "Custom Theme Colour",
description: "Customise the overall theme colour of SEQTA Learn.",
id: 4,
Component: PickerSwatch,
props: {
onClick: showColourPicker
}
}, },
{ {
title: "Edit Sidebar Layout", title: "Edit Sidebar Layout",
description: "Customise the sidebar layout.", description: "Reorder pages on the sidebar",
id: 5, id: 5,
Component: Button, Component: Button,
props: { props: {
@@ -141,9 +154,28 @@
text: "Edit" text: "Edit"
} }
}, },
{
title: "Custom Theme Colour",
description: "Customise the overall theme colour of SEQTA Learn",
id: 4,
Component: PickerSwatch,
props: {
onClick: showColourPicker
}
},
{
title: "Icon Only Sidebar",
description: "Show only icons in the sidebar for a compact layout",
id: 14,
Component: Switch,
props: {
state: $settingsState.iconOnlySidebar ?? false,
onChange: (isOn: boolean) => settingsState.iconOnlySidebar = isOn
}
},
{ {
title: "Animations", title: "Animations",
description: "Enables animations on certain pages.", description: "Enable animations on certain pages",
id: 6, id: 6,
Component: Switch, Component: Switch,
props: { props: {
@@ -162,27 +194,37 @@
} }
}, },
{ {
title: "Default Page", title: "Transparency Effects",
description: "The page to load when SEQTA Learn is opened.", description: "Enable transparency effects on certain elements, such as blur (May impact battery life)",
id: 10, id: 1,
Component: Select, Component: Switch,
props: { props: {
state: $settingsState.defaultPage, state: $settingsState.transparencyEffects,
onChange: (value: string) => settingsState.defaultPage = value, onChange: (isOn: boolean) => settingsState.transparencyEffects = isOn
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: "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", title: "News Feed Source",
description: "Choose sources of your news feed.", description: "Choose the sources for your news feed",
id: 11, id: 11,
Component: Select, Component: Select,
props: { props: {
@@ -206,7 +248,50 @@
] as option} ] as option}
{@render Setting(option)} {@render Setting(option)}
{/each} {/each}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Adaptive Theme Colour</h2>
<p class="text-xs">Change the theme colour based on the current class (e.g. when viewing a course or assessments page)</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeColour ?? false}
onChange={(isOn: boolean) => settingsState.adaptiveThemeColour = isOn}
/>
</div>
</div>
{#if $settingsState.adaptiveThemeColour}
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
<div class="pr-4">
<h2 class="text-sm font-bold">Soft Gradient</h2>
<p class="text-xs">Use a soft gradient instead of a solid colour when viewing a class</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeGradient ?? false}
onChange={(isOn: boolean) => settingsState.adaptiveThemeGradient = isOn}
/>
</div>
</div>
<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} {#each pluginSettings as plugin}
<div class="border-none"> <div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40 {!(plugin as any).disableToggle && Object.keys(plugin.settings).length === 0 ? 'hidden' : ''}"> <div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40 {!(plugin as any).disableToggle && Object.keys(plugin.settings).length === 0 ? 'hidden' : ''}">
@@ -233,9 +318,9 @@
async () => { async () => {
await updatePluginSetting(plugin.pluginId, 'enabled', true); 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; return;
} }
@@ -248,8 +333,8 @@
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)} {#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]} {#each Object.entries(plugin.settings) as [key, setting]}
<!-- Skip the 'enabled' setting if it's part of the settings object --> <!-- Skip the 'enabled' setting and hide cloud-only settings when not signed in -->
{#if key !== 'enabled'} {#if key !== 'enabled' && !(key === 'useCloudPfp' && !cloudState.isLoggedIn)}
<div class="flex justify-between items-center px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
<div class="pr-4"> <div class="pr-4">
<h2 class="text-sm font-bold">{setting.title || key}</h2> <h2 class="text-sm font-bold">{setting.title || key}</h2>
@@ -262,13 +347,15 @@
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)} onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/> />
{:else if setting.type === 'number'} {:else if setting.type === 'number'}
<Slider <div class="w-28 shrink-0">
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default} <Slider
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)} state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
min={setting.min} onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
max={setting.max} min={setting.min}
step={setting.step} max={setting.max}
/> step={setting.step}
/>
</div>
{:else if setting.type === 'string'} {:else if setting.type === 'string'}
<input <input
type="text" type="text"
@@ -310,6 +397,25 @@
</div> </div>
{/each} {/each}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">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> <div class="p-1 border-none"></div>
{@render Setting({ {@render Setting({
@@ -377,6 +483,15 @@
/> />
</div> </div>
</div> </div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">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> </div>
{/if} {/if}
</div> </div>
+133 -33
View File
@@ -7,6 +7,7 @@
import SkeletonLoader from '../components/SkeletonLoader.svelte'; import SkeletonLoader from '../components/SkeletonLoader.svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState' import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import type { Theme } from '../types/Theme' import type { Theme } from '../types/Theme'
import { visibleStoreThemes, buildCoverSlidesForThemes } from '@/interface/utils/themeStoreFlavours'
import browser from 'webextension-polyfill' import browser from 'webextension-polyfill'
import ThemeModal from '../components/store/ThemeModal.svelte' import ThemeModal from '../components/store/ThemeModal.svelte'
import Header from '../components/store/Header.svelte' import Header from '../components/store/Header.svelte'
@@ -15,13 +16,23 @@
import { loadBackground } from '@/seqta/ui/ImageBackgrounds' import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
import Backgrounds from '../components/store/Backgrounds.svelte' import Backgrounds from '../components/store/Backgrounds.svelte'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
// State variables // State variables
let searchTerm = $state(''); let searchTerm = $state('');
let themes = $state<Theme[]>([]); 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 loading = $state(true);
let darkMode = $state(false); let darkMode = $state(false);
let displayTheme = $state<Theme | null>(null); let displayTheme = $state<Theme | null>(null);
@@ -30,6 +41,7 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let selectedBackground = $state<string | null>(null); let selectedBackground = $state<string | null>(null);
let showSignInOverlay = $state(false);
const fetchCurrentThemes = async () => { const fetchCurrentThemes = async () => {
const themes = await themeManager.getAvailableThemes(); const themes = await themeManager.getAvailableThemes();
@@ -48,20 +60,64 @@
activeTab = tab; activeTab = tab;
}; };
// Fetch themes and initialize app /** Featured themes first; within each group, newest by `created_at` (API: Unix seconds). */
function compareStoreThemes(a: Theme, b: Theme): number {
const fa = a.featured === true ? 1 : 0;
const fb = b.featured === true ? 1 : 0;
if (fa !== fb) return fb - fa;
const ca = a.created_at ?? 0;
const cb = b.created_at ?? 0;
if (ca !== cb) return cb - ca;
return a.name.localeCompare(b.name);
}
const toggleFavorite = async (theme: Theme) => {
const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !theme.is_favorited;
const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite',
themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean };
if (result?.success) {
const delta = isFavorite ? 1 : -1;
themes = themes.map((t) =>
t.id === theme.id
? { ...t, is_favorited: isFavorite, favorite_count: Math.max(0, (t.favorite_count ?? 0) + delta) }
: t
);
if (displayTheme?.id === theme.id) {
displayTheme = {
...displayTheme,
is_favorited: isFavorite,
favorite_count: Math.max(0, (displayTheme.favorite_count ?? 0) + delta),
};
}
}
};
// Fetch themes via background script (avoids CORS when store runs inside SEQTA page)
const fetchThemes = async () => { const fetchThemes = async () => {
try { try {
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${(new Date()).getTime()}`, { cache: 'no-store' }); const token = await cloudAuth.getStoredToken();
const data = await response.json(); const data = (await browser.runtime.sendMessage({
themes = data.themes; type: 'fetchThemes',
token: token ?? undefined,
// Shuffle for cover themes })) as {
const shuffled = [...themes].sort(() => 0.5 - Math.random()); success?: boolean;
coverThemes = shuffled.slice(0, 3); data?: { themes: Theme[] };
error?: string;
};
if (!data?.success || !data?.data?.themes) {
throw new Error(data?.error || 'Failed to fetch themes');
}
themes = [...data.data.themes].sort(compareStoreThemes);
loading = false; loading = false;
} catch (error) { } catch (err) {
console.error('Failed to fetch themes', error); console.error('Failed to fetch themes', err);
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs
} }
}; };
@@ -75,11 +131,37 @@
darkMode = $settingsState.DarkMode; darkMode = $settingsState.DarkMode;
}); });
// Filter themes based on search term // Filter themes (list is already featured-first, then newest; filter preserves order)
let filteredThemes = $derived(themes.filter(theme => let filteredThemes = $derived(
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || listThemes.filter(
theme.description.toLowerCase().includes(searchTerm.toLowerCase()) (theme) =>
)); theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
theme.description.toLowerCase().includes(searchTerm.toLowerCase()),
),
);
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(() => { $effect(() => {
loadBackground(); loadBackground();
@@ -91,6 +173,17 @@
console.error(error); console.error(error);
} }
}); });
// Refetch themes when user logs in (from another tab) to get is_favorited
let lastLoggedIn = $state(false);
$effect(() => {
if (cloudLoggedIn && !lastLoggedIn) {
lastLoggedIn = true;
fetchThemes();
} else if (!cloudLoggedIn) {
lastLoggedIn = false;
}
});
</script> </script>
<div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}"> <div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}">
@@ -107,34 +200,37 @@
<!-- Themes Tab Content --> <!-- Themes Tab Content -->
{#if activeTab === 'themes'} {#if activeTab === 'themes'}
{#if searchTerm === ''} {#if searchTerm === ''}
<CoverSwiper {coverThemes} {setDisplayTheme} /> <CoverSwiper slides={coverSlides} {setDisplayTheme} />
{/if} {/if}
<!-- ThemeGrid to display filtered themes --> <!-- 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} {#if displayTheme}
<ThemeModal <ThemeModal
currentThemes={currentThemes} currentThemes={currentThemes}
allThemes={themes} allThemes={listThemes}
allStoreThemeRows={themes}
theme={displayTheme} theme={displayTheme}
{displayTheme} {displayTheme}
{setDisplayTheme} {setDisplayTheme}
onInstall={async () => { {toggleFavorite}
if (displayTheme) { isLoggedIn={cloudLoggedIn}
await themeManager.downloadTheme(displayTheme); onRequestSignIn={() => (showSignInOverlay = true)}
await themeManager.setTheme(displayTheme.id); onInstall={async (themeId: string) => {
themeUpdates.triggerUpdate(); if (displayTheme) await installThemeFromStore(themeId, displayTheme);
await fetchCurrentThemes();
}
}} }}
onRemove={async () => { onRemove={async (themeId: string) => {
if (displayTheme?.id) { console.debug('deleting theme', themeId);
console.debug('deleting theme', displayTheme.id); await removeThemeFromStore(themeId);
await themeManager.deleteTheme(displayTheme.id);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
}} }}
/> />
{/if} {/if}
@@ -144,4 +240,8 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if showSignInOverlay}
<SignInToFavoriteModal onClose={() => (showSignInOverlay = false)} />
{/if}
</div> </div>
+57 -10
View File
@@ -4,7 +4,10 @@
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { fade } 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' import { settingsState } from '@/seqta/utils/listeners/SettingsState'
@@ -21,9 +24,9 @@
handleImageVariableChange, handleImageVariableChange,
handleCoverImageUpload handleCoverImageUpload
} from '../utils/themeImageHandlers'; } 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 { 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 { themeID } = $props<{ themeID: string }>()
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
@@ -40,7 +43,9 @@
coverImage: null, coverImage: null,
isEditable: true, isEditable: true,
hideThemeName: false, hideThemeName: false,
forceDark: undefined forceTheme: undefined,
forceDark: undefined,
adaptiveCssVariables: [],
}) })
let closedAccordions = $state<string[]>([]) let closedAccordions = $state<string[]>([])
let themeLoaded = $state(false); 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 themeLoaded = true
} else { } else {
themeLoaded = true themeLoaded = true
@@ -114,6 +125,14 @@
blob: image.blob blob: image.blob
})) }))
themeClone.coverImage = theme.coverImage themeClone.coverImage = theme.coverImage
themeClone.userEdited = true
if (shouldForceThemeAppearance(themeClone)) {
themeClone.forceTheme = true;
} else {
themeClone.forceTheme = false;
themeClone.forceDark = undefined;
}
themeManager.clearPreview(); themeManager.clearPreview();
await themeManager.saveTheme(themeClone); await themeManager.saveTheme(themeClone);
@@ -270,7 +289,7 @@
<h1 class='text-xl font-semibold'>Theme Creator</h1> <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='pr-0.5 no-underline font-IconFamily'>{'\ueb44'}</span>
<span class='underline'> <span class='underline'>
Need help? Check out the docs! Need help? Check out the docs!
@@ -317,6 +336,27 @@
<Divider /> <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 [ {#each [
{ {
type: 'switch', type: 'switch',
@@ -332,21 +372,28 @@
title: 'Force Theme', title: 'Force Theme',
description: 'Force users to use either dark or light mode', description: 'Force users to use either dark or light mode',
props: { props: {
state: theme.forceDark !== undefined, state: shouldForceThemeAppearance(theme),
onChange: (value: boolean) => theme = { ...theme, forceDark: value ? false : undefined } onChange: (value: boolean) => {
if (value) {
theme = { ...theme, forceTheme: true, forceDark: false };
} else {
theme = { ...theme, forceTheme: false, forceDark: undefined };
}
}
} }
}, },
{ {
type: 'conditional', type: 'conditional',
props: { props: {
condition: theme.forceDark !== undefined, condition: shouldForceThemeAppearance(theme),
children: { children: {
type: 'lightDarkToggle', type: 'lightDarkToggle',
title: 'Mode', title: 'Mode',
description: 'Choose whether to force light or dark mode', description: 'Choose whether to force light or dark mode',
props: { props: {
state: theme.forceDark === true, 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 = { export type Theme = {
id: string;
name: string; name: string;
description: string; description: string;
coverImage: string; coverImage: string;
marqueeImage: string; marqueeImage?: string;
id: string; theme_json_url?: string;
is_favorited?: boolean;
favorite_count?: number;
download_count?: number;
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;
}; };
+22
View File
@@ -0,0 +1,22 @@
import type { Action } from "svelte/action";
/**
* Svelte action that moves the element to a different DOM target.
* Defaults to the nearest ShadowRoot so styles remain intact when the app
* is rendered inside a shadow DOM. Falls back to document.body otherwise.
* Keeps all Svelte reactivity/events intact while escaping ancestor stacking contexts.
*/
export const portal: Action<HTMLElement, HTMLElement | ShadowRoot | undefined> = (node, target) => {
const root = node.getRootNode();
const dest = target ?? (root instanceof ShadowRoot ? root : document.body);
dest.appendChild(node);
return {
update(newTarget) {
(newTarget ?? dest).appendChild(node);
},
destroy() {
node.remove();
},
};
};
+165
View File
@@ -0,0 +1,165 @@
import type { Theme, ThemeCoverSlide, ThemeFlavour } from "@/interface/types/Theme";
export function isHiddenStoreTheme(theme: Theme): boolean {
return theme.theme_role === "slave";
}
/** Grid and search: omit slave rows (when API sends a flattened list). */
export function visibleStoreThemes(themes: Theme[]): Theme[] {
return themes.filter((t) => !isHiddenStoreTheme(t));
}
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"], "permissions": ["tabs", "notifications", "storage"],
"host_permissions": ["https://newsapi.org/", "*://*/*"], "host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
"background": { "background": {
"service_worker": "background.ts" "service_worker": "background.ts"
}, },
"content_security_policy": { "content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'" "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org"
}, },
"content_scripts": [ "content_scripts": [
{ {
@@ -32,7 +32,12 @@
], ],
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": ["resources/icons/*", "resources/update-image.webp"], "resources": [
"resources/icons/*",
"resources/update-image.webp",
"resources/pdfjs/pdf.worker.min.mjs",
"resources/pdfjs/pdf.legacy.min.mjs"
],
"matches": ["*://*/*"] "matches": ["*://*/*"]
} }
] ]
@@ -6,6 +6,7 @@ import {
Setting, Setting,
} from "@/plugins/core/settingsHelpers"; } from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import { waitForElm } from "@/seqta/utils/waitForElm";
const settings = defineSettings({ const settings = defineSettings({
speed: numberSetting({ speed: numberSetting({
@@ -35,13 +36,10 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
settings: instance.settings, settings: instance.settings,
run: async (api) => { run: async (api) => {
// Create the background elements const [container, menu] = await Promise.all([
const container = document.getElementById("container"); waitForElm("#container", true),
const menu = document.getElementById("menu"); waitForElm("#menu", true),
]);
if (!container || !menu) {
return () => {};
}
const backgrounds = [ const backgrounds = [
{ classes: ["bg"] }, { classes: ["bg"] },
+174 -121
View File
@@ -7,11 +7,11 @@ import {
import { type Plugin } from "@/plugins/core/types"; import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML"; import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import { import {
clearStuck, clearStuck,
getClassByPattern, getClassByPattern,
initStorage, initStorage,
injectWeightingsTab,
letterToNumber, letterToNumber,
parseAssessments, parseAssessments,
processAssessments, processAssessments,
@@ -20,6 +20,7 @@ import {
interface weightingsStorage { interface weightingsStorage {
weightings: Record<string, string>; weightings: Record<string, string>;
assessments: Record<string, string>; assessments: Record<string, string>;
weightingOverrides: Record<string, string>;
} }
const settings = defineSettings({ const settings = defineSettings({
@@ -37,6 +38,8 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
const instance = new AssessmentsAveragePluginClass(); const instance = new AssessmentsAveragePluginClass();
let overrideListenerController: AbortController | null = null;
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = { const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
id: "assessments-average", id: "assessments-average",
name: "Assessment Averages", name: "Assessment Averages",
@@ -58,130 +61,180 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
); );
await parseAssessments(api); await parseAssessments(api);
await renderSubjectAverage(api);
const sampleAssessmentItem = document.querySelector( overrideListenerController?.abort();
"[class*='AssessmentItem__AssessmentItem___']", overrideListenerController = new AbortController();
document.addEventListener(
"betterseqta:overrideChanged",
() => renderSubjectAverage(api),
{ signal: overrideListenerController.signal },
); );
if (!sampleAssessmentItem) return; const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
const assessmentItemClass = const observer = new MutationObserver(() => {
Array.from(sampleAssessmentItem.classList).find((c) => applySubjectColourToOverallResult();
c.startsWith("AssessmentItem__AssessmentItem___"), });
) || ""; observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
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>
`;
} }
});
assessmentsList.insertBefore( api.seqta.onMount("[class*='SelectedAssessment__']", () => {
stringToHTML(/* html */ ` injectWeightingsTab(api);
<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,
);
}); });
}, },
}; };
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; export default assessmentsAveragePlugin;
+374 -48
View File
@@ -1,8 +1,12 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts"; import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
import ReactFiber from "@/seqta/utils/ReactFiber.ts"; import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import {
ensurePdfjsWorker,
getPdfjsPageContextUrls,
} from "@/lib/pdfjsExtension.ts";
import * as pdfjs from "pdfjs-dist"; 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) { export async function initStorage(api: any) {
await api.storage.loaded; await api.storage.loaded;
@@ -13,6 +17,9 @@ export async function initStorage(api: any) {
if (!api.storage.assessments) { if (!api.storage.assessments) {
api.storage.assessments = {}; api.storage.assessments = {};
} }
if (!api.storage.weightingOverrides) {
api.storage.weightingOverrides = {};
}
} }
export function clearStuck(api: any) { export function clearStuck(api: any) {
@@ -75,47 +82,84 @@ function createWeightLabel(
assessmentItem: Element, assessmentItem: Element,
weighting: string | undefined, weighting: string | undefined,
) { ) {
const statsContainer = assessmentItem.querySelector( let statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___']`, `[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
) as HTMLElement; ) as HTMLElement | null;
if ( if (!statsContainer) {
!statsContainer || const statsClass = getClassByPattern(document, "AssessmentItem__stats___");
statsContainer.querySelector(".betterseqta-weight-label") statsContainer = document.createElement("div");
) statsContainer.className = statsClass;
return; statsContainer.classList.add("betterseqta-stats-container");
const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`);
if (thermoscore) {
thermoscore.insertAdjacentElement("afterend", statsContainer);
} else {
assessmentItem.appendChild(statsContainer);
}
}
const label = statsContainer.querySelector( const hasNativeLabel = !!statsContainer.querySelector(
`[class*='Label__Label___']`, `[class*='Label__Label___']:not(.betterseqta-weight-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___']`,
); );
statsContainer.style.justifyContent = hasNativeLabel
? "space-between"
: "flex-end";
const displayText =
weighting && weighting !== "processing" && weighting !== "N/A"
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
: "N/A";
const existingLabel = statsContainer.querySelector(
".betterseqta-weight-label",
) as HTMLElement | null;
if (existingLabel) {
const textNodes = Array.from(existingLabel.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE,
);
if (textNodes.length) textNodes[0].textContent = displayText;
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"; if (innerTextDiv) innerTextDiv.textContent = "Weight";
const textNodes = Array.from(weightLabel.childNodes).filter( const textNodes = Array.from(weightLabel.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE, (node) => node.nodeType === Node.TEXT_NODE,
); );
if (textNodes.length) { if (textNodes.length) {
textNodes[0].textContent = textNodes[0].textContent = displayText;
weighting && weighting !== "processing" } else {
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%` weightLabel.appendChild(document.createTextNode(displayText));
: "N/A";
} }
statsContainer.style.position = "relative";
weightLabel.style.position = "absolute";
weightLabel.style.right = "0";
weightLabel.style.top = "50%";
weightLabel.style.transform = "translateY(-50%)";
statsContainer.appendChild(weightLabel); statsContainer.appendChild(weightLabel);
} }
@@ -219,6 +263,13 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
export async function extractPDFText(url: string): Promise<string> { export async function extractPDFText(url: string): Promise<string> {
try { try {
if (isFirefox) { 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) => { return new Promise((resolve, reject) => {
const script = document.createElement("script"); const script = document.createElement("script");
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`; const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
@@ -232,13 +283,15 @@ export async function extractPDFText(url: string): Promise<string> {
(function() { (function() {
const requestId = '${requestId}'; const requestId = '${requestId}';
const url = '${escapedUrl}'; const url = '${escapedUrl}';
const pdfLibSrc = '${pdfLibInj}';
const pdfWorkerSrc = '${pdfWorkerInj}';
if (window.pdfjsLib) { if (window.pdfjsLib) {
extractPDF(); extractPDF();
} else { } else {
const pdfjsScript = document.createElement('script'); const pdfjsScript = document.createElement('script');
pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.min.js'; pdfjsScript.src = pdfLibSrc;
pdfjsScript.type = 'text/javascript'; pdfjsScript.type = 'module';
pdfjsScript.onload = function() { pdfjsScript.onload = function() {
extractPDF(); extractPDF();
@@ -256,7 +309,7 @@ export async function extractPDFText(url: string): Promise<string> {
function extractPDF() { function extractPDF() {
try { try {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = ''; window.pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('GET', url, true); xhr.open('GET', url, true);
@@ -502,7 +555,11 @@ export async function parseAssessments(api: any) {
"[class*='AssessmentList__items___']", "[class*='AssessmentList__items___']",
).getState(); ).getState();
const marks = state["marks"]; const marks = [
...(state["marks"] ?? []),
...(state["upcoming"] ?? []),
...(state["pending"] ?? []),
];
if (!marks) return; if (!marks) return;
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api))); await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
@@ -515,15 +572,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
let count = 0; let count = 0;
for (const assessmentItem of assessmentItems) { 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( const titleEl = assessmentItem.querySelector(
`[class*='AssessmentItem__title___']`, `[class*='AssessmentItem__title___']`,
); );
@@ -533,12 +581,23 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
if (!title) continue; if (!title) continue;
const assessmentID = api.storage.assessments?.[title]; const assessmentID = api.storage.assessments?.[title];
const weighting = assessmentID const autoWeighting = assessmentID
? api.storage.weightings?.[assessmentID] ? api.storage.weightings?.[assessmentID]
: undefined; : undefined;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
const weighting = override ?? autoWeighting;
createWeightLabel(assessmentItem, weighting); createWeightLabel(assessmentItem, weighting);
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
if ( if (
weighting === null || weighting === null ||
weighting === undefined || weighting === undefined ||
@@ -546,8 +605,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
weighting === "processing" weighting === "processing"
) { ) {
hasInaccurateWeighting = true; hasInaccurateWeighting = true;
weightedTotal += grade; continue
totalWeight += 1;
} else { } else {
const weight = parseFloat(weighting); const weight = parseFloat(weighting);
@@ -570,3 +628,271 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
count, 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">Override %</label>
<input
id="betterseqta-weight-override"
type="number"
min="0"
step="5"
placeholder="${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 { [assessmentID]: _, ...rest } = api.storage.weightingOverrides;
api.storage.weightingOverrides = rest;
} else {
const val = parseFloat(raw);
if (isNaN(val) || val < 0) {
input.style.borderColor = "rgba(255,80,80,0.6)";
statusEl.textContent = "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)";
api.storage.weightingOverrides = {
...api.storage.weightingOverrides,
[assessmentID]: String(val),
};
}
statusEl.textContent = "Saved";
statusEl.style.color = "";
setTimeout(() => (statusEl.textContent = ""), 2000);
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
};
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);
let populated = false;
newTab.addEventListener("click", () => {
if (!populated) {
buildWeightingsTabContent(api, newSheet);
populated = true;
}
});
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");
});
});
}
@@ -7,9 +7,11 @@
interface FilterOptions { interface FilterOptions {
subject: string; subject: string;
sortBy: "due" | "grade" | "subject" | "title"; sortBy: "due" | "grade" | "subject" | "title" | "year";
} }
const HIDDEN_ASSESSMENTS_KEY = "betterseqta-hidden-assessments";
function percentageToLetter(percentage: number): string { function percentageToLetter(percentage: number): string {
const letterMap: Record<number, string> = { const letterMap: Record<number, string> = {
100: "A+", 100: "A+",
@@ -41,48 +43,108 @@
let filteredAssessments: any[] = []; let filteredAssessments: any[] = [];
let statusGroups: Record<string, any[]> = {}; let statusGroups: Record<string, any[]> = {};
let columns: { key: string; title: string; className: string; icon: string }[] = [];
function getAssessmentYear(a: any): number {
const dateStr = a.due || a.date || a.dueDate || a.created;
return dateStr ? new Date(dateStr).getFullYear() : 0;
}
function getAssessmentType(a: any): string {
return (a.type || a.assessmentType || a.taskType || "Other").toString();
}
function getAssessmentGrade(a: any): string {
const val = getGradeValue(a);
if (val === null) return "No grade";
return percentageToLetter(val);
}
function getGroupKey(assessment: any): string {
switch (currentFilters.sortBy) {
case "due":
return determineStatus(assessment);
case "year":
return String(getAssessmentYear(assessment) || "Unknown");
case "subject":
return assessment.code || "Unknown";
case "grade":
return getAssessmentGrade(assessment);
case "title":
const first = (assessment.title || "?")[0].toUpperCase();
return /[A-Z0-9]/.test(first) ? first : "#";
default:
return determineStatus(assessment);
}
}
function sortCompare(a: any, b: any): number {
return new Date(a.due || a.date || 0).getTime() - new Date(b.due || b.date || 0).getTime();
}
const STATUS_COLUMNS = [
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "📅" },
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "⏰" },
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "🚨" },
{ key: "SUBMITTED", title: "Submitted", className: "column-submitted", icon: "📝" },
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "✅" },
];
function buildGroupsAndColumns() {
if (!data?.assessments) return { filteredAssessments: [], statusGroups: {}, columns: [] };
const subjectFilters = settingsState.subjectfilters || {};
const hiddenAssessmentIds = new Set(
(JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]")).map(String)
);
const filtered = data.assessments.filter((a: any) => {
if (hiddenAssessmentIds.has(String(a.id))) return false;
if (subjectFilters[a.code] === false) return false;
return currentFilters.subject === "all" || a.code === currentFilters.subject;
});
const groups: Record<string, any[]> = {};
filtered.forEach((assessment) => {
const key = getGroupKey(assessment);
if (!groups[key]) groups[key] = [];
groups[key].push(assessment);
});
Object.keys(groups).forEach((key) => {
groups[key].sort(sortCompare);
});
let cols: { key: string; title: string; className: string; icon: string }[];
if (currentFilters.sortBy === "due") {
cols = STATUS_COLUMNS;
} else {
const keys = Object.keys(groups).filter((k) => groups[k]?.length > 0);
if (currentFilters.sortBy === "year") {
cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: "📆" }));
} else if (currentFilters.sortBy === "subject") {
const subjectTitles = new Map(data?.subjects?.map((s: any) => [s.code, `${s.code} - ${s.title}`]) || []);
cols = keys.sort().map((k) => ({ key: k, title: subjectTitles.get(k) || k, className: "column-custom", icon: "📚" }));
} else {
cols = keys.sort().map((k) => ({ key: k, title: k, className: "column-custom", icon: "📋" }));
}
}
return { filteredAssessments: filtered, statusGroups: groups, columns: cols };
}
$: if (data) {
const _ = currentFilters.sortBy && currentFilters.subject;
const result = buildGroupsAndColumns();
filteredAssessments = result.filteredAssessments;
statusGroups = result.statusGroups;
columns = result.columns;
}
function updateAssessments() { function updateAssessments() {
filteredAssessments = data.assessments.filter((a: any) => { const result = buildGroupsAndColumns();
const subjectMatch = filteredAssessments = result.filteredAssessments;
currentFilters.subject === "all" || a.code === currentFilters.subject; statusGroups = result.statusGroups;
return subjectMatch; columns = result.columns;
});
filteredAssessments.sort((a: any, b: any) => {
switch (currentFilters.sortBy) {
case "due":
return new Date(a.due).getTime() - new Date(b.due).getTime();
case "grade":
const gradeA = getGradeValue(a);
const gradeB = getGradeValue(b);
if (gradeA === null && gradeB === null) return 0;
if (gradeA === null) return 1;
if (gradeB === null) return -1;
return gradeB - gradeA;
case "subject":
return a.code.localeCompare(b.code);
case "title":
return a.title.localeCompare(b.title);
default:
return 0;
}
});
statusGroups = {
UPCOMING: [],
DUE_SOON: [],
OVERDUE: [],
SUBMITTED: [],
MARKS_RELEASED: [],
};
filteredAssessments.forEach((assessment) => {
const status = determineStatus(assessment);
if (statusGroups[status]) {
statusGroups[status].push(assessment);
}
});
} }
function getDueDateClass(assessment: any): string { function getDueDateClass(assessment: any): string {
@@ -123,6 +185,56 @@
} }
} }
function hideAssessment(assessment: any) {
const hidden = JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]");
const id = String(assessment.id);
if (!hidden.includes(id)) {
hidden.push(id);
localStorage.setItem(HIDDEN_ASSESSMENTS_KEY, JSON.stringify(hidden));
visibilityRefresh++;
closeAllMenus();
updateAssessments();
}
}
function hideSubject(subjectCode: string) {
const filters = { ...(settingsState.subjectfilters || {}) };
filters[subjectCode] = false;
settingsState.subjectfilters = filters;
closeAllMenus();
updateAssessments();
}
function unhideSubject(subjectCode: string) {
const filters = { ...(settingsState.subjectfilters || {}) };
filters[subjectCode] = true;
settingsState.subjectfilters = filters;
updateAssessments();
}
function unhideAssessment(assessmentId: string) {
const hidden = JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]");
const idStr = String(assessmentId);
const filtered = hidden.filter((id: string) => id !== idStr);
localStorage.setItem(HIDDEN_ASSESSMENTS_KEY, JSON.stringify(filtered));
visibilityRefresh++;
updateAssessments();
}
function initSubjectFilters() {
const filters = settingsState.subjectfilters || {};
let updated = false;
data.subjects.forEach((s: any) => {
if (!Object.prototype.hasOwnProperty.call(filters, s.code)) {
filters[s.code] = true;
updated = true;
}
});
if (updated) {
settingsState.subjectfilters = filters;
}
}
function checkForCelebration() { function checkForCelebration() {
const overdueCount = statusGroups.OVERDUE?.length || 0; const overdueCount = statusGroups.OVERDUE?.length || 0;
const dueSoonCount = statusGroups.DUE_SOON?.length || 0; const dueSoonCount = statusGroups.DUE_SOON?.length || 0;
@@ -201,6 +313,20 @@
} }
let openMenuId: string | null = null; let openMenuId: string | null = null;
let showVisibilityPanel = false;
let visibilityRefresh = 0;
$: hiddenSubjects = data?.subjects?.filter(
(s: any) => (settingsState.subjectfilters || {})[s.code] === false
) || [];
$: hiddenAssessmentIds = (() => {
visibilityRefresh; // Dependency for reactivity
return new Set((JSON.parse(localStorage.getItem(HIDDEN_ASSESSMENTS_KEY) || "[]")).map(String));
})();
$: hiddenAssessmentsWithInfo = data?.assessments?.filter(
(a: any) => hiddenAssessmentIds.has(String(a.id))
) || [];
$: hasHiddenItems = hiddenSubjects.length > 0 || hiddenAssessmentsWithInfo.length > 0;
function toggleMenu(assessmentId: string, event: Event) { function toggleMenu(assessmentId: string, event: Event) {
event.stopPropagation(); event.stopPropagation();
@@ -211,44 +337,13 @@
openMenuId = null; openMenuId = null;
} }
$: { $: if (data) {
if (data) { initSubjectFilters();
updateAssessments(); updateAssessments();
} void currentFilters.sortBy;
void currentFilters.subject;
} }
const columns = [
{
key: "UPCOMING",
title: "Upcoming",
className: "column-upcoming",
icon: "📅",
},
{
key: "DUE_SOON",
title: "Due Soon",
className: "column-due-soon",
icon: "⏰",
},
{
key: "OVERDUE",
title: "Overdue",
className: "column-overdue",
icon: "🚨",
},
{
key: "SUBMITTED",
title: "Submitted",
className: "column-submitted",
icon: "📝",
},
{
key: "MARKS_RELEASED",
title: "Marked",
className: "column-marked",
icon: "✅",
},
];
</script> </script>
<svelte:window on:click={closeAllMenus} /> <svelte:window on:click={closeAllMenus} />
@@ -263,15 +358,58 @@
<option value={subject.code}>{subject.code} - {subject.title}</option> <option value={subject.code}>{subject.code} - {subject.title}</option>
{/each} {/each}
</select> </select>
<select class="filter-select" bind:value={currentFilters.sortBy}> <select class="filter-select" bind:value={currentFilters.sortBy} title="Group by - columns change based on this">
<option value="due">Sort by Due Date</option> <option value="due">Group: Status</option>
<option value="grade">Sort by Grade</option> <option value="year">Group: Year</option>
<option value="subject">Sort by Subject</option> <option value="subject">Group: Subject</option>
<option value="title">Sort by Title</option> <option value="grade">Group: Grade</option>
<option value="title">Group: Title (A-Z)</option>
</select> </select>
{#if hasHiddenItems}
<button
class="visibility-toggle"
class:active={showVisibilityPanel}
on:click={() => (showVisibilityPanel = !showVisibilityPanel)}
title="Manage hidden subjects and assessments"
>
👁 Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})
</button>
{/if}
</div> </div>
</div> </div>
{#if showVisibilityPanel && hasHiddenItems}
<div class="visibility-panel">
<h4 class="visibility-panel-title">Hidden items</h4>
{#if hiddenSubjects.length > 0}
<div class="visibility-section">
<span class="visibility-label">Subjects:</span>
<div class="visibility-chips">
{#each hiddenSubjects as subject}
<span class="visibility-chip">
{subject.code}
<button class="visibility-unhide" on:click={() => unhideSubject(subject.code)}>Show</button>
</span>
{/each}
</div>
</div>
{/if}
{#if hiddenAssessmentsWithInfo.length > 0}
<div class="visibility-section">
<span class="visibility-label">Assessments:</span>
<div class="visibility-chips">
{#each hiddenAssessmentsWithInfo as assessment}
<span class="visibility-chip">
{assessment.title}
<button class="visibility-unhide" on:click={() => unhideAssessment(assessment.id)}>Show</button>
</span>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<div id="main-grid-content"> <div id="main-grid-content">
{#if filteredAssessments.length === 0} {#if filteredAssessments.length === 0}
<div class="empty-state"> <div class="empty-state">
@@ -340,6 +478,12 @@
Mark as Not Complete Mark as Not Complete
</button> </button>
{/if} {/if}
<button class="menu-item menu-item-hide" on:click={() => hideAssessment(assessment)}>
Hide assessment
</button>
<button class="menu-item menu-item-hide" on:click={() => hideSubject(assessment.code)}>
Hide subject ({assessment.code})
</button>
</div> </div>
</div> </div>
{/if} {/if}
@@ -349,7 +493,7 @@
{#if !assessment.results && !isCompleted} {#if !assessment.results && !isCompleted}
<div class="assessment-meta"> <div class="assessment-meta">
<div class="due-date {dueDateClass}"> <div class="due-date {dueDateClass}">
📅 {formatDate(assessment.due, assessment.submitted)} 📅 {formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)}
</div> </div>
</div> </div>
{/if} {/if}
@@ -381,4 +525,4 @@
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -56,6 +56,18 @@ async function loadUpcoming(student: number) {
return res.payload; return res.payload;
} }
function normalizeAssessmentDates(t: any, subject: Subject): any {
const normalized = { ...t };
// Past API may use different date fields - ensure we have 'due' for year filter & display
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
}
if (!normalized.programmeID) normalized.programmeID = subject.programme;
if (!normalized.metaclassID) normalized.metaclassID = subject.metaclass;
if (!normalized.code && t.subject) normalized.code = t.subject;
return normalized;
}
async function loadPast(student: number, subjects: Subject[]) { async function loadPast(student: number, subjects: Subject[]) {
const map: Record<number, any> = {}; const map: Record<number, any> = {};
await Promise.all( await Promise.all(
@@ -65,10 +77,22 @@ async function loadPast(student: number, subjects: Subject[]) {
metaclass: s.metaclass, metaclass: s.metaclass,
student, student,
}); });
if (res.payload.tasks) { const processAssessment = (t: any) => {
res.payload.tasks.forEach((t: any) => { if (t && t.id) {
map[t.id] = t; const merged = {
}); ...t,
programmeID: t.programmeID || t.programme || s.programme,
metaclassID: t.metaclassID || t.metaclass || s.metaclass,
code: t.code || t.subject || s.code,
};
map[t.id] = normalizeAssessmentDates(merged, s);
}
};
if (res.payload?.pending && Array.isArray(res.payload.pending)) {
res.payload.pending.forEach(processAssessment);
}
if (res.payload?.tasks && Array.isArray(res.payload.tasks)) {
res.payload.tasks.forEach(processAssessment);
} }
}), }),
); );
@@ -1,9 +1,10 @@
import type { Plugin } from "../../core/types"; import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api"; import { getAssessmentsData } from "./api";
import { renderErrorState, renderSkeletonLoader } from "./ui"; import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
const assessmentsOverviewPlugin: Plugin<{}> = { const assessmentsOverviewPlugin: Plugin<{}> = {
id: "assessments-overview", id: "assessments-overview",
@@ -16,6 +17,8 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
styles, styles,
run: async () => { run: async () => {
if (isSeqtaEngageExperience()) return;
const menu = (await waitForElm( const menu = (await waitForElm(
'[data-key="assessments"] > .sub > ul', '[data-key="assessments"] > .sub > ul',
true, true,
@@ -65,7 +68,6 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
try { try {
const data = await getAssessmentsData(); const data = await getAssessmentsData();
const { renderGrid } = await import("./ui");
renderGrid(container, data); renderGrid(container, data);
} catch (err) { } catch (err) {
console.error("Failed to load assessments:", err); console.error("Failed to load assessments:", err);
@@ -34,19 +34,38 @@
} }
.filter-select { .filter-select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background: #ffffff !important; background: #ffffff !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2364748b'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
background-position: right 0.9rem center !important;
background-repeat: no-repeat !important;
background-size: 1rem !important;
border: 2px solid #e2e8f0; border: 2px solid #e2e8f0;
border-radius: 8px; border-radius: 10px;
color: #1a1a1a; color: #1a1a1a;
padding: 0.75rem 1rem; color-scheme: light;
padding: 0.75rem 2.5rem 0.75rem 1rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
font-family: Rubik, sans-serif;
line-height: 1.2;
transition: all 0.2s ease; transition: all 0.2s ease;
cursor: pointer; cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
min-width: 180px; min-width: 180px;
} }
.filter-select::-ms-expand {
display: none;
}
.filter-select option {
background: #ffffff;
color: #1a1a1a;
}
.filter-select:focus { .filter-select:focus {
outline: none; outline: none;
border-color: #d41e3a; border-color: #d41e3a;
@@ -61,8 +80,10 @@
/* Dark mode dropdowns */ /* Dark mode dropdowns */
.dark .filter-select { .dark .filter-select {
background: var(--background-primary) !important; background: var(--background-primary) !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='rgba(255,255,255,0.72)'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
border-color: var(--background-secondary); border-color: var(--background-secondary);
color: var(--text-primary); color: var(--text-primary);
color-scheme: dark;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
} }
@@ -73,7 +94,8 @@
.dark .filter-select:hover { .dark .filter-select:hover {
border-color: var(--background-secondary); border-color: var(--background-secondary);
background: var(--background-secondary); background: var(--background-secondary) !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='rgba(255,255,255,0.72)'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important;
} }
.dark .filter-select option { .dark .filter-select option {
@@ -335,6 +357,141 @@
color: #ef4444; color: #ef4444;
} }
.menu-item.menu-item-hide {
color: #64748b;
}
.dark .menu-item.menu-item-hide {
color: var(--text-primary);
opacity: 0.8;
}
.visibility-toggle {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 8px;
border: 2px solid #e2e8f0;
background: #ffffff;
color: #64748b;
cursor: pointer;
transition: all 0.2s ease;
}
.visibility-toggle:hover {
border-color: #cbd5e1;
color: #1a1a1a;
}
.visibility-toggle.active {
border-color: #d41e3a;
background: rgba(212, 30, 58, 0.08);
color: #d41e3a;
}
.dark .visibility-toggle {
background: var(--background-primary);
border-color: var(--background-secondary);
color: var(--text-primary);
}
.dark .visibility-toggle:hover {
border-color: rgba(255, 255, 255, 0.2);
}
.dark .visibility-toggle.active {
border-color: #d41e3a;
background: rgba(212, 30, 58, 0.15);
color: #d41e3a;
}
.visibility-panel {
padding: 1rem 1.25rem;
margin: 0 1rem 1rem;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.dark .visibility-panel {
background: var(--background-secondary);
border-color: rgba(255, 255, 255, 0.1);
}
.visibility-panel-title {
font-size: 0.875rem;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 0.75rem;
}
.dark .visibility-panel-title {
color: var(--text-primary);
}
.visibility-section {
margin-bottom: 0.5rem;
}
.visibility-section:last-child {
margin-bottom: 0;
}
.visibility-label {
font-size: 0.75rem;
font-weight: 500;
color: #64748b;
display: block;
margin-bottom: 0.25rem;
}
.dark .visibility-label {
color: var(--text-primary);
opacity: 0.7;
}
.visibility-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.visibility-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: #e2e8f0;
border-radius: 6px;
font-size: 0.8125rem;
color: #1a1a1a;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.dark .visibility-chip {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
.visibility-unhide {
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 4px;
border: none;
background: #d41e3a;
color: white;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.visibility-unhide:hover {
background: #b91c33;
}
.assessment-title { .assessment-title {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
@@ -455,6 +612,10 @@
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%); background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
} }
.column-custom .column-header {
background: linear-gradient(135deg, #ffffff 0%, #f1f5f9 100%);
}
/* Dark mode column headers */ /* Dark mode column headers */
.dark .column-upcoming .column-header { .dark .column-upcoming .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%); background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%);
@@ -476,6 +637,10 @@
background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%); background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%);
} }
.dark .column-custom .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a5f 100%);
}
/* Subject filter view */ /* Subject filter view */
.subject-section { .subject-section {
margin-bottom: 2rem; margin-bottom: 2rem;
@@ -7,7 +7,7 @@ import localforage from "localforage";
const settings = defineSettings({ const settings = defineSettings({
uploader: componentSetting({ uploader: componentSetting({
title: "Background Music", 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, component: BackgroundMusicSetting,
}), }),
volume: numberSetting({ volume: numberSetting({
@@ -99,7 +99,7 @@ async function startPlayback(volume: number): Promise<void> {
const backgroundMusicPlugin: Plugin<typeof settings> = { const backgroundMusicPlugin: Plugin<typeof settings> = {
id: "background-music", id: "background-music",
name: "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", version: "1.0.0",
settings, settings,
styles, styles,
@@ -142,7 +142,6 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
if (!currentAudio) return; if (!currentAudio) return;
const pauseOnHidden = (api.settings as any).pauseOnHidden ?? true; const pauseOnHidden = (api.settings as any).pauseOnHidden ?? true;
if (!pauseOnHidden) return; if (!pauseOnHidden) return;
if (document.visibilityState === "hidden") { if (document.visibilityState === "hidden") {
if (visibilityResumeTimeout !== null) { if (visibilityResumeTimeout !== null) {
clearTimeout(visibilityResumeTimeout); clearTimeout(visibilityResumeTimeout);
@@ -183,5 +182,3 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
}; };
export default backgroundMusicPlugin; export default backgroundMusicPlugin;
@@ -0,0 +1,246 @@
/**
* BetterSEQTA Security core XSS-focused protections for HTML rendered from SEQTA APIs.
*
* Execution vs detection: SEQTA loads message bodies in same-origin `iframe.userHTML`.
* Scripts may run during parse before our scan completes. We set `sandbox="allow-same-origin"`
* (without `allow-scripts`) on those iframes so script execution is suppressed while we can
* still read `contentDocument` for scanning and existing theme/CSS injection.
*
* The warning UI is mounted on document.body (fixed layer aligned to the reading pane) so
* React replacing `.uiFrameWrapper` / iframe siblings does not destroy it.
*/
import type { Plugin } from "../../core/types";
import {
analyzeHtmlThreats,
type ThreatAnalysis,
} from "@/seqta/security/analyzeHtmlThreats";
import {
mountBlockedContentUi,
SECURITY_MESSAGE_OVERLAY_CLASS,
} from "@/seqta/security/blockedContentUi";
import { eventManager } from "@/seqta/utils/listeners/EventManager";
const USER_HTML_IFRAME_EVENT = "bssSecurityUserHtmlIframe";
const userHtmlIframeLoadHooked = new WeakSet<HTMLIFrameElement>();
/** Tear down body overlay + listeners for this iframe (safe navigation or cleanup). */
const messageOverlayCleanups = new WeakMap<HTMLIFrameElement, () => void>();
function teardownMessageSecurityOverlay(iframe: HTMLIFrameElement): void {
const fn = messageOverlayCleanups.get(iframe);
if (fn) {
fn();
messageOverlayCleanups.delete(iframe);
}
}
function applyMessageIframeSandbox(iframe: HTMLIFrameElement): void {
if (iframe.dataset.bssUserHtmlSandbox === "1") return;
iframe.dataset.bssUserHtmlSandbox = "1";
iframe.setAttribute("sandbox", "allow-same-origin");
}
function wipeIframeDocument(iframe: HTMLIFrameElement): void {
try {
const d = iframe.contentDocument;
if (!d) return;
d.open();
d.write(
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body></body></html>",
);
d.close();
} catch {
/* ignore */
}
}
/**
* After we replace a malicious document, the iframe fires `load` again with this blank shell.
* That pass must not tear down the blocker UI or the iframe would recover for one frame.
*/
function isPostWipeBlankDocument(doc: Document): boolean {
const body = doc.body;
if (!body || body.childElementCount > 0) return false;
if ((body.textContent ?? "").trim().length > 0) return false;
const meta = doc.head?.querySelector('meta[charset="utf-8"]');
if (!meta) return false;
return doc.documentElement.outerHTML.length < 800;
}
/**
* Full-screen body layer positioned over the reading pane so SEQTA/React can replace iframe
* markup without removing this node.
*/
function mountBodyAnchoredMessageOverlay(
iframe: HTMLIFrameElement,
anchor: HTMLElement,
opts: {
analysis: ThreatAnalysis;
rawSnippet: string;
contextTitle?: string;
},
): void {
teardownMessageSecurityOverlay(iframe);
const shell = document.createElement("div");
shell.className = SECURITY_MESSAGE_OVERLAY_CLASS;
Object.assign(shell.style, {
position: "fixed",
zIndex: "2147483646",
overflow: "hidden",
pointerEvents: "auto",
boxSizing: "border-box",
padding: "12px",
background: "rgba(24,24,27,0.35)",
});
const inner = document.createElement("div");
Object.assign(inner.style, {
width: "100%",
height: "100%",
boxSizing: "border-box",
});
shell.appendChild(inner);
let raf = 0;
const syncRect = (): void => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
if (!iframe.isConnected) {
teardownMessageSecurityOverlay(iframe);
return;
}
if (!anchor.isConnected) {
teardownMessageSecurityOverlay(iframe);
return;
}
const r = anchor.getBoundingClientRect();
const pad = 10;
const left = Math.max(8, r.left - pad);
const top = Math.max(8, r.top - pad);
const width = Math.min(window.innerWidth - left - 8, r.width + pad * 2);
const height = Math.min(window.innerHeight - top - 8, r.height + pad * 2);
shell.style.left = `${left}px`;
shell.style.top = `${top}px`;
shell.style.width = `${Math.max(0, width)}px`;
shell.style.height = `${Math.max(0, height)}px`;
});
};
syncRect();
document.body.appendChild(shell);
const ro = new ResizeObserver(syncRect);
ro.observe(anchor);
window.addEventListener("resize", syncRect);
window.addEventListener("scroll", syncRect, true);
const unmountPanel = mountBlockedContentUi(inner, {
surface: "message",
analysis: opts.analysis,
rawSnippet: opts.rawSnippet,
contextTitle: opts.contextTitle,
rootOverlay: true,
});
const cleanup = (): void => {
cancelAnimationFrame(raf);
ro.disconnect();
window.removeEventListener("resize", syncRect);
window.removeEventListener("scroll", syncRect, true);
unmountPanel();
shell.remove();
};
messageOverlayCleanups.set(iframe, cleanup);
}
function handleUserHtmlIframeLoaded(iframe: HTMLIFrameElement): void {
let idoc: Document | null = null;
try {
idoc = iframe.contentDocument;
} catch {
return;
}
if (!idoc?.documentElement) return;
const wrapper =
iframe.closest(".uiFrameWrapper") ??
iframe.closest(".iframeWrapper") ??
iframe.parentElement;
if (!wrapper) return;
if (
iframe.dataset.bssAwaitingWipeLoad === "1" &&
isPostWipeBlankDocument(idoc)
) {
iframe.dataset.bssAwaitingWipeLoad = "";
return;
}
iframe.dataset.bssAwaitingWipeLoad = "";
teardownMessageSecurityOverlay(iframe);
iframe.style.visibility = "";
iframe.style.height = "";
iframe.style.minHeight = "";
const html = idoc.documentElement.outerHTML;
const analysis = analyzeHtmlThreats(html);
if (!analysis.blocked) return;
const pane = iframe.closest('[class*="ReadingPane__ReadingPane"]');
const anchor = (pane ?? wrapper) as HTMLElement;
iframe.dataset.bssAwaitingWipeLoad = "1";
wipeIframeDocument(iframe);
iframe.style.visibility = "hidden";
iframe.style.height = "0";
iframe.style.minHeight = "0";
const subject = pane
?.querySelector('[class*="Message__subject___"]')
?.textContent?.trim();
mountBodyAnchoredMessageOverlay(iframe, anchor, {
analysis,
rawSnippet: html.slice(0, 50_000),
contextTitle: subject,
});
}
const betterSeqtaSecurityPlugin: Plugin = {
id: "better-seqta-security",
name: "BetterSEQTA Security",
description:
"Blocks risky HTML in messages and notices and surfaces administrator-ready incident reports.",
version: "1.0.0",
settings: {},
run: () => {
const { unregister } = eventManager.register(
USER_HTML_IFRAME_EVENT,
{
elementType: "iframe",
customCheck: (element) => element.classList.contains("userHTML"),
},
(element) => {
const iframe = element as HTMLIFrameElement;
if (userHtmlIframeLoadHooked.has(iframe)) return;
userHtmlIframeLoadHooked.add(iframe);
applyMessageIframeSandbox(iframe);
const onLoad = () => handleUserHtmlIframeLoaded(iframe);
iframe.addEventListener("load", onLoad);
queueMicrotask(onLoad);
},
);
return unregister;
},
};
export default betterSeqtaSecurityPlugin;
@@ -120,7 +120,6 @@ export default defineLazyPlugin({
settings, settings,
disableToggle: true, disableToggle: true,
defaultEnabled: false, defaultEnabled: false,
beta: true,
styles: styles, styles: styles,
// Lazy loader - only imports the heavy plugin when actually needed // Lazy loader - only imports the heavy plugin when actually needed
@@ -145,7 +145,6 @@ const globalSearchPlugin: Plugin<typeof settings> = {
settings: settingsInstance.settings, settings: settingsInstance.settings,
disableToggle: true, disableToggle: true,
defaultEnabled: false, defaultEnabled: false,
beta: true,
styles: styles, styles: styles,
run: async (api) => { run: async (api) => {
@@ -0,0 +1,748 @@
import type { Plugin } from "../../core/types";
import { booleanSetting } from "@/plugins/core/settingsHelpers";
import { waitForElm } from "@/seqta/utils/waitForElm";
import styles from "./styles.css?inline";
const messageFoldersSettings = {
showTagsInAllMessages: booleanSetting({
default: true,
title: "Show folder tags in All Messages",
description:
"When off, folder tags are not shown on the message list until you select a folder.",
}),
hideFolderedMessagesInAll: booleanSetting({
default: true,
title: "Hide foldered messages in All Messages",
description:
"When on, messages assigned to a custom folder are hidden from the inbox until you open that folder.",
}),
} as const;
interface Folder {
id: string;
name: string;
color: string;
}
interface MessageFoldersStorage {
folders: Folder[];
messageAssignments: Record<string, string[]>;
}
const FOLDER_COLORS = [
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b",
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316",
];
const FOLDER_ICON_SVG = `<svg style="width:24px;height:24px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const PLUS_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`;
const CHECK_SVG_WHITE = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#fff" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>`;
const CLOSE_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>`;
const EDIT_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>`;
const TRASH_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`;
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFoldersStorage> = {
id: "messageFolders",
name: "Message Folders",
description: "Organize direct messages into custom folders",
version: "1.0.0",
settings: messageFoldersSettings,
disableToggle: true,
defaultEnabled: true,
run: async (api) => {
const styleEl = document.createElement("style");
styleEl.textContent = styles;
document.head.appendChild(styleEl);
await api.storage.loaded;
if (!api.storage.folders) api.storage.folders = [];
if (!api.storage.messageAssignments) api.storage.messageAssignments = {};
let activeFolderId: string | null = null;
let messageListObserver: MutationObserver | null = null;
let sidebarObserver: MutationObserver | null = null;
let actionsObserver: MutationObserver | null = null;
let openDropdown: HTMLElement | null = null;
let dropdownCloseHandler: ((e: MouseEvent) => void) | null = null;
const unregisters: Array<{ unregister: () => void }> = [];
// ── Storage accessors ──
const getFolders = (): Folder[] => api.storage.folders ?? [];
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
const saveFolders = (folders: Folder[]) => {
api.storage.folders = [...folders];
};
const saveAssignments = (assignments: Record<string, string[]>) => {
api.storage.messageAssignments = { ...assignments };
};
const getMessageFolderIds = (messageId: string): string[] => {
const assignments = getAssignments();
const ids: string[] = [];
for (const [folderId, msgIds] of Object.entries(assignments)) {
if (msgIds.includes(messageId)) ids.push(folderId);
}
return ids;
};
const toggleMessageInFolder = (messageId: string, folderId: string) => {
const assignments = getAssignments();
if (!assignments[folderId]) assignments[folderId] = [];
const idx = assignments[folderId].indexOf(messageId);
if (idx >= 0) {
assignments[folderId].splice(idx, 1);
} else {
assignments[folderId].push(messageId);
}
saveAssignments(assignments);
};
const getFolderMessageCount = (folderId: string): number => {
return (getAssignments()[folderId] ?? []).length;
};
const restoreSubjectPlain = (subject: Element) => {
subject.querySelector(".bsplus-msg-badges")?.remove();
const textWrap = subject.querySelector(".bsplus-subject-text");
if (textWrap) {
subject.textContent = textWrap.textContent ?? "";
}
};
const isMessageInAnyCustomFolder = (messageId: string): boolean => {
for (const msgIds of Object.values(getAssignments())) {
if (msgIds.includes(messageId)) return true;
}
return false;
};
const shouldShowBadgesInList = (): boolean => {
return api.settings.showTagsInAllMessages || activeFolderId !== null;
};
// ── Confirm modal ──
const showConfirmModal = (
title: string,
message: string,
onConfirm: () => void,
) => {
const overlay = document.createElement("div");
overlay.className = "bsplus-modal-overlay";
const modal = document.createElement("div");
modal.className = "bsplus-modal";
modal.innerHTML = `
<h3>${title}</h3>
<p>${message}</p>
<div class="bsplus-modal-actions">
<button class="bsplus-modal-btn-cancel">Cancel</button>
<button class="bsplus-modal-btn-danger">Delete</button>
</div>
`;
overlay.appendChild(modal);
const remove = () => {
overlay.remove();
document.removeEventListener("keydown", onKey);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") remove();
};
overlay.addEventListener("click", (e) => {
if (e.target === overlay) remove();
});
modal.querySelector(".bsplus-modal-btn-cancel")!.addEventListener("click", remove);
modal.querySelector(".bsplus-modal-btn-danger")!.addEventListener("click", () => {
onConfirm();
remove();
});
document.body.appendChild(overlay);
document.addEventListener("keydown", onKey);
};
// ── Sidebar folder UI ──
const renderSidebarFolders = () => {
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (!sidebar) return;
const ol = sidebar.querySelector("ol");
if (!ol) return;
let section = ol.querySelector(".bsplus-folders-section");
if (!section) {
section = document.createElement("div");
section.className = "bsplus-folders-section";
ol.appendChild(section);
}
const folders = getFolders();
const existingInput = section.querySelector(".bsplus-folder-input");
const existingColors = section.querySelector(".bsplus-folder-colors");
section.innerHTML = "";
// Header
const header = document.createElement("div");
header.className = "bsplus-folders-header";
const label = document.createElement("span");
label.textContent = "Folders";
header.appendChild(label);
const addBtn = document.createElement("button");
addBtn.className = "bsplus-folders-add-btn";
addBtn.title = "New folder";
addBtn.innerHTML = PLUS_SVG;
addBtn.addEventListener("click", (e) => {
e.stopPropagation();
showNewFolderInput(section!);
});
header.appendChild(addBtn);
section.appendChild(header);
// "All Messages" item
const allItem = document.createElement("div");
allItem.className = `bsplus-folder-item${activeFolderId === null ? " bsplus-folder-active" : ""}`;
allItem.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" style="fill: currentcolor; opacity: 0.5; flex-shrink: 0;"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
<span class="bsplus-folder-name">All Messages</span>
`;
allItem.addEventListener("click", () => {
activeFolderId = null;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
});
section.appendChild(allItem);
// Folder items
for (const folder of folders) {
const item = document.createElement("div");
item.className = `bsplus-folder-item${activeFolderId === folder.id ? " bsplus-folder-active" : ""}`;
item.dataset.folderId = folder.id;
const dot = document.createElement("div");
dot.className = "bsplus-folder-dot";
dot.style.background = folder.color;
item.appendChild(dot);
const name = document.createElement("span");
name.className = "bsplus-folder-name";
name.textContent = folder.name;
item.appendChild(name);
const actions = document.createElement("div");
actions.className = "bsplus-folder-actions";
const editBtn = document.createElement("button");
editBtn.className = "bsplus-folder-action-btn";
editBtn.title = "Rename";
editBtn.innerHTML = EDIT_SVG;
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
showEditFolderInput(section!, folder);
});
actions.appendChild(editBtn);
const deleteBtn = document.createElement("button");
deleteBtn.className = "bsplus-folder-action-btn";
deleteBtn.title = "Delete";
deleteBtn.innerHTML = TRASH_SVG;
deleteBtn.addEventListener("click", (e) => {
e.stopPropagation();
showConfirmModal(
"Delete folder",
`Remove "${folder.name}"? Messages won't be deleted.`,
() => {
const folders = getFolders().filter((f) => f.id !== folder.id);
saveFolders(folders);
const assignments = getAssignments();
delete assignments[folder.id];
saveAssignments(assignments);
if (activeFolderId === folder.id) activeFolderId = null;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
},
);
});
actions.appendChild(deleteBtn);
item.appendChild(actions);
const count = document.createElement("span");
count.className = "bsplus-folder-count";
const c = getFolderMessageCount(folder.id);
count.textContent = c > 0 ? String(c) : "";
item.appendChild(count);
item.addEventListener("click", () => {
activeFolderId = folder.id;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
});
section.appendChild(item);
}
// Restore input if it was open
if (existingInput || existingColors) {
// Don't restore let user re-trigger
}
};
const showNewFolderInput = (container: Element, editFolder?: Folder) => {
const existing = container.querySelector(".bsplus-folder-input");
if (existing) existing.remove();
container.querySelector(".bsplus-folder-colors")?.remove();
let selectedColor = editFolder?.color ?? FOLDER_COLORS[Math.floor(Math.random() * FOLDER_COLORS.length)];
const row = document.createElement("div");
row.className = "bsplus-folder-input";
const input = document.createElement("input");
input.type = "text";
input.placeholder = editFolder ? "Rename folder…" : "Folder name…";
input.value = editFolder?.name ?? "";
input.maxLength = 30;
const confirmBtn = document.createElement("button");
confirmBtn.className = "bsplus-folder-input-confirm";
confirmBtn.innerHTML = CHECK_SVG_WHITE;
const cancelBtn = document.createElement("button");
cancelBtn.className = "bsplus-folder-input-cancel";
cancelBtn.innerHTML = CLOSE_SVG;
row.appendChild(input);
row.appendChild(confirmBtn);
row.appendChild(cancelBtn);
// Color picker
const colorRow = document.createElement("div");
colorRow.className = "bsplus-folder-colors";
for (const color of FOLDER_COLORS) {
const swatch = document.createElement("button");
swatch.className = `bsplus-folder-color-opt${color === selectedColor ? " bsplus-color-selected" : ""}`;
swatch.style.background = color;
swatch.addEventListener("click", (e) => {
e.stopPropagation();
selectedColor = color;
colorRow.querySelectorAll(".bsplus-folder-color-opt").forEach((s) =>
s.classList.toggle("bsplus-color-selected", (s as HTMLElement).style.background === color),
);
});
colorRow.appendChild(swatch);
}
const confirm = () => {
const name = input.value.trim();
if (!name) return;
if (editFolder) {
const folders = getFolders().map((f) =>
f.id === editFolder.id ? { ...f, name, color: selectedColor } : f,
);
saveFolders(folders);
} else {
const folder: Folder = { id: generateId(), name, color: selectedColor };
saveFolders([...getFolders(), folder]);
}
applyBadges();
renderSidebarFolders();
};
confirmBtn.addEventListener("click", (e) => {
e.stopPropagation();
confirm();
});
cancelBtn.addEventListener("click", (e) => {
e.stopPropagation();
renderSidebarFolders();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") confirm();
if (e.key === "Escape") renderSidebarFolders();
});
container.appendChild(row);
container.appendChild(colorRow);
requestAnimationFrame(() => input.focus());
};
const showEditFolderInput = (container: Element, folder: Folder) => {
showNewFolderInput(container, folder);
};
// ── Intercept native sidebar clicks to clear folder filter ──
const attachNativeSidebarListeners = () => {
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (!sidebar) return;
const ol = sidebar.querySelector("ol");
if (!ol) return;
ol.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest(".bsplus-folders-section")) return;
const li = target.closest("li");
if (li && ol.contains(li)) {
if (activeFolderId !== null) {
activeFolderId = null;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
}
}
});
};
// ── "Add to folder" button in message action bar ──
const injectFolderButton = (actionsBar: Element) => {
if (actionsBar.querySelector(".bsplus-folder-btn")) return;
const wrapper = document.createElement("div");
wrapper.className = "bsplus-folder-btn";
wrapper.style.position = "relative";
wrapper.style.display = "inline-block";
const btn = document.createElement("button");
const btnClasses = actionsBar.querySelector("button")?.className ?? "";
btn.className = btnClasses;
btn.title = "Add to folder";
btn.innerHTML = FOLDER_ICON_SVG;
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
closeDropdown();
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
const messageId = selectedMsg?.getAttribute("data-message");
if (!messageId) return;
showFolderDropdown(wrapper, messageId);
});
wrapper.appendChild(btn);
const moreMenu = actionsBar.querySelector("[class*='MenuButton__Menu___']");
if (moreMenu) {
actionsBar.insertBefore(wrapper, moreMenu);
} else {
actionsBar.appendChild(wrapper);
}
};
const showFolderDropdown = (anchor: HTMLElement, messageId: string) => {
const dropdown = document.createElement("div");
dropdown.className = "bsplus-folder-dropdown";
const folders = getFolders();
const currentFolderIds = getMessageFolderIds(messageId);
if (folders.length === 0) {
const empty = document.createElement("div");
empty.className = "bsplus-folder-dropdown-empty";
empty.textContent = "No folders yet";
dropdown.appendChild(empty);
} else {
for (const folder of folders) {
const isChecked = currentFolderIds.includes(folder.id);
const item = document.createElement("button");
item.className = `bsplus-folder-dropdown-item${isChecked ? " bsplus-checked" : ""}`;
const check = document.createElement("div");
check.className = "bsplus-folder-dropdown-check";
check.style.borderColor = isChecked ? folder.color : "";
check.style.background = isChecked ? folder.color : "";
check.innerHTML = CHECK_SVG_WHITE;
const dot = document.createElement("div");
dot.className = "bsplus-folder-dot";
dot.style.background = folder.color;
const name = document.createElement("span");
name.textContent = folder.name;
item.appendChild(check);
item.appendChild(dot);
item.appendChild(name);
item.addEventListener("click", (e) => {
e.stopPropagation();
toggleMessageInFolder(messageId, folder.id);
const nowChecked = getMessageFolderIds(messageId).includes(folder.id);
item.classList.toggle("bsplus-checked", nowChecked);
check.style.borderColor = nowChecked ? folder.color : "";
check.style.background = nowChecked ? folder.color : "";
applyBadges();
applyFolderFilter();
renderSidebarFolders();
});
dropdown.appendChild(item);
}
}
anchor.appendChild(dropdown);
openDropdown = dropdown;
dropdownCloseHandler = (e: MouseEvent) => {
if (!dropdown.contains(e.target as Node) && !anchor.contains(e.target as Node)) {
closeDropdown();
}
};
setTimeout(() => {
document.addEventListener("click", dropdownCloseHandler!, true);
}, 0);
};
const closeDropdown = () => {
if (openDropdown) {
openDropdown.remove();
openDropdown = null;
}
if (dropdownCloseHandler) {
document.removeEventListener("click", dropdownCloseHandler, true);
dropdownCloseHandler = null;
}
};
// ── Message badges ──
const applyBadges = () => {
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
if (!shouldShowBadgesInList()) {
for (const li of messageItems) {
const subject = li.querySelector("[class*='MessageList__subject___']");
if (subject && (subject.querySelector(".bsplus-msg-badges") || subject.querySelector(".bsplus-subject-text"))) {
restoreSubjectPlain(subject);
} else {
li.querySelector(".bsplus-msg-badges")?.remove();
}
}
return;
}
const folders = getFolders();
const assignments = getAssignments();
for (const li of messageItems) {
const msgId = li.getAttribute("data-message");
if (!msgId) continue;
let badgeContainer = li.querySelector(".bsplus-msg-badges") as HTMLElement | null;
const folderIds = [];
for (const [fId, mIds] of Object.entries(assignments)) {
if (mIds.includes(msgId)) folderIds.push(fId);
}
if (folderIds.length === 0) {
badgeContainer?.remove();
continue;
}
if (!badgeContainer) {
badgeContainer = document.createElement("div");
badgeContainer.className = "bsplus-msg-badges";
const subject = li.querySelector("[class*='MessageList__subject___']");
if (subject) {
if (!subject.querySelector(".bsplus-subject-text")) {
const textWrap = document.createElement("span");
textWrap.className = "bsplus-subject-text";
textWrap.textContent = subject.textContent;
subject.textContent = "";
subject.appendChild(textWrap);
}
subject.appendChild(badgeContainer);
} else {
li.appendChild(badgeContainer);
}
}
badgeContainer.innerHTML = "";
for (const fId of folderIds) {
const folder = folders.find((f) => f.id === fId);
if (!folder) continue;
const badge = document.createElement("span");
badge.className = "bsplus-msg-badge";
badge.style.background = folder.color;
badge.textContent = folder.name;
badge.title = `Filter by "${folder.name}"`;
badge.addEventListener("click", (e) => {
e.stopPropagation();
activeFolderId = folder.id;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
});
badgeContainer.appendChild(badge);
}
}
};
// ── Folder filtering ──
const applyFolderFilter = () => {
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
const moreBtn = document.querySelector("[class*='MessageList__MessageList___'] ol > button");
if (activeFolderId === null) {
if (api.settings.hideFolderedMessagesInAll) {
for (const li of messageItems) {
const msgId = li.getAttribute("data-message");
if (msgId && isMessageInAnyCustomFolder(msgId)) {
li.classList.add("bsplus-folder-hidden");
} else {
li.classList.remove("bsplus-folder-hidden");
}
}
} else {
for (const li of messageItems) {
li.classList.remove("bsplus-folder-hidden");
}
}
if (moreBtn) (moreBtn as HTMLElement).classList.remove("bsplus-folder-hidden");
return;
}
const folderMsgIds = getAssignments()[activeFolderId] ?? [];
for (const li of messageItems) {
const msgId = li.getAttribute("data-message");
if (msgId && folderMsgIds.includes(msgId)) {
li.classList.remove("bsplus-folder-hidden");
} else {
li.classList.add("bsplus-folder-hidden");
}
}
if (moreBtn) (moreBtn as HTMLElement).classList.add("bsplus-folder-hidden");
};
// ── Observers ──
const setupMessageListObserver = () => {
const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol");
if (!messageList || messageListObserver) return;
messageListObserver = new MutationObserver(() => {
applyBadges();
applyFolderFilter();
});
messageListObserver.observe(messageList, { childList: true, subtree: false });
};
const setupActionsObserver = () => {
if (actionsObserver) return;
const target = document.querySelector("[class*='Viewer__Viewer___']") ?? document.querySelector("div.messages");
if (!target) return;
actionsObserver = new MutationObserver(() => {
const actionsBar = document.querySelector("[class*='Message__actions___']");
if (actionsBar && !actionsBar.querySelector(".bsplus-folder-btn")) {
injectFolderButton(actionsBar);
}
});
actionsObserver.observe(target, { childList: true, subtree: true });
};
// ── Main page handler ──
const handleMessagesPage = async () => {
await waitForElm("[class*='Viewer__sidebar___'] ol", true, 50, 100);
renderSidebarFolders();
attachNativeSidebarListeners();
await waitForElm("[class*='MessageList__MessageList___'] ol", true, 50, 100);
applyBadges();
applyFolderFilter();
setupMessageListObserver();
// The actions bar only exists when a message is selected/open,
// so we observe the whole viewer for it to appear dynamically
setupActionsObserver();
// If a message is already selected, inject immediately
const actionsBar = document.querySelector("[class*='Message__actions___']");
if (actionsBar) injectFolderButton(actionsBar);
// Re-observe the sidebar for SEQTA re-renders
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (sidebar && !sidebarObserver) {
sidebarObserver = new MutationObserver(() => {
const ol = sidebar.querySelector("ol");
if (ol && !ol.querySelector(".bsplus-folders-section")) {
renderSidebarFolders();
attachNativeSidebarListeners();
}
});
sidebarObserver.observe(sidebar, { childList: true, subtree: true });
}
};
// ── Lifecycle ──
const mountUnsub = api.seqta.onMount("div.messages", handleMessagesPage);
unregisters.push(mountUnsub);
unregisters.push(
api.settings.onChange("showTagsInAllMessages", () => {
applyBadges();
}),
);
unregisters.push(
api.settings.onChange("hideFolderedMessagesInAll", () => {
applyFolderFilter();
}),
);
return () => {
for (const u of unregisters) u.unregister();
messageListObserver?.disconnect();
sidebarObserver?.disconnect();
actionsObserver?.disconnect();
closeDropdown();
styleEl.remove();
document.querySelectorAll(".bsplus-folders-section").forEach((el) => el.remove());
document.querySelectorAll(".bsplus-folder-btn").forEach((el) => el.remove());
document.querySelectorAll(".bsplus-msg-badges").forEach((el) => el.remove());
document.querySelectorAll("[class*='MessageList__subject___']").forEach((subject) => {
if (subject.querySelector(".bsplus-subject-text")) {
restoreSubjectPlain(subject);
}
});
document.querySelectorAll(".bsplus-folder-hidden").forEach((el) =>
el.classList.remove("bsplus-folder-hidden"),
);
document.querySelectorAll(".bsplus-modal-overlay").forEach((el) => el.remove());
};
},
};
export default messageFoldersPlugin;
@@ -0,0 +1,491 @@
/* ── Sidebar folder section ── */
.bsplus-folders-section {
border-top: 1px solid var(--background-secondary, rgba(128, 128, 128, 0.2));
margin-top: 4px;
padding-top: 4px;
}
.bsplus-folders-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px 2px;
user-select: none;
}
.bsplus-folders-header span {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-primary, #666);
opacity: 0.5;
}
.bsplus-folders-add-btn {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 20px !important;
height: 20px !important;
min-width: 0 !important;
border: none !important;
background: transparent !important;
opacity: 0.5;
cursor: pointer;
border-radius: 4px !important;
padding: 0 !important;
margin: 0 !important;
transition: all 0.2s ease;
text-align: center !important;
}
.bsplus-folders-add-btn:hover {
opacity: 1;
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
}
/* ── Folder list items ── */
.bsplus-folder-item {
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
transition: background 0.15s ease;
position: relative;
gap: 8px;
user-select: none;
}
.bsplus-folder-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
}
.bsplus-folder-item.bsplus-folder-active {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.12));
}
.bsplus-folder-item.bsplus-folder-active::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--better-main, #007bff);
border-radius: 0 2px 2px 0;
}
.bsplus-folder-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.bsplus-folder-name {
font-size: 13px;
color: var(--text-primary, #333);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bsplus-folder-count {
font-size: 11px;
color: var(--text-primary, #999);
opacity: 0.5;
flex-shrink: 0;
}
.bsplus-folder-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.bsplus-folder-item:hover .bsplus-folder-actions {
opacity: 1;
}
.bsplus-folder-action-btn {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 20px !important;
height: 20px !important;
min-width: 0 !important;
border: none !important;
background: transparent !important;
opacity: 0.6;
cursor: pointer;
border-radius: 4px !important;
padding: 0 !important;
margin: 0 !important;
transition: all 0.15s ease;
}
.bsplus-folder-action-btn:hover {
opacity: 1;
background: var(--background-secondary, rgba(128, 128, 128, 0.15)) !important;
}
/* ── Inline folder name input ── */
.bsplus-folder-input {
display: flex;
align-items: center;
padding: 4px 12px;
gap: 6px;
}
.bsplus-folder-input input {
flex: 1;
min-width: 0;
padding: 4px 8px;
font-size: 13px;
border: 1px solid var(--background-secondary, #ccc);
border-radius: 6px;
background: var(--background-secondary, #f5f5f5);
color: var(--text-primary, #333);
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.bsplus-folder-input input:focus {
border-color: var(--better-main, #007bff);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2);
}
.bsplus-folder-input-confirm,
.bsplus-folder-input-cancel {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 24px !important;
height: 24px !important;
min-width: 0 !important;
border: none !important;
border-radius: 4px !important;
cursor: pointer;
padding: 0 !important;
margin: 0 !important;
transition: all 0.15s ease;
}
.bsplus-folder-input-confirm {
background: var(--better-main, #007bff) !important;
}
.bsplus-folder-input-confirm:hover {
transform: scale(1.1);
}
.bsplus-folder-input-cancel {
background: transparent !important;
opacity: 0.6;
}
.bsplus-folder-input-cancel:hover {
opacity: 1;
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
}
/* ── Color picker row ── */
.bsplus-folder-colors {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
padding: 4px 12px 6px;
max-width: 120px;
}
.bsplus-folder-color-opt {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.2s ease,
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1);
padding: 0;
background: none;
box-sizing: border-box;
}
.bsplus-folder-color-opt:hover {
transform: scale(1.25);
box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.15);
}
.bsplus-folder-color-opt.bsplus-color-selected {
border-color: var(--text-primary, #333);
transform: scale(1.15);
box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.2);
}
.bsplus-folder-color-opt.bsplus-color-selected:hover {
transform: scale(1.25);
}
/* ── "Add to folder" button in message actions bar ── */
.bsplus-folder-btn {
position: relative;
}
.bsplus-folder-btn svg {
fill: currentColor;
}
/* ── Folder dropdown ── */
.bsplus-folder-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 180px;
background: var(--background-primary, #fff);
border: 1px solid var(--background-secondary, #e0e0e0);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 1000;
overflow: hidden;
animation: bsplus-dropdown-in 0.15s ease-out;
}
@keyframes bsplus-dropdown-in {
from {
opacity: 0;
transform: translateY(-4px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.bsplus-folder-dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.1s ease;
border: none;
background: transparent;
width: 100%;
text-align: left;
color: var(--text-primary, #333);
font-size: 13px;
}
.bsplus-folder-dropdown-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
}
.bsplus-folder-dropdown-check {
width: 16px;
height: 16px;
border: 2px solid var(--background-secondary, #ccc);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s ease;
}
.bsplus-folder-dropdown-item.bsplus-checked .bsplus-folder-dropdown-check {
background: var(--better-main, #007bff);
border-color: var(--better-main, #007bff);
}
.bsplus-folder-dropdown-check svg {
width: 10px;
height: 10px;
color: white;
opacity: 0;
transition: opacity 0.1s ease;
}
.bsplus-folder-dropdown-item.bsplus-checked .bsplus-folder-dropdown-check svg {
opacity: 1;
}
.bsplus-folder-dropdown-empty {
padding: 12px;
text-align: center;
font-size: 12px;
color: var(--text-primary, #999);
opacity: 0.5;
}
/* ── Let primary column use available space instead of being clipped ── */
[class*='MessageList__primary___'] {
flex: 1 1 0% !important;
min-width: 0 !important;
overflow: hidden !important;
}
/* ── Make subject line a flex row so badges sit inline ── */
[class*='MessageList__subject___'] {
display: flex !important;
align-items: center;
gap: 6px;
min-width: 0 !important;
overflow: hidden !important;
}
/* ── Subject text truncates to make room for badges ── */
.bsplus-subject-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1 1 auto;
}
/* ── Shrink the secondary column to its content ── */
[class*='MessageList__secondary___'] {
flex: 0 0 auto !important;
width: auto !important;
min-width: 0 !important;
max-width: 200px !important;
}
/* ── Constrain the flags/attachment icon column ── */
[class*='MessageList__flags___'] {
width: 24px !important;
min-width: 0 !important;
flex-shrink: 0 !important;
}
/* ── Message list folder badges ── */
.bsplus-msg-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
margin-left: auto;
}
.bsplus-msg-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
border-radius: 8px;
font-size: 10px;
font-weight: 500;
line-height: 1.4;
color: white;
white-space: nowrap;
cursor: pointer;
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.bsplus-msg-badge:hover {
opacity: 0.85;
transform: scale(1.05);
}
/* ── Folder filtering (hide messages not in active folder) ── */
.bsplus-folder-hidden {
display: none !important;
}
/* ── Delete confirmation modal ── */
@keyframes bsplus-modal-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes bsplus-modal-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.bsplus-modal-overlay {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
animation: bsplus-modal-overlay-in 0.2s ease-out forwards;
}
.bsplus-modal {
padding: 1rem 1.5rem;
margin: 0 1rem;
min-width: 16rem;
max-width: 22rem;
width: 100%;
box-sizing: border-box;
background: var(--background-primary, #fff);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid var(--background-secondary, #e0e0e0);
animation: bsplus-modal-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.bsplus-modal h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #333);
}
.bsplus-modal p {
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--text-primary, #666);
opacity: 0.8;
}
.bsplus-modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.bsplus-modal-actions button {
padding: 0.4rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.bsplus-modal-btn-cancel {
background: transparent;
border: 1px solid var(--background-secondary, #ccc);
color: var(--text-primary, #333);
}
.bsplus-modal-btn-cancel:hover {
background: var(--background-secondary, rgba(128, 128, 128, 0.1));
}
.bsplus-modal-btn-danger {
background: #e53e3e;
border: none;
color: white;
}
.bsplus-modal-btn-danger:hover {
background: #c53030;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35);
}
@@ -1,4 +1,5 @@
import type { Plugin } from "../../core/types"; import type { Plugin } from "../../core/types";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
interface NotificationCollectorStorage { interface NotificationCollectorStorage {
lastNotificationCount: number; lastNotificationCount: number;
@@ -15,6 +16,10 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
disableToggle: true, disableToggle: true,
run: async (api) => { run: async (api) => {
if (isSeqtaEngageExperience()) {
return () => {};
}
let pollInterval: number | null = null; let pollInterval: number | null = null;
let isVisible = !document.hidden; let isVisible = !document.hidden;
let baseInterval = 30000; // 30 seconds let baseInterval = 30000; // 30 seconds
+42 -13
View File
@@ -1,11 +1,21 @@
import type { Plugin } from "@/plugins/core/types"; import type { Plugin } from "@/plugins/core/types";
import { componentSetting, defineSettings } from "@/plugins/core/settingsHelpers"; import {
booleanSetting,
componentSetting,
defineSettings,
} from "@/plugins/core/settingsHelpers";
import ProfilePictureSetting from "./ProfilePictureSetting.svelte"; import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import localforage from "localforage"; import localforage from "localforage";
const settings = defineSettings({ const settings = defineSettings({
useCloudPfp: booleanSetting({
default: false,
title: "Use BetterSEQTA Cloud profile picture",
description: "Use your cloud account avatar instead of the uploaded image below",
}),
picture: componentSetting({ picture: componentSetting({
title: "Profile Picture", title: "Profile Picture",
description: "Upload or remove your custom profile image", description: "Upload or remove your custom profile image",
@@ -13,12 +23,11 @@ const settings = defineSettings({
}), }),
}); });
const profilePicturePlugin: Plugin<typeof settings> = { const profilePicturePlugin: Plugin<typeof settings> = {
id: "profile-picture", id: "profile-picture",
name: "Custom Profile Picture", name: "Custom Profile Picture",
description: "Use your own image in place of the profile icon", description: "Use your own image in place of the profile icon",
version: "1.1.0", version: "1.2.0",
settings: settings, settings: settings,
disableToggle: true, disableToggle: true,
defaultEnabled: false, defaultEnabled: false,
@@ -37,14 +46,12 @@ const profilePicturePlugin: Plugin<typeof settings> = {
let img: HTMLImageElement | null = null; let img: HTMLImageElement | null = null;
let currentBlobUrl: string | undefined; let currentBlobUrl: string | undefined;
// Setup localforage instance
const store = localforage.createInstance({ const store = localforage.createInstance({
name: "profile-picture-store", name: "profile-picture-store",
storeName: "profilePicture", storeName: "profilePicture",
}); });
async function updateImageFromStore() { async function applyProfileImage() {
// Remove old image if present
if (img) { if (img) {
img.remove(); img.remove();
img = null; img = null;
@@ -53,6 +60,19 @@ const profilePicturePlugin: Plugin<typeof settings> = {
URL.revokeObjectURL(currentBlobUrl); URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = undefined; currentBlobUrl = undefined;
} }
const useCloud = api.settings.useCloudPfp;
const pfpUrl = cloudAuth.state.user?.pfpUrl;
if (useCloud && pfpUrl) {
img = document.createElement("img");
img.className = "userInfoImg";
img.src = pfpUrl;
if (svg) svg.style.display = "none";
container.appendChild(img);
return;
}
const blob = await store.getItem<Blob>("profile-picture"); const blob = await store.getItem<Blob>("profile-picture");
if (blob && blob instanceof Blob) { if (blob && blob instanceof Blob) {
currentBlobUrl = URL.createObjectURL(blob); currentBlobUrl = URL.createObjectURL(blob);
@@ -66,15 +86,25 @@ const profilePicturePlugin: Plugin<typeof settings> = {
} }
} }
// Initial load await applyProfileImage();
await updateImageFromStore();
// Listen for profile picture updates const onLocalPictureUpdated = () => {
const handler = () => { updateImageFromStore(); }; void applyProfileImage();
window.addEventListener('profile-picture-updated', handler); };
window.addEventListener("profile-picture-updated", onLocalPictureUpdated);
const cloudUnsub = cloudAuth.subscribe(() => {
void applyProfileImage();
});
const useCloudUnreg = api.settings.onChange("useCloudPfp", () => {
void applyProfileImage();
});
return () => { return () => {
window.removeEventListener('profile-picture-updated', handler); useCloudUnreg.unregister();
cloudUnsub();
window.removeEventListener("profile-picture-updated", onLocalPictureUpdated);
if (img) img.remove(); if (img) img.remove();
if (svg) svg.style.display = ""; if (svg) svg.style.display = "";
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl); if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
@@ -83,4 +113,3 @@ const profilePicturePlugin: Plugin<typeof settings> = {
}; };
export default profilePicturePlugin; export default profilePicturePlugin;
+3 -4
View File
@@ -1,11 +1,10 @@
import renderSvelte from "@/interface/main"; import renderSvelte from "@/interface/main";
import themeCreator from "@/interface/pages/themeCreator.svelte";
import { unmount } from "svelte";
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager"; import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
import { unmount } from "svelte";
import themeCreator from "@/interface/pages/themeCreator.svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
let themeCreatorSvelteApp: any = null; let themeCreatorSvelteApp: any = null;
const themeManager = ThemeManager.getInstance();
/** /**
* Open the Theme Creator sidebar, it is an embedded page loaded similar to the extension popup * Open the Theme Creator sidebar, it is an embedded page loaded similar to the extension popup
@@ -41,7 +40,7 @@ export function OpenThemeCreator(themeID: string = "") {
closeButton.textContent = "×"; closeButton.textContent = "×";
closeButton.addEventListener("click", () => { closeButton.addEventListener("click", () => {
CloseThemeCreator(); CloseThemeCreator();
themeManager.clearPreview(); ThemeManager.getInstance().clearPreview();
}); });
document.body.appendChild(closeButton); document.body.appendChild(closeButton);
+1
View File
@@ -10,6 +10,7 @@ const themesPlugin: Plugin = {
run: async (_) => { run: async (_) => {
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
await themeManager.prepareThemeAfterCloudSync();
await themeManager.initialize(); await themeManager.initialize();
}, },
}; };
+296 -31
View File
@@ -1,7 +1,21 @@
import localforage from "localforage"; import localforage from "localforage";
import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes"; import browser from "webextension-polyfill";
import {
type CustomTheme,
getForcedDarkMode,
type LoadedCustomTheme,
shouldForceThemeAppearance,
} from "@/types/CustomThemes";
import { BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY } from "@/seqta/utils/cloudSettingsSync";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import debounce from "@/seqta/utils/debounce"; import debounce from "@/seqta/utils/debounce";
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { updateAllColors } from "@/seqta/ui/colors/Manager";
import {
clearCustomThemeAdaptiveCssVariables,
setCustomThemeAdaptiveCssVariables,
} from "@/seqta/ui/colors/customThemeAdaptiveBindings";
type ThemeContent = { type ThemeContent = {
id: string; id: string;
@@ -12,8 +26,19 @@ type ThemeContent = {
CanChangeColour?: boolean; CanChangeColour?: boolean;
CustomCSS?: string; CustomCSS?: string;
hideThemeName?: boolean; hideThemeName?: boolean;
forceTheme?: boolean;
forceDark?: boolean; forceDark?: boolean;
images: { id: string; variableName: string; data: string }[]; // data: base64 adaptiveCssVariables?: string[];
images?: { id: string; variableName: string; data: string }[]; // data: base64
};
export type InstallThemeMeta = {
fromStore: boolean;
/** Server list `updated_at` (Unix seconds); set when installing from store. */
serverUpdatedAtSec?: number;
forceTheme?: boolean;
adaptiveCssVariables?: string[];
images?: { id: string; variableName: string; data: string }[]; // data: base64
}; };
export class ThemeManager { export class ThemeManager {
@@ -26,6 +51,7 @@ export class ThemeManager {
private originalPreviewTheme: boolean | null = null; private originalPreviewTheme: boolean | null = null;
private imageUrlCache: Map<string, string> = new Map(); private imageUrlCache: Map<string, string> = new Map();
private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 }; private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 };
private storeUpdateCheckRunning = false;
private constructor() { private constructor() {
console.debug("[ThemeManager] Initializing..."); console.debug("[ThemeManager] Initializing...");
@@ -141,6 +167,33 @@ export class ThemeManager {
} }
} }
/**
* After cloud restore, IndexedDB/theme storage is only reachable from page context (not MV3 SW).
* Background sets BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY; we fetch the store JSON here before setTheme().
* The resolved id matches cloud sync **`themeId` / `selectedTheme`**: it may be a standard theme uuid or a
* flavour (slave) variant id **`downloadAndInstallStoreTheme`** is the same code path as the theme store installer.
*/
public async prepareThemeAfterCloudSync(): Promise<void> {
try {
const snap = await browser.storage.local.get(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
const pending = snap[BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY];
if (pending === undefined) return;
await browser.storage.local.remove(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
if (typeof pending !== "string") return;
const id = pending.trim();
if (!id) return;
const existing = (await localforage.getItem(id)) as CustomTheme | null;
if (existing) return;
await this.downloadAndInstallStoreTheme({ id, name: id });
} catch (e) {
console.warn("[ThemeManager] prepareThemeAfterCloudSync:", e);
}
}
/** /**
* Initialize the theme system and restore previous state * Initialize the theme system and restore previous state
*/ */
@@ -174,6 +227,8 @@ export class ThemeManager {
} }
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error during initialization:", error); console.error("[ThemeManager] Error during initialization:", error);
} finally {
void this.checkStoreThemeUpdates();
} }
} }
@@ -208,7 +263,7 @@ export class ThemeManager {
console.debug("[ThemeManager] Storing original settings"); console.debug("[ThemeManager] Storing original settings");
settingsState.originalSelectedColor = settingsState.selectedColor; settingsState.originalSelectedColor = settingsState.selectedColor;
if (theme.forceDark) { if (shouldForceThemeAppearance(theme)) {
settingsState.originalDarkMode = settingsState.DarkMode; settingsState.originalDarkMode = settingsState.DarkMode;
} }
} }
@@ -239,6 +294,7 @@ export class ThemeManager {
this.currentTheme = theme; this.currentTheme = theme;
settingsState.selectedTheme = themeId; settingsState.selectedTheme = themeId;
} }
void updateAllColors();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error setting theme:", error); console.error("[ThemeManager] Error setting theme:", error);
} }
@@ -269,9 +325,10 @@ export class ThemeManager {
} }
// Apply theme settings // Apply theme settings
if (theme.forceDark !== undefined) { if (shouldForceThemeAppearance(theme)) {
console.debug("[ThemeManager] Setting dark mode:", theme.forceDark); const dark = getForcedDarkMode(theme);
settingsState.DarkMode = theme.forceDark; console.debug("[ThemeManager] Setting dark mode:", dark);
settingsState.DarkMode = dark;
} }
// Use the stored selected color if available, otherwise use the default // Use the stored selected color if available, otherwise use the default
@@ -288,6 +345,8 @@ export class ThemeManager {
); );
settingsState.selectedColor = theme.defaultColour; settingsState.selectedColor = theme.defaultColour;
} }
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error applying theme:", error); console.error("[ThemeManager] Error applying theme:", error);
} }
@@ -344,9 +403,18 @@ export class ThemeManager {
if (this.currentTheme) { if (this.currentTheme) {
// Store the current color with the theme before removing it // Store the current color with the theme before removing it
const selectedColor = settingsState.selectedColor;
const markUserEditedForColor =
this.currentTheme.userEdited !== true &&
this.currentTheme.installedFromStore !== false &&
selectedColor &&
this.currentTheme.defaultColour &&
selectedColor.trim().toLowerCase() !==
this.currentTheme.defaultColour.trim().toLowerCase();
await localforage.setItem(this.currentTheme.id, { await localforage.setItem(this.currentTheme.id, {
...this.currentTheme, ...this.currentTheme,
selectedColor: settingsState.selectedColor, selectedColor,
...(markUserEditedForColor ? { userEdited: true } : {}),
}); });
} }
@@ -372,6 +440,7 @@ export class ThemeManager {
if (clearSelectedTheme) { if (clearSelectedTheme) {
settingsState.selectedTheme = ""; settingsState.selectedTheme = "";
} }
clearCustomThemeAdaptiveCssVariables();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error removing theme:", error); console.error("[ThemeManager] Error removing theme:", error);
} }
@@ -426,18 +495,24 @@ export class ThemeManager {
public async saveTheme(theme: LoadedCustomTheme): Promise<void> { public async saveTheme(theme: LoadedCustomTheme): Promise<void> {
console.debug("[ThemeManager] Saving theme:", theme.name); console.debug("[ThemeManager] Saving theme:", theme.name);
try { try {
await localforage.setItem(theme.id, theme); const existing = (await localforage.getItem(theme.id)) as CustomTheme | null;
let toSave = theme;
if (existing?.userEdited === true && theme.userEdited !== false) {
toSave = { ...theme, userEdited: true };
}
await localforage.setItem(toSave.id, toSave);
const themeIds = (await localforage.getItem("customThemes")) as const themeIds = (await localforage.getItem("customThemes")) as
| string[] | string[]
| null; | null;
if (themeIds) { if (themeIds) {
if (!themeIds.includes(theme.id)) { if (!themeIds.includes(toSave.id)) {
themeIds.push(theme.id); themeIds.push(toSave.id);
await localforage.setItem("customThemes", themeIds); await localforage.setItem("customThemes", themeIds);
} }
} else { } else {
await localforage.setItem("customThemes", [theme.id]); await localforage.setItem("customThemes", [toSave.id]);
} }
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error saving theme:", error); console.error("[ThemeManager] Error saving theme:", error);
@@ -470,34 +545,87 @@ export class ThemeManager {
} }
} }
private readonly THEME_API_BASE = 'https://betterseqta.org/api';
private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes';
/** /**
* Download and install a theme from the store * Fetch JSON from a URL via background script (avoids CORS when running inside SEQTA page)
*/
private async fetchFromUrl(url: string): Promise<any> {
const result = (await browser.runtime.sendMessage({
type: 'fetchFromUrl',
url,
})) as { data?: unknown; error?: string };
if (result?.error) throw new Error(result.error);
return result?.data;
}
/**
* Download and install a theme from the store.
* Uses API first (increments download_count), falls back to GitHub if unreachable.
*/ */
public async downloadTheme(themeContent: { public async downloadTheme(themeContent: {
id: string; id: string;
name: string; name: string;
description: string; description?: string;
coverImage: string; coverImage?: string;
theme_json_url?: string;
updated_at?: number;
}): Promise<void> { }): Promise<void> {
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
try { try {
if (!themeContent.id) return; await this.downloadAndInstallStoreTheme(themeContent);
const response = await fetch(
`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes/${themeContent.id}/theme.json`,
);
const themeData = (await response.json()) as ThemeContent;
await this.installTheme(themeData);
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error downloading theme:", error); console.error("[ThemeManager] Error downloading theme:", error);
} }
} }
/**
* Fetch theme.json from the store and install (throws on failure).
*/
private async downloadAndInstallStoreTheme(themeContent: {
id: string;
name: string;
description?: string;
coverImage?: string;
theme_json_url?: string;
updated_at?: number;
}): Promise<void> {
console.debug("[ThemeManager] Downloading theme:", themeContent.name);
if (!themeContent.id) {
throw new Error("Missing theme id");
}
let themeData: ThemeContent;
try {
const downloadData = (await this.fetchFromUrl(
`${this.THEME_API_BASE}/themes/${themeContent.id}/download`,
)) as { success?: boolean; data?: { theme_json_url: string } };
if (!downloadData?.success || !downloadData?.data?.theme_json_url) {
throw new Error("Failed to get theme download URL");
}
themeData = (await this.fetchFromUrl(
downloadData.data.theme_json_url,
)) as ThemeContent;
} catch (apiError) {
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
const githubUrl = `${this.GITHUB_THEMES_BASE}/${themeContent.id}/theme.json`;
themeData = (await this.fetchFromUrl(githubUrl)) as ThemeContent;
}
await this.installTheme(themeData, {
fromStore: true,
serverUpdatedAtSec: themeContent.updated_at,
});
}
/** /**
* Install a theme from theme data * Install a theme from theme data
*/ */
public async installTheme(themeData: ThemeContent): Promise<void> { public async installTheme(
themeData: ThemeContent,
meta?: InstallThemeMeta,
): Promise<void> {
console.debug("[ThemeManager] Installing theme:", themeData.name); console.debug("[ThemeManager] Installing theme:", themeData.name);
try { try {
// Validate required fields // Validate required fields
@@ -505,6 +633,9 @@ export class ThemeManager {
throw new Error("Theme is missing required fields (id or name)"); throw new Error("Theme is missing required fields (id or name)");
} }
const fromStore = meta?.fromStore ?? false;
const serverUpdatedAtSec = meta?.serverUpdatedAtSec;
// Handle cover image (optional) // Handle cover image (optional)
let coverImageBlob = null; let coverImageBlob = null;
if (themeData.coverImage) { if (themeData.coverImage) {
@@ -539,7 +670,6 @@ export class ThemeManager {
}) })
.filter((img) => img !== null) ?? []; .filter((img) => img !== null) ?? [];
// Create theme with defaults for optional fields
const theme: LoadedCustomTheme = { const theme: LoadedCustomTheme = {
id: themeData.id, id: themeData.id,
name: themeData.name, name: themeData.name,
@@ -554,6 +684,19 @@ export class ThemeManager {
isEditable: false, isEditable: false,
hideThemeName: themeData.hideThemeName ?? false, hideThemeName: themeData.hideThemeName ?? false,
forceDark: themeData.forceDark, forceDark: themeData.forceDark,
installedFromStore: fromStore,
userEdited: fromStore ? false : undefined,
storeSyncedAtSec:
fromStore && serverUpdatedAtSec != null
? serverUpdatedAtSec
: undefined,
forceTheme:
themeData.forceTheme !== undefined
? themeData.forceTheme
: themeData.forceDark !== undefined
? true
: undefined,
adaptiveCssVariables: themeData.adaptiveCssVariables,
}; };
await this.saveTheme(theme); await this.saveTheme(theme);
@@ -563,6 +706,115 @@ export class ThemeManager {
} }
} }
/**
* Compare installed store themes to GET /api/themes and refresh when the server is newer.
* Skips themes with userEdited: true (theme creator / popup save, or custom accent vs default).
*/
private static STORE_CHECK_KEY = "bsplus_lastStoreThemeCheck";
private static STORE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
public async checkStoreThemeUpdates(): Promise<void> {
if (this.storeUpdateCheckRunning) return;
const lastCheck = Number(localStorage.getItem(ThemeManager.STORE_CHECK_KEY) || 0);
if (Date.now() - lastCheck < ThemeManager.STORE_CHECK_INTERVAL_MS) return;
this.storeUpdateCheckRunning = true;
localStorage.setItem(ThemeManager.STORE_CHECK_KEY, String(Date.now()));
try {
const token = await cloudAuth.getStoredToken();
const res = (await browser.runtime.sendMessage({
type: "fetchThemes",
token: token ?? undefined,
})) as {
success?: boolean;
data?: { themes?: Array<{ id: string; updated_at?: number }> };
};
if (!res?.success || !res.data?.themes?.length) return;
const serverById = new Map<string, number>();
for (const t of res.data.themes) {
if (
typeof t.id === "string" &&
typeof t.updated_at === "number"
) {
serverById.set(t.id, t.updated_at);
}
}
if (serverById.size === 0) return;
const themeIds = (await localforage.getItem("customThemes")) as
| string[]
| null;
if (!themeIds?.length) return;
for (const id of themeIds) {
const theme = (await localforage.getItem(id)) as CustomTheme | null;
if (!theme || theme.userEdited === true) {
continue;
}
// File imports explicitly set installedFromStore: false — never auto-sync.
if (theme.installedFromStore === false) {
continue;
}
const serverUpdated = serverById.get(id);
if (serverUpdated == null) {
continue;
}
if (
theme.installedFromStore === true &&
theme.storeSyncedAtSec != null
) {
if (serverUpdated <= theme.storeSyncedAtSec) {
continue;
}
}
// Else: legacy installs from before store metadata (installedFromStore/storeSyncedAtSec
// unset) or incomplete rows — still listed on the server, so sync to latest once.
const wasSelected = settingsState.selectedTheme === id;
try {
if (wasSelected) {
await this.disableTheme();
}
await this.downloadAndInstallStoreTheme({
id: theme.id,
name: theme.name,
updated_at: serverUpdated,
});
console.log(
"[ThemeManager] Theme auto-updated from store:",
theme.name,
);
if (wasSelected) {
await this.setTheme(id, false);
}
themeUpdates.triggerUpdate();
} catch (err) {
console.error("[ThemeManager] Store theme auto-update failed:", id, err);
if (wasSelected) {
try {
await this.setTheme(id, false);
} catch (restoreErr) {
console.error(
"[ThemeManager] Failed to restore theme after update error:",
restoreErr,
);
}
}
}
}
} catch (e) {
console.error("[ThemeManager] checkStoreThemeUpdates error:", e);
} finally {
this.storeUpdateCheckRunning = false;
}
}
/** /**
* Share a theme by exporting it * Share a theme by exporting it
*/ */
@@ -583,6 +835,9 @@ export class ThemeManager {
isEditable, isEditable,
selectedColor, selectedColor,
allowBackgrounds, allowBackgrounds,
installedFromStore,
storeSyncedAtSec,
userEdited,
...themeBasics ...themeBasics
} = theme; } = theme;
@@ -620,7 +875,7 @@ export class ThemeManager {
public async previewTheme(theme: LoadedCustomTheme): Promise<void> { public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
console.debug("[ThemeManager] Previewing theme:", theme.name); console.debug("[ThemeManager] Previewing theme:", theme.name);
try { try {
const { CustomCSS, CustomImages, defaultColour, forceDark } = theme; const { CustomCSS, CustomImages, defaultColour } = theme;
// Store original settings only if this is a new theme // Store original settings only if this is a new theme
if (!theme.webURL) { if (!theme.webURL) {
@@ -666,13 +921,16 @@ export class ThemeManager {
this.previousImageVariableNames = newImageVariableNames; this.previousImageVariableNames = newImageVariableNames;
// Apply theme settings // Apply theme settings
if (forceDark !== undefined) { if (shouldForceThemeAppearance(theme)) {
settingsState.DarkMode = forceDark; settingsState.DarkMode = getForcedDarkMode(theme);
} }
if (defaultColour) { if (defaultColour) {
settingsState.selectedColor = defaultColour; settingsState.selectedColor = defaultColour;
} }
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
void updateAllColors();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error previewing theme:", error); console.error("[ThemeManager] Error previewing theme:", error);
} }
@@ -738,15 +996,18 @@ export class ThemeManager {
this.previousImageVariableNames = newImageVariableNames; this.previousImageVariableNames = newImageVariableNames;
} }
// Always apply dark mode setting // Always apply dark mode setting when theme forces appearance
if (theme.forceDark !== undefined) { if (shouldForceThemeAppearance(theme as CustomTheme)) {
settingsState.DarkMode = theme.forceDark; settingsState.DarkMode = getForcedDarkMode(theme as CustomTheme);
} }
// Only apply color if this is a new theme // Only apply color if this is a new theme
if (!theme.webURL && theme.defaultColour) { if (!theme.webURL && theme.defaultColour) {
settingsState.selectedColor = theme.defaultColour; settingsState.selectedColor = theme.defaultColour;
} }
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
void updateAllColors();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error updating theme preview:", error); console.error("[ThemeManager] Error updating theme preview:", error);
} }
@@ -784,6 +1045,8 @@ export class ThemeManager {
this.previewStyleElement = null; this.previewStyleElement = null;
} }
clearCustomThemeAdaptiveCssVariables();
// Restore original settings // Restore original settings
const storedColor = localStorage.getItem("originalPreviewColor"); const storedColor = localStorage.getItem("originalPreviewColor");
@@ -813,6 +1076,8 @@ export class ThemeManager {
settingsState.DarkMode = this.originalPreviewTheme; settingsState.DarkMode = this.originalPreviewTheme;
this.originalPreviewTheme = null; this.originalPreviewTheme = null;
} }
void updateAllColors();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error clearing preview:", error); console.error("[ThemeManager] Error clearing preview:", error);
} }
+7 -2
View File
@@ -63,7 +63,12 @@ function resetTimetableStyles(): void {
} }
async function handleTimetable(): Promise<void> { async function handleTimetable(): Promise<void> {
await waitForElm(".time", true, 10); // SEQTA uses `.times` blocks on entries, not necessarily `.time`; avoid infinite polling on a missing selector.
try {
await waitForElm(".timetablepage .times, .timetablepage .entry.class", true, 50, 200);
} catch {
/* timetable body may render after the shell */
}
// Convert time format if needed // Convert time format if needed
if (settingsState.timeFormat == "12") { if (settingsState.timeFormat == "12") {
@@ -130,7 +135,7 @@ function handleTimetableAssessmentHide(): void {
const hideOn = document.createElement("button"); const hideOn = document.createElement("button");
hideOn.className = "uiButton timetable-hide iconFamily"; hideOn.className = "uiButton timetable-hide iconFamily";
hideOn.innerHTML = "&#128065;"; hideOn.innerHTML = "&#xeab3;";
hideControls.appendChild(hideOn); hideControls.appendChild(hideOn);
+348
View File
@@ -0,0 +1,348 @@
import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm";
import styles from "./styles.css?inline";
interface TimetableEntryData {
ci: number;
description: string;
room: string;
staff: string;
}
interface TimetableOverrides {
[ci: string]: { room?: string; staff?: string };
}
interface TimetableOverridesBySubject {
[description: string]: { room?: string; staff?: string };
}
interface TimetableStorage {
timetableOverrides?: TimetableOverrides;
timetableOverridesBySubject?: TimetableOverridesBySubject;
}
/** SEQTA timetable entries use .teacher and .room as direct children, and data-instance for ci */
function getRoomAndTeacherElements(entry: HTMLElement): {
roomEl: HTMLElement | null;
teacherEl: HTMLElement | null;
} {
const roomEl = entry.querySelector(".room") as HTMLElement | null;
const teacherEl = entry.querySelector(".teacher") as HTMLElement | null;
return { roomEl, teacherEl };
}
const EDIT_ICON_SVG =
'<svg width="24" height="24" viewBox="0 0 24 24"><g style="fill: currentcolor;"><path d="M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z"/></g></svg>';
function showEditModal(
item: TimetableEntryData,
overrides: TimetableOverrides | undefined,
overridesBySubject: TimetableOverridesBySubject | undefined,
onSave: (
ci: number,
room: string,
staff: string,
applyToFuture: boolean,
) => void,
onClear: (ci: number) => void,
): void {
const overlay = document.createElement("div");
overlay.className = "timetable-edit-modal-overlay";
const modal = document.createElement("div");
modal.className = "timetable-edit-modal";
const override = overrides?.[String(item.ci)] ?? overridesBySubject?.[item.description];
const roomValue = override?.room ?? item.room ?? "";
const staffValue = override?.staff ?? item.staff ?? "";
const escapeHtml = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
const title = escapeHtml(item.description);
modal.innerHTML = `
<h3>Edit ${title}</h3>
<label for="timetable-edit-room">Room</label>
<input type="text" id="timetable-edit-room" value="${roomValue.replace(/"/g, "&quot;")}" placeholder="Room" />
<label for="timetable-edit-staff">Teacher</label>
<input type="text" id="timetable-edit-staff" value="${staffValue.replace(/"/g, "&quot;")}" placeholder="Teacher" />
<div class="timetable-edit-modal-checkbox">
<input type="checkbox" id="timetable-edit-apply-future" />
<label for="timetable-edit-apply-future">Apply to future weeks</label>
</div>
<div class="timetable-edit-modal-actions">
${override ? '<button type="button" class="timetable-edit-btn-clear">Clear</button>' : ""}
<button type="button" class="timetable-edit-btn-cancel">Cancel</button>
<button type="button" class="timetable-edit-btn-save">Save</button>
</div>
`;
overlay.appendChild(modal);
const removeModal = () => {
overlay.remove();
document.removeEventListener("keydown", handleKeydown);
};
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") removeModal();
};
overlay.addEventListener("click", (e) => {
if (e.target === overlay) removeModal();
});
modal.addEventListener("click", (e) => e.stopPropagation());
modal.addEventListener("mousedown", (e) => e.stopPropagation());
modal.addEventListener("mouseup", (e) => e.stopPropagation());
const roomInput = modal.querySelector("#timetable-edit-room") as HTMLInputElement;
const staffInput = modal.querySelector("#timetable-edit-staff") as HTMLInputElement;
const applyFutureCheckbox = modal.querySelector("#timetable-edit-apply-future") as HTMLInputElement;
modal.querySelector(".timetable-edit-btn-save")?.addEventListener("click", () => {
onSave(
item.ci,
roomInput.value.trim(),
staffInput.value.trim(),
applyFutureCheckbox?.checked ?? false,
);
removeModal();
});
modal.querySelector(".timetable-edit-btn-cancel")?.addEventListener("click", removeModal);
const clearBtn = modal.querySelector(".timetable-edit-btn-clear");
if (clearBtn) {
clearBtn.addEventListener("click", () => {
onClear(item.ci);
removeModal();
});
}
document.body.appendChild(overlay);
document.addEventListener("keydown", handleKeydown);
roomInput?.focus();
}
const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
id: "timetableEdit",
name: "Edit Rooms & Teachers",
description: "Edit room and teacher names in timetable classes",
version: "1.0.0",
settings: {},
disableToggle: true,
defaultEnabled: true,
run: async (api) => {
const styleEl = document.createElement("style");
styleEl.textContent = styles;
document.head.appendChild(styleEl);
await api.storage.loaded;
let observer: MutationObserver | null = null;
let quickbarObserver: MutationObserver | null = null;
let lastClickedCi: number | null = null;
let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null;
const getOverrides = (): TimetableOverrides =>
api.storage.timetableOverrides ?? {};
const getOverridesBySubject = (): TimetableOverridesBySubject =>
api.storage.timetableOverridesBySubject ?? {};
const getEffectiveOverride = (
ci: number,
description: string,
): { room?: string; staff?: string } | undefined =>
getOverrides()[String(ci)] ?? getOverridesBySubject()[description];
const processEntry = (entry: HTMLElement): void => {
if (entry.classList.contains("assessment") || entry.hasAttribute("data-timetable-edit-processed")) return;
const ciStr = entry.getAttribute("data-instance");
if (!ciStr) return;
const ci = parseInt(ciStr, 10);
if (isNaN(ci)) return;
const { roomEl, teacherEl } = getRoomAndTeacherElements(entry);
if (!roomEl && !teacherEl) return;
const titleEl = entry.querySelector(".title");
const description = titleEl?.textContent?.trim() ?? "";
const room = roomEl?.textContent?.trim() ?? "";
const staff = teacherEl?.textContent?.trim() ?? "";
const item: TimetableEntryData = { ci, description, room, staff };
entry.setAttribute("data-timetable-edit-processed", "true");
const override = getEffectiveOverride(ci, description);
if (override) {
if (override.room !== undefined && roomEl) roomEl.textContent = override.room;
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
}
const captureClick = (e: MouseEvent) => {
lastClickedCi = ci;
lastClickedEntry = { roomEl, teacherEl, item };
};
entry.addEventListener("click", captureClick, true);
};
const processAllEntries = () => {
document.querySelectorAll(".timetablepage .entry.class").forEach((entry) => {
processEntry(entry as HTMLElement);
});
};
const addEditButtonToQuickbar = (quickbar: HTMLElement) => {
if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return;
const actions = quickbar.querySelector(".actions");
if (!actions) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "uiButton timetable-edit-quickbar-btn";
btn.title = "Edit room and teacher";
btn.innerHTML = EDIT_ICON_SVG;
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const ci = lastClickedCi;
const entryData = lastClickedEntry;
if (!ci || !entryData) return;
const qb = (e.currentTarget as HTMLElement).closest(".quickbar");
if (!qb) return;
const quickbarRoom = qb.querySelector(".meta .room")?.textContent?.trim() ?? "";
const quickbarTeacher = qb.querySelector(".meta .teacher")?.textContent?.trim() ?? "";
const quickbarTitle = qb.querySelector(".title")?.textContent?.trim() ?? "";
const item: TimetableEntryData = {
ci,
description: quickbarTitle || entryData.item.description,
room: quickbarRoom || entryData.item.room,
staff: quickbarTeacher || entryData.item.staff,
};
showEditModal(
item,
getOverrides(),
getOverridesBySubject(),
(ci, room, staff, applyToFuture) => {
if (applyToFuture) {
const bySubject = { ...getOverridesBySubject() };
bySubject[item.description] = {
room: room || undefined,
staff: staff || undefined,
};
api.storage.timetableOverridesBySubject = bySubject;
} else {
const current = getOverrides();
api.storage.timetableOverrides = {
...current,
[String(ci)]: { room: room || undefined, staff: staff || undefined },
};
}
if (entryData.roomEl) entryData.roomEl.textContent = room;
if (entryData.teacherEl) entryData.teacherEl.textContent = staff;
processAllEntries();
},
(ci) => {
const current = getOverrides();
delete current[String(ci)];
api.storage.timetableOverrides = current;
const bySubject = getOverridesBySubject();
delete bySubject[item.description];
api.storage.timetableOverridesBySubject = bySubject;
if (entryData.roomEl) entryData.roomEl.textContent = item.room;
if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff;
processAllEntries();
},
);
});
actions.insertBefore(btn, actions.firstChild);
};
const syncQuickbarFromDOM = () => {
const quickbar = document.querySelector(
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
);
if (quickbar && quickbar.getAttribute("data-type") === "class") {
const titleEl = quickbar.querySelector(".title");
const roomEl = quickbar.querySelector(".meta .room");
const teacherEl = quickbar.querySelector(".meta .teacher");
if (titleEl && roomEl && teacherEl && lastClickedCi !== null && lastClickedEntry) {
addEditButtonToQuickbar(quickbar as HTMLElement);
}
}
};
const setupQuickbarObserver = () => {
const timetablePage = document.querySelector(".timetablepage");
if (!timetablePage || quickbarObserver) return;
quickbarObserver = new MutationObserver(() => {
const quickbar = document.querySelector(
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
);
if (quickbar?.getAttribute("data-type") === "class") {
addEditButtonToQuickbar(quickbar as HTMLElement);
}
});
quickbarObserver.observe(timetablePage, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class"],
});
};
const handleTimetable = async () => {
// Class entries (`div.entry.class`) load after the page shell; don't fail the whole
// setup if they are slow or briefly absent (e.g. navigation). Observers still catch them.
try {
await waitForElm(".timetablepage .entry.class", true, 50, 300);
} catch {
/* entries may appear later */
}
processAllEntries();
setupQuickbarObserver();
syncQuickbarFromDOM();
const timetablePage = document.querySelector(".timetablepage");
if (timetablePage && !observer) {
observer = new MutationObserver(() => {
document.querySelectorAll(".timetablepage .entry.class").forEach((entry) => {
if (!entry.hasAttribute("data-timetable-edit-processed")) {
processEntry(entry as HTMLElement);
}
});
});
observer.observe(timetablePage, { childList: true, subtree: true });
}
};
const { unregister } = api.seqta.onMount(".timetablepage", handleTimetable);
return () => {
unregister();
observer?.disconnect();
quickbarObserver?.disconnect();
styleEl.remove();
document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => {
el.removeAttribute("data-timetable-edit-processed");
});
document.querySelectorAll(".timetable-edit-quickbar-btn").forEach((el) => el.remove());
};
},
};
export default timetableEditPlugin;
@@ -0,0 +1,188 @@
/* Timetable Edit Plugin - BetterSEQTA Plus style */
/* Edit button in quickbar */
.timetable-edit-quickbar-btn {
padding: 0;
margin: 0;
background: transparent !important;
border: none !important;
cursor: pointer;
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
}
.timetable-edit-quickbar-btn:hover {
transform: scale(1.05);
}
.timetable-edit-quickbar-btn:active {
transform: scale(0.95);
}
.timetable-edit-quickbar-btn svg {
fill: currentColor;
width: 24px;
height: 24px;
}
/* Edit modal animations */
@keyframes timetable-edit-overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes timetable-edit-modal-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Edit modal overlay - fix click-through with proper stacking */
.timetable-edit-modal-overlay {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
pointer-events: auto;
animation: timetable-edit-overlay-in 0.2s ease-out forwards;
}
.timetable-edit-modal {
padding: 1rem 1.5rem;
margin: 0 1rem;
min-width: 18rem;
max-width: 24rem;
width: 100%;
box-sizing: border-box;
background: var(--background-primary);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
pointer-events: auto;
border: 1px solid var(--background-secondary);
animation: timetable-edit-modal-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.timetable-edit-modal h3 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.timetable-edit-modal label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
opacity: 0.8;
}
.timetable-edit-modal input[type="text"] {
width: 100%;
min-width: 0;
padding: 0.5rem 1rem 0.5rem 0.75rem;
margin-bottom: 1rem;
font-size: 0.875rem;
border: 1px solid var(--background-secondary);
border-radius: 0.5rem;
background: var(--background-secondary);
color: var(--text-primary);
box-sizing: border-box;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
user-select: text;
-webkit-user-select: text;
}
.timetable-edit-modal input[type="text"]:focus {
outline: none;
border-color: var(--better-main, #007bff);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.timetable-edit-modal-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.timetable-edit-modal-checkbox input {
width: auto;
margin: 0;
}
.timetable-edit-modal-checkbox label {
margin: 0;
cursor: pointer;
}
.timetable-edit-modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1rem;
flex-wrap: wrap;
}
.timetable-edit-modal-actions button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.timetable-edit-modal-actions .timetable-edit-btn-clear {
background: transparent;
border: 1px solid var(--background-secondary);
color: var(--text-primary);
}
.timetable-edit-modal-actions .timetable-edit-btn-clear:hover {
background: var(--background-secondary);
transform: translateY(-1px);
}
.timetable-edit-modal-actions .timetable-edit-btn-cancel {
background: transparent;
border: 1px solid var(--background-secondary);
color: var(--text-primary);
}
.timetable-edit-modal-actions .timetable-edit-btn-cancel:hover {
background: var(--background-secondary);
transform: translateY(-1px);
}
.timetable-edit-modal-actions .timetable-edit-btn-save {
background: var(--better-main, #007bff);
border: none;
color: var(--text-color, white);
}
.timetable-edit-modal-actions .timetable-edit-btn-save:hover {
transform: scale(1.03) translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.35);
}
.timetable-edit-modal-actions .timetable-edit-btn-save:active {
transform: scale(0.98) translateY(0);
box-shadow: none;
}
+6
View File
@@ -2,6 +2,7 @@ import { PluginManager } from "./core/manager";
// Lightweight plugins (load immediately) // Lightweight plugins (load immediately)
import timetablePlugin from "./built-in/timetable"; import timetablePlugin from "./built-in/timetable";
import timetableEditPlugin from "./built-in/timetableEdit";
import notificationCollectorPlugin from "./built-in/notificationCollector"; import notificationCollectorPlugin from "./built-in/notificationCollector";
import themesPlugin from "./built-in/themes"; import themesPlugin from "./built-in/themes";
import animatedBackgroundPlugin from "./built-in/animatedBackground"; import animatedBackgroundPlugin from "./built-in/animatedBackground";
@@ -9,6 +10,8 @@ import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
import profilePicturePlugin from "./built-in/profilePicture"; import profilePicturePlugin from "./built-in/profilePicture";
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview"; import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
import backgroundMusicPlugin from "./built-in/backgroundMusic"; import backgroundMusicPlugin from "./built-in/backgroundMusic";
import betterSeqtaSecurityPlugin from "./built-in/betterSeqtaSecurity";
//import messageFoldersPlugin from "./built-in/messageFolders";
//import testPlugin from './built-in/test'; //import testPlugin from './built-in/test';
// Heavy plugins (lazy-loaded only when enabled) // Heavy plugins (lazy-loaded only when enabled)
@@ -19,13 +22,16 @@ const pluginManager = PluginManager.getInstance();
// Register built-in plugins // Register built-in plugins
pluginManager.registerPlugin(themesPlugin); pluginManager.registerPlugin(themesPlugin);
pluginManager.registerPlugin(betterSeqtaSecurityPlugin);
pluginManager.registerPlugin(animatedBackgroundPlugin); pluginManager.registerPlugin(animatedBackgroundPlugin);
pluginManager.registerPlugin(assessmentsAveragePlugin); pluginManager.registerPlugin(assessmentsAveragePlugin);
pluginManager.registerPlugin(notificationCollectorPlugin); pluginManager.registerPlugin(notificationCollectorPlugin);
pluginManager.registerPlugin(timetablePlugin); pluginManager.registerPlugin(timetablePlugin);
pluginManager.registerPlugin(timetableEditPlugin);
pluginManager.registerPlugin(profilePicturePlugin); pluginManager.registerPlugin(profilePicturePlugin);
pluginManager.registerPlugin(assessmentsOverviewPlugin); pluginManager.registerPlugin(assessmentsOverviewPlugin);
pluginManager.registerPlugin(backgroundMusicPlugin); pluginManager.registerPlugin(backgroundMusicPlugin);
//pluginManager.registerPlugin(messageFoldersPlugin);
//pluginManager.registerPlugin(testPlugin); //pluginManager.registerPlugin(testPlugin);
// Register heavy plugins with lazy loading // Register heavy plugins with lazy loading
+269 -52
View File
@@ -17,14 +17,19 @@ import { StorageChangeHandler } from "@/seqta/utils/listeners/StorageChanges";
import { eventManager } from "@/seqta/utils/listeners/EventManager"; import { eventManager } from "@/seqta/utils/listeners/EventManager";
// UI and theme management // UI and theme management
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import RegisterClickListeners from "@/seqta/utils/listeners/ClickListeners"; import RegisterClickListeners from "@/seqta/utils/listeners/ClickListeners";
import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements"; import { AddBetterSEQTAElements } from "@/seqta/ui/AddBetterSEQTAElements";
import { updateAllColors } from "@/seqta/ui/colors/Manager"; import { updateAllColors } from "@/seqta/ui/colors/Manager";
import loading from "@/seqta/ui/Loading"; import loading from "@/seqta/ui/Loading";
import { SendNewsPage } from "@/seqta/utils/SendNewsPage"; import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
import { getEngageRoutePage } from "@/seqta/utils/engageRoute";
import {
loadEngageHomePage,
updateEngageHomeMenuActive,
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup"; import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification";
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes"; import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
@@ -82,7 +87,13 @@ export function hideSideBar() {
} }
} }
let betterSeqtaFinishLoadDone = false;
let engageHashListenerAttached = false;
export async function finishLoad() { export async function finishLoad() {
if (betterSeqtaFinishLoadDone) return;
betterSeqtaFinishLoadDone = true;
try { try {
document.querySelector(".legacy-root")?.classList.remove("hidden"); document.querySelector(".legacy-root")?.classList.remove("hidden");
@@ -94,14 +105,7 @@ export async function finishLoad() {
console.error("Error during loading cleanup:", err); console.error("Error during loading cleanup:", err);
} }
// Check and show privacy statement notification (before what's new) runStartupPopupQueue();
if (!document.getElementById("privacy-notification")) {
await showPrivacyNotification();
}
if (settingsState.justupdated && !document.getElementById("whatsnewbk") && !document.getElementById("privacy-notification")) {
OpenWhatsNewPopup();
}
} }
export function GetCSSElement(file: string) { export function GetCSSElement(file: string) {
@@ -115,19 +119,19 @@ export function GetCSSElement(file: string) {
} }
function removeThemeTagsFromNotices() { function removeThemeTagsFromNotices() {
// Grabs an array of the notice iFrames
const userHTMLArray = document.getElementsByClassName("userHTML"); const userHTMLArray = document.getElementsByClassName("userHTML");
// Iterates through the array, applying the iFrame css
for (const item of userHTMLArray) { for (const item of userHTMLArray) {
// Grabs the HTML of the body tag const iframe = item as HTMLIFrameElement;
const item1 = item as HTMLIFrameElement; try {
const body = item1.contentWindow!.document.querySelectorAll("body")[0]; const doc = iframe.contentDocument;
if (body) { if (!doc?.body) continue;
// Replaces the theme tag with nothing const body = doc.body;
const bodyText = body.innerHTML; const bodyText = body.innerHTML;
body.innerHTML = bodyText body.innerHTML = bodyText
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "") .replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " "); .replace(/ +/, " ");
} catch {
// Cross-origin or otherwise inaccessible iframe (common during Engage load / filter frames)
} }
} }
} }
@@ -202,7 +206,20 @@ function SortMessagePageItems(messagesParentElement: any) {
async function LoadPageElements(): Promise<void> { async function LoadPageElements(): Promise<void> {
await AddBetterSEQTAElements(); await AddBetterSEQTAElements();
const sublink: string | undefined = window.location.href.split("/")[4]; const sublink: string | undefined = isSeqtaEngageExperience()
? getEngageRoutePage()
: window.location.href.split("/")[4];
if (isSeqtaEngageExperience() && !engageHashListenerAttached) {
engageHashListenerAttached = true;
window.addEventListener("hashchange", () => {
if (getEngageRoutePage() === "home") {
void loadEngageHomePage();
} else {
updateEngageHomeMenuActive(false);
}
});
}
eventManager.register( eventManager.register(
"messagesAdded", "messagesAdded",
@@ -296,6 +313,28 @@ async function handleNotices(node: Element): Promise<void> {
} }
async function handleSublink(sublink: string | undefined): Promise<void> { async function handleSublink(sublink: string | undefined): Promise<void> {
if (isSeqtaEngageExperience()) {
switch (sublink) {
case undefined:
window.location.replace(
`${location.origin}/#?page=/${settingsState.defaultPage}`,
);
if (settingsState.defaultPage === "home") void loadEngageHomePage();
finishLoad();
break;
case "home":
window.location.replace(`${location.origin}/#?page=/home`);
console.info("[BetterSEQTA+] Started Init (SEQTA Engage home)");
if (settingsState.onoff) void loadEngageHomePage();
finishLoad();
break;
default:
finishLoad();
break;
}
return;
}
switch (sublink) { switch (sublink) {
case "news": case "news":
await handleNewsPage(); await handleNewsPage();
@@ -382,15 +421,22 @@ async function handleDashboard(node: Element): Promise<void> {
document.head.append(style); document.head.append(style);
await waitForElm(".dashlet", true, 10); await waitForElm(".dashlet", true, 10);
animate( try {
".dashboard > *", const children = document.querySelectorAll(".dashboard > *");
{ opacity: [0, 1], y: [10, 0] }, if (children.length) {
{ animate(
delay: stagger(0.1), children,
duration: 0.5, { opacity: [0, 1], y: [10, 0] },
ease: [0.22, 0.03, 0.26, 1], {
}, delay: stagger(0.1),
); duration: 0.5,
ease: [0.22, 0.03, 0.26, 1],
},
);
}
} catch {
// Avoid uncaught errors if motion hits an unexpected DOM state during load.
}
document.head.querySelector("style.dashboardHider")?.remove(); document.head.querySelector("style.dashboardHider")?.remove();
} }
@@ -400,15 +446,22 @@ async function handleDocuments(node: Element): Promise<void> {
if (!settingsState.animations) return; if (!settingsState.animations) return;
await waitForElm(".document", true, 10); await waitForElm(".document", true, 10);
animate( try {
".documents tbody tr.document", const rows = document.querySelectorAll(".documents tbody tr.document");
{ opacity: [0, 1], y: [10, 0] }, if (rows.length) {
{ animate(
delay: stagger(0.05), rows,
duration: 0.5, { opacity: [0, 1], y: [10, 0] },
ease: [0.22, 0.03, 0.26, 1], {
}, delay: stagger(0.05),
); duration: 0.5,
ease: [0.22, 0.03, 0.26, 1],
},
);
}
} catch {
// ignore
}
} }
async function handleReports(node: Element): Promise<void> { async function handleReports(node: Element): Promise<void> {
@@ -416,15 +469,22 @@ async function handleReports(node: Element): Promise<void> {
if (!settingsState.animations) return; if (!settingsState.animations) return;
await waitForElm(".report", true, 10); await waitForElm(".report", true, 10);
animate( try {
".reports .item", const items = document.querySelectorAll(".reports .item");
{ opacity: [0, 1], y: [10, 0] }, if (items.length) {
{ animate(
delay: stagger(0.05, { startDelay: 0.2 }), items,
duration: 0.5, { opacity: [0, 1], y: [10, 0] },
ease: [0.22, 0.03, 0.26, 1], {
}, delay: stagger(0.05, { startDelay: 0.2 }),
); duration: 0.5,
ease: [0.22, 0.03, 0.26, 1],
},
);
}
} catch {
// ignore
}
} }
function CheckNoticeTextColour(notice: any) { function CheckNoticeTextColour(notice: any) {
@@ -448,7 +508,86 @@ function CheckNoticeTextColour(notice: any) {
); );
} }
function watchForEngageLogin() {
if (!document.querySelector(".login")) {
return;
}
const observer = new MutationObserver(() => {
if (!document.querySelector(".login")) {
observer.disconnect();
location.reload();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
/** Wait until Engage shows either the login shell or the main app (`#content`), so we never call `LoadPageElements` while still on login (which would hang on `waitForElm("#content")`). */
function waitForEngageLoginOrContent(): Promise<"login" | "app" | "timeout"> {
if (document.querySelector(".login")) {
return Promise.resolve("login");
}
if (document.getElementById("content")) {
return Promise.resolve("app");
}
return new Promise((resolve) => {
let settled = false;
const finish = (mode: "login" | "app") => {
if (settled) return;
settled = true;
mo.disconnect();
window.clearTimeout(tid);
resolve(mode);
};
const check = () => {
if (document.querySelector(".login")) finish("login");
else if (document.getElementById("content")) finish("app");
};
const mo = new MutationObserver(check);
mo.observe(document.documentElement, { subtree: true, childList: true });
const tid = window.setTimeout(() => {
if (settled) return;
mo.disconnect();
settled = true;
if (document.querySelector(".login")) resolve("login");
else if (document.getElementById("content")) resolve("app");
else {
console.warn(
"[BetterSEQTA+] Engage: timed out waiting for .login or #content; unblocking load UI.",
);
resolve("timeout");
}
}, 120_000);
});
}
export function tryLoad() { export function tryLoad() {
if (isSeqtaEngageExperience()) {
updateIframesWithDarkMode();
window.addEventListener("load", () => removeThemeTagsFromNotices(), { once: true });
const runEngageLoad = async () => {
const mode = await waitForEngageLoginOrContent();
if (mode === "login") {
finishLoad();
watchForEngageLogin();
return;
}
if (mode === "timeout") {
finishLoad();
void waitForElm("#content").then(() => void LoadPageElements());
return;
}
await LoadPageElements();
};
if (document.readyState === "complete") {
void runEngageLoad();
} else {
window.addEventListener("load", () => void runEngageLoad(), { once: true });
}
return;
}
waitForElm(".login").then(() => { waitForElm(".login").then(() => {
finishLoad(); finishLoad();
}); });
@@ -466,13 +605,10 @@ export function tryLoad() {
}); });
updateIframesWithDarkMode(); updateIframesWithDarkMode();
// Waits for page to call on load, run scripts window.addEventListener(
document.addEventListener(
"load", "load",
function () { () => removeThemeTagsFromNotices(),
removeThemeTagsFromNotices(); { once: true },
},
true,
); );
} }
@@ -489,6 +625,7 @@ function ReplaceMenuSVG(element: HTMLElement, svg: string) {
const processedSymbol = Symbol("processed"); const processedSymbol = Symbol("processed");
export async function ObserveMenuItemPosition() { export async function ObserveMenuItemPosition() {
if (isSeqtaEngageExperience()) return;
await waitForElm("#menu > ul > li"); await waitForElm("#menu > ul > li");
eventManager.register( eventManager.register(
@@ -612,6 +749,15 @@ export function init() {
if (settingsState.onoff) { if (settingsState.onoff) {
console.info("[BetterSEQTA+] Enabled"); console.info("[BetterSEQTA+] Enabled");
if (settingsState.DarkMode) document.documentElement.classList.add("dark"); if (settingsState.DarkMode) document.documentElement.classList.add("dark");
if (settingsState.iconOnlySidebar) {
if (document.body) {
document.body.classList.add("icon-only-sidebar");
} else {
document.addEventListener("DOMContentLoaded", () => {
document.body?.classList.add("icon-only-sidebar");
});
}
}
document.querySelector(".legacy-root")?.classList.add("hidden"); document.querySelector(".legacy-root")?.classList.add("hidden");
ObserveMenuItemPosition(); ObserveMenuItemPosition();
@@ -619,12 +765,83 @@ export function init() {
new StorageChangeHandler(); new StorageChangeHandler();
new MessageHandler(); new MessageHandler();
updateAllColors(); void updateAllColors();
window.addEventListener("hashchange", () => {
if (settingsState.adaptiveThemeColour) void updateAllColors();
});
loading(); loading();
InjectCustomIcons(); InjectCustomIcons();
HideMenuItems(); HideMenuItems();
tryLoad(); tryLoad();
// Auto-focus WISP direct online submission editor when pane opens
eventManager.register(
"wispassessmentAdded",
{
customCheck: (el) =>
el.classList.contains("wispassessment") ||
el.querySelector(".wispassessment") !== null,
},
(element) => {
const wispassessment = element.classList.contains("wispassessment")
? (element as Element)
: element.querySelector(".wispassessment");
if (!wispassessment) return;
const focusEditableBody = (iframe: HTMLIFrameElement) => {
try {
const doc = iframe.contentDocument;
const win = iframe.contentWindow;
if (doc?.body && win) {
const editable =
doc.body.querySelector(".cke_editable") || doc.body;
const el = editable as HTMLElement;
el.focus();
const range = doc.createRange();
range.selectNodeContents(el);
range.collapse(true);
const sel = win.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
return true;
}
} catch (_) {}
return false;
};
const focusEditor = () => {
const iframe = wispassessment.querySelector(".cke_wysiwyg_frame");
if (iframe instanceof HTMLIFrameElement) {
if (focusEditableBody(iframe)) return;
iframe.focus();
return;
}
const ckeditor = (window as any).CKEDITOR;
if (ckeditor?.instances?.editor1) {
try {
ckeditor.instances.editor1.focus();
} catch (_) {}
}
};
const iframe = wispassessment.querySelector(".cke_wysiwyg_frame");
if (iframe instanceof HTMLIFrameElement) {
iframe.addEventListener(
"load",
() => focusEditableBody(iframe),
{ once: true },
);
}
[1000, 1200, 1500].forEach((delay) =>
setTimeout(focusEditor, delay),
);
},
);
setTimeout(() => { setTimeout(() => {
const legacyElement = document.querySelector( const legacyElement = document.querySelector(
".outside-container .bottom-container", ".outside-container .bottom-container",
Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { analyzeHtmlThreats } from "./analyzeHtmlThreats";
describe("analyzeHtmlThreats", () => {
it("does not flag benign HTML", () => {
const r = analyzeHtmlThreats("<p>Hello <strong>world</strong></p>");
expect(r.blocked).toBe(false);
expect(r.findings).toHaveLength(0);
});
it("flags script tags", () => {
const r = analyzeHtmlThreats('<p>x</p><script>alert(1)</script>');
expect(r.blocked).toBe(true);
expect(r.findings.some((f) => f.kind === "script_tag")).toBe(true);
});
it("flags javascript: URLs", () => {
const r = analyzeHtmlThreats('<a href="javascript:void(0)">click</a>');
expect(r.blocked).toBe(true);
expect(r.findings.some((f) => f.kind === "dangerous_url_scheme")).toBe(
true,
);
});
it("flags inline event handlers", () => {
const r = analyzeHtmlThreats('<img src="https://example.com/x.png" onerror="alert(1)">');
expect(r.blocked).toBe(true);
expect(
r.findings.some((f) => f.kind === "inline_event_handler"),
).toBe(true);
});
it("allows data:image/png sources", () => {
const r = analyzeHtmlThreats(
'<img alt="i" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==">',
);
expect(r.blocked).toBe(false);
});
it("flags data:text/html", () => {
const r = analyzeHtmlThreats(
'<iframe src="data:text/html,%3Cscript%3E%3C/script%3E"></iframe>',
);
expect(r.blocked).toBe(true);
});
});
+190
View File
@@ -0,0 +1,190 @@
import DOMPurify from "dompurify";
export interface ThreatFinding {
kind: string;
detail: string;
}
export interface ThreatAnalysis {
blocked: boolean;
findings: ThreatFinding[];
}
const INLINE_HANDLER_RE = /^on[a-z]+$/i;
const DANGEROUS_SCHEME_RE =
/^\s*(javascript|vbscript|about\s*:|file\s*:)/i;
/** Inline data URIs except common raster images (emails often embed PNG/JPEG). */
function isDangerousDataUri(url: string): boolean {
const v = url.trim().toLowerCase();
if (!v.startsWith("data:")) return false;
if (/^data:image\/(png|jpe?g|gif|webp|bmp)([;,]|$)/i.test(v)) return false;
return true;
}
/** Patterns inside executable contexts (script bodies). */
const SCRIPT_TEXT_SUSPICIOUS =
/\beval\s*\(|new\s+Function\s*\(|document\s*\.\s*write|\.execScript\s*\(/i;
function addFinding(
findings: ThreatFinding[],
kind: string,
detail: string,
): void {
if (findings.some((f) => f.kind === kind && f.detail === detail)) return;
findings.push({ kind, detail });
}
function inspectUrlAttr(attrName: string, value: string): ThreatFinding[] {
const out: ThreatFinding[] = [];
const v = value.trim();
if (!v) return out;
if (DANGEROUS_SCHEME_RE.test(v) || isDangerousDataUri(v)) {
out.push({
kind: "dangerous_url_scheme",
detail: `${attrName}="${v.slice(0, 120)}${v.length > 120 ? "…" : ""}"`,
});
}
return out;
}
function walkElement(el: Element, findings: ThreatFinding[]): void {
const tag = el.tagName.toLowerCase();
if (tag === "script") {
const src = el.getAttribute("src")?.trim() ?? "";
if (
src &&
(DANGEROUS_SCHEME_RE.test(src) || isDangerousDataUri(src))
) {
addFinding(findings, "script_src", `script src="${src.slice(0, 160)}"`);
} else if (!src && el.textContent && SCRIPT_TEXT_SUSPICIOUS.test(el.textContent)) {
addFinding(
findings,
"script_pattern",
"Inline script contains suspicious patterns (eval/new Function/document.write).",
);
} else {
addFinding(findings, "script_tag", "A script element is present in HTML.");
}
return;
}
if (tag === "meta") {
const httpEquiv = el.getAttribute("http-equiv")?.toLowerCase();
if (httpEquiv === "refresh") {
addFinding(
findings,
"meta_refresh",
'meta http-equiv="refresh" can redirect or execute unexpectedly.',
);
}
}
if (tag === "iframe" || tag === "frame") {
const src = el.getAttribute("src")?.trim() ?? "";
const srcdoc = el.getAttribute("srcdoc") ?? "";
findings.push(...inspectUrlAttr("iframe[src]", src));
if (srcdoc.length > 0) {
addFinding(
findings,
"iframe_srcdoc",
"iframe srcdoc may embed arbitrary markup; nested analysis follows.",
);
nestedAnalyze(srcdoc, findings, 2);
}
}
if (tag === "object" || tag === "embed") {
const url =
el.getAttribute("data") ?? el.getAttribute("src") ?? "";
findings.push(...inspectUrlAttr(`${tag}[src/data]`, url));
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name;
if (INLINE_HANDLER_RE.test(name)) {
addFinding(
findings,
"inline_event_handler",
`${tag}.${name}`,
);
}
const val = attr.value ?? "";
if (
name === "href" ||
name === "src" ||
name === "action" ||
name === "formaction" ||
name === "poster" ||
name === "data"
) {
findings.push(...inspectUrlAttr(`${tag}[${name}]`, val));
}
if (name === "style" && /\burl\s*\(\s*["']?\s*javascript:/i.test(val)) {
addFinding(findings, "css_javascript_url", `${tag} style contains javascript: URL.`);
}
}
for (const child of Array.from(el.children)) {
walkElement(child, findings);
}
}
function nestedAnalyze(fragment: string, findings: ThreatFinding[], depth: number): void {
if (depth <= 0) return;
let doc: Document;
try {
doc = new DOMParser().parseFromString(fragment, "text/html");
} catch {
return;
}
walkElement(doc.documentElement, findings);
}
/**
* High-confidence HTML threat signals for user-generated / API HTML (messages, notices).
*
* Note: This runs after load for iframes in many cases; pairing with iframe `sandbox`
* (see BetterSEQTA Security plugin) is required for reliable script blocking see plugin comments.
*/
export function analyzeHtmlThreats(html: string): ThreatAnalysis {
const findings: ThreatFinding[] = [];
if (!html || !html.trim()) {
return { blocked: false, findings: [] };
}
let doc: Document;
try {
doc = new DOMParser().parseFromString(html, "text/html");
} catch {
return { blocked: false, findings: [] };
}
walkElement(doc.documentElement, findings);
/** SEQTA home modal path uses DOMPurify with onclick allowed; flag removal under stricter rules. */
const permissive = DOMPurify.sanitize(html, {
ADD_ATTR: ["onclick"],
ALLOWED_URI_REGEXP:
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|chrome-extension):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
});
const strict = DOMPurify.sanitize(html, {
ALLOW_DATA_ATTR: false,
ALLOW_UNKNOWN_PROTOCOLS: false,
});
if (strict !== permissive) {
addFinding(
findings,
"dompurify_delta",
"Content was altered under strict sanitization (likely inline handlers or risky markup).",
);
}
return {
blocked: findings.length > 0,
findings,
};
}
+234
View File
@@ -0,0 +1,234 @@
import type { ThreatAnalysis } from "./analyzeHtmlThreats";
import {
buildIncidentReport,
copyIncidentReport,
downloadIncidentReportPdf,
formatIncidentReportPlainText,
openIncidentReportEmail,
type IncidentReport,
} from "./incidentReport";
/** Mounted by BetterSEQTA Security on messages / notices when HTML is blocked. */
export const SECURITY_BLOCK_HOST_CLASS = "bss-security-block-host";
/** Body-fixed layer for message pane (survives React replacing iframe wrappers). */
export const SECURITY_MESSAGE_OVERLAY_CLASS = "bss-security-message-overlay";
const HOST_CLASS = SECURITY_BLOCK_HOST_CLASS;
const REPORT_LAYER_CLASS = "bss-security-report-layer";
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
style: Partial<CSSStyleDeclaration>,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
Object.assign(node.style, style);
if (text !== undefined) node.textContent = text;
return node;
}
function button(
label: string,
onClick: () => void,
): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = label;
Object.assign(btn.style, {
padding: "10px 18px",
borderRadius: "8px",
border: "1px solid rgba(255,255,255,0.25)",
background: "rgba(220,38,38,0.35)",
color: "#fff",
cursor: "pointer",
fontSize: "14px",
fontWeight: "600",
transition: "background 0.2s, transform 0.2s",
} as CSSStyleDeclaration);
btn.addEventListener("mouseenter", () => {
btn.style.background = "rgba(220,38,38,0.55)";
});
btn.addEventListener("mouseleave", () => {
btn.style.background = "rgba(220,38,38,0.35)";
});
btn.addEventListener("click", onClick);
return btn;
}
function removeExistingReportLayer(doc: Document): void {
doc.querySelectorAll(`.${REPORT_LAYER_CLASS}`).forEach((n) => n.remove());
}
export interface MountBlockedContentOptions {
surface: "message" | "notice";
analysis: ThreatAnalysis;
rawSnippet?: string;
contextTitle?: string;
contextSubtitle?: string;
hostDocument?: Document;
/**
* Panel lives on document.body (fixed layer). Omits `position: relative` so the caller can pin position/size.
*/
rootOverlay?: boolean;
}
export function mountBlockedContentUi(
container: HTMLElement,
options: MountBlockedContentOptions,
): () => void {
const doc = options.hostDocument ?? document;
container.innerHTML = "";
container.classList.add(HOST_CLASS);
const basePanel: Partial<CSSStyleDeclaration> = {
boxSizing: "border-box",
minHeight: options.rootOverlay ? "min(100%, 260px)" : "220px",
padding: "24px",
borderRadius: "12px",
background:
"linear-gradient(145deg, rgba(30,30,35,0.98), rgba(20,20,24,0.98))",
border: "1px solid rgba(239,68,68,0.45)",
color: "#f4f4f5",
fontFamily: "system-ui, Segoe UI, Roboto, sans-serif",
lineHeight: "1.5",
};
if (options.rootOverlay) {
Object.assign(container.style, {
...basePanel,
height: "100%",
overflow: "auto",
});
} else {
Object.assign(container.style, {
...basePanel,
position: "relative",
});
}
const title = el("h2", {
margin: "0 0 12px 0",
fontSize: "20px",
fontWeight: "700",
color: "#fff",
}, "BetterSEQTA Security");
const lead = el("p", {
margin: "0 0 12px 0",
fontSize: "15px",
color: "#e4e4e7",
});
lead.textContent =
"This content was not shown because BetterSEQTA+ detected potentially malicious HTML (for example scripts or dangerous links). This helps protect your account from cross-site scripting.";
const admin = el("p", {
margin: "0 0 20px 0",
fontSize: "14px",
color: "#a1a1aa",
});
admin.textContent =
"Contact your school SEQTA or IT administrator so they can remove or fix the message or notice at source.";
const actions = el("div", {
display: "flex",
flexWrap: "wrap",
gap: "12px",
alignItems: "center",
});
let latestReport: IncidentReport | null = null;
const openReport = async () => {
latestReport = await buildIncidentReport({
surface: options.surface,
analysis: options.analysis,
rawSnippet: options.rawSnippet,
contextTitle: options.contextTitle,
contextSubtitle: options.contextSubtitle,
});
removeExistingReportLayer(doc);
const layer = doc.createElement("div");
layer.className = REPORT_LAYER_CLASS;
Object.assign(layer.style, {
position: "fixed",
inset: "0",
zIndex: "2147483647",
background: "rgba(0,0,0,0.55)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
boxSizing: "border-box",
});
const panel = doc.createElement("div");
Object.assign(panel.style, {
maxWidth: "640px",
width: "100%",
maxHeight: "85vh",
overflow: "auto",
background: "#18181b",
color: "#fafafa",
borderRadius: "12px",
border: "1px solid #3f3f46",
padding: "24px",
boxShadow: "0 25px 50px rgba(0,0,0,0.45)",
});
const pre = doc.createElement("pre");
pre.style.whiteSpace = "pre-wrap";
pre.style.wordBreak = "break-word";
pre.style.fontSize = "12px";
pre.style.lineHeight = "1.45";
pre.style.margin = "0 0 16px 0";
pre.textContent = formatIncidentReportPlainText(latestReport);
const row = doc.createElement("div");
row.style.display = "flex";
row.style.flexWrap = "wrap";
row.style.gap = "10px";
const closeLayer = () => layer.remove();
row.appendChild(
button("Close", closeLayer),
);
row.appendChild(
button("Copy report", async () => {
if (!latestReport) return;
await copyIncidentReport(latestReport);
}),
);
row.appendChild(
button("Download PDF", () => {
if (!latestReport) return;
downloadIncidentReportPdf(latestReport);
}),
);
row.appendChild(
button("Email", () => {
if (!latestReport) return;
openIncidentReportEmail(latestReport);
}),
);
panel.appendChild(pre);
panel.appendChild(row);
layer.appendChild(panel);
layer.addEventListener("click", (e) => {
if (e.target === layer) closeLayer();
});
doc.body.appendChild(layer);
};
actions.appendChild(button("View report", () => void openReport()));
container.appendChild(title);
container.appendChild(lead);
container.appendChild(admin);
container.appendChild(actions);
return () => {
container.innerHTML = "";
container.classList.remove(HOST_CLASS);
removeExistingReportLayer(doc);
};
}
+156
View File
@@ -0,0 +1,156 @@
import { jsPDF } from "jspdf";
import browser from "webextension-polyfill";
import type { ThreatAnalysis, ThreatFinding } from "./analyzeHtmlThreats";
export type ThreatSurface = "message" | "notice";
export interface IncidentReport {
generatedAtIso: string;
surface: ThreatSurface;
extensionVersion: string;
pageUrl: string;
contextTitle?: string;
contextSubtitle?: string;
analysis: ThreatAnalysis;
contentFingerprint?: string;
}
async function sha256Hex(text: string): Promise<string> {
const enc = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest("SHA-256", enc);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function getExtensionVersion(): string {
try {
return browser.runtime?.getManifest?.()?.version ?? "unknown";
} catch {
return "unknown";
}
}
export async function buildIncidentReport(opts: {
surface: ThreatSurface;
analysis: ThreatAnalysis;
rawSnippet?: string;
contextTitle?: string;
contextSubtitle?: string;
extensionVersion?: string;
}): Promise<IncidentReport> {
let fingerprint: string | undefined;
if (opts.rawSnippet?.trim()) {
try {
if (typeof crypto !== "undefined" && crypto.subtle) {
fingerprint = await sha256Hex(opts.rawSnippet.slice(0, 50_000));
}
} catch {
fingerprint = undefined;
}
}
const version = opts.extensionVersion ?? getExtensionVersion();
return {
generatedAtIso: new Date().toISOString(),
surface: opts.surface,
extensionVersion: version,
pageUrl:
typeof window !== "undefined" ? window.location.href : "",
contextTitle: opts.contextTitle,
contextSubtitle: opts.contextSubtitle,
analysis: opts.analysis,
contentFingerprint: fingerprint,
};
}
function formatFindings(findings: ThreatFinding[]): string {
return findings.map((f, i) => `${i + 1}. [${f.kind}] ${f.detail}`).join("\n");
}
export function formatIncidentReportPlainText(report: IncidentReport): string {
const lines = [
"BetterSEQTA+ Security — incident report",
"=====================================",
"",
`Generated (UTC): ${report.generatedAtIso}`,
`Surface: ${report.surface}`,
`Extension version: ${report.extensionVersion}`,
`Page URL: ${report.pageUrl}`,
];
if (report.contextTitle) lines.push(`Title / subject: ${report.contextTitle}`);
if (report.contextSubtitle) lines.push(`Detail: ${report.contextSubtitle}`);
if (report.contentFingerprint) {
lines.push(`Content SHA-256 (truncated input): ${report.contentFingerprint}`);
}
lines.push("", "Findings:", formatFindings(report.analysis.findings), "");
lines.push(
"Next steps:",
"- Contact your school SEQTA / IT administrator and ask them to remove or sanitise the malicious content at source.",
"- Attach this report (PDF or pasted text) when reporting.",
);
return lines.join("\n");
}
export async function copyIncidentReport(report: IncidentReport): Promise<void> {
const text = formatIncidentReportPlainText(report);
await navigator.clipboard.writeText(text);
}
export function downloadIncidentReportPdf(report: IncidentReport): void {
const doc = new jsPDF({ unit: "pt", format: "a4" });
const margin = 48;
let y = margin;
const lineHeight = 14;
const pageHeight = doc.internal.pageSize.getHeight();
const maxWidth = doc.internal.pageSize.getWidth() - margin * 2;
const pushLines = (text: string, bold = false) => {
doc.setFont("helvetica", bold ? "bold" : "normal");
const wrapped = doc.splitTextToSize(text, maxWidth) as string[];
for (const line of wrapped) {
if (y > pageHeight - margin) {
doc.addPage();
y = margin;
}
doc.text(line, margin, y);
y += lineHeight;
}
};
pushLines("BetterSEQTA+ Security — incident report", true);
pushLines(`Generated (UTC): ${report.generatedAtIso}`);
pushLines(`Surface: ${report.surface}`);
pushLines(`Extension version: ${report.extensionVersion}`);
pushLines(`Page URL: ${report.pageUrl}`);
if (report.contextTitle) pushLines(`Title / subject: ${report.contextTitle}`);
if (report.contextSubtitle) pushLines(`Detail: ${report.contextSubtitle}`);
if (report.contentFingerprint) {
pushLines(`Content SHA-256 (truncated input): ${report.contentFingerprint}`);
}
pushLines("");
pushLines("Findings:", true);
for (const f of report.analysis.findings) {
pushLines(`• [${f.kind}] ${f.detail}`);
}
pushLines("");
pushLines("Next steps:", true);
pushLines(
"Contact your school SEQTA / IT administrator and ask them to remove or sanitise the malicious content at source. Attach this PDF when reporting.",
);
doc.save(`betterseqta-security-report-${report.surface}-${Date.now()}.pdf`);
}
export function openIncidentReportEmail(report: IncidentReport): void {
const subject = encodeURIComponent(
"SEQTA: suspected malicious HTML blocked by BetterSEQTA+ Security",
);
const body = encodeURIComponent(
formatIncidentReportPlainText(report).slice(0, 1800) +
"\n\n[If truncated: use Copy in the report dialog for the full text.]",
);
window.location.href = `mailto:?subject=${subject}&body=${body}`;
}
+413 -18
View File
@@ -1,7 +1,11 @@
import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings"; import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { SendNewsPage } from "@/seqta/utils/SendNewsPage"; import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
import { attachNotificationsPanelAnimation } from "@/seqta/utils/attachNotificationsPanelAnimation";
import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton"; import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour"; import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { appendBackgroundToUI } from "./ImageBackgrounds"; import { appendBackgroundToUI } from "./ImageBackgrounds";
@@ -13,6 +17,9 @@ import { delay } from "@/seqta/utils/delay";
let cachedUserInfo: any = null; let cachedUserInfo: any = null;
let LightDarkModeSnakeEggButton = 0; let LightDarkModeSnakeEggButton = 0;
let sidebarAccessibilityObserver: MutationObserver | null = null;
let sidebarTabOrderAnimationFrame: number | null = null;
let sidebarAccessibilityListenersAttached = false;
export async function getUserInfo() { export async function getUserInfo() {
if (cachedUserInfo) return cachedUserInfo; if (cachedUserInfo) return cachedUserInfo;
@@ -39,6 +46,17 @@ export async function getUserInfo() {
} }
export async function AddBetterSEQTAElements() { export async function AddBetterSEQTAElements() {
if (isSeqtaEngageExperience()) {
await waitForElm("#content");
addExtensionSettings();
if (settingsState.onoff) {
await injectEngageHomeButton();
}
void setupEngageSettingsButton();
void addEngageUserInfo();
return;
}
if (settingsState.onoff) { if (settingsState.onoff) {
if (settingsState.DarkMode) { if (settingsState.DarkMode) {
document.documentElement.classList.add("dark"); document.documentElement.classList.add("dark");
@@ -66,11 +84,13 @@ export async function AddBetterSEQTAElements() {
setupEventListeners(); setupEventListeners();
await addDarkLightToggle(); await addDarkLightToggle();
customizeMenuToggle(); customizeMenuToggle();
setupSidebarAccessibility();
} }
addExtensionSettings(); addExtensionSettings();
await createSettingsButton(); await createSettingsButton();
setupSettingsButton(); setupSettingsButton();
attachNotificationsPanelAnimation();
} }
function createHomeButton(fragment: DocumentFragment, _: HTMLElement) { function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
@@ -113,6 +133,10 @@ function updateUserInfo(info: {
userName: string | null; userName: string | null;
}) { }) {
const titlebar = document.getElementsByClassName("titlebar")[0]; const titlebar = document.getElementsByClassName("titlebar")[0];
const metadata = [info.meta.code, info.meta.governmentID]
.filter((value): value is string => Boolean(value))
.join(" // ");
const displayName = info.userDesc || info.userName || "";
titlebar.append( titlebar.append(
stringToHTML(/* html */ ` stringToHTML(/* html */ `
@@ -128,10 +152,10 @@ function updateUserInfo(info: {
<div class="userInfo"> <div class="userInfo">
<div class="userInfoText"> <div class="userInfoText">
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center;">
<p class="userInfohouse userInfoCode"></p> <p class="userInfohouse userInfoCode" style="display: none;"></p>
<p class="userInfoName">${info.userDesc}</p> ${displayName ? `<p class="userInfoName">${displayName}</p>` : ""}
</div> </div>
<p class="userInfoCode">${info.meta.code} // ${info.meta.governmentID}</p> ${metadata ? `<p class="userInfoCode">${metadata}</p>` : ""}
</div> </div>
</div> </div>
`).firstChild!, `).firstChild!,
@@ -171,9 +195,12 @@ async function updateStudentInfo(students: any) {
const houseelement = document.getElementsByClassName( const houseelement = document.getElementsByClassName(
"userInfohouse", "userInfohouse",
)[0] as HTMLElement; )[0] as HTMLElement | undefined;
if (!houseelement) return;
const student = students[index] ?? {}; const student = students[index] ?? {};
let text = "N/A"; let text = "";
if (student.house) { if (student.house) {
text = `${student.year ?? ""}${student.house}`; text = `${student.year ?? ""}${student.house}`;
@@ -193,6 +220,7 @@ async function updateStudentInfo(students: any) {
} }
houseelement.innerText = text; houseelement.innerText = text;
houseelement.style.display = text ? "block" : "none";
} }
function createNewsButton(fragment: DocumentFragment, menu: HTMLElement) { function createNewsButton(fragment: DocumentFragment, menu: HTMLElement) {
@@ -213,22 +241,27 @@ function setupEventListeners() {
const homebutton = document.getElementById("homebutton"); const homebutton = document.getElementById("homebutton");
const newsbutton = document.getElementById("newsbutton"); const newsbutton = document.getElementById("newsbutton");
homebutton?.addEventListener("click", function () { const activateMenuAction = (button: HTMLElement, action: () => void) => {
if ( if (
!homebutton.classList.contains("draggable") && button.classList.contains("draggable") ||
!homebutton.classList.contains("active") button.classList.contains("active")
) { ) {
loadHomePage(); return;
} }
action();
};
homebutton?.addEventListener("click", function () {
activateMenuAction(homebutton, () => {
loadHomePage();
});
}); });
newsbutton?.addEventListener("click", function () { newsbutton?.addEventListener("click", function () {
if ( activateMenuAction(newsbutton, () => {
!newsbutton.classList.contains("draggable") &&
!newsbutton.classList.contains("active")
) {
SendNewsPage(); SendNewsPage();
} });
}); });
menuCover?.addEventListener("click", function () { menuCover?.addEventListener("click", function () {
@@ -240,8 +273,9 @@ function setupEventListeners() {
}); });
} }
async function createSettingsButton() { async function createSettingsButton(parent?: Element) {
document.getElementById("content")!.append( const target = parent ?? document.getElementById("content")!;
target.append(
stringToHTML(/* html */ ` stringToHTML(/* html */ `
<button class="addedButton tooltip" id="AddedSettings"> <button class="addedButton tooltip" id="AddedSettings">
<svg width="24" height="24" viewBox="0 0 24 24"> <svg width="24" height="24" viewBox="0 0 24 24">
@@ -253,17 +287,165 @@ async function createSettingsButton() {
); );
} }
/** Engage mounts the sidebar inside batched React trees; EventManager-based waitForElm can miss `#menu`. Polling `waitForElm` matches the real DOM reliably. */
async function waitForEngageMenuList(): Promise<HTMLElement | null> {
const poll = true as const;
const interval = 100;
const trySelectors: { selector: string; maxIterations: number }[] = [
{ selector: "#menu > ul > li", maxIterations: 500 },
{ selector: "#menu ul", maxIterations: 350 },
{ selector: "#menu", maxIterations: 350 },
];
for (const { selector, maxIterations } of trySelectors) {
try {
await waitForElm(selector, poll, interval, maxIterations);
} catch {
continue;
}
if (selector === "#menu > ul > li") {
const ul = document.querySelector("#menu > ul") as HTMLElement | null;
if (ul) return ul;
} else if (selector === "#menu ul") {
const ul = document.querySelector("#menu ul") as HTMLElement | null;
if (ul) return ul;
} else {
const menu = document.getElementById("menu");
const ul =
(menu?.querySelector("ul") as HTMLElement | null) ??
(menu?.firstElementChild as HTMLElement | null);
if (ul) return ul;
}
}
console.warn(
"[BetterSEQTA+] Engage: could not find a menu list to inject the home button",
);
return null;
}
async function injectEngageHomeButton() {
if (document.getElementById("homebutton")) return;
const menuList = await waitForEngageMenuList();
if (!menuList || document.getElementById("homebutton")) return;
const li = stringToHTML(
/* html */ `<li class="item" data-key="home" id="homebutton" data-path="/home" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" /></svg><span>Home</span></label></li>`,
).firstChild as HTMLElement;
menuList.insertBefore(li, menuList.firstElementChild);
document.getElementById("homebutton")?.addEventListener("click", () => {
const btn = document.getElementById("homebutton") as HTMLElement;
if (
btn.classList.contains("draggable") ||
btn.classList.contains("active")
) {
return;
}
window.location.replace(`${location.origin}/#?page=/home`);
void loadEngageHomePage();
});
}
async function getEngageUserInfo() {
const response = await fetch(`${location.origin}/seqta/parent/login`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
mode: "normal",
query: null,
redirect_url: location.origin + "/",
}),
});
return (await response.json()).payload as {
userDesc: string | null;
userName: string | null;
userCode: string | null;
email: string | null;
type: string | null;
};
}
async function addEngageUserInfo() {
const content = await waitForElm("#content") as HTMLElement;
let info: Awaited<ReturnType<typeof getEngageUserInfo>>;
try {
info = await getEngageUserInfo();
} catch (error) {
console.error("[BetterSEQTA+] Failed to get Engage user info:", error);
return;
}
const displayName = info.userDesc || info.userName || "";
const subText = info.userCode || info.email || "";
const titlebar = document.createElement("div");
titlebar.classList.add("titlebar", "engage-titlebar");
titlebar.append(
stringToHTML(/* html */ `
<div class="userInfo">
<div class="userInfoText">
${displayName ? `<p class="userInfoName">${displayName}</p>` : ""}
${subText ? `<p class="userInfoCode">${subText}</p>` : ""}
</div>
</div>
`).firstChild!,
);
const iconNode = stringToHTML(/* html */ `
<div class="userInfosvgdiv tooltip" id="engage-logouttooltip-wrap">
<svg class="userInfosvg" viewBox="0 0 24 24"><path fill="var(--text-primary)" d="M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"></path></svg>
<div class="tooltiptext topmenutooltip" id="engage-logouttooltip">
<button class="logout engage-logout">
<svg style="width:20px;height:20px;vertical-align:middle;" viewBox="0 0 24 24"><path fill="currentColor" d="M17 7L15.59 8.41L18.17 11H8V13H18.17L15.59 15.58L17 17L22 12M4 5H12V3H4C2.9 3 2 3.9 2 5V19C2 20.1 2.9 21 4 21H12V19H4V5Z"/></svg>
</button>
</div>
</div>
`).firstChild!;
titlebar.append(iconNode);
content.appendChild(titlebar);
titlebar.querySelector<HTMLElement>(".engage-logout")?.addEventListener("click", async () => {
await fetch(`${location.origin}/seqta/parent/logout`, { method: "POST" });
location.reload();
});
}
async function setupEngageSettingsButton() {
try {
const notificationsWrapper = await waitForElm(
"#content > div.connectedNotificationsWrapper > div",
);
const parent = notificationsWrapper.parentElement!;
await addDarkLightToggle(parent);
await createSettingsButton(parent);
setupSettingsButton();
attachNotificationsPanelAnimation();
} catch {
await addDarkLightToggle();
await createSettingsButton();
setupSettingsButton();
attachNotificationsPanelAnimation();
}
}
function GetLightDarkModeString() { function GetLightDarkModeString() {
return settingsState.DarkMode return settingsState.DarkMode
? "Switch to light theme" ? "Switch to light theme"
: "Switch to dark theme"; : "Switch to dark theme";
} }
async function addDarkLightToggle() { async function addDarkLightToggle(parent?: Element) {
const SUN_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_80"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_80)"><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -4,-2.2100000381469727 -4,0 C-4,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`; const SUN_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_80"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_80)"><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -4,-2.2100000381469727 -4,0 C-4,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
const MOON_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_263"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_263)"><g style="display: block;" transform="matrix(1.5,0,0,1.5,7,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -1.2920000553131104,-2.2100000381469727 -1.2920000553131104,0 C-1.2920000553131104,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(-1,0,0,-1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`; const MOON_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_263"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_263)"><g style="display: block;" transform="matrix(1.5,0,0,1.5,7,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -1.2920000553131104,-2.2100000381469727 -1.2920000553131104,0 C-1.2920000553131104,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(-1,0,0,-1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
document.getElementById("content")!.append( const toggleTarget = parent ?? document.getElementById("content")!;
toggleTarget.append(
stringToHTML(/* html */ ` stringToHTML(/* html */ `
<button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton"> <button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton">
<svg xmlns="http://www.w3.org/2000/svg">${settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG}</svg> <svg xmlns="http://www.w3.org/2000/svg">${settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG}</svg>
@@ -319,3 +501,216 @@ function customizeMenuToggle() {
menuToggle.appendChild(line); menuToggle.appendChild(line);
} }
} }
function setupSidebarAccessibility() {
updateSidebarAccessibility();
const menu = document.getElementById("menu");
if (!menu) return;
sidebarAccessibilityObserver?.disconnect();
sidebarAccessibilityObserver = new MutationObserver(() => {
scheduleSidebarAccessibilityUpdate();
});
sidebarAccessibilityObserver.observe(menu, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["class", "style"],
});
if (!sidebarAccessibilityListenersAttached) {
document.addEventListener("keydown", handleSidebarKeyboardActivation);
sidebarAccessibilityListenersAttached = true;
}
}
function scheduleSidebarAccessibilityUpdate() {
if (sidebarTabOrderAnimationFrame !== null) {
cancelAnimationFrame(sidebarTabOrderAnimationFrame);
}
sidebarTabOrderAnimationFrame = requestAnimationFrame(() => {
sidebarTabOrderAnimationFrame = null;
updateSidebarAccessibility();
});
}
function handleSidebarKeyboardActivation(event: KeyboardEvent) {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const menuItem = target.closest("#menu li, #menu section") as
| HTMLElement
| null;
if (!menuItem || target !== menuItem) return;
if (event.key === "Tab") {
const menu = document.getElementById("menu");
if (!menu) return;
const visibleList = getVisibleSidebarList(menu);
if (!visibleList) return;
const visibleEntries = getDirectSidebarEntries(visibleList);
if (visibleEntries.length === 0) return;
const boundaryEntry = event.shiftKey
? visibleEntries[0]
: visibleEntries[visibleEntries.length - 1];
if (boundaryEntry !== menuItem) return;
const parentEntry = getSidebarListParentEntry(visibleList);
if (!parentEntry) return;
event.preventDefault();
parentEntry.classList.remove("active");
scheduleSidebarAccessibilityUpdate();
requestAnimationFrame(() => {
parentEntry.focus();
});
return;
}
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
const childSubmenu = menuItem.querySelector(":scope > .sub > ul") as
| HTMLElement
| null;
menuItem.click();
scheduleSidebarAccessibilityUpdate();
if (childSubmenu) {
focusFirstSidebarSubmenuEntry(menuItem);
}
}
function updateSidebarAccessibility() {
const menu = document.getElementById("menu");
if (!menu) return;
const visibleEntries = new Set(getVisibleSidebarEntries(menu));
const menuEntries = menu.querySelectorAll("li.item, section.item, li, section");
for (const entry of menuEntries) {
if (!(entry instanceof HTMLElement)) continue;
const label = entry.querySelector(":scope > label") as HTMLLabelElement | null;
if (!label) continue;
const childSubmenu = entry.querySelector(":scope > .sub") as HTMLElement | null;
const isHidden =
entry.offsetParent === null ||
window.getComputedStyle(entry).display === "none" ||
window.getComputedStyle(label).display === "none" ||
!visibleEntries.has(entry);
if (isHidden) {
entry.tabIndex = -1;
label.tabIndex = -1;
entry.setAttribute("aria-hidden", "true");
label.setAttribute("aria-hidden", "true");
if (childSubmenu) {
childSubmenu.setAttribute("aria-hidden", "true");
}
continue;
}
entry.tabIndex = 0;
label.tabIndex = -1;
entry.removeAttribute("aria-hidden");
label.removeAttribute("aria-hidden");
if (!entry.hasAttribute("role")) {
entry.setAttribute("role", "button");
}
const accessibleLabel = label.textContent?.trim();
if (accessibleLabel) {
entry.setAttribute("aria-label", accessibleLabel);
}
if (childSubmenu) {
const isExpanded = entry.classList.contains("active");
entry.setAttribute("aria-expanded", String(isExpanded));
childSubmenu.setAttribute("aria-hidden", String(!isExpanded));
} else {
entry.removeAttribute("aria-expanded");
}
}
}
function getVisibleSidebarEntries(menu = document.getElementById("menu")) {
if (!menu) return [] as HTMLElement[];
const visibleList = getVisibleSidebarList(menu);
if (!visibleList) return [] as HTMLElement[];
return getDirectSidebarEntries(visibleList);
}
function getDirectSidebarEntries(list: HTMLElement) {
return Array.from(list.querySelectorAll(":scope > li, :scope > section")).filter(
(entry): entry is HTMLElement => entry instanceof HTMLElement,
);
}
function getVisibleSidebarList(menu: HTMLElement) {
let currentList = menu.querySelector(":scope > ul") as HTMLElement | null;
while (currentList) {
const activeSubmenuParent = currentList.querySelector(
":scope > li.hasChildren.active, :scope > section.hasChildren.active",
) as HTMLElement | null;
if (!activeSubmenuParent) {
return currentList;
}
const nextList = activeSubmenuParent.querySelector(
":scope > .sub > ul",
) as HTMLElement | null;
if (!nextList) {
return currentList;
}
currentList = nextList;
}
return null;
}
function getSidebarListParentEntry(list: HTMLElement) {
return list.closest(".sub")?.parentElement instanceof HTMLElement
? (list.closest(".sub")!.parentElement as HTMLElement)
: null;
}
function focusFirstSidebarSubmenuEntry(parentEntry: HTMLElement) {
const menu = document.getElementById("menu");
if (!menu) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!parentEntry.classList.contains("active")) return;
const visibleList = getVisibleSidebarList(menu);
if (!visibleList || getSidebarListParentEntry(visibleList) !== parentEntry) {
return;
}
const firstEntry = getDirectSidebarEntries(visibleList).find(
(entry) =>
entry.offsetParent !== null &&
window.getComputedStyle(entry).display !== "none",
);
firstEntry?.focus({ preventScroll: true });
});
});
}
+3 -6
View File
@@ -18,12 +18,9 @@ export class SettingsResizer {
if (!iframePopup) return; if (!iframePopup) return;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
const idealHeight = viewportHeight - 80 - 15; // -80px for the top of the popup const rawIdeal = viewportHeight - 80 - 15; // room below top chrome
const idealHeight = Math.min(Math.max(rawIdeal, 280), 600);
if (idealHeight > 600) { iframePopup.style.height = `${idealHeight}px`;
iframePopup.style.height = "600px";
} else {
iframePopup.style.height = `${idealHeight}px`;
}
} }
} }
+202 -7
View File
@@ -1,25 +1,105 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import Color from "color";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour"; import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { lightenAndPaleColor } from "./lightenAndPaleColor"; import { lightenAndPaleColor } from "./lightenAndPaleColor";
import ColorLuminance from "./ColorLuminance"; import ColorLuminance from "./ColorLuminance";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
import { getCustomThemeAdaptiveCssVariableBindings } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
import darkLogo from "@/resources/icons/betterseqta-light-full.png"; import darkLogo from "@/resources/icons/betterseqta-light-full.png";
import lightLogo from "@/resources/icons/betterseqta-dark-full.png"; import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
const ADAPTIVE_THEME_TRANSITION_MS = 400;
let colorTransitionRafId: number | null = null;
let lastInterpolatedHex: string | null = null;
// Helper functions // Helper functions
const setCSSVar = (varName: any, value: any) => const setCSSVar = (varName: any, value: any) =>
document.documentElement.style.setProperty(varName, value); document.documentElement.style.setProperty(varName, value);
const applyProperties = (props: any) => const applyProperties = (props: any) =>
Object.entries(props).forEach(([key, value]) => setCSSVar(key, value)); Object.entries(props).forEach(([key, value]) => setCSSVar(key, value));
export function updateAllColors() { function easeInOutCubic(t: number): number {
// Determine the color to use return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
const selectedColor = }
settingsState.selectedColor !== ""
? settingsState.selectedColor
: "#007bff";
/** Best-effort parse of a single sRGB hex from a colour string (hex, rgb, or gradient). */
function parseRepresentativeHex(s: string): string | null {
if (!s || !s.trim()) return null;
const trimmed = s.trim();
try {
return Color(trimmed).hex();
} catch {
// continue
}
if (trimmed.includes("gradient")) {
const regex =
/#[0-9a-fA-F]{6}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)/gi;
const stops = trimmed.match(regex);
if (stops?.length) {
try {
return Color(stops[0]).hex();
} catch {
// continue
}
}
}
const hexMatch = trimmed.match(/#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})\b/);
if (hexMatch) {
try {
return Color(hexMatch[0]).hex();
} catch {
// continue
}
}
const rgbaMatch = trimmed.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
if (rgbaMatch) {
try {
return Color.rgb(
Number(rgbaMatch[1]),
Number(rgbaMatch[2]),
Number(rgbaMatch[3]),
).hex();
} catch {
// continue
}
}
return null;
}
function getFromHex(): string | null {
const fromComputed = parseRepresentativeHex(
getComputedStyle(document.documentElement).getPropertyValue("--better-main").trim(),
);
if (fromComputed) return fromComputed;
return lastInterpolatedHex;
}
function cancelColorTransition() {
if (colorTransitionRafId !== null) {
cancelAnimationFrame(colorTransitionRafId);
colorTransitionRafId = null;
}
}
function getRepresentativeRgbChannels(s: string): { r: number; g: number; b: number } | null {
const parsedHex = parseRepresentativeHex(s);
if (!parsedHex) return null;
try {
const [r, g, b] = Color(parsedHex).rgb().array();
return {
r: Math.round(r),
g: Math.round(g),
b: Math.round(b),
};
} catch {
return null;
}
}
function applyColorsWith(selectedColor: string) {
if (settingsState.transparencyEffects) { if (settingsState.transparencyEffects) {
document.documentElement.classList.add("transparencyEffects"); document.documentElement.classList.add("transparencyEffects");
} }
@@ -28,7 +108,7 @@ export function updateAllColors() {
const commonProps = { const commonProps = {
"--better-sub": "#161616", "--better-sub": "#161616",
"--better-alert-highlight": "#c61851", "--better-alert-highlight": "#c61851",
"--better-main": settingsState.selectedColor, "--better-main": selectedColor,
}; };
// Mode-based properties, applied if storedSetting is provided // Mode-based properties, applied if storedSetting is provided
@@ -63,6 +143,39 @@ export function updateAllColors() {
// Apply all the properties // Apply all the properties
applyProperties({ ...commonProps, ...modeProps, ...dynamicProps }); applyProperties({ ...commonProps, ...modeProps, ...dynamicProps });
if (settingsState.selectedTheme) {
const channels = getRepresentativeRgbChannels(selectedColor);
for (const binding of getCustomThemeAdaptiveCssVariableBindings()) {
if (!binding.channel) {
setCSSVar(binding.cssVarName, selectedColor);
continue;
}
if (!channels) {
continue;
}
if (binding.channel === "r") {
setCSSVar(binding.cssVarName, String(channels.r));
} else if (binding.channel === "g") {
setCSSVar(binding.cssVarName, String(channels.g));
} else {
setCSSVar(binding.cssVarName, String(channels.b));
}
}
}
// Let themes opt-in to overriding only adaptive accent output.
// A theme can define `--adaptive-better-main` from adaptive channel bindings.
if (settingsState.selectedTheme && settingsState.adaptiveThemeColour) {
const adaptiveOverride = getComputedStyle(document.documentElement)
.getPropertyValue("--adaptive-better-main")
.trim();
if (adaptiveOverride) {
setCSSVar("--better-main", adaptiveOverride);
}
}
let alliframes = document.getElementsByTagName("iframe"); let alliframes = document.getElementsByTagName("iframe");
for (let i = 0; i < alliframes.length; i++) { for (let i = 0; i < alliframes.length; i++) {
@@ -79,3 +192,85 @@ export function updateAllColors() {
} }
} }
} }
function toSoftGradient(hex: string): string {
const base = Color(hex);
const analogous = base.rotate(30).lighten(0.25).saturate(0.15);
const mid = base.mix(analogous, 0.5).hex();
return `linear-gradient(135deg, ${hex} 0%, ${mid} 50%, ${analogous.hex()} 100%)`;
}
export async function updateAllColors() {
let effectiveColor =
settingsState.selectedColor !== ""
? settingsState.selectedColor
: "#007bff";
let adaptiveHex: string | null = null;
if (settingsState.adaptiveThemeColour) {
const adaptiveColor = await getAdaptiveColour();
if (adaptiveColor) {
adaptiveHex = adaptiveColor;
effectiveColor = settingsState.adaptiveThemeGradient
? toSoftGradient(adaptiveColor)
: adaptiveColor;
}
}
const baseSelected =
settingsState.selectedColor !== "" ? settingsState.selectedColor : "#007bff";
const toHex =
adaptiveHex ?? parseRepresentativeHex(baseSelected);
const shouldAnimate =
settingsState.adaptiveThemeColour &&
(settingsState.adaptiveThemeColourTransition ?? true) &&
!!toHex;
const applyImmediate = () => {
cancelColorTransition();
applyColorsWith(effectiveColor);
if (toHex) lastInterpolatedHex = toHex;
};
if (!shouldAnimate) {
applyImmediate();
return;
}
const fromHex = getFromHex();
if (!fromHex || !toHex || fromHex === toHex) {
applyImmediate();
return;
}
const useSoftGradientOnFrames =
!!adaptiveHex && !!settingsState.adaptiveThemeGradient;
cancelColorTransition();
const start = performance.now();
const step = (now: number) => {
const elapsed = now - start;
const t = Math.min(1, elapsed / ADAPTIVE_THEME_TRANSITION_MS);
const eased = easeInOutCubic(t);
const interpolatedHex = Color(fromHex).mix(Color(toHex), eased).hex();
const display = useSoftGradientOnFrames
? toSoftGradient(interpolatedHex)
: interpolatedHex;
applyColorsWith(display);
if (t < 1) {
colorTransitionRafId = requestAnimationFrame(step);
} else {
colorTransitionRafId = null;
applyColorsWith(effectiveColor);
lastInterpolatedHex = toHex;
}
};
colorTransitionRafId = requestAnimationFrame(step);
}
@@ -0,0 +1,74 @@
/** Tracks which author-declared CSS variables mirror the effective accent; not persisted in settings storage. */
const VALID_CUSTOM_PROP = /^--[a-zA-Z0-9_-]{1,120}$/;
const VALID_CHANNEL = /^(r|g|b)$/;
export type AdaptiveChannel = "r" | "g" | "b";
export type AdaptiveCssVariableBinding = {
cssVarName: string;
channel?: AdaptiveChannel;
};
let boundBindings: AdaptiveCssVariableBinding[] = [];
function parseAdaptiveBinding(
rawBinding: string,
): AdaptiveCssVariableBinding | null {
const trimmed = rawBinding.trim();
if (!trimmed) return null;
const [rawName, rawChannel] = trimmed.split(":", 2);
const cssVarName = rawName?.trim() ?? "";
if (!VALID_CUSTOM_PROP.test(cssVarName)) return null;
if (!rawChannel) return { cssVarName };
const channel = rawChannel.trim().toLowerCase();
if (!VALID_CHANNEL.test(channel)) return null;
return { cssVarName, channel: channel as AdaptiveChannel };
}
export function normalizeAdaptiveCssVariableBindings(
names: string[] | undefined,
): AdaptiveCssVariableBinding[] {
if (!names?.length) return [];
const out: AdaptiveCssVariableBinding[] = [];
const seen = new Set<string>();
for (const raw of names) {
const parsed = parseAdaptiveBinding(raw);
if (!parsed) continue;
const key = `${parsed.cssVarName}:${parsed.channel ?? "full"}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(parsed);
}
return out;
}
export function setCustomThemeAdaptiveCssVariables(
names: string[] | undefined,
): void {
for (const binding of boundBindings) {
document.documentElement.style.removeProperty(binding.cssVarName);
}
boundBindings = normalizeAdaptiveCssVariableBindings(names);
}
export function getCustomThemeAdaptiveCssVariableBindings(): AdaptiveCssVariableBinding[] {
return boundBindings;
}
// Backward-compatible helper for existing callsites.
export function getCustomThemeAdaptiveCssVariables(): string[] {
return boundBindings.map((b) => b.cssVarName);
}
export function clearCustomThemeAdaptiveCssVariables(): void {
for (const binding of boundBindings) {
document.documentElement.style.removeProperty(binding.cssVarName);
}
boundBindings = [];
}
+169 -31
View File
@@ -1,6 +1,10 @@
interface ElementConfig { interface ElementConfig {
selector: string; selector: string;
action: (element: Element) => void; action: (element: Element) => void;
/** When true, element is not added to processedElements so the action runs every time (e.g. overwriting container content) */
alwaysRun?: boolean;
/** When true, never add to processedElements so the action can run again after DOM resets (e.g. home day column) */
neverMarkProcessed?: boolean;
} }
interface ContentConfig { interface ContentConfig {
@@ -10,6 +14,12 @@ interface ContentConfig {
// Track processed elements to avoid re-randomizing // Track processed elements to avoid re-randomizing
const processedElements = new WeakSet<Element>(); const processedElements = new WeakSet<Element>();
/** Marks mock-generated `.day` rows so granular rules do not re-randomize them */
const MOCK_DAY_ATTR = "data-bsp-mock-day";
/** Skip MutationObserver-driven reprocessing while we inject the home mock (avoids feedback loops) */
let suppressMockMutations = false;
function debounce(func: Function, wait: number): Function { function debounce(func: Function, wait: number): Function {
let timeout: NodeJS.Timeout; let timeout: NodeJS.Timeout;
return function executedFunction(...args: any[]) { return function executedFunction(...args: any[]) {
@@ -42,19 +52,19 @@ function getRandomDate(): Date {
const contentConfig: ContentConfig = { const contentConfig: ContentConfig = {
lessonTitle: { lessonTitle: {
selector: ".day h2", selector: `.day:not([${MOCK_DAY_ATTR}]) h2`,
action: (element) => { action: (element) => {
element.textContent = getRandomElement(mockData.subjects); element.textContent = getRandomElement(mockData.subjects);
}, },
}, },
teacher: { teacher: {
selector: ".day h3:first-of-type", selector: `.day:not([${MOCK_DAY_ATTR}]) h3:first-of-type`,
action: (element) => { action: (element) => {
element.textContent = getRandomElement(mockData.teachers); element.textContent = getRandomElement(mockData.teachers);
}, },
}, },
classroom: { classroom: {
selector: ".day h3:last-of-type", selector: `.day:not([${MOCK_DAY_ATTR}]) h3:last-of-type`,
action: (element) => { action: (element) => {
element.textContent = getRandomElement(mockData.classrooms); element.textContent = getRandomElement(mockData.classrooms);
}, },
@@ -77,6 +87,18 @@ const contentConfig: ContentConfig = {
element.textContent = getRandomElement(mockData.assessmentTitles); element.textContent = getRandomElement(mockData.assessmentTitles);
}, },
}, },
assessmentTitleInTooltip: {
selector: ".assessmenttooltip .tooltiptext p",
action: (element) => {
element.textContent = getRandomElement(mockData.assessmentTitles);
},
},
assessmentTitleInDetail: {
selector: "[class*='AssessmentItem__title___'], .assessment-title",
action: (element) => {
element.textContent = getRandomElement(mockData.assessmentTitles);
},
},
assessmentSubject: { assessmentSubject: {
selector: ".upcoming-assessment .upcoming-details h5", selector: ".upcoming-assessment .upcoming-details h5",
action: (element) => { action: (element) => {
@@ -92,7 +114,8 @@ const contentConfig: ContentConfig = {
noticeContent: { noticeContent: {
selector: ".notice .contents", selector: ".notice .contents",
action: (element) => { action: (element) => {
element.textContent = "Content has been redacted for privacy."; element.textContent =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
}, },
}, },
upcomingCheckboxes: { upcomingCheckboxes: {
@@ -135,7 +158,7 @@ const contentConfig: ContentConfig = {
selector: selector:
'[class*="MessageList__recipients___"] [class*="MessageList__value___"]', '[class*="MessageList__recipients___"] [class*="MessageList__value___"]',
action: (element) => { action: (element) => {
element.textContent = "Recipient(s) Redacted"; element.textContent = getRandomElement(mockData.messages.recipients);
}, },
}, },
@@ -175,16 +198,15 @@ const contentConfig: ContentConfig = {
documentNames: { documentNames: {
selector: ".document td.title", selector: ".document td.title",
action: (element) => { action: (element) => {
element.textContent = "Document Name Redacted"; element.textContent = getRandomElement(mockData.documentTitles);
}, },
}, },
forumTopics: { forumTopics: {
selector: "#menu .sub ul li:not([data-colour]):not(.hasChildren) label", selector: "#menu .sub ul li:not([data-colour]):not(.hasChildren) label",
action: (element) => { action: (element) => {
// Only redact if not in assessments section
const assessmentsSection = element.closest('[data-key="assessments"]'); const assessmentsSection = element.closest('[data-key="assessments"]');
if (!assessmentsSection) { if (!assessmentsSection) {
element.textContent = "Forum Topic Redacted"; element.textContent = getRandomElement(mockData.forumTopics);
} }
}, },
}, },
@@ -210,25 +232,27 @@ const contentConfig: ContentConfig = {
courseNames: { courseNames: {
selector: "#menu .sub ul li[data-colour] label", selector: "#menu .sub ul li[data-colour] label",
action: (element) => { action: (element) => {
element.textContent = "Course Name Redacted"; element.textContent = getRandomElement(mockData.subjects);
}, },
}, },
yearGroups: { yearGroups: {
selector: "#menu .sub > ul > li > label", selector: "#menu .sub > ul > li > label",
action: (element) => { action: (element) => {
element.textContent = "Year Group Redacted"; const yearGroup = Math.floor(Math.random() * 5) + 8;
element.textContent = `Year ${yearGroup}`;
}, },
}, },
newsArticleTitle: { newsArticleTitle: {
selector: ".ArticleText a", selector: ".ArticleText a",
action: (element) => { action: (element) => {
element.textContent = "News Article Title Redacted"; element.textContent = getRandomElement(mockData.notices);
}, },
}, },
newsArticleContent: { newsArticleContent: {
selector: ".ArticleText p", selector: ".ArticleText p",
action: (element) => { action: (element) => {
element.textContent = "News Article Content Redacted"; element.textContent =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
}, },
}, },
userHouse: { userHouse: {
@@ -237,6 +261,60 @@ const contentConfig: ContentConfig = {
element.textContent = "House"; element.textContent = "House";
}, },
}, },
// Timetable page: replace class names, teachers, rooms with fake data
timetableEntryTitle: {
selector: ".timetablepage .entry .title",
action: (element) => {
element.textContent = getRandomElement(mockData.subjects);
},
},
timetableEntryTeacher: {
selector: ".timetablepage .entry .teacher, .timetablepage .quickbar .meta .teacher",
action: (element) => {
element.textContent = getRandomElement(mockData.teachers);
},
},
timetableEntryRoom: {
selector: ".timetablepage .entry .room, .timetablepage .quickbar .meta .room",
action: (element) => {
element.textContent = getRandomElement(mockData.classrooms);
},
},
quickbarTitle: {
selector: ".timetablepage .quickbar .title",
action: (element) => {
element.textContent = getRandomElement(mockData.subjects);
},
},
// Home page: replace entire day with mock schedule (care + 7 lessons 8:553:15)
homeDayContainer: {
selector: "#day-container",
neverMarkProcessed: true,
action: (element) => {
const container = element as HTMLElement;
if (!container.closest(".timetable-container")) return; // only on home
if (container.classList.contains("loading") || container.innerHTML.trim() === "") {
delete container.dataset.bspMockSchedule;
return;
}
if (
container.dataset.bspMockSchedule === "1" &&
container.querySelector(`[${MOCK_DAY_ATTR}]`)
) {
return;
}
suppressMockMutations = true;
const schedule = getMockDaySchedule();
container.innerHTML = schedule;
container.classList.remove("loading");
container.dataset.bspMockSchedule = "1";
requestAnimationFrame(() => {
suppressMockMutations = false;
});
},
},
}; };
const mockData = { const mockData = {
@@ -367,7 +445,26 @@ const mockData = {
"Field Trip", "Field Trip",
"Cultural Festival", "Cultural Festival",
], ],
documentTitles: [
"Course Outline",
"Assignment Brief",
"Study Guide",
"Reference Material",
"Worksheet",
"Reading List",
"Project Guidelines",
],
forumTopics: [
"General Discussion",
"Homework Help",
"Resource Share",
"Class Updates",
"Study Group",
"Q&A",
"Announcements",
],
messages: { messages: {
recipients: ["Students", "Class", "Year Group", "Parents", "Guardians"],
subjects: [ subjects: [
"Mid-year Exams", "Mid-year Exams",
"Science project due soon", "Science project due soon",
@@ -573,6 +670,35 @@ Register through the PE department or see your house captains for more informati
] ]
}; };
/** Mock day schedule for home timetable: care 8:308:55, then 7 lessons 8:553:15 (45m each), 20m recess, lunch. */
function getMockDaySchedule(): string {
const blocks: { title: string; teacher: string; room: string; from: string; until: string }[] = [
{ title: "Care Group", teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "8:30am", until: "8:55am" },
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "8:55am", until: "9:40am" },
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "9:40am", until: "10:25am" },
{ title: "Recess", teacher: "—", room: "—", from: "10:25am", until: "10:45am" },
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "10:45am", until: "11:30am" },
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "11:30am", until: "12:15pm" },
{ title: "Lunch", teacher: "—", room: "—", from: "12:15pm", until: "1:00pm" },
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "1:00pm", until: "1:45pm" },
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "1:45pm", until: "2:30pm" },
{ title: getRandomElement(mockData.subjects), teacher: getRandomElement(mockData.teachers), room: getRandomElement(mockData.classrooms), from: "2:30pm", until: "3:15pm" },
];
const colours = ["#8e8e8e", "#4FBBFE", "#59F675", "#fa915d", "#9c27b0", "#2196f3", "#4caf50", "#ff9800", "#e91e63", "#673ab7"];
return blocks
.map(
(b, i) =>
`<div class="day" ${MOCK_DAY_ATTR} style="--item-colour: ${colours[i % colours.length]};">
<h2>${b.title}</h2>
<h3>${b.teacher}</h3>
<h3>${b.room}</h3>
<h4>${b.from} ${b.until}</h4>
<h5> </h5>
</div>`,
)
.join("");
}
export function getMockNotices() { export function getMockNotices() {
return { return {
payload: mockData.noticesData payload: mockData.noticesData
@@ -617,12 +743,15 @@ export function getMockAssessmentsData() {
{ submitted: false, score: null, dayOffset: () => Math.floor(Math.random() * -3) - 1 }, // Recently overdue { submitted: false, score: null, dayOffset: () => Math.floor(Math.random() * -3) - 1 }, // Recently overdue
]; ];
const assessments = Array.from({ length: 12 }, (_, i) => { const currentYear = new Date().getFullYear();
const assessments = Array.from({ length: 14 }, (_, i) => {
const subj = subjects[i % subjects.length]; const subj = subjects[i % subjects.length];
const template = statusTemplates[i % statusTemplates.length]; const template = statusTemplates[i % statusTemplates.length];
const due = new Date(); const due = new Date();
due.setDate(due.getDate() + template.dayOffset()); due.setDate(due.getDate() + template.dayOffset());
if (i >= 10) due.setFullYear(currentYear - 1);
const types = ["Assignment", "Test", "Exam", "Project", "Presentation", "Report"];
const assessment: any = { const assessment: any = {
id: i + 1, id: i + 1,
title: mockData.assessmentTitles[i % mockData.assessmentTitles.length], title: mockData.assessmentTitles[i % mockData.assessmentTitles.length],
@@ -631,6 +760,7 @@ export function getMockAssessmentsData() {
metaclassID: subj.metaclass, metaclassID: subj.metaclass,
due: due.toISOString(), due: due.toISOString(),
submitted: template.submitted, submitted: template.submitted,
type: types[i % types.length],
}; };
if (template.score && typeof template.score === 'function') { if (template.score && typeof template.score === 'function') {
@@ -650,20 +780,21 @@ export function getMockAssessmentsData() {
const debouncedProcessElements = debounce(processNewElements, 1); const debouncedProcessElements = debounce(processNewElements, 1);
function processNewElements() { function processNewElements() {
Object.entries(contentConfig).forEach(([_, { selector, action }]) => { Object.entries(contentConfig).forEach(([_, config]) => {
const { selector, action, alwaysRun, neverMarkProcessed } = config;
const elements = document.querySelectorAll(selector); const elements = document.querySelectorAll(selector);
elements.forEach((element: Element) => { elements.forEach((element: Element) => {
// Only process elements that haven't been processed before if (alwaysRun || neverMarkProcessed || !processedElements.has(element)) {
if (!processedElements.has(element)) {
action(element); action(element);
processedElements.add(element); if (!alwaysRun && !neverMarkProcessed) {
processedElements.add(element);
}
} }
}); });
}); });
} }
let observer: MutationObserver | null = null; let observer: MutationObserver | null = null;
let intervalId: NodeJS.Timeout | null = null;
export default function hideSensitiveContent() { export default function hideSensitiveContent() {
// Initial processing of existing elements // Initial processing of existing elements
@@ -672,6 +803,8 @@ export default function hideSensitiveContent() {
// Set up MutationObserver if not already created // Set up MutationObserver if not already created
if (!observer) { if (!observer) {
observer = new MutationObserver((mutations) => { observer = new MutationObserver((mutations) => {
if (suppressMockMutations) return;
let shouldProcess = false; let shouldProcess = false;
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
@@ -693,9 +826,25 @@ export default function hideSensitiveContent() {
}); });
} }
// Also trigger on large DOM replacements (like page navigation) // Large DOM replacements (e.g. page navigation). Skip only when #day-container gains many *mock* rows (our inject).
if (mutation.addedNodes.length > 5 || mutation.removedNodes.length > 5) { if (mutation.addedNodes.length > 5 || mutation.removedNodes.length > 5) {
shouldProcess = true; const target = mutation.target as Element;
if (target.id === "day-container") {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as Element;
if (
el.classList?.contains("day") &&
!el.hasAttribute(MOCK_DAY_ATTR)
) {
shouldProcess = true;
break;
}
}
}
} else {
shouldProcess = true;
}
} }
} }
@@ -724,13 +873,6 @@ export default function hideSensitiveContent() {
attributeFilter: ['class', 'id'] // Watch for class/id changes that might affect our selectors attributeFilter: ['class', 'id'] // Watch for class/id changes that might affect our selectors
}); });
} }
// Fallback: periodic check for new elements (especially useful for SPA navigation)
if (!intervalId) {
intervalId = setInterval(() => {
debouncedProcessElements();
}, 500); // Check every 500ms as a fallback
}
} }
// Function to stop observing (useful for cleanup) // Function to stop observing (useful for cleanup)
@@ -739,8 +881,4 @@ export function stopHidingSensitiveContent() {
observer.disconnect(); observer.disconnect();
observer = null; observer = null;
} }
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
} }
+20 -15
View File
@@ -9,21 +9,8 @@ import Settings from "@/interface/pages/settings.svelte";
let isSettingsRendered = false; let isSettingsRendered = false;
export function addExtensionSettings() { function extensionOutsideClickHandler(extensionPopup: HTMLElement) {
const extensionPopup = document.createElement("div"); return (event: MouseEvent) => {
extensionPopup.classList.add("outside-container", "hide");
extensionPopup.id = "ExtensionPopup";
const extensionContainer = document.querySelector(
"#container",
) as HTMLDivElement;
if (extensionContainer) extensionContainer.appendChild(extensionPopup);
const container = document.getElementById("container");
new SettingsResizer();
container!.onclick = (event) => {
if (!SettingsClicked) return; if (!SettingsClicked) return;
if (!(event.target as HTMLElement).closest("#AddedSettings")) { if (!(event.target as HTMLElement).closest("#AddedSettings")) {
@@ -33,6 +20,24 @@ export function addExtensionSettings() {
}; };
} }
export function addExtensionSettings() {
if (document.getElementById("ExtensionPopup")) return;
const extensionPopup = document.createElement("div");
extensionPopup.classList.add("outside-container", "hide");
extensionPopup.id = "ExtensionPopup";
const extensionContainer =
document.querySelector("#container") ?? document.getElementById("container");
const mountParent = extensionContainer ?? document.body;
mountParent.appendChild(extensionPopup);
new SettingsResizer();
const handler = extensionOutsideClickHandler(extensionPopup);
(extensionContainer ?? document.body).addEventListener("click", handler, false);
}
export function renderSettingsIfNeeded() { export function renderSettingsIfNeeded() {
if (isSettingsRendered) return; if (isSettingsRendered) return;
+239
View File
@@ -0,0 +1,239 @@
import browser from "webextension-polyfill";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
const STORAGE_KEYS = {
clientId: "bsplus_client_id",
accessToken: "bsplus_token",
refreshToken: "bsplus_refresh_token",
user: "bsplus_user",
} as const;
export type CloudUser = {
id: string;
email?: string;
username?: string;
displayName?: string;
pfpUrl?: string;
admin_level?: number;
};
export type CloudAuthState = {
isLoggedIn: boolean;
user: CloudUser | null;
};
/** Callback invoked when auth state changes */
type Listener = { (state: CloudAuthState): void };
class CloudAuthService {
private static instance: CloudAuthService;
private listeners = new Set<Listener>();
private _state: CloudAuthState = { isLoggedIn: false, user: null };
private constructor() {
void this.loadFromStorage();
browser.storage.onChanged.addListener((changes, areaName) => {
if (
areaName === "local" &&
(changes[STORAGE_KEYS.accessToken] ||
changes[STORAGE_KEYS.user] ||
changes[STORAGE_KEYS.clientId])
) {
void this.loadFromStorage();
}
});
}
public static getInstance(): CloudAuthService {
if (!CloudAuthService.instance) {
CloudAuthService.instance = new CloudAuthService();
}
return CloudAuthService.instance;
}
public get state(): CloudAuthState {
return this._state;
}
public subscribe(listener: Listener): () => void {
this.listeners.add(listener);
listener(this._state);
return () => this.listeners.delete(listener);
}
private async loadFromStorage(): Promise<void> {
const result = await browser.storage.local.get([
STORAGE_KEYS.accessToken,
STORAGE_KEYS.user,
]);
const token = result[STORAGE_KEYS.accessToken] as string | undefined;
const user = result[STORAGE_KEYS.user] as CloudUser | undefined;
this._state = {
isLoggedIn: !!token,
user: user ?? null,
};
this.notify();
}
private notify(): void {
for (const listener of this.listeners) {
listener(this._state);
}
}
/** Pull cloud settings backup after a fresh sign-in (matches manual “Download from cloud”). */
private triggerCloudSettingsDownloadAfterLogin(accessToken: string): void {
void browser.runtime
.sendMessage({
type: "cloudSettingsDownload",
token: accessToken,
})
.then((res: unknown) => {
const r = res as { success?: boolean; notFound?: boolean; error?: string } | undefined;
if (r?.success || r?.notFound) return;
if (r?.error) {
console.warn("[BetterSEQTA+] Cloud settings download after login:", r.error);
}
})
.catch((err) => {
console.warn("[BetterSEQTA+] Cloud settings download after login failed:", err);
});
}
public async getStoredToken(): Promise<string | null> {
const result = await browser.storage.local.get(STORAGE_KEYS.accessToken);
return (result[STORAGE_KEYS.accessToken] as string) ?? null;
}
private async getClientId(): Promise<string> {
let clientId = (settingsState as any)[STORAGE_KEYS.clientId] as string | undefined;
if (!clientId) {
const stored = await browser.storage.local.get(STORAGE_KEYS.clientId);
clientId = stored[STORAGE_KEYS.clientId] as string | undefined;
}
if (!clientId) {
const reserveResult = (await browser.runtime.sendMessage({
type: "cloudReserveClient",
redirect_uri: REDIRECT_URI,
})) as { client_id?: string; error?: string };
if (!reserveResult?.client_id) {
throw new Error(reserveResult?.error ?? "Failed to reserve client");
}
clientId = reserveResult.client_id;
(settingsState as any).setKey(STORAGE_KEYS.clientId, clientId);
}
return clientId;
}
public async startLogin(): Promise<{ success: boolean; error?: string }> {
try {
const clientId = await this.getClientId();
const result = (await browser.runtime.sendMessage({
type: "cloudStartLogin",
client_id: clientId,
redirect_uri: REDIRECT_URI,
})) as { success?: boolean; error?: string };
if (result?.success) {
return { success: true };
}
return { success: false, error: result?.error ?? "Failed to open login page" };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to open login page",
};
}
}
public async login(
login: string,
password: string
): Promise<{ success: boolean; error?: string }> {
try {
const clientId = await this.getClientId();
const result = (await browser.runtime.sendMessage({
type: "cloudLogin",
client_id: clientId,
redirect_uri: REDIRECT_URI,
login: login.trim(),
password,
})) as {
access_token?: string;
refresh_token?: string;
user?: CloudUser;
error?: string;
};
if (result?.access_token && result?.refresh_token) {
(settingsState as any).setKey(STORAGE_KEYS.accessToken, result.access_token);
(settingsState as any).setKey(STORAGE_KEYS.refreshToken, result.refresh_token);
(settingsState as any).setKey(STORAGE_KEYS.user, result.user ?? null);
this._state = {
isLoggedIn: true,
user: result.user ?? null,
};
this.notify();
this.triggerCloudSettingsDownloadAfterLogin(result.access_token);
return { success: true };
}
return {
success: false,
error: result?.error ?? "Login failed",
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Login failed",
};
}
}
public async logout(): Promise<void> {
await browser.storage.local.remove([
STORAGE_KEYS.accessToken,
STORAGE_KEYS.refreshToken,
STORAGE_KEYS.user,
"cloudAccessToken",
"cloudUsername",
]);
this._state = { isLoggedIn: false, user: null };
this.notify();
}
public async refreshToken(): Promise<boolean> {
const result = await browser.storage.local.get([
STORAGE_KEYS.refreshToken,
STORAGE_KEYS.clientId,
]);
const refreshToken = result[STORAGE_KEYS.refreshToken] as string | undefined;
const clientId = result[STORAGE_KEYS.clientId] as string | undefined;
if (!refreshToken || !clientId) return false;
const refreshResult = (await browser.runtime.sendMessage({
type: "cloudRefresh",
refresh_token: refreshToken,
client_id: clientId,
})) as {
access_token?: string;
refresh_token?: string;
user?: CloudUser;
error?: string;
};
if (refreshResult?.access_token && refreshResult?.refresh_token) {
(settingsState as any).setKey(STORAGE_KEYS.accessToken, refreshResult.access_token);
(settingsState as any).setKey(STORAGE_KEYS.refreshToken, refreshResult.refresh_token);
(settingsState as any).setKey(STORAGE_KEYS.user, refreshResult.user ?? null);
this._state = {
isLoggedIn: true,
user: refreshResult.user ?? null,
};
this.notify();
return true;
}
return false;
}
}
export const cloudAuth = CloudAuthService.getInstance();
@@ -0,0 +1,864 @@
import { animate } from "motion";
import browser from "webextension-polyfill";
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
import debounce from "@/seqta/utils/debounce";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
import {
type EngageParentChild,
type EngageParentTimetableItem,
fetchEngageParentChildren,
fetchEngageParentTimetableWeek,
isDateInCachedWeek,
toISODate,
weekRangeContaining,
} from "@/seqta/utils/Loaders/engageParentTimetable";
import { analyzeHtmlThreats } from "@/seqta/security/analyzeHtmlThreats";
import { mountBlockedContentUi } from "@/seqta/security/blockedContentUi";
export function updateEngageHomeMenuActive(isHome: boolean): void {
const home = document.getElementById("homebutton");
if (!home) return;
if (isHome) {
for (const el of document.querySelectorAll("#menu li.active")) {
if (el !== home) el.classList.remove("active");
}
home.classList.add("active");
} else {
home.classList.remove("active");
}
}
const STORAGE_KEY_STUDENT = () =>
`bsplus.engageTimetable.student.${location.origin}`;
let engageViewDate = new Date();
let engageWeekFrom = "";
let engageWeekUntil = "";
let engageWeekItems: EngageParentTimetableItem[] = [];
let engageSelectedStudentId: string | null = null;
let engageListenersCleanup: (() => void) | null = null;
function formatDateString(date: Date): string {
return `${date.toLocaleString("en-us", { weekday: "short" })} ${date.toLocaleDateString("en-au")}`;
}
function setEngageTimetableSubtitle(): void {
const el = document.getElementById("engage-home-lesson-subtitle");
if (!el) return;
const today = new Date();
const isSameMonth =
today.getFullYear() === engageViewDate.getFullYear() &&
today.getMonth() === engageViewDate.getMonth();
if (isSameMonth) {
const dayDiff = today.getDate() - engageViewDate.getDate();
switch (dayDiff) {
case 0:
el.textContent = "Today's Lessons";
break;
case 1:
el.textContent = "Yesterday's Lessons";
break;
case -1:
el.textContent = "Tomorrow's Lessons";
break;
default:
el.textContent = formatDateString(engageViewDate);
}
} else {
el.textContent = formatDateString(engageViewDate);
}
}
function makeEngageLessonDiv(
lesson: EngageParentTimetableItem,
index: number,
): HTMLElement {
let from = lesson.from?.substring(0, 5) ?? "";
let until = lesson.until?.substring(0, 5) ?? "";
if (settingsState.timeFormat === "12") {
from = convertTo12HourFormat(from);
until = convertTo12HourFormat(until);
}
const title =
lesson.type === "class"
? lesson.description
: lesson.type || "Lesson";
const div = document.createElement("div");
div.className = "day";
div.id = `engage-lesson-${lesson.code}-${index}`;
div.style.cssText = "--item-colour: #8e8e8e;";
const h2 = document.createElement("h2");
h2.textContent = title;
const hStaff = document.createElement("h3");
hStaff.textContent = lesson.staff?.trim() || "—";
const hRoom = document.createElement("h3");
hRoom.textContent = lesson.room?.trim() || "—";
const hTime = document.createElement("h4");
hTime.textContent = `${from} ${until}`;
const hPeriod = document.createElement("h5");
hPeriod.textContent = lesson.period?.trim() || "";
div.append(h2, hStaff, hRoom, hTime, hPeriod);
return div;
}
function renderEngageDayLessons(): void {
const dayContainer = document.getElementById("engage-day-container");
if (!dayContainer) return;
const dayStr = toISODate(engageViewDate);
const lessons = engageWeekItems
.filter((item) => item.date === dayStr)
.sort((a, b) => a.from.localeCompare(b.from));
dayContainer.innerHTML = "";
if (lessons.length === 0) {
dayContainer.innerHTML = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
<p>No lessons for this day.</p>
</div>`;
} else {
lessons.forEach((lesson, i) => {
dayContainer.appendChild(makeEngageLessonDiv(lesson, i));
});
}
dayContainer.classList.remove("loading");
setEngageTimetableSubtitle();
}
async function fetchWeekAndRender(): Promise<void> {
const dayContainer = document.getElementById("engage-day-container");
if (!dayContainer || !engageSelectedStudentId) return;
dayContainer.classList.add("loading");
dayContainer.innerHTML = "";
const { from, until } = weekRangeContaining(engageViewDate);
try {
engageWeekItems = await fetchEngageParentTimetableWeek(
from,
until,
engageSelectedStudentId,
);
engageWeekFrom = from;
engageWeekUntil = until;
} catch (e) {
console.error("[BetterSEQTA+] Engage parent timetable failed:", e);
engageWeekItems = [];
engageWeekFrom = from;
engageWeekUntil = until;
}
renderEngageDayLessons();
}
function shiftEngageDay(delta: number): void {
const next = new Date(engageViewDate);
next.setDate(next.getDate() + delta);
engageViewDate = next;
const dayContainer = document.getElementById("engage-day-container");
dayContainer?.classList.add("loading");
dayContainer && (dayContainer.innerHTML = "");
if (
engageWeekFrom &&
engageWeekUntil &&
isDateInCachedWeek(engageViewDate, engageWeekFrom, engageWeekUntil)
) {
renderEngageDayLessons();
} else {
void fetchWeekAndRender();
}
}
function populateChildSelector(
select: HTMLSelectElement,
children: EngageParentChild[],
): void {
select.innerHTML = "";
for (const c of children) {
const opt = document.createElement("option");
opt.value = c.id;
opt.textContent = c.name || `Student ${c.id}`;
select.appendChild(opt);
}
const stored = localStorage.getItem(STORAGE_KEY_STUDENT());
const validStored = stored && children.some((c) => c.id === stored);
engageSelectedStudentId = validStored
? stored!
: children[0]?.id ?? null;
if (engageSelectedStudentId) {
select.value = engageSelectedStudentId;
localStorage.setItem(STORAGE_KEY_STUDENT(), engageSelectedStudentId);
}
}
function bindEngageTimetableUi(): void {
engageListenersCleanup?.();
const cleanups: Array<() => void> = [];
const back = document.getElementById("engage-home-timetable-back");
const forward = document.getElementById("engage-home-timetable-forward");
const select = document.getElementById(
"engage-child-selector",
) as HTMLSelectElement | null;
const onBack = () => shiftEngageDay(-1);
const onForward = () => shiftEngageDay(1);
back?.addEventListener("click", onBack);
forward?.addEventListener("click", onForward);
cleanups.push(
() => back?.removeEventListener("click", onBack),
() => forward?.removeEventListener("click", onForward),
);
const onSelectChange = () => {
if (!select) return;
engageSelectedStudentId = select.value;
localStorage.setItem(STORAGE_KEY_STUDENT(), engageSelectedStudentId);
void fetchWeekAndRender();
};
select?.addEventListener("change", onSelectChange);
cleanups.push(() =>
select?.removeEventListener("change", onSelectChange),
);
engageListenersCleanup = () => {
cleanups.forEach((fn) => fn());
engageListenersCleanup = null;
};
}
/* ——— Notices (duplicated from Learn `LoadHomePage`; fetch uses `/seqta/parent/load/notices`.) ——— */
const ENGAGE_NOTICE_CONTAINER_ID = "engage-notice-container";
const ENGAGE_NOTICES_DATE_ID = "engage-notices-date";
function processEngageNoticeColor(colour: unknown): string | undefined {
if (typeof colour !== "string") return undefined;
const rgb = GetThresholdOfColor(colour);
if (rgb < 100 && settingsState.DarkMode) {
return undefined;
}
return colour;
}
function processEngageNotices(response: any, labelArray: string[]): void {
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
if (!noticeContainer) return;
noticeContainer.innerHTML = "";
const notices = response?.payload;
if (!Array.isArray(notices)) {
const emptyState = document.createElement("div");
emptyState.classList.add("day-empty");
const img = document.createElement("img");
img.src = browser.runtime.getURL(LogoLight);
const text = document.createElement("p");
text.innerText = "No notices for today.";
emptyState.append(img, text);
noticeContainer.append(emptyState);
return;
}
if (!notices.length) {
const emptyState = document.createElement("div");
emptyState.classList.add("day-empty");
const img = document.createElement("img");
img.src = browser.runtime.getURL(LogoLight);
const text = document.createElement("p");
text.innerText = "No notices for today.";
emptyState.append(img, text);
noticeContainer.append(emptyState);
return;
}
const fragment = document.createDocumentFragment();
notices.forEach((notice: any) => {
const shouldInclude =
settingsState.mockNotices ||
labelArray.length === 0 ||
labelArray.includes(JSON.stringify(notice.label));
if (shouldInclude) {
const colour = processEngageNoticeColor(notice.colour);
const noticeElement = createEngageNoticeElement(notice, colour);
fragment.appendChild(noticeElement);
}
});
noticeContainer.appendChild(fragment);
}
function createEngageNoticeElement(
notice: any,
colour: string | undefined,
): Node {
const textPreview =
notice.contents
.replace(/<[^>]*>/g, "")
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/\s+/g, " ")
.trim()
.substring(0, 150) + (notice.contents.length > 150 ? "..." : "");
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const htmlContent = `
<div class="notice-unified-content notice-card-state" data-notice-id="${noticeId}" style="--colour: ${colour || "#8e8e8e"}; position: relative; background: var(--background-primary); cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
${notice.label_title || "General"}
</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn" style="opacity: 0; pointer-events: none;">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${textPreview}</div>
</div>`;
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
element.addEventListener("click", () =>
openEngageNoticeModal(notice, colour, element),
);
return element;
}
function openEngageNoticeModal(
notice: any,
colour: string | undefined,
sourceElement: HTMLElement,
) {
const cleanContent = notice.contents
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " ");
const threatAnalysis = analyzeHtmlThreats(cleanContent);
const noticeBlocked = threatAnalysis.blocked;
const noticeBodyInner = noticeBlocked
? `<div class="notice-content-body bss-security-notice-mount"></div>`
: `<div class="notice-content-body">${cleanContent}</div>`;
document.getElementById("notice-modal")?.remove();
const sourceRect = sourceElement.getBoundingClientRect();
let scrollY = Math.round(window.scrollY);
let scrollX = Math.round(window.scrollX);
let sourceLeft = sourceRect.left;
let sourceTop = sourceRect.top;
let sourceWidth = sourceRect.width;
let sourceHeight = sourceRect.height;
const modalHtml = `
<div id="notice-modal" class="notice-modal-overlay" style="opacity: 0;">
<div class="notice-modal-transition" style="
position: fixed;
left: ${sourceLeft + scrollX}px;
top: ${sourceTop + scrollY}px;
width: ${sourceWidth}px;
height: ${sourceHeight}px;
transform-origin: center;
z-index: 10001;
">
<div class="notice-modal-content notice-transitioning">
<div class="notice-unified-content notice-card-state">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
${notice.label_title || "General"}
</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
${noticeBodyInner}
</div>
</div>
</div>
</div>`;
const modal = stringToHTML(modalHtml).firstChild as HTMLElement;
const transitionContainer = modal.querySelector(
".notice-modal-transition",
) as HTMLElement;
const unifiedContent = modal.querySelector(
".notice-unified-content",
) as HTMLElement;
const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement;
document.body.appendChild(modal);
if (noticeBlocked) {
const mountEl = modal.querySelector(
".bss-security-notice-mount",
) as HTMLElement | null;
if (mountEl) {
mountBlockedContentUi(mountEl, {
surface: "notice",
analysis: threatAnalysis,
rawSnippet: cleanContent.slice(0, 50_000),
contextTitle: String(notice.title ?? ""),
contextSubtitle: [notice.staff, notice.label_title]
.filter(Boolean)
.join(" · "),
});
}
}
sourceElement.setAttribute("data-transitioning", "true");
sourceElement.style.opacity = "0";
sourceElement.style.transform = "scale(0.95)";
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let targetWidth = Math.round(
Math.min(Math.max(sourceWidth, 800), viewportWidth - 40),
);
const tempMeasureDiv = document.createElement("div");
tempMeasureDiv.style.position = "absolute";
tempMeasureDiv.style.left = "-9999px";
tempMeasureDiv.style.width = targetWidth + "px";
tempMeasureDiv.style.visibility = "hidden";
tempMeasureDiv.innerHTML = `
<div class="notice-unified-content notice-modal-state" style="position: relative; width: 100%; padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1);">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge">${notice.label_title || "General"}</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
${
noticeBlocked
? `<div class="notice-content-body bss-security-notice-mount" style="min-height:280px;"></div>`
: `<div class="notice-content-body">${cleanContent}</div>`
}
</div>
`;
document.body.appendChild(tempMeasureDiv);
const measuredHeight =
tempMeasureDiv.firstElementChild!.getBoundingClientRect().height;
document.body.removeChild(tempMeasureDiv);
let targetHeight = Math.round(
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
);
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
const closeModal = () => {
window.removeEventListener("resize", handleResize);
document.removeEventListener("keydown", handleEscape);
if (!settingsState.animations) {
modal.remove();
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
sourceElement.removeAttribute("data-transitioning");
return;
}
animate(
modal,
{
backgroundColor: ["rgba(0, 0, 0, 0.5)", "rgba(0, 0, 0, 0)"],
backdropFilter: ["blur(4px)", "blur(0px)"],
},
{ duration: 0.2 },
);
animate(
transitionContainer,
{ opacity: [1, 0] },
{ duration: 0.2, delay: 0.3 },
);
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
modal.style.pointerEvents = "none";
animate(
transitionContainer,
{
left: [targetLeft + scrollX, sourceLeft + scrollX],
top: [targetTop, sourceTop + scrollY],
width: [targetWidth, sourceWidth],
height: [targetHeight, sourceHeight],
scale: [1, 1],
},
{
duration: 0.35,
type: "spring",
stiffness: 400,
damping: 35,
},
).finished.then(async () => {
modal.remove();
sourceElement.removeAttribute("data-transitioning");
});
};
closeBtn?.addEventListener("click", closeModal);
modal?.addEventListener("click", (e) => {
if (e.target === modal) {
closeModal();
}
});
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeModal();
document.removeEventListener("keydown", handleEscape);
window.removeEventListener("resize", handleResize);
}
};
document.addEventListener("keydown", handleEscape);
const handleResize = () => {
const newSourceRect = sourceElement.getBoundingClientRect();
const newScrollY = Math.round(window.scrollY);
const newScrollX = Math.round(window.scrollX);
const computedStyle = getComputedStyle(sourceElement);
const transform = computedStyle.transform;
let scaleX = 1,
scaleY = 1;
if (transform && transform !== "none") {
const matrix = transform.match(/matrix.*\((.+)\)/);
if (matrix) {
const values = matrix[1].split(", ");
scaleX = parseFloat(values[0]);
scaleY = parseFloat(values[3]);
}
}
const newSourceWidth = newSourceRect.width / scaleX;
const newSourceHeight = newSourceRect.height / scaleY;
const deltaX = (newSourceWidth - newSourceRect.width) / 2;
const deltaY = (newSourceHeight - newSourceRect.height) / 2;
const newSourceLeft = newSourceRect.left - deltaX;
const newSourceTop = newSourceRect.top - deltaY;
const newViewportWidth = window.innerWidth;
const newViewportHeight = window.innerHeight;
const newTargetWidth = Math.round(
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
);
const currentHeight = unifiedContent.getBoundingClientRect().height;
const newTargetHeight = Math.round(
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
);
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
const newTargetTop =
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
transitionContainer.style.left =
Math.round(newTargetLeft + newScrollX) + "px";
transitionContainer.style.top = Math.round(newTargetTop) + "px";
transitionContainer.style.width = Math.round(newTargetWidth) + "px";
transitionContainer.style.height = Math.round(newTargetHeight) + "px";
sourceLeft = newSourceLeft;
sourceTop = newSourceTop;
sourceWidth = newSourceWidth;
sourceHeight = newSourceHeight;
targetLeft = newTargetLeft;
targetTop = newTargetTop;
targetWidth = newTargetWidth;
targetHeight = newTargetHeight;
scrollY = newScrollY;
scrollX = newScrollX;
};
window.addEventListener("resize", handleResize);
if (settingsState.animations) {
animate(modal, { opacity: [0, 1] }, { duration: 0.2 });
animate(
transitionContainer,
{
left: [sourceLeft + scrollX, targetLeft + scrollX],
top: [sourceTop + scrollY, targetTop],
width: [sourceWidth, targetWidth],
height: [sourceHeight, targetHeight],
scale: [1, 1],
},
{
duration: 0.5,
type: "spring",
stiffness: 280,
damping: 24,
},
);
unifiedContent.classList.remove("notice-card-state");
unifiedContent.classList.add("notice-modal-state");
} else {
modal.style.opacity = "1";
transitionContainer.style.left = Math.round(targetLeft + scrollX) + "px";
transitionContainer.style.top = Math.round(targetTop) + "px";
transitionContainer.style.width = Math.round(targetWidth) + "px";
transitionContainer.style.height = Math.round(targetHeight) + "px";
unifiedContent.classList.remove("notice-card-state");
unifiedContent.classList.add("notice-modal-state");
}
}
async function fetchEngageNoticesFromApi(
date: string,
labelTokens: string[],
): Promise<void> {
try {
const data = settingsState.mockNotices
? getMockNotices()
: await (
await fetch(`${location.origin}/seqta/parent/load/notices`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({ date }),
})
).json();
processEngageNotices(data, labelTokens);
} catch (e) {
console.warn("[BetterSEQTA+] Engage notices request failed:", e);
processEngageNotices({ payload: [] }, labelTokens);
}
}
function bindEngageNoticesDateInput(
labelTokens: string[],
initialDate: string,
): () => void {
const dateControl = document.getElementById(
ENGAGE_NOTICES_DATE_ID,
) as HTMLInputElement | null;
if (!dateControl) {
return () => {};
}
dateControl.value = initialDate;
const debouncedInputChange = debounce((e: Event) => {
void fetchEngageNoticesFromApi(
(e.target as HTMLInputElement).value,
labelTokens,
);
}, 250);
dateControl.addEventListener("input", debouncedInputChange);
return () => dateControl.removeEventListener("input", debouncedInputChange);
}
async function initEngageNoticesUi(todayFormatted: string): Promise<void> {
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
if (!noticeContainer) return;
let labelFilterValues: string[] = [];
try {
const prefsRes = await fetch(`${location.origin}/seqta/parent/load/prefs?`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({ asArray: true, request: "userPrefs" }),
});
const prefs = await prefsRes.json();
const payload = prefs?.payload;
if (Array.isArray(payload)) {
labelFilterValues = payload
.filter((item: { name?: string }) => item.name === "notices.filters")
.map((item: { value?: string }) => item.value)
.filter((v): v is string => typeof v === "string");
}
} catch {
labelFilterValues = [];
}
const labelTokens =
labelFilterValues.length > 0
? String(labelFilterValues[0]).split(" ").filter(Boolean)
: [];
const dateControl = document.getElementById(ENGAGE_NOTICES_DATE_ID);
if (dateControl) {
(dateControl as HTMLInputElement).value = todayFormatted;
}
await fetchEngageNoticesFromApi(todayFormatted, labelTokens);
const cleanup = bindEngageNoticesDateInput(labelTokens, todayFormatted);
engageMergeNoticeCleanup(cleanup);
noticeContainer.classList.remove("loading");
}
function engageMergeNoticeCleanup(noticeCleanup: () => void): void {
const prev = engageListenersCleanup;
engageListenersCleanup = () => {
prev?.();
noticeCleanup();
};
}
function showEngageTimetableError(message: string): void {
const dayContainer = document.getElementById("engage-day-container");
if (!dayContainer) return;
dayContainer.classList.remove("loading");
dayContainer.innerHTML = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
<p>${message}</p>
</div>`;
}
function showEngageNoticesSectionError(message: string): void {
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
if (!noticeContainer) return;
noticeContainer.classList.remove("loading");
noticeContainer.innerHTML = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
<p>${message}</p>
</div>`;
}
/** SEQTA Engage parent home: child timetable (today view) using parent APIs. */
export async function loadEngageHomePage(): Promise<void> {
updateEngageHomeMenuActive(true);
document.title = "Home ― SEQTA Engage";
let main: HTMLElement;
try {
/* Engage mounts `#main` after React hydrates; a single rAF often loses the race on cold load. */
main = (await waitForElm("#main", true, 100, 200)) as HTMLElement;
} catch {
console.warn(
"[BetterSEQTA+] Engage home: timed out waiting for #main (shell not ready).",
);
return;
}
engageListenersCleanup?.();
engageViewDate = new Date();
main.innerHTML = "";
/* `stringToHTML` returns `document.body`; use firstElementChild so we don't append a whitespace text node (which would drop #engage-home-container and break queries). */
const engageHomeBody = stringToHTML(/* html */ `
<div class="home-root" id="engage-home-root">
<div class="home-container" id="engage-home-container">
<div class="border timetable-container">
<div class="home-subtitle">
<div class="engage-timetable-title-cluster">
<h2 id="engage-home-lesson-subtitle">Today's Lessons</h2>
<select id="engage-child-selector" class="engage-child-select" aria-label="Student"></select>
</div>
<div class="timetable-arrows">
<svg width="24" height="24" viewBox="0 0 24 24" style="transform: scale(-1,1)" id="engage-home-timetable-back">
<g style="fill: currentcolor;"><path d="M8.578 16.359l4.594-4.594-4.594-4.594 1.406-1.406 6 6-6 6z"></path></g>
</svg>
<svg width="24" height="24" viewBox="0 0 24 24" id="engage-home-timetable-forward">
<g style="fill: currentcolor;"><path d="M8.578 16.359l4.594-4.594-4.594-4.594 1.406-1.406 6 6-6 6z"></path></g>
</svg>
</div>
</div>
<div class="day-container loading" id="engage-day-container"></div>
</div>
<div class="border notices-container">
<div style="display: flex; justify-content: space-between">
<h2 class="home-subtitle">Notices</h2>
<input type="date" id="engage-notices-date" />
</div>
<div class="notice-container upcoming-items loading" id="engage-notice-container"></div>
</div>
</div>
</div>
`);
const engageHomeRoot = engageHomeBody.firstElementChild as HTMLElement | null;
if (engageHomeRoot) {
main.appendChild(engageHomeRoot);
} else {
console.error(
"[BetterSEQTA+] Engage home: parsed markup had no root element (check DOMPurify / stringToHTML).",
);
return;
}
bindEngageTimetableUi();
setEngageTimetableSubtitle();
const select = document.getElementById(
"engage-child-selector",
) as HTMLSelectElement | null;
const todayFormatted = toISODate(new Date());
let children: EngageParentChild[];
try {
try {
children = await fetchEngageParentChildren();
} catch (e) {
console.error("[BetterSEQTA+] Engage parent child list failed:", e);
showEngageTimetableError("Could not load students for this account.");
return;
}
if (!select) {
showEngageTimetableError("Could not initialize the home view.");
showEngageNoticesSectionError("Could not initialize notices.");
return;
}
if (children.length === 0) {
select.disabled = true;
showEngageTimetableError("No linked students found.");
return;
}
populateChildSelector(select, children);
if (!engageSelectedStudentId) {
showEngageTimetableError("No student selected.");
return;
}
await fetchWeekAndRender();
} finally {
await initEngageNoticesUi(todayFormatted);
}
}
+71 -14
View File
@@ -13,6 +13,8 @@ import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments"; import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent"; import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip"; import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip";
import { analyzeHtmlThreats } from "@/seqta/security/analyzeHtmlThreats";
import { mountBlockedContentUi } from "@/seqta/security/blockedContentUi";
let LessonInterval: any; let LessonInterval: any;
let currentSelectedDate = new Date(); let currentSelectedDate = new Date();
@@ -129,9 +131,9 @@ export async function loadHomePage() {
.filter((item: any) => item.name === "notices.filters") .filter((item: any) => item.name === "notices.filters")
.map((item: any) => item.value); .map((item: any) => item.value);
if (labelArray.length > 0) { const noticeContainer = document.getElementById("notice-container");
const noticeContainer = document.getElementById("notice-container"); if (noticeContainer) {
if (noticeContainer) { if (labelArray.length > 0) {
const dateControl = document.querySelector( const dateControl = document.querySelector(
'input[type="date"]', 'input[type="date"]',
) as HTMLInputElement; ) as HTMLInputElement;
@@ -140,6 +142,17 @@ export async function loadHomePage() {
setupNotices(labelArray[0].split(" "), TodayFormatted); setupNotices(labelArray[0].split(" "), TodayFormatted);
} }
noticeContainer.classList.remove("loading"); noticeContainer.classList.remove("loading");
} else {
noticeContainer.classList.remove("loading");
noticeContainer.innerHTML = "";
const emptyState = document.createElement("div");
emptyState.classList.add("day-empty");
const img = document.createElement("img");
img.src = browser.runtime.getURL(LogoLight);
const text = document.createElement("p");
text.innerText = "No notices available.";
emptyState.append(img, text);
noticeContainer.append(emptyState);
} }
} }
@@ -243,8 +256,9 @@ function setupNotices(labelArray: string[], date: string) {
).json(); ).json();
processNotices(data, labelArray); processNotices(data, labelArray);
} catch (error) { } catch {
console.error("[BetterSEQTA+] Failed to fetch notices:", error); // Notices failed to load; processNotices will show empty state if container exists
processNotices({ payload: [] }, labelArray);
} }
}; };
@@ -278,12 +292,28 @@ function processNotices(response: any, labelArray: string[]) {
NoticeContainer.innerHTML = ""; NoticeContainer.innerHTML = "";
const notices = response.payload; const notices = response?.payload;
if (!Array.isArray(notices)) {
const emptyState = document.createElement("div");
emptyState.classList.add("day-empty");
const img = document.createElement("img");
img.src = browser.runtime.getURL(LogoLight);
const text = document.createElement("p");
text.innerText = "No notices for today.";
emptyState.append(img, text);
NoticeContainer.append(emptyState);
return;
}
if (!notices.length) { if (!notices.length) {
const dummyNotice = document.createElement("div"); const emptyState = document.createElement("div");
dummyNotice.textContent = "No notices for today."; emptyState.classList.add("day-empty");
dummyNotice.classList.add("dummynotice"); const img = document.createElement("img");
NoticeContainer.append(dummyNotice); img.src = browser.runtime.getURL(LogoLight);
const text = document.createElement("p");
text.innerText = "No notices for today.";
emptyState.append(img, text);
NoticeContainer.append(emptyState);
return; return;
} }
@@ -356,6 +386,12 @@ function openNoticeModal(
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "") .replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " "); .replace(/ +/, " ");
const threatAnalysis = analyzeHtmlThreats(cleanContent);
const noticeBlocked = threatAnalysis.blocked;
const noticeBodyInner = noticeBlocked
? `<div class="notice-content-body bss-security-notice-mount"></div>`
: `<div class="notice-content-body">${cleanContent}</div>`;
document.getElementById("notice-modal")?.remove(); document.getElementById("notice-modal")?.remove();
const sourceRect = sourceElement.getBoundingClientRect(); const sourceRect = sourceElement.getBoundingClientRect();
@@ -389,7 +425,7 @@ function openNoticeModal(
<button class="notice-close-btn">&times;</button> <button class="notice-close-btn">&times;</button>
</div> </div>
<h2 class="notice-content-title">${notice.title}</h2> <h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div> ${noticeBodyInner}
</div> </div>
</div> </div>
</div> </div>
@@ -406,6 +442,23 @@ function openNoticeModal(
document.body.appendChild(modal); document.body.appendChild(modal);
if (noticeBlocked) {
const mountEl = modal.querySelector(
".bss-security-notice-mount",
) as HTMLElement | null;
if (mountEl) {
mountBlockedContentUi(mountEl, {
surface: "notice",
analysis: threatAnalysis,
rawSnippet: cleanContent.slice(0, 50_000),
contextTitle: String(notice.title ?? ""),
contextSubtitle: [notice.staff, notice.label_title]
.filter(Boolean)
.join(" · "),
});
}
}
sourceElement.setAttribute("data-transitioning", "true"); sourceElement.setAttribute("data-transitioning", "true");
sourceElement.style.opacity = "0"; sourceElement.style.opacity = "0";
sourceElement.style.transform = "scale(0.95)"; sourceElement.style.transform = "scale(0.95)";
@@ -431,7 +484,11 @@ function openNoticeModal(
<button class="notice-close-btn">&times;</button> <button class="notice-close-btn">&times;</button>
</div> </div>
<h2 class="notice-content-title">${notice.title}</h2> <h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div> ${
noticeBlocked
? `<div class="notice-content-body bss-security-notice-mount" style="min-height:280px;"></div>`
: `<div class="notice-content-body">${cleanContent}</div>`
}
</div> </div>
`; `;
document.body.appendChild(tempMeasureDiv); document.body.appendChild(tempMeasureDiv);
@@ -440,7 +497,7 @@ function openNoticeModal(
document.body.removeChild(tempMeasureDiv); document.body.removeChild(tempMeasureDiv);
let targetHeight = Math.round( let targetHeight = Math.round(
Math.min(Math.max(measuredHeight, 200), viewportHeight * 0.85), Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
); );
let targetLeft = Math.round((viewportWidth - targetWidth) / 2); let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY; let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
@@ -552,7 +609,7 @@ function openNoticeModal(
); );
const currentHeight = unifiedContent.getBoundingClientRect().height; const currentHeight = unifiedContent.getBoundingClientRect().height;
const newTargetHeight = Math.round( const newTargetHeight = Math.round(
Math.min(Math.max(currentHeight, 200), newViewportHeight * 0.85), Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
); );
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2); const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
const newTargetTop = const newTargetTop =
@@ -0,0 +1,87 @@
const TIMETABLE_URL = "/seqta/parent/load/timetable";
export interface EngageParentChild {
name: string;
id: string;
}
export interface EngageParentTimetableItem {
date: string;
period: string;
code: string;
description: string;
staff: string;
type: string;
room: string;
from: string;
until: string;
programmeID?: number;
metaID?: number;
assessments?: unknown[];
}
export function toISODate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** MondaySunday range (inclusive) containing `date`, as YYYY-MM-DD. */
export function weekRangeContaining(date: Date): { from: string; until: string } {
const local = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const dow = local.getDay();
const diff = dow === 0 ? -6 : 1 - dow;
local.setDate(local.getDate() + diff);
const monday = local;
const sunday = new Date(monday);
sunday.setDate(sunday.getDate() + 6);
return { from: toISODate(monday), until: toISODate(sunday) };
}
function isInWeekRange(
isoDay: string,
weekFrom: string,
weekUntil: string,
): boolean {
return isoDay >= weekFrom && isoDay <= weekUntil;
}
export function isDateInCachedWeek(
date: Date,
weekFrom: string,
weekUntil: string,
): boolean {
return isInWeekRange(toISODate(date), weekFrom, weekUntil);
}
async function postParentTimetable(body: object): Promise<any> {
const res = await fetch(`${location.origin}${TIMETABLE_URL}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify(body),
});
return res.json();
}
export async function fetchEngageParentChildren(): Promise<EngageParentChild[]> {
const data = await postParentTimetable({ list: true });
const raw = data?.payload;
if (!Array.isArray(raw)) return [];
return raw.map((row: { name?: string; id?: string | number }) => ({
name: String(row?.name ?? ""),
id: String(row?.id ?? ""),
}));
}
export async function fetchEngageParentTimetableWeek(
from: string,
until: string,
studentId: string,
): Promise<EngageParentTimetableItem[]> {
const student = /^\d+$/.test(studentId) ? Number(studentId) : studentId;
const data = await postParentTimetable({ from, until, student });
const items = data?.payload?.items;
return Array.isArray(items) ? items : [];
}
@@ -0,0 +1,71 @@
import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState";
import { openPopup } from "./PopupManager";
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
/** Same hosting pattern as the What's New update video (GitHub raw). */
const BS_CLOUD_DEMO_VIDEO_URL =
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/bsclouddemo.webm";
export function shouldShowBsCloudAutoSyncAnnouncement(): boolean {
return !settingsState.bsCloudAutoSyncAnnouncementShown;
}
/**
* One-time announcement for BetterSEQTA Cloud automatic settings sync (after other startup popups).
* Video layout matches {@link OpenWhatsNewPopup} (`whatsnewImgContainer` / `whatsnewImg`).
*/
export function showBsCloudAutoSyncAnnouncement(onDismissed?: () => void) {
if (document.getElementById("whatsnewbk")) {
onDismissed?.();
return;
}
if (!shouldShowBsCloudAutoSyncAnnouncement()) {
onDismissed?.();
return;
}
const header = stringToHTML(
/* html */
`<div class="whatsnewHeader bsCloudAutoSyncAnnouncementHeader">
<h1>BetterSEQTA Cloud</h1>
</div>`,
).firstChild as HTMLElement;
const imageContainer = document.createElement("div");
imageContainer.classList.add("whatsnewImgContainer");
const video = document.createElement("video");
const source = document.createElement("source");
source.setAttribute("src", BS_CLOUD_DEMO_VIDEO_URL);
source.setAttribute("type", "video/webm");
video.autoplay = true;
video.muted = true;
video.loop = true;
video.appendChild(source);
video.classList.add("whatsnewImg");
imageContainer.appendChild(video);
attachPopupMediaFullscreen(video);
const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer privacyStatement" style="height: 50%; overflow-y: auto; font-size: 1.2rem; line-height: 1.6;">
<p>
<strong class="bsCloudAccent">BetterSEQTA Cloud</strong> can keep your BetterSEQTA+ settings backed up and in
sync across browsers. Optional <strong>automatic settings sync</strong> runs when you are signed in (passwords
and tokens are never included).
</p>
<p>
Close this dialog when you are done. We will not show this announcement again.
</p>
<p class="bsCloudAutoSyncSignupCallout">Sign up in BetterSEQTA settings</p>
</div>
`).firstChild as HTMLElement;
settingsState.bsCloudAutoSyncAnnouncementShown = true;
openPopup({
header,
content: [imageContainer, text],
afterClose: onDismissed,
});
}
@@ -0,0 +1,54 @@
import { settingsState } from "../listeners/SettingsState";
import { animate as motionAnimate } from "motion";
export function shouldShowEngageParentsAnnouncement(): boolean {
return !settingsState.engageParentsAnnouncementShown;
}
/**
* Non-blocking bottom-right toast announcing SEQTA Engage support. Shown once.
*/
export function showEngageParentsToast() {
if (!shouldShowEngageParentsAnnouncement()) return;
settingsState.engageParentsAnnouncementShown = true;
const toast = document.createElement("div");
toast.className = "bsplus-toast";
toast.innerHTML = /* html */ `
<div class="bsplus-toast-content">
<strong>BetterSEQTA+ now supports <span class="seqtaEngageAccent">SEQTA Engage</span></strong>
<p>Buy your mum a BetterSEQTA Plus! Parents now get themes, a cleaner home page, and all the Plus polish on SEQTA Engage.</p>
</div>
<button class="bsplus-toast-close" aria-label="Dismiss">&times;</button>
`;
toast.style.opacity = "0";
document.getElementById("container")?.append(toast);
if (settingsState.animations) {
(motionAnimate as any)(
toast,
{ opacity: [0, 1], y: [40, 0] },
{ duration: 0.35, easing: [0.22, 0.03, 0.26, 1] },
);
} else {
toast.style.opacity = "1";
}
const dismiss = () => {
if (settingsState.animations) {
(motionAnimate as any)(
toast,
{ opacity: [1, 0], y: [0, 40] },
{ duration: 0.2, easing: [0.22, 0.03, 0.26, 1] },
).then(() => toast.remove());
} else {
toast.remove();
}
};
toast.querySelector(".bsplus-toast-close")!.addEventListener("click", dismiss);
setTimeout(dismiss, 10000);
}
@@ -1,13 +1,31 @@
import stringToHTML from "../stringToHTML"; import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState"; import { settingsState } from "../listeners/SettingsState";
import { openPopup } from "./PopupManager"; import { openPopup } from "./PopupManager";
import { attachPopupMediaFullscreenIfPresent } from "./attachPopupMediaFullscreen";
export function showPrivacyNotification() { const PRIVACY_STATEMENT_VERSION = "2025-12-19";
const lastUpdated = "2025-12-19";
if (document.getElementById("whatsnewbk")) return; export function shouldShowPrivacyNotification(): boolean {
if (settingsState.privacyStatementShown) return; if (settingsState.privacyStatementShown) return false;
if (settingsState.privacyStatementLastUpdated && new Date(settingsState.privacyStatementLastUpdated) > new Date(lastUpdated)) return; if (
settingsState.privacyStatementLastUpdated &&
new Date(settingsState.privacyStatementLastUpdated) >
new Date(PRIVACY_STATEMENT_VERSION)
) {
return false;
}
return true;
}
export function showPrivacyNotification(onDismissed?: () => void) {
if (document.getElementById("whatsnewbk")) {
onDismissed?.();
return;
}
if (!shouldShowPrivacyNotification()) {
onDismissed?.();
return;
}
const header = stringToHTML( const header = stringToHTML(
/* html */ /* html */
@@ -42,11 +60,14 @@ export function showPrivacyNotification() {
</div> </div>
`).firstChild as HTMLElement; `).firstChild as HTMLElement;
attachPopupMediaFullscreenIfPresent(text, "img.aboutImg");
settingsState.privacyStatementLastUpdated = "2025-12-20"; settingsState.privacyStatementLastUpdated = "2025-12-20";
settingsState.privacyStatementShown = true; settingsState.privacyStatementShown = true;
openPopup({ openPopup({
header, header,
content: [text], content: [text],
afterClose: onDismissed,
}); });
} }
+63 -2
View File
@@ -2,8 +2,9 @@ import stringToHTML from "../stringToHTML";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import kofi from "@/resources/kofi.png?base64"; import kofi from "@/resources/kofi.png?base64";
import { openPopup } from "./PopupManager"; import { openPopup } from "./PopupManager";
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
export function OpenWhatsNewPopup() { export function OpenWhatsNewPopup(onDismissed?: () => void) {
const header = stringToHTML( const header = stringToHTML(
/* html */ /* html */
`<div class="whatsnewHeader"> `<div class="whatsnewHeader">
@@ -20,7 +21,7 @@ export function OpenWhatsNewPopup() {
source.setAttribute( source.setAttribute(
"src", "src",
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/update-video.mp4", "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/update-video.webm",
); );
video.autoplay = true; video.autoplay = true;
video.muted = true; video.muted = true;
@@ -28,10 +29,68 @@ export function OpenWhatsNewPopup() {
video.appendChild(source); video.appendChild(source);
video.classList.add("whatsnewImg"); video.classList.add("whatsnewImg");
imageContainer.appendChild(video); imageContainer.appendChild(video);
attachPopupMediaFullscreen(video);
const text = stringToHTML(/* html */ ` const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;"> <div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
<h1>3.6.5 - Assessment weighting override & fixes</h1>
<li>Added the ability to override/add weightings to assessments (on assessment page).</li>
<li>Fixed the display of weightings that could not automatically be discovered.</li>
<li>Fixed the formatting of the weighting tag that was broken due to a SEQTA update.</li>
<h1>3.6.4 - Theme flavours and fixes, Upcoming Assements improvement</h1>
<li>Added advanced colour adjustments variables for theme customisation.</li>
<li>Improved logic for upcoming assements dashlet to improve compatibility.</li>
<li>BS Cloud can now automatically download themes from other devices.</li>
<li>Added theme flavours for multiple colour variations of the same theme.</li>
<h1>3.6.3 - Assessment overview fix</h1>
<li>Fixed assessments overview failing to load.</li>
<h1>3.6.2 - Cloud backup, various fixes & SEQTA Engage support</h1>
<li>BetterSEQTA Cloud: back up and restore extension settings from your account (General settings).</li>
<li>Optional automatic cloud sync if signed in (on by default).</li>
<li>Option to use cloud profile photo as the local SEQTA profile picture</li>
<li>Firefox: fixed the extension settings popup.</li>
<li>SEQTA Engage: Added BetterSEQTA Plus support for SEQTA Engage for Parents.</li>
<li>Added smooth transitions to adaptive themes (on by default)</li>
<li>Added adaptive theme variables to custom themes (try it out with the Windows XP theme)</li>
<li>Fixed today's lessons on the homepage misbehaving in developer mode.</li>
<li>Reduced overlap between BetterSEQTA subject averages and SEQTA's built-in averages UI.</li>
<li>Updated outdated in-app links and update some under the hood code (Vite 8).</li>
<li>Added a notifications panel animation to work like settings.</li>
<li>Fix timetable edit plugin not working correctly.</li>
<h1>3.5.3 - Adaptive theme updates</h1>
<li>Fixed adaptive theming on current-year course and assessment pages.</li>
<h1>3.5.2 - PDF & store compliance</h1>
<li>Put PDF.js with the extension so assessment weighting stays compatible with Chrome Web Store rules</li>
<h1>3.5.1 - QR & session link fix</h1>
<li>Fixed DesQTA Connect Mobile App QR generation on Chrome</li>
<h1>3.5.0 - Adaptive Theme, Timetable Editor & More</h1>
<li>Added adaptive theme colour</li>
<li>Added optional soft gradient for adaptive theme when viewing a class</li>
<li>Added timetable editor</li>
<li>Added icon-only sidebar option for a compact layout</li>
<li>Added empty states for notices and homepage cards</li>
<li>Added BetterSEQTA Cloud sign-in and improved store browsing details</li>
<li>Improved popup rendering to better handle floating UI elements</li>
<li>Fixed assessment colouring issues</li>
<li>Fixed icon loading on SEQTA pages and improved Windows dropdown styling</li>
<li>Fixed sidebar issues with compact mode, keyboard focus, and tab navigation</li>
<li>Fixed unnecessary notice modal scrolling and other popup styling issues</li>
<li>Added new kanban categories to the assessments overview</li>
<li>Added DesQTA QR code generation in settings for linking to the DesQTA (BetterSEQTA) mobile app</li>
<li>New store with improved theme browsing and backgrounds</li>
<li>Other minor bug fixes and improvements</li>
<h1>3.4.16 - Subject Averages Fixes</h1>
<li>Fixed subject averages not showing correctly with letter grades and weighted marks</li>
<h1>3.4.15 - SEQTA New UI Patch</h1> <h1>3.4.15 - SEQTA New UI Patch</h1>
<li>Fixed compatibility issues caused by the new SEQTA UI update</li> <li>Fixed compatibility issues caused by the new SEQTA UI update</li>
<li>Adjusted styling to match updated SEQTA layout changes</li> <li>Adjusted styling to match updated SEQTA layout changes</li>
@@ -323,5 +382,7 @@ export function OpenWhatsNewPopup() {
openPopup({ openPopup({
header, header,
content: [imageContainer, text, footer], content: [imageContainer, text, footer],
afterClose: onDismissed,
clearJustUpdated: true,
}); });
} }
+21 -1
View File
@@ -4,6 +4,13 @@ import { animate as motionAnimate, stagger } from "motion";
type AnimationTarget = string | Element | Element[] | NodeList | null; type AnimationTarget = string | Element | Element[] | NodeList | null;
let isClosing = false; let isClosing = false;
let pendingAfterClose: (() => void) | undefined;
function invokeAfterClose() {
const fn = pendingAfterClose;
pendingAfterClose = undefined;
fn?.();
}
export async function closePopup() { export async function closePopup() {
if (isClosing) return; if (isClosing) return;
@@ -16,12 +23,14 @@ export async function closePopup() {
if (!background || !popup) { if (!background || !popup) {
isClosing = false; isClosing = false;
invokeAfterClose();
return; return;
} }
if (!settingsState.animations) { if (!settingsState.animations) {
background.remove(); background.remove();
isClosing = false; isClosing = false;
invokeAfterClose();
return; return;
} }
@@ -33,19 +42,28 @@ export async function closePopup() {
background.remove(); background.remove();
isClosing = false; isClosing = false;
invokeAfterClose();
} }
interface OpenPopupOptions { interface OpenPopupOptions {
header?: Node | null; header?: Node | null;
content?: (Node | null | undefined)[]; content?: (Node | null | undefined)[];
animateSelector?: AnimationTarget; animateSelector?: AnimationTarget;
/** Called once after this popup is fully closed (including skip-animation path). */
afterClose?: () => void;
/** When true, clears the post-update flag when this popup opens (What's New only). */
clearJustUpdated?: boolean;
} }
export function openPopup({ export function openPopup({
header, header,
content = [], content = [],
animateSelector = ".whatsnewTextContainer *", animateSelector = ".whatsnewTextContainer *",
afterClose,
clearJustUpdated = false,
}: OpenPopupOptions = {}) { }: OpenPopupOptions = {}) {
pendingAfterClose = afterClose;
const background = document.createElement("div"); const background = document.createElement("div");
background.id = "whatsnewbk"; background.id = "whatsnewbk";
background.classList.add("whatsnewBackground"); background.classList.add("whatsnewBackground");
@@ -88,7 +106,9 @@ export function openPopup({
} }
} }
delete settingsState.justupdated; if (clearJustUpdated) {
delete settingsState.justupdated;
}
background.addEventListener("click", (event) => { background.addEventListener("click", (event) => {
if (event.target === background) void closePopup(); if (event.target === background) void closePopup();
@@ -0,0 +1,32 @@
import { settingsState } from "../listeners/SettingsState";
import { OpenWhatsNewPopup } from "./OpenWhatsNewPopup";
import {
shouldShowEngageParentsAnnouncement,
showEngageParentsToast,
} from "./OpenEngageParentsAnnouncement";
type QueueStep = (goNext: () => void) => void;
/**
* Runs startup modals in order: What's New (if the extension just updated),
* then shows the SEQTA Engage toast (once, non-blocking).
*/
export function runStartupPopupQueue() {
const steps: QueueStep[] = [];
if (settingsState.justupdated) {
steps.push((goNext) => OpenWhatsNewPopup(goNext));
}
function runNext() {
const step = steps.shift();
if (step) step(runNext);
else {
if (shouldShowEngageParentsAnnouncement()) {
showEngageParentsToast();
}
}
}
runNext();
}
@@ -0,0 +1,158 @@
/**
* Makes popup hero images/videos open a padded overlay (not browser fullscreen) on click.
* Escape or backdrop click dismisses it. Clicks use stopPropagation so the
* parent SEQTA popup does not close.
*/
import { settingsState } from "../listeners/SettingsState";
const FULLSCREENABLE_CLASS = "popup-media-fullscreenable";
const OVERLAY_VISIBLE_CLASS = "bsplus-popup-media-overlay-backdrop--visible";
const OVERLAY_ANIM_MS = 280;
function isImageOrVideo(el: Element): el is HTMLImageElement | HTMLVideoElement {
return el instanceof HTMLImageElement || el instanceof HTMLVideoElement;
}
export function attachPopupMediaFullscreen(el: HTMLImageElement | HTMLVideoElement) {
el.classList.add(FULLSCREENABLE_CLASS);
el.setAttribute("tabindex", "0");
el.setAttribute("role", "button");
el.setAttribute("aria-label", "View larger");
el.title = "Click to view larger";
const open = (e: Event) => {
e.preventDefault();
e.stopPropagation();
openMediaOverlayViewer(el);
};
el.addEventListener("click", open);
el.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
open(e);
}
});
}
function openMediaOverlayViewer(source: HTMLImageElement | HTMLVideoElement) {
const backdrop = document.createElement("div");
backdrop.id = "bsplus-popup-media-overlay";
backdrop.className = "bsplus-popup-media-overlay-backdrop";
const inner = document.createElement("div");
inner.className = "bsplus-popup-media-overlay-inner";
const slot = document.createElement("div");
slot.className = "bsplus-popup-media-overlay-slot";
let media: HTMLImageElement | HTMLVideoElement;
if (source instanceof HTMLVideoElement) {
const v = source;
const nv = document.createElement("video");
nv.classList.add("bsplus-popup-media-overlay-media");
nv.controls = true;
nv.playsInline = true;
nv.loop = v.loop;
nv.muted = v.muted;
nv.volume = v.volume;
for (const s of v.querySelectorAll("source")) {
const ns = document.createElement("source");
ns.src = (s as HTMLSourceElement).src;
const t = (s as HTMLSourceElement).type;
if (t) ns.type = t;
nv.appendChild(ns);
}
nv.addEventListener(
"loadeddata",
() => {
try {
nv.currentTime = v.currentTime;
} catch {
/* ignore */
}
void nv.play().catch(() => {});
},
{ once: true },
);
v.pause();
nv.load();
media = nv;
} else {
const img = document.createElement("img");
img.classList.add("bsplus-popup-media-overlay-media");
img.src = source.currentSrc || source.src;
img.alt = source.alt || "";
media = img;
}
media.addEventListener("click", (e) => e.stopPropagation());
slot.appendChild(media);
inner.append(slot);
backdrop.appendChild(inner);
document.body.append(backdrop);
if (!settingsState.animations) {
backdrop.classList.add("bsplus-popup-media-overlay--instant");
backdrop.classList.add(OVERLAY_VISIBLE_CLASS);
} else {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
backdrop.classList.add(OVERLAY_VISIBLE_CLASS);
});
});
}
inner.addEventListener("click", (e) => e.stopPropagation());
let done = false;
const removeOverlay = () => {
if (source instanceof HTMLVideoElement && media instanceof HTMLVideoElement) {
try {
source.currentTime = media.currentTime;
} catch {
/* ignore */
}
void source.play().catch(() => {});
}
backdrop.remove();
};
const close = () => {
if (done) return;
done = true;
document.removeEventListener("keydown", onDocKey, true);
if (!settingsState.animations) {
removeOverlay();
return;
}
backdrop.classList.remove(OVERLAY_VISIBLE_CLASS);
window.setTimeout(removeOverlay, OVERLAY_ANIM_MS);
};
const onDocKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
ev.stopPropagation();
close();
}
};
document.addEventListener("keydown", onDocKey, true);
backdrop.addEventListener("click", () => {
close();
});
}
export function attachPopupMediaFullscreenIfPresent(
root: ParentNode,
selector: string,
) {
const el = root.querySelector(selector);
if (el && isImageOrVideo(el)) {
attachPopupMediaFullscreen(el);
}
}
+23 -10
View File
@@ -53,11 +53,22 @@ export async function SendNewsPage() {
const newscontainer = document.querySelector("#news-container"); const newscontainer = document.querySelector("#news-container");
document.getElementById("newsloading")?.remove(); document.getElementById("newsloading")?.remove();
// Create a document fragment to batch DOM operations const articles = response?.news?.articles;
if (!Array.isArray(articles) || articles.length === 0) {
const emptyState = document.createElement("div");
emptyState.classList.add("day-empty");
const img = document.createElement("img");
img.src = browser.runtime.getURL(LogoLightOutline);
const text = document.createElement("p");
text.innerText = "No news articles available right now.";
emptyState.append(img, text);
newscontainer?.append(emptyState);
return;
}
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
// Map over articles to create elements articles.forEach((article: any) => {
response.news.articles.forEach((article: any) => {
const newsarticle = document.createElement("a"); const newsarticle = document.createElement("a");
newsarticle.classList.add("NewsArticle"); newsarticle.classList.add("NewsArticle");
newsarticle.href = article.url; newsarticle.href = article.url;
@@ -85,12 +96,14 @@ export async function SendNewsPage() {
title.target = "_blank"; title.target = "_blank";
const description = document.createElement("p"); const description = document.createElement("p");
const articleDescription = typeof article.description === "string"
? article.description
: "No description available.";
article.description = description.innerHTML =
article.description.length > 400 articleDescription.length > 400
? article.description.substring(0, 400) + "..." ? articleDescription.substring(0, 400) + "..."
: article.description; : articleDescription;
description.innerHTML = article.description;
articletext.append(title, description); articletext.append(title, description);
newsarticle.append(articleimage, articletext); newsarticle.append(articleimage, articletext);
@@ -102,10 +115,10 @@ export async function SendNewsPage() {
if (!settingsState.animations) return; if (!settingsState.animations) return;
const articles = Array.from(document.querySelectorAll(".NewsArticle")); const animatedArticles = Array.from(document.querySelectorAll(".NewsArticle"));
animate( animate(
articles.slice(0, 20), animatedArticles.slice(0, 20),
{ opacity: [0, 1], y: [10, 0], scale: [0.99, 1] }, { opacity: [0, 1], y: [10, 0], scale: [0.99, 1] },
{ {
delay: stagger(0.1), delay: stagger(0.1),
+119
View File
@@ -0,0 +1,119 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
/**
* Parses the current page from window.location.hash.
* Supports both old and current URL formats, e.g.
* /courses/SEMESTER/X:Y and /courses/X:Y
* /assessments/SEMESTER/X:Y and /assessments/X:Y
* e.g. #?page=/courses/2023S/4804:11066,
* #?page=/courses/4804:11066,
* #?page=/assessments/2023S/4621:10772,
* #?page=/assessments/4621:10772
*/
function parsePageContext(): { programme: number; metaclass: number } | null {
const hash = window.location.hash || "";
const match = hash.match(/[?&]page=\/(courses|assessments)\/(?:[^/]+\/)?(\d+):(\d+)/);
if (!match) return null;
const programme = parseInt(match[2], 10);
const metaclass = parseInt(match[3], 10);
if (isNaN(programme) || isNaN(metaclass)) return null;
return { programme, metaclass };
}
/**
* Fetches subjects and finds the subject matching programme:metaclass.
*/
async function getSubjectCode(
programme: number,
metaclass: number
): Promise<string | null> {
try {
const res = await fetch(`${location.origin}/seqta/student/load/subjects?`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({}),
});
const data = await res.json();
const payload = data?.payload;
if (!Array.isArray(payload)) return null;
for (const semester of payload) {
const subjects = semester?.subjects;
if (!Array.isArray(subjects)) continue;
const subject = subjects.find(
(s: any) =>
s &&
Number(s.programme) === programme &&
Number(s.metaclass) === metaclass
);
if (subject?.code) return subject.code;
}
return null;
} catch (error) {
console.warn("[BetterSEQTA+] Adaptive theme: failed to load subjects:", error);
return null;
}
}
/**
* Fetches user prefs and returns the colour for the given subject code.
*/
async function getSubjectColour(
subjectCode: string,
userId: number
): Promise<string | null> {
try {
const res = await fetch(`${location.origin}/seqta/student/load/prefs?`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
request: "userPrefs",
asArray: true,
user: userId,
}),
});
const data = await res.json();
const payload = data?.payload;
if (!Array.isArray(payload)) return null;
const pref = payload.find(
(p: { name: string; value: string }) =>
p.name === `timetable.subject.colour.${subjectCode}`
);
return pref?.value ?? null;
} catch (error) {
console.warn("[BetterSEQTA+] Adaptive theme: failed to load prefs:", error);
return null;
}
}
/**
* Returns the adaptive theme colour for the current page context, or null.
* When viewing a course or assessments page, returns the subject's assigned colour.
*/
export async function getAdaptiveColour(): Promise<string | null> {
const context = parsePageContext();
if (!context) return null;
const subjectCode = await getSubjectCode(context.programme, context.metaclass);
if (!subjectCode) return null;
let userId: number;
try {
const userInfo = await getUserInfo();
userId = userInfo?.id;
if (typeof userId !== "number") return null;
} catch {
return null;
}
const colour = await getSubjectColour(subjectCode, userId);
if (!colour || typeof colour !== "string") return null;
// Basic hex validation
if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(colour)) return colour;
if (/^[0-9A-Fa-f]{6}$/.test(colour)) return `#${colour}`;
return null;
}

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