Compare commits

...

55 Commits

Author SHA1 Message Date
SethBurkart123 304ce2e128 feat: refine startup announcement cards 2026-05-23 22:53:06 +10:00
AdenMGB 0bc6beb0f1 chore: bump ver & release notes 2026-05-23 09:08:31 +09:30
AdenMGB 68173a8b75 fix: fix custom teacher names not applying to popup 2026-05-23 08:58:21 +09:30
Aden Lindsay 7583d0ee47 Merge pull request #439 from StroepWafel/main
feat: Theme of the Month
2026-05-19 20:36:01 +09:30
codefactor-io 6c79fe3588 [CodeFactor] Apply fixes 2026-05-19 10:53:27 +00:00
StroepWafel c0a8a76105 feat: Theme Of The Month 2026-05-19 20:19:50 +09:30
StroepWafel 6ad214bb09 Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus 2026-05-18 20:29:35 +09:30
AdenMGB b4598668d4 feat: re enable message folders with improvments 2026-05-13 13:30:27 +09:30
AdenMGB 01e679eab6 Revert "fix: add some better detection logic for assements widget #429"
This reverts commit 01cd5d1428.
2026-05-06 17:31:41 +09:30
Aden Lindsay f57bd107b9 Merge pull request #436 from Jaxx7594/asessment-average-manual-input
Feat: ability to manually input weightings for assessments
2026-05-05 19:51:00 +09:30
Jaxon Lewis-Wilson aa5d193e55 assessmentsAverage: Fix inaccurate weight when a weight == N/A
N/A weights were automatically set to a weight of 1 for some reason. I removed it from the calculations completely with this commit.
2026-05-05 18:14:06 +08:00
Jaxon Lewis-Wilson da5bc7ab11 assessmentsAverage: Fix weight display upon setting override 2026-05-05 18:10:13 +08:00
Jaxon Lewis-Wilson b0857054eb assessmentsAverage: Fix unmarked/upcoming assessment indexing and weight display 2026-05-05 17:56:06 +08:00
Jaxon Lewis-Wilson f721bf6609 Revert "feat: dont inject weightings page in assements without results"
This reverts commit 2aecd63850.
Reverting so that I can solve the indexing issue. Only marked assessments are getting indexed, which is incorrect behaviour that slipped testing when the plugin was first made.
2026-05-05 16:32:12 +08:00
AdenMGB 2aecd63850 feat: dont inject weightings page in assements without results 2026-05-05 17:44:58 +09:30
Jaxx7594 f35520029f assessmentAverage: Remove remnant comment 2026-05-04 22:53:05 +08:00
Jaxx7594 95994fcd3a Merge branch 'main' into asessment-average-manual-input 2026-05-04 22:46:29 +08:00
Jaxon Lewis-Wilson 999f12e958 assessmentsAverage: Add changes to changelog 2026-05-04 22:39:53 +08:00
Jaxon Lewis-Wilson 260afac294 assessmentsAverage: Fix display of missing weighting, and minor change to override section. 2026-05-04 18:36:16 +08:00
Jaxon Lewis-Wilson 678a958351 assessmentsAverage: Add ability to override/set weighting per assessment. 2026-05-04 18:32:32 +08:00
SethBurkart123 608fc96c4e chore: temporarily disable message folders plugin and remove from changelog 2026-05-01 15:39:20 +10:00
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
StroepWafel a1131cf6cd Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus 2026-04-29 21:47:11 +09:30
Aden Lindsay 3f493ac716 chore: add forgoten custom messages editor to whatsnew 2026-04-29 18:53:13 +09:30
Aden Lindsay e64ef7f95c Custom Message Folders (#431)
* feat: start custom messages plugin

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

* add notes

* patch fix theme overrides for adaptive colour

* idk

* Update package.json

* fix issue spelling

* Update OpenWhatsNewPopup.ts

* Update OpenWhatsNewPopup.ts

* fix: remove debug line from .gitignore

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

* chore: finalise patch notes and fix grammer

* Add empty line to .gitignore

---------

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

Bumps version to 3.6.3.
2026-04-21 19:08:30 +10:00
StroepWafel 44116edca5 patch fix theme overrides for adaptive colour 2026-04-20 22:50:39 +09:30
StroepWafel 37be31859f add notes 2026-04-20 21:47:05 +09:30
StroepWafel 10667f17b4 Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus 2026-04-20 21:43:15 +09:30
StroepWafel 0ca0c7cf43 add handlers for individual Channels 2026-04-20 21:43:05 +09:30
SethBurkart123 a0038ac871 chore: bump version to 3.6.2 2026-04-20 15:53:20 +10:00
SethBurkart123 49824e9eab refactor: remove alarms permission, throttle cloud sync to once per day on page load 2026-04-20 15:47:22 +10:00
SethBurkart123 01eeb18638 perf: throttle theme update check to daily and remove redundant cloud poll on page load 2026-04-20 15:39:37 +10:00
SethBurkart123 3702443ece chore: bump version to 3.6.1 2026-04-20 15:33:04 +10:00
SethBurkart123 87ba75ff41 feat: simplify startup popups and convert engage announcement to toast
Remove auto-showing privacy statement and BS Cloud announcement from
startup queue. Convert SEQTA Engage announcement from a blocking modal
to a subtle bottom-right dismissable toast.
2026-04-20 14:59:24 +10:00
SethBurkart123 f9406fb469 feat: redesign Cloud settings UI and switch to OAuth redirect login
- Move Cloud section inline with other settings, remove dedicated header bar
- Replace in-extension login form with browser redirect to accounts.betterseqta.org
- Background script intercepts OAuth callback URL to capture tokens
- Add animated CloudPanel overlay (same pattern as ColourPicker)
- Hide cloud sync details and profile picture setting when not signed in
- Simplify CloudSettingsSync UI, reduce text verbosity
- Fix settings download to merge keys instead of clear+set
- Add legacy-to-plugin settings migration for cloud sync
- Shorten profile picture and default page descriptions
- Make DisclaimerModal title/message dynamic
- Update CloudHeader button styling to match other buttons
2026-04-20 13:42:49 +10:00
AdenMGB 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
44 changed files with 5168 additions and 692 deletions
+2 -1
View File
@@ -22,4 +22,5 @@ betterseqtaplus-safari/
.parcel-cache .parcel-cache
.env .env
.env.submit .env.submit
dependency-graph.svg dependency-graph.svg
+122 -22
View File
@@ -5,7 +5,7 @@
"": { "": {
"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",
@@ -13,7 +13,7 @@
"@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",
@@ -52,17 +52,17 @@
"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",
@@ -127,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=="],
@@ -147,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=="],
@@ -293,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=="],
@@ -349,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=="],
@@ -409,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=="],
@@ -527,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=="],
@@ -631,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=="],
@@ -693,6 +735,8 @@
"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=="],
@@ -773,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=="],
@@ -787,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=="],
@@ -995,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=="],
@@ -1125,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=="],
@@ -1143,6 +1213,8 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"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=="], "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=="],
@@ -1293,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=="],
@@ -1397,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=="],
@@ -1423,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=="],
@@ -1477,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=="],
@@ -1485,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=="],
@@ -1555,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=="],
@@ -1591,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=="],
@@ -1621,6 +1699,8 @@
"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=="], "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=="],
@@ -1633,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=="],
@@ -1715,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=="],
@@ -1725,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=="],
@@ -1741,6 +1829,14 @@
"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=="],
@@ -1865,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=="],
+10 -3
View File
@@ -35,13 +35,16 @@ Upserts the callers settings backup.
```json ```json
{ {
"schemaVersion": 1, "schemaVersion": 1,
"themeId": "uuid-string-or-empty",
"data": { "data": {
"...": "flat key-value map mirroring extension storage (see Payload shape)" "...": "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. - **`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`). - **`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: **Success response:** HTTP `200` (or `201` if you prefer create semantics). Example:
@@ -67,15 +70,19 @@ Returns the callers latest settings backup.
```json ```json
{ {
"schemaVersion": 1, "schemaVersion": 1,
"themeId": "uuid-string-or-empty",
"data": { }, "data": { },
"updated_at": "2026-04-07T12:00:00.000Z" "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). - **`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. - **`schemaVersion`**: optional but recommended; should match what was stored.
- **`updated_at`**: optional; included for UX if the client shows “last backup” time. - **`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. **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. **Error responses:** `401` if the token is invalid, etc.
@@ -128,6 +135,6 @@ This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages,
## Client reference (extension) ## Client reference (extension)
- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, and **`bsplus_cloud_settings_known_remote_updated_at`**). - 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: `applyDownloadedEnvelope` after `GET`; local auth keys, sensitive device keys, and the client-only watermark key are merged back after `chrome.storage.local.clear()`. - 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`. - 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`.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"version": "3.6.0", "version": "3.6.5",
"type": "module", "type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and 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",
+104 -3
View File
@@ -5,9 +5,25 @@ import {
initCloudSettingsAutoSync, initCloudSettingsAutoSync,
performCloudSettingsDownloadWithRetry, performCloudSettingsDownloadWithRetry,
performCloudSettingsUploadWithRetry, performCloudSettingsUploadWithRetry,
requestCloudSettingsDebouncedUpload,
runCloudSettingsPoll, runCloudSettingsPoll,
} from "./background/cloudSettingsAutoSync"; } from "./background/cloudSettingsAutoSync";
/**
* Session-only dev-mode override of the content API base.
*
* Stored in a module-level variable (not `chrome.storage`) so it is wiped
* automatically when the browser/service-worker process restarts. Content
* scripts re-sync this on every page load via `setDevApiBase` so the value
* survives transient service-worker terminations within the same browser
* session.
*/
const DEFAULT_API_BASE = "https://betterseqta.org";
let DEV_API_BASE: string | null = null;
function apiBase(): string {
return DEV_API_BASE ?? DEFAULT_API_BASE;
}
function reloadSeqtaPages() { function reloadSeqtaPages() {
const result = browser.tabs.query({}); const result = browser.tabs.query({});
function open(tabs: any) { function open(tabs: any) {
@@ -28,7 +44,7 @@ type MessageSender = { (response?: unknown): void };
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean { function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
const { token } = request; const { token } = request;
const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`; const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`; const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -56,7 +72,7 @@ function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boo
} }
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers }) fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers })
.then((r) => r.json()) .then((r) => r.json())
.then(sendResponse) .then(sendResponse)
.catch((err) => { .catch((err) => {
@@ -133,6 +149,74 @@ function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
return true; 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,
});
// Fetch full user info
const userRes = await fetch("https://accounts.betterseqta.org/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});
if (userRes.ok) {
const user = await userRes.json();
await browser.storage.local.set({ bsplus_user: user });
} else if (userId) {
await browser.storage.local.set({ bsplus_user: { id: userId } });
}
// Trigger cloud settings download
void performCloudSettingsDownloadWithRetry(token).catch((err) => {
console.warn("[Background] Cloud settings download after login:", err);
});
} catch (err) {
console.error("[Background] Failed to process login callback:", err);
}
})();
// Close the callback tab
void browser.tabs.remove(tabId);
}
} catch (err) {
console.error("[Background] Error parsing callback URL:", err);
}
}
});
}
initCloudLoginCallbackListener();
function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean { function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
const { refresh_token, client_id } = request; const { refresh_token, client_id } = request;
if (!refresh_token || !client_id) { if (!refresh_token || !client_id) {
@@ -214,7 +298,7 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean
return false; return false;
} }
const isFavorite = action === "favorite"; const isFavorite = action === "favorite";
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, { fetch(`${apiBase()}/api/themes/${themeId}/favorite`, {
method: isFavorite ? "POST" : "DELETE", method: isFavorite ? "POST" : "DELETE",
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
@@ -241,8 +325,19 @@ function isSeqtaOrigin(origin: string): boolean {
} }
} }
function handleSetDevApiBase(request: any): boolean {
const url = typeof request?.url === "string" ? request.url.trim() : null;
if (url && /^https?:\/\//.test(url)) {
DEV_API_BASE = url.replace(/\/$/, "");
} else {
DEV_API_BASE = null;
}
return false;
}
const MESSAGE_HANDLERS: Record<string, MessageHandler> = { const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
reloadTabs: () => reloadSeqtaPages(), reloadTabs: () => reloadSeqtaPages(),
setDevApiBase: handleSetDevApiBase,
extensionPages: (req) => { extensionPages: (req) => {
browser.tabs.query({}).then((tabs) => { browser.tabs.query({}).then((tabs) => {
for (const tab of tabs) { for (const tab of tabs) {
@@ -269,6 +364,7 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
fetchFromUrl: handleFetchFromUrl, fetchFromUrl: handleFetchFromUrl,
cloudReserveClient: handleCloudReserveClient, cloudReserveClient: handleCloudReserveClient,
cloudLogin: handleCloudLogin, cloudLogin: handleCloudLogin,
cloudStartLogin: handleCloudStartLogin,
cloudRefresh: handleCloudRefresh, cloudRefresh: handleCloudRefresh,
cloudFavorite: handleCloudFavorite, cloudFavorite: handleCloudFavorite,
cloudSettingsUpload: handleCloudSettingsUpload, cloudSettingsUpload: handleCloudSettingsUpload,
@@ -277,6 +373,10 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
void runCloudSettingsPoll(); void runCloudSettingsPoll();
return false; return false;
}, },
cloudSettingsRequestDebouncedUpload: () => {
requestCloudSettingsDebouncedUpload();
return false;
},
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => { getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
(async () => { (async () => {
try { try {
@@ -395,6 +495,7 @@ function getDefaultValues(): SettingsState {
adaptiveThemeColour: false, adaptiveThemeColour: false,
adaptiveThemeGradient: false, adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true, adaptiveThemeColourTransition: true,
themeOfTheMonthDisabled: false,
autoCloudSettingsSync: true, autoCloudSettingsSync: true,
}; };
} }
+28 -14
View File
@@ -3,8 +3,10 @@ import {
applyDownloadedEnvelope, applyDownloadedEnvelope,
buildUploadPayload, buildUploadPayload,
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
isKeyIncludedInCloudUploadPayload, isKeyIncludedInCloudUploadPayload,
resolveThemeIdForPostSyncDownload,
setKnownRemoteUpdatedAt, setKnownRemoteUpdatedAt,
} from "@/seqta/utils/cloudSettingsSync"; } from "@/seqta/utils/cloudSettingsSync";
@@ -13,9 +15,9 @@ export const CLOUD_SUMMARY_URL = `${ACCOUNTS_BASE}/api/user/cloud-summary`;
const CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`; const CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`;
const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`; const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`;
const ALARM_NAME = "bsplus_cloud_settings_auto_sync";
const PERIOD_MINUTES = 60;
const UPLOAD_DEBOUNCE_MS = 2000; const UPLOAD_DEBOUNCE_MS = 2000;
const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000;
const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll";
type CloudSummaryResponse = { type CloudSummaryResponse = {
desqta?: unknown; desqta?: unknown;
@@ -220,7 +222,15 @@ async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
error: data?.error ?? `Download failed (${r.status})`, error: data?.error ?? `Download failed (${r.status})`,
}; };
} }
const themeIdToEnsure = resolveThemeIdForPostSyncDownload(data);
await applyDownloadedEnvelope(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?.(); reloadSeqtaPagesFn?.();
const updated_at = data?.updated_at as string | undefined; const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at); await setKnownRemoteUpdatedAt(updated_at);
@@ -323,6 +333,9 @@ export function runCloudSettingsPoll(): Promise<void> {
if (pollInFlight) return pollInFlight; if (pollInFlight) return pollInFlight;
pollInFlight = (async () => { pollInFlight = (async () => {
try { 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(); await runCloudSettingsPollInner();
} catch (e) { } catch (e) {
console.error("[BS+ cloud sync] Poll error:", e); console.error("[BS+ cloud sync] Poll error:", e);
@@ -349,6 +362,17 @@ function scheduleDebouncedUpload(): void {
}, UPLOAD_DEBOUNCE_MS); }, 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> { async function runDebouncedUploadJob(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>; const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return; if (!isAutoCloudSyncEnabled(all)) return;
@@ -360,14 +384,11 @@ async function runDebouncedUploadJob(): Promise<void> {
} }
} }
async function syncAlarmWithStorage(): Promise<void> { async function syncAutoUploadWithStorage(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>; const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) { if (!isAutoCloudSyncEnabled(all)) {
await browser.alarms.clear(ALARM_NAME);
clearUploadDebounce(); clearUploadDebounce();
return;
} }
await browser.alarms.create(ALARM_NAME, { periodInMinutes: PERIOD_MINUTES });
} }
function onStorageChanged( function onStorageChanged(
@@ -377,7 +398,7 @@ function onStorageChanged(
if (area !== "local") return; if (area !== "local") return;
if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) { if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) {
void syncAlarmWithStorage(); void syncAutoUploadWithStorage();
} }
const keys = Object.keys(changes); const keys = Object.keys(changes);
@@ -392,15 +413,8 @@ function onStorageChanged(
})(); })();
} }
function onAlarm(alarm: browser.Alarms.Alarm): void {
if (alarm.name !== ALARM_NAME) return;
void runCloudSettingsPoll();
}
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void { export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
reloadSeqtaPagesFn = deps.reloadSeqtaPages; reloadSeqtaPagesFn = deps.reloadSeqtaPages;
browser.alarms.onAlarm.addListener(onAlarm);
browser.storage.onChanged.addListener(onStorageChanged); browser.storage.onChanged.addListener(onStorageChanged);
void syncAlarmWithStorage();
} }
+210 -2
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");
@@ -2609,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___"] {
@@ -2735,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;
@@ -3725,6 +3726,150 @@ div.day-empty {
color: var(--text-primary); color: var(--text-primary);
} }
.themeOfTheMonthCard {
position: fixed;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
z-index: 48;
width: min(360px, calc(100vw - 36px));
overflow: visible;
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
border-radius: 20px;
background: var(--background-primary);
color: var(--text-primary);
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
animation: themeOfTheMonthCardIn 0.24s ease-out;
}
.themeOfTheMonthCard::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
overflow: hidden;
border-radius: inherit;
background: inherit;
}
.themeOfTheMonthCardClosing {
pointer-events: none;
animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
}
.themeOfTheMonthCardClose {
position: absolute !important;
top: 4px !important;
right: 4px !important;
z-index: 2;
width: 32px;
height: 32px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 16px !important;
background: rgba(0, 0, 0, 0.42);
color: white;
cursor: pointer;
font-size: 1.35rem;
line-height: 1;
}
.themeOfTheMonthCardImage {
display: block;
width: 100%;
height: 150px;
margin: 0;
border-radius: 20px 20px 0 0;
object-fit: cover;
}
.themeOfTheMonthCardBody {
padding: 14px 16px 16px;
}
.themeOfTheMonthCardEyebrow {
margin: 0 0 6px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--better-pri, #6366f1) 82%, var(--text-primary) 18%);
}
.themeOfTheMonthCard h2 {
margin: 0;
font-size: 1.2rem;
line-height: 1.2;
}
.themeOfTheMonthCardDescription {
display: -webkit-box;
margin: 8px 0 14px;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
font-size: 0.92rem;
line-height: 1.45;
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
}
.themeOfTheMonthCardActions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.themeOfTheMonthCardPrimary,
.themeOfTheMonthCardSecondary {
appearance: none;
border: none;
cursor: pointer;
border-radius: 9999px;
padding: 0.58rem 0.9rem;
font-size: 0.86rem;
font-weight: 700;
transition: transform 0.15s ease, filter 0.15s ease, background 0.15s ease;
}
.themeOfTheMonthCardPrimary {
background: var(--better-pri, #6366f1);
color: white;
}
.themeOfTheMonthCardSecondary {
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
color: var(--text-primary);
}
.themeOfTheMonthCardPrimary:hover,
.themeOfTheMonthCardSecondary:hover {
filter: brightness(1.08);
transform: translateY(-1px);
}
.themeOfTheMonthCardPrimary:active,
.themeOfTheMonthCardSecondary:active {
transform: translateY(0);
}
@keyframes themeOfTheMonthCardIn {
from {
opacity: 0;
transform: translateY(18px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes themeOfTheMonthCardOut {
to {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
}
@media (max-width: 900px) {
.themeOfTheMonthCard {
z-index: 2147483645;
}
}
.bsplus-theme-highlight {
animation: bsplusThemeHighlightPulse 1.4s ease-in-out 2;
}
@keyframes bsplusThemeHighlightPulse {
0%, 100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--better-pri, #6366f1) 0%, transparent);
}
50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--better-pri, #6366f1) 60%, transparent);
}
}
.popup-media-fullscreenable { .popup-media-fullscreenable {
cursor: pointer; cursor: pointer;
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
@@ -4371,3 +4516,66 @@ h2.home-subtitle {
font-size: 20px; font-size: 20px;
font-weight: 400; font-weight: 400;
} }
.bsplus-toast {
position: fixed;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
z-index: 10000;
width: min(360px, calc(100vw - 36px));
padding: 14px 16px 16px;
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
border-radius: 20px;
background: var(--background-primary, #fff);
color: var(--text-primary, #1a1a1a);
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
font-size: 0.9rem;
line-height: 1.45;
}
.bsplus-toast-eyebrow {
margin: 0 0 6px !important;
font-size: 0.72rem !important;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, #ea580c 82%, var(--text-primary) 18%);
opacity: 1 !important;
}
.dark .bsplus-toast-eyebrow {
color: color-mix(in srgb, #fb923c 82%, var(--text-primary) 18%);
}
.bsplus-toast-content strong {
display: block;
padding-right: 34px;
font-size: 1.2rem;
line-height: 1.2;
}
.bsplus-toast-content p:not(.bsplus-toast-eyebrow) {
display: -webkit-box;
margin: 8px 0 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
font-size: 0.92rem;
line-height: 1.45;
}
.bsplus-toast-close {
position: absolute !important;
top: 4px !important;
right: 4px !important;
z-index: 2;
width: 32px;
height: 32px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 16px !important;
background: rgba(0, 0, 0, 0.42);
color: white;
cursor: pointer;
font-size: 1.35rem;
line-height: 1;
transition: filter 0.15s ease;
}
.bsplus-toast-close:hover {
filter: brightness(1.08);
}
+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>
@@ -2,17 +2,17 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import DisclaimerModal from "./DisclaimerModal.svelte";
import Button from "./Button.svelte"; import Button from "./Button.svelte";
import Switch from "./Switch.svelte"; import Switch from "./Switch.svelte";
let { showDisclaimer } = $props<{
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
}>();
let cloudState = $state(cloudAuth.state); let cloudState = $state(cloudAuth.state);
let busy = $state(false); let busy = $state(false);
let statusMessage = $state<string | null>(null); let statusMessage = $state<string | null>(null);
let statusError = $state<string | null>(null); let statusError = $state<string | null>(null);
let lastUploadAt = $state<string | null>(null);
let lastDownloadAt = $state<string | null>(null);
let showRestoreConfirm = $state(false);
$effect(() => { $effect(() => {
const unsub = cloudAuth.subscribe((s) => { const unsub = cloudAuth.subscribe((s) => {
@@ -21,13 +21,6 @@
return unsub; return unsub;
}); });
function formatNow(): string {
return new Date().toLocaleString(undefined, {
dateStyle: "short",
timeStyle: "short",
});
}
async function upload() { async function upload() {
const token = await cloudAuth.getStoredToken(); const token = await cloudAuth.getStoredToken();
if (!token) return; if (!token) return;
@@ -40,8 +33,7 @@
token, token,
})) as { success?: boolean; error?: string }; })) as { success?: boolean; error?: string };
if (res?.success) { if (res?.success) {
statusMessage = "Settings saved to the cloud."; statusMessage = "Settings uploaded.";
lastUploadAt = formatNow();
} else { } else {
statusError = res?.error ?? "Upload failed"; statusError = res?.error ?? "Upload failed";
} }
@@ -53,11 +45,10 @@
} }
function promptDownload() { function promptDownload() {
showRestoreConfirm = true; showDisclaimer(confirmDownload, () => {});
} }
async function confirmDownload() { async function confirmDownload() {
showRestoreConfirm = false;
const token = await cloudAuth.getStoredToken(); const token = await cloudAuth.getStoredToken();
if (!token) return; if (!token) return;
busy = true; busy = true;
@@ -69,8 +60,7 @@
token, token,
})) as { success?: boolean; error?: string; notFound?: boolean }; })) as { success?: boolean; error?: string; notFound?: boolean };
if (res?.success) { if (res?.success) {
statusMessage = "Settings restored from the cloud. SEQTA tabs were reloaded."; statusMessage = "Settings restored.";
lastDownloadAt = formatNow();
} else { } else {
statusError = res?.error ?? "Download failed"; statusError = res?.error ?? "Download failed";
} }
@@ -82,22 +72,13 @@
} }
</script> </script>
<div {#if cloudState.isLoggedIn}
class="w-full rounded-xl border border-zinc-200/60 bg-zinc-50/80 px-4 py-2.5 dark:border-zinc-700/50 dark:bg-zinc-900/40" <div class="flex flex-col gap-2.5">
> <div class="flex items-center justify-between gap-3">
<h3 class="text-xs font-bold text-zinc-800 dark:text-zinc-100">Cloud settings backup</h3> <div>
<p class="mt-0.5 text-[11px] leading-snug text-zinc-500 dark:text-zinc-400"> <p class="text-[11px] font-semibold text-zinc-800 dark:text-zinc-100">Automatic sync</p>
Upload copies this browsers BetterSEQTA+ settings to your account. Download replaces local settings with the <p class="text-[10px] text-zinc-500 dark:text-zinc-400">Syncs settings when SEQTA loads and when you make changes</p>
cloud copy (your sign-in stays on this device). </div>
</p>
<div
class="mt-2 flex flex-col gap-2 rounded-lg border border-zinc-200/50 bg-white/60 px-3 py-2.5 dark:border-zinc-600/40 dark:bg-zinc-800/40"
>
<div class="flex items-start justify-between gap-3">
<p class="min-w-0 flex-1 pt-0.5 text-[11px] font-semibold leading-tight text-zinc-800 dark:text-zinc-100">
Automatic sync
</p>
<div class="shrink-0"> <div class="shrink-0">
<Switch <Switch
state={$settingsState.autoCloudSettingsSync !== false} state={$settingsState.autoCloudSettingsSync !== false}
@@ -105,62 +86,35 @@
/> />
</div> </div>
</div> </div>
<p class="text-[10px] leading-snug text-zinc-500 dark:text-zinc-400">
When signed in, each time SEQTA loads and also hourly, if the cloud backup is newer it will replace local <div class="flex flex-wrap gap-2">
settings. Settings you change will upload shortly after you adjust them. <Button
</p> text={busy ? "Please wait\u2026" : "Upload"}
<p class="text-[10px] leading-snug text-zinc-500 dark:text-zinc-400"> onClick={upload}
Passwords, tokens, and other sensitive data are not included in the backup. 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 <a
href="https://betterseqta.org/privacy" href="https://betterseqta.org/privacy"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="ml-0.5 inline font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 transition-all duration-200 hover:text-emerald-700 hover:decoration-emerald-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-emerald-400 dark:decoration-emerald-400/50 dark:hover:text-emerald-300 dark:focus-visible:ring-offset-zinc-800 rounded-sm" 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>
Privacy policy
</a>
</p> </p>
</div> </div>
<div class="mt-2 flex flex-wrap gap-2">
<Button
text={busy ? "Please wait…" : "Upload to cloud"}
onClick={upload}
disabled={busy || !cloudState.isLoggedIn}
/>
<Button
text={busy ? "Please wait…" : "Download from cloud"}
onClick={promptDownload}
disabled={busy || !cloudState.isLoggedIn}
/>
</div>
{#if !cloudState.isLoggedIn}
<p class="mt-2 text-[11px] text-zinc-500 dark:text-zinc-400">
Sign in from the BetterSEQTA Cloud header above to sync settings.
</p>
{/if}
{#if statusMessage}
<p class="mt-2 text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
{/if}
{#if statusError}
<p class="mt-2 text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
{/if}
{#if lastUploadAt || lastDownloadAt}
<p class="mt-1 text-[10px] text-zinc-400 dark:text-zinc-500">
{#if lastUploadAt}<span>Last upload: {lastUploadAt}</span>{/if}
{#if lastUploadAt && lastDownloadAt}<span class="mx-1">·</span>{/if}
{#if lastDownloadAt}<span>Last download: {lastDownloadAt}</span>{/if}
</p>
{/if}
</div>
{#if showRestoreConfirm}
<DisclaimerModal
title="Restore from cloud?"
message="This will replace BetterSEQTA+ settings in this browser with your cloud backup. Your BetterSEQTA Cloud sign-in on this device will be kept. Continue?"
onConfirm={confirmDownload}
onCancel={() => (showRestoreConfirm = false)}
/>
{/if} {/if}
@@ -3,7 +3,6 @@
import { animate } from "motion"; import { animate } from "motion";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import CloudLoginForm from "@/interface/components/store/CloudLoginForm.svelte";
let { onClose } = $props<{ onClose: () => void }>(); let { onClose } = $props<{ onClose: () => void }>();
let modalElement: HTMLElement; let modalElement: HTMLElement;
@@ -23,6 +22,10 @@
); );
} }
}); });
async function handleSignIn() {
await cloudAuth.startLogin();
}
</script> </script>
<div <div
@@ -52,7 +55,16 @@
Sign in to the Theme Store to save favorites across devices, or create an account to get started. Sign in to the Theme Store to save favorites across devices, or create an account to get started.
</p> </p>
<CloudLoginForm compact onSuccess={onClose} /> <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"> <div class="flex justify-end mt-4">
<button <button
@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import CloudLoginForm from "./CloudLoginForm.svelte";
let { alwaysShowUserName = false } = $props<{ let { alwaysShowUserName = false, onClick = undefined } = $props<{
/** When true (e.g. narrow extension popup), show display name below sm breakpoint */
alwaysShowUserName?: boolean; alwaysShowUserName?: boolean;
onClick?: () => void;
}>(); }>();
let cloudState = $state(cloudAuth.state); let cloudState = $state(cloudAuth.state);
@@ -42,6 +41,19 @@
open = false; open = false;
} }
async function handleSignIn() {
await cloudAuth.startLogin();
open = false;
}
function handleButtonClick() {
if (onClick) {
onClick();
} else {
open = !open;
}
}
function getInitials(): string { function getInitials(): string {
const u = cloudState.user; const u = cloudState.user;
if (!u) return "?"; if (!u) return "?";
@@ -55,35 +67,35 @@
<div class="relative flex items-center" bind:this={dropdownEl}> <div class="relative flex items-center" bind:this={dropdownEl}>
<button <button
type="button" type="button"
onclick={() => (open = !open)} onclick={handleButtonClick}
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-100/80 dark:bg-zinc-700/80 hover:bg-zinc-200/80 dark:hover:bg-zinc-600/80 transition-colors duration-200 text-base font-medium text-zinc-900 dark:text-white" 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.isLoggedIn}
{#if cloudState.user?.pfpUrl} {#if cloudState.user?.pfpUrl}
<img <img
src={cloudState.user.pfpUrl} src={cloudState.user.pfpUrl}
alt="" alt=""
class="w-8 h-8 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600" class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
/> />
{:else} {:else}
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-sm"> <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()} {getInitials()}
</div> </div>
{/if} {/if}
<span <span
class={alwaysShowUserName class={alwaysShowUserName
? "inline max-w-[10rem] truncate text-sm" ? "inline max-w-[10rem] truncate text-[0.75rem]"
: "hidden max-w-24 truncate sm:inline text-base"} : "hidden max-w-24 truncate sm:inline text-[0.75rem]"}
> >
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"} {cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</span> </span>
{:else} {:else}
<span class="text-xl font-IconFamily" aria-hidden="true">{'\ued53'}</span> <span class="text-sm font-IconFamily" aria-hidden="true">{'\ued53'}</span>
<span class="text-base font-medium">Sign in</span> <span class="text-[0.75rem]">Sign in</span>
{/if} {/if}
</button> </button>
{#if open} {#if !onClick && open}
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
@@ -127,11 +139,21 @@
</button> </button>
</div> </div>
{:else} {:else}
<CloudLoginForm <div class="flex flex-col gap-3">
onSuccess={() => { <p class="text-sm text-zinc-600 dark:text-zinc-400">
open = false; 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} {/if}
</div> </div>
</div> </div>
@@ -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,57 +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-[5/1] max-h-[500px]" 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 || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-full" /> <img src={slide.imageUrl} alt="" class="object-cover w-full h-full" />
{#if theme.featured === true} {#if slide.badgeFeatured === true}
<div class="absolute top-4 left-4 z-[2] pointer-events-none"> <div class="absolute top-4 left-4 z-[2] pointer-events-none">
<span <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" 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" 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"> <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" /> <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> </svg>
Featured Featured
</span> </span>
</div> </div>
{/if} {/if}
<div class='absolute bottom-0 left-0 p-8 z-[1]'> <div class="absolute bottom-0 left-0 p-8 z-[1]">
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2> <h2 class="text-4xl font-bold text-white">{slide.title}</h2>
{#if theme.author} {#if slide.subtitle}
<p class="text-sm text-white/90 mt-1 mb-1 line-clamp-1">By {theme.author}</p> <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} {/if}
<p class='text-lg text-white'>{theme.description}</p>
</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>
@@ -1,14 +1,54 @@
<script lang="ts"> <script lang="ts">
import type { Theme } from '@/interface/types/Theme' import type { Theme } from '@/interface/types/Theme'
import {
masterGridDisplayDownloadCount,
gridCardPreviewImageUrls,
} from '@/interface/utils/themeStoreFlavours'
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{ import emblaCarouselSvelte from 'embla-carousel-svelte';
import Autoplay from 'embla-carousel-autoplay';
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn, allStoreThemeRows } = $props<{
theme: Theme; theme: Theme;
onClick: () => void; onClick: () => void;
toggleFavorite: (theme: Theme) => void; toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean; isLoggedIn: boolean;
onRequestSignIn?: () => void; 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 menuOpen = $state(false);
let menuRef: HTMLDivElement; let menuRef: HTMLDivElement;
@@ -111,7 +151,7 @@
<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"> <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" /> <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> </svg>
{(theme.download_count ?? 0).toLocaleString()} {displayDownloadCount.toLocaleString()}
</span> </span>
<span class="flex items-center gap-1"> <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"> <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">
@@ -122,8 +162,40 @@
</div> </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 || theme.coverImage} 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,13 +2,23 @@
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, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{ let {
themes,
searchTerm,
setDisplayTheme,
toggleFavorite,
isLoggedIn,
onRequestSignIn,
allStoreThemeRows,
} = $props<{
themes: Theme[]; themes: Theme[];
searchTerm: string; searchTerm: string;
setDisplayTheme: (theme: Theme) => void; setDisplayTheme: (theme: Theme) => void;
toggleFavorite: (theme: Theme) => void; toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean; isLoggedIn: boolean;
onRequestSignIn?: () => void; 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) =>
@@ -25,6 +35,7 @@
{toggleFavorite} {toggleFavorite}
{isLoggedIn} {isLoggedIn}
{onRequestSignIn} {onRequestSignIn}
{allStoreThemeRows}
/> />
{/each} {/each}
File diff suppressed because it is too large Load Diff
+23 -24
View File
@@ -15,14 +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 CloudHeader from "@/interface/components/store/CloudHeader.svelte";
import { settingsPopup } from "../hooks/SettingsPopup"; import { settingsPopup } from "../hooks/SettingsPopup";
let devModeSequence = ""; let devModeSequence = "";
let settingsActiveTab = $state(0); 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) => {
@@ -67,15 +69,23 @@
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(() => { onMount(() => {
settingsPopup.addListener(() => { settingsPopup.addListener(() => {
showColourPicker = false; showColourPicker = false;
showCloudPanel = false;
}); });
if (standalone) { if (standalone) {
@@ -277,25 +287,13 @@
{/if} {/if}
</div> </div>
<div
class="flex shrink-0 items-center justify-between gap-2 px-4 py-2.5 border-b border-zinc-200/40 dark:border-zinc-700/40"
>
<div class="min-w-0 flex-1">
<h2 class="text-sm font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h2>
<p class="text-xs text-zinc-500 dark:text-zinc-400">Account & sync</p>
</div>
<div class="shrink-0">
<CloudHeader alwaysShowUserName />
</div>
</div>
<TabbedContainer <TabbedContainer
bind:activeTab={settingsActiveTab} 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 },
@@ -310,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;
+104 -13
View File
@@ -12,9 +12,37 @@
import PickerSwatch from "@/interface/components/PickerSwatch.svelte" import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte" import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
import CloudSettingsSync from "@/interface/components/CloudSettingsSync.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 { showThemeOfTheMonthPopupNow } from "@/seqta/utils/Openers/OpenThemeOfTheMonthPopup"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup" import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync" import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase"
let devApiBaseInput = $state<string>(getStoredOverride() ?? "")
let devApiBaseActive = $state<string | null>(getStoredOverride())
function applyDevApiBase() {
const trimmed = devApiBaseInput.trim()
if (trimmed === "") {
setApiBase(null)
devApiBaseActive = null
return
}
if (!/^https?:\/\//.test(trimmed)) {
alert("Please enter a full URL starting with http:// or https://")
return
}
setApiBase(trimmed)
devApiBaseActive = trimmed.replace(/\/$/, "")
}
function clearDevApiBase() {
devApiBaseInput = ""
setApiBase(null)
devApiBaseActive = null
}
import { getAllPluginSettings } from "@/plugins" import { 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"
@@ -53,6 +81,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) {
@@ -95,9 +129,10 @@
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() { async function exportCloudSettingsJsonToFile() {
@@ -196,12 +231,11 @@
}, },
{ {
title: "Default Page", title: "Default Page",
description: description: "Choose which page loads first when you open SEQTA",
"The page to load when SEQTA Learn or SEQTA Engage opens (uses the same #?page=/… URL as SEQTA). BetterSEQTA home on Engage only applies when Home is selected.",
id: 10, id: 10,
Component: Select, Component: Select,
props: { props: {
state: $settingsState.defaultPage, state: $settingsState.defaultPage ?? "home",
onChange: (value: string) => (settingsState.defaultPage = value), onChange: (value: string) => (settingsState.defaultPage = value),
options: [ options: [
{ value: "home", label: "Home" }, { value: "home", label: "Home" },
@@ -310,8 +344,9 @@
async () => { async () => {
await updatePluginSetting(plugin.pluginId, 'enabled', true); await updatePluginSetting(plugin.pluginId, 'enabled', true);
}, },
() => { () => {},
} "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;
} }
@@ -324,8 +359,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>
@@ -388,6 +423,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({
@@ -401,10 +455,6 @@
} }
})} })}
<div class="border-none py-3">
<CloudSettingsSync />
</div>
{#if $settingsState.devMode} {#if $settingsState.devMode}
<div class="flex-col p-1 my-1 bg-gradient-to-br from-white rounded-xl border shadow-sm to-zinc-100 border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40"> <div class="flex-col p-1 my-1 bg-gradient-to-br from-white rounded-xl border shadow-sm to-zinc-100 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="flex justify-between items-center px-4 py-3">
@@ -459,6 +509,22 @@
/> />
</div> </div>
</div> </div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Show Theme of the Month</h2>
<p class="text-xs">Fetch and show the current month's popup now (ignores dismissed state)</p>
</div>
<div>
<Button
onClick={async () => {
closeExtensionPopup();
await new Promise((resolve) => setTimeout(resolve, 100));
await showThemeOfTheMonthPopupNow();
}}
text="Show Now"
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
<div class="pr-4"> <div class="pr-4">
<h2 class="text-sm font-bold">Export cloud settings JSON</h2> <h2 class="text-sm font-bold">Export cloud settings JSON</h2>
@@ -468,6 +534,31 @@
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" /> <Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 px-4 py-3">
<div class="flex justify-between items-start gap-3">
<div class="pr-4">
<h2 class="text-sm font-bold">API Base URL (session only)</h2>
<p class="text-xs">Override the content API host for this browser session. Cleared on restart. Affects themes, theme of the month, and other server-driven content.</p>
{#if devApiBaseActive}
<p class="text-xs mt-1 text-amber-600 dark:text-amber-400">
Override active: <span class="font-mono">{devApiBaseActive}</span>
</p>
{/if}
</div>
</div>
<div class="flex gap-2 items-center">
<input
type="text"
placeholder="https://betterseqta.org"
bind:value={devApiBaseInput}
class="flex-1 px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
/>
<Button onClick={applyDevApiBase} text="Apply" />
{#if devApiBaseActive}
<Button onClick={clearDevApiBase} text="Clear" />
{/if}
</div>
</div>
</div> </div>
{/if} {/if}
</div> </div>
+67 -19
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'
@@ -17,6 +18,7 @@
import Backgrounds from '../components/store/Backgrounds.svelte' import Backgrounds from '../components/store/Backgrounds.svelte'
import { cloudAuth } from '@/seqta/utils/CloudAuth' import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte' import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
import { consumePendingHighlightThemeId } from '@/seqta/utils/openThemeStoreWithHighlight'
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn); let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
@@ -26,7 +28,12 @@
// 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);
@@ -108,7 +115,6 @@
throw new Error(data?.error || 'Failed to fetch themes'); throw new Error(data?.error || 'Failed to fetch themes');
} }
themes = [...data.data.themes].sort(compareStoreThemes); themes = [...data.data.themes].sort(compareStoreThemes);
coverThemes = themes.slice(0, 3);
loading = false; loading = false;
} catch (err) { } catch (err) {
@@ -117,24 +123,73 @@
} }
}; };
function focusThemeById(themeId: string) {
const match = themes.find((t) => t.id === themeId)
?? themes.find((t) => t.flavours?.some((f) => f.id === themeId));
if (match) {
activeTab = 'themes';
searchTerm = '';
displayTheme = match;
}
}
function onHighlightThemeEvent(e: Event) {
const detail = (e as CustomEvent).detail;
if (detail?.themeId && typeof detail.themeId === 'string') {
focusThemeById(detail.themeId);
}
}
// On mount // On mount
onMount(async () => { onMount(async () => {
window.addEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
await fetchThemes(); await fetchThemes();
await fetchCurrentThemes(); await fetchCurrentThemes();
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true'; darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
darkMode = $settingsState.DarkMode; darkMode = $settingsState.DarkMode;
const pending = consumePendingHighlightThemeId();
if (pending) focusThemeById(pending);
return () => {
window.removeEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
};
}); });
// Filter themes (list is already featured-first, then newest; filter preserves order) // Filter themes (list is already featured-first, then newest; filter preserves order)
let filteredThemes = $derived( let filteredThemes = $derived(
themes.filter( listThemes.filter(
(theme) => (theme) =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
theme.description.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();
selectedBackground selectedBackground
@@ -172,12 +227,13 @@
<!-- 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 <ThemeGrid
themes={filteredThemes} themes={filteredThemes}
allStoreThemeRows={themes}
{searchTerm} {searchTerm}
{setDisplayTheme} {setDisplayTheme}
{toggleFavorite} {toggleFavorite}
@@ -188,28 +244,20 @@
{#if displayTheme} {#if displayTheme}
<ThemeModal <ThemeModal
currentThemes={currentThemes} currentThemes={currentThemes}
allThemes={themes} allThemes={listThemes}
allStoreThemeRows={themes}
theme={displayTheme} theme={displayTheme}
{displayTheme} {displayTheme}
{setDisplayTheme} {setDisplayTheme}
{toggleFavorite} {toggleFavorite}
isLoggedIn={cloudLoggedIn} isLoggedIn={cloudLoggedIn}
onRequestSignIn={() => (showSignInOverlay = true)} onRequestSignIn={() => (showSignInOverlay = true)}
onInstall={async () => { onInstall={async (themeId: string) => {
if (displayTheme) { if (displayTheme) await installThemeFromStore(themeId, displayTheme);
await themeManager.downloadTheme(displayTheme);
await themeManager.setTheme(displayTheme.id);
themeUpdates.triggerUpdate();
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}
+32
View File
@@ -1,3 +1,17 @@
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; id: string;
name: string; name: string;
@@ -15,4 +29,22 @@ export type Theme = {
created_at?: number; created_at?: number;
/** Unix seconds — last server update (GET /api/themes). */ /** Unix seconds — last server update (GET /api/themes). */
updated_at?: number; 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;
}; };
+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";
}
+1 -1
View File
@@ -15,7 +15,7 @@
"64": "resources/icons/icon-64.png" "64": "resources/icons/icon-64.png"
} }
}, },
"permissions": ["tabs", "notifications", "storage", "alarms"], "permissions": ["tabs", "notifications", "storage"],
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"], "host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
"background": { "background": {
"service_worker": "background.ts" "service_worker": "background.ts"
+137 -128
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,143 +61,149 @@ 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 assessmentItemClass =
Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"),
) || "";
const metaContainerClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__metaContainer___",
);
const metaClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__meta___",
);
const simpleResultClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__simpleResult___",
);
const titleClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__title___",
);
const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
if (!thermoscoreElement) return;
const thermoscoreClass =
Array.from(thermoscoreElement.classList).find((c) =>
c.startsWith("Thermoscore__Thermoscore___"),
) || "";
const fillClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__fill___",
);
const textClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__text___",
);
const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
if (!assessmentsList) return;
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
if (!marks || !marks.length) return;
const assessmentItems = Array.from(
assessmentsList.querySelectorAll(
`[class*='AssessmentItem__AssessmentItem___']`,
),
).filter(
(item) =>
!item
.querySelector(`[class*='AssessmentItem__title___']`)
?.textContent?.includes("Subject Average"),
);
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
await processAssessments(api, assessmentItems);
if (!count || totalWeight === 0) return;
const avg = weightedTotal / totalWeight;
const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce(
(acc, [k, v]) => {
acc[v] = k;
return acc;
},
{} as Record<number, string>,
);
const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade
? letterAvg
: `${avg.toFixed(2)}%`;
const existing = assessmentsList.querySelector(
`[class*='AssessmentItem__title___']`,
);
if (existing?.textContent === "Subject Average") return;
let warningHTML = "";
if (hasInaccurateWeighting) {
warningHTML = /* html */ `
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
Some weightings unavailable
</div>
`;
}
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();
const observer = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
const wrapper = document.querySelector(".assessmentsWrapper"); const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) { if (wrapper) {
const observer = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
observer.observe(wrapper, { childList: true, subtree: true }); observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000); setTimeout(() => observer.disconnect(), 10000);
} }
}); });
api.seqta.onMount("[class*='SelectedAssessment__']", () => {
injectWeightingsTab(api);
});
}, },
}; };
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() { function applySubjectColourToOverallResult() {
const selectedAssessmentItem = document.querySelector( const selectedAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']", "[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
+358 -49
View File
@@ -17,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) {
@@ -79,52 +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";
} }
// Stack weight under Max/native stats — absolute right:0 overlapped the max column (#414).
statsContainer.style.display = "flex";
statsContainer.style.flexDirection = "column";
statsContainer.style.alignItems = "flex-end";
statsContainer.style.gap = "2px";
statsContainer.style.justifyContent = "center";
weightLabel.style.position = "relative";
weightLabel.style.inset = "unset";
weightLabel.style.transform = "none";
statsContainer.appendChild(weightLabel); statsContainer.appendChild(weightLabel);
} }
@@ -228,7 +263,8 @@ 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 { lib: pdfLibUrl, worker: pdfWorkerUrl } =
getPdfjsPageContextUrls();
const escJsSingleQuoted = (s: string) => const escJsSingleQuoted = (s: string) =>
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const pdfLibInj = escJsSingleQuoted(pdfLibUrl); const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
@@ -519,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)));
@@ -532,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___']`,
); );
@@ -550,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 ||
@@ -563,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);
@@ -587,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");
});
});
}
@@ -1,7 +1,7 @@
import type { Plugin } from "../../core/types"; import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api"; import { getAssessmentsData } from "./api";
import { 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"; import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
@@ -68,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);
@@ -0,0 +1,962 @@
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;
emoji: string;
}
interface MessageFoldersStorage {
folders: Folder[];
messageAssignments: Record<string, string[]>;
}
const FOLDER_COLORS = [
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b",
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316",
];
const FOLDER_HEROICONS = [
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`,
];
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>`;
const CHEVRON_SVG = `<svg style="width:12px;height:12px;flex-shrink:0;transition:transform .2s" viewBox="0 0 24 24"><path fill="#888" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>`;
const DRAG_SVG = `<svg style="width:14px;height:14px;flex-shrink:0;cursor:grab" viewBox="0 0 24 24"><path fill="#888" d="M6.5 12.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/></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: "2.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;
let foldedSection: HTMLElement | null = null;
const unregisters: Array<{ unregister: () => void }> = [];
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 assignMessageToFolder = (messageId: string, folderId: string, add: boolean) => {
const assignments = getAssignments();
if (!assignments[folderId]) assignments[folderId] = [];
const idx = assignments[folderId].indexOf(messageId);
if (add && idx < 0) {
assignments[folderId].push(messageId);
} else if (!add && idx >= 0) {
assignments[folderId].splice(idx, 1);
}
saveAssignments(assignments);
};
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;
};
const getSelectedMessageId = (): string | null => {
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
return selectedMsg?.getAttribute("data-message") ?? null;
};
const getMessageIdFromEvent = (target: HTMLElement): string | null => {
const li = target.closest("li[data-message]");
return li?.getAttribute("data-message") ?? null;
};
const getAllVisibleMessageIds = (): string[] => {
const ids: string[] = [];
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
const id = li.getAttribute("data-message");
if (id) ids.push(id);
});
return ids;
};
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);
};
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") as HTMLElement;
if (!section) {
section = document.createElement("div");
section.className = "bsplus-folders-section";
ol.appendChild(section);
}
foldedSection = section;
const folders = getFolders();
section.innerHTML = "";
const header = document.createElement("div");
header.className = "bsplus-folders-header";
header.dataset.folded = "false";
const collapseBtn = document.createElement("button");
collapseBtn.className = "bsplus-folders-collapse";
collapseBtn.innerHTML = CHEVRON_SVG;
collapseBtn.title = "Collapse";
collapseBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isFolded = collapseBtn.classList.toggle("bsplus-folded");
section.classList.toggle("bsplus-section-folded", isFolded);
collapseBtn.title = isFolded ? "Expand" : "Collapse";
});
header.appendChild(collapseBtn);
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);
const allItem = document.createElement("div");
allItem.className = `bsplus-folder-item bsplus-all-msgs${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();
setTimeout(() => {
applyFolderFilter();
applyBadges();
}, 100);
});
section.appendChild(allItem);
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;
item.draggable = true;
const dragHandle = document.createElement("div");
dragHandle.className = "bsplus-folder-drag";
dragHandle.innerHTML = DRAG_SVG;
item.appendChild(dragHandle);
const dot = document.createElement("div");
dot.className = "bsplus-folder-dot";
dot.style.background = folder.color;
item.appendChild(dot);
const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
item.appendChild(iconSpan);
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();
setTimeout(() => {
applyFolderFilter();
applyBadges();
}, 100);
});
item.addEventListener("dragstart", (e) => {
e.dataTransfer?.setData("text/plain", `reorder:${folder.id}`);
item.classList.add("bsplus-dragging");
});
item.addEventListener("dragend", () => {
item.classList.remove("bsplus-dragging");
document.querySelectorAll(".bsplus-folder-item").forEach((el) => el.classList.remove("bsplus-drag-over"));
});
item.addEventListener("dragover", (e) => {
e.preventDefault();
const data = e.dataTransfer?.getData("text/plain") || "";
if (data.startsWith("reorder:") && !data.includes(folder.id)) {
item.classList.add("bsplus-drag-over");
}
});
item.addEventListener("dragleave", () => {
item.classList.remove("bsplus-drag-over");
});
item.addEventListener("drop", (e) => {
e.preventDefault();
item.classList.remove("bsplus-drag-over");
const data = e.dataTransfer?.getData("text/plain") || "";
if (data.startsWith("reorder:")) {
const draggedId = data.replace("reorder:", "");
const folders = getFolders();
const draggedIdx = folders.findIndex((f) => f.id === draggedId);
const targetIdx = folders.findIndex((f) => f.id === folder.id);
if (draggedIdx >= 0 && targetIdx >= 0 && draggedIdx !== targetIdx) {
const [removed] = folders.splice(draggedIdx, 1);
folders.splice(targetIdx, 0, removed);
saveFolders(folders);
renderSidebarFolders();
}
}
});
section.appendChild(item);
}
section.addEventListener("dragover", (e) => {
e.preventDefault();
});
section.addEventListener("drop", (e) => {
e.preventDefault();
const data = e.dataTransfer?.getData("text/plain") || "";
if (data.startsWith("msg:")) {
const messageId = data.replace("msg:", "");
const folderId = (e.target as HTMLElement).closest("[data-folder-id]")?.getAttribute("data-folder-id");
if (messageId && folderId) {
assignMessageToFolder(messageId, folderId, true);
applyBadges();
applyFolderFilter();
renderSidebarFolders();
}
}
});
attachDragListeners();
};
const attachDragListeners = () => {
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
if (li.getAttribute("data-bsplus-drag") === "true") return;
li.setAttribute("data-bsplus-drag", "true");
li.draggable = true;
li.addEventListener("dragstart", (e) => {
const id = li.getAttribute("data-message");
if (id) {
e.dataTransfer?.setData("text/plain", `msg:${id}`);
li.classList.add("bsplus-msg-dragging");
}
});
li.addEventListener("dragend", () => {
li.classList.remove("bsplus-msg-dragging");
document.querySelectorAll(".bsplus-folder-item").forEach((el) => el.classList.remove("bsplus-drag-over"));
});
});
};
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)];
let selectedIcon = editFolder?.emoji ?? FOLDER_HEROICONS[Math.floor(Math.random() * FOLDER_HEROICONS.length)];
const row = document.createElement("div");
row.className = "bsplus-folder-input";
const input = document.createElement("input");
input.type = "text";
input.placeholder = editFolder ? "Rename folder\u2026" : "Folder name\u2026";
input.value = editFolder?.name ?? "";
input.maxLength = 30;
const iconBtn = document.createElement("button");
iconBtn.className = "bsplus-folder-icon-btn";
iconBtn.title = "Pick icon";
iconBtn.innerHTML = selectedIcon;
iconBtn.addEventListener("click", (e) => {
e.stopPropagation();
const picker = container.querySelector(".bsplus-icon-picker") as HTMLElement | null;
if (picker) {
picker.remove();
return;
}
showIconPicker(container, (iconSvg) => {
selectedIcon = iconSvg;
iconBtn.innerHTML = iconSvg;
});
});
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(iconBtn);
row.appendChild(input);
row.appendChild(confirmBtn);
row.appendChild(cancelBtn);
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, emoji: selectedIcon } : f,
);
saveFolders(folders);
} else {
const folder: Folder = { id: generateId(), name, color: selectedColor, emoji: selectedIcon };
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 showIconPicker = (container: Element, onSelect: (iconSvg: string) => void) => {
const existing = container.querySelector(".bsplus-icon-picker");
if (existing) existing.remove();
const picker = document.createElement("div");
picker.className = "bsplus-icon-picker";
for (const icon of FOLDER_HEROICONS) {
const btn = document.createElement("button");
btn.className = "bsplus-icon-opt";
btn.innerHTML = icon;
btn.addEventListener("click", (e) => {
e.stopPropagation();
onSelect(icon);
picker.remove();
});
picker.appendChild(btn);
}
container.appendChild(picker);
};
const showEditFolderInput = (container: Element, folder: Folder) => {
showNewFolderInput(container, folder);
};
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();
}
}
});
};
const closeDropdown = () => {
if (openDropdown) {
openDropdown.remove();
openDropdown = null;
}
if (dropdownCloseHandler) {
document.removeEventListener("click", dropdownCloseHandler, true);
dropdownCloseHandler = null;
}
};
const showFolderDropdown = (anchor: HTMLElement, messageId: string) => {
closeDropdown();
const dropdown = document.createElement("div");
dropdown.className = "bsplus-folder-dropdown";
dropdown.dataset.msgId = messageId;
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" : ""}`;
item.dataset.folderId = folder.id;
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 iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
const name = document.createElement("span");
name.textContent = folder.name;
item.appendChild(check);
item.appendChild(dot);
item.appendChild(iconSpan);
item.appendChild(name);
item.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
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 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();
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 showContextMenu = (e: MouseEvent, messageId: string) => {
e.preventDefault();
e.stopPropagation();
closeDropdown();
const existing = document.querySelector(".bsplus-context-menu");
if (existing) existing.remove();
const menu = document.createElement("div");
menu.className = "bsplus-context-menu";
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
const title = document.createElement("div");
title.className = "bsplus-context-title";
title.textContent = "Add to folder";
menu.appendChild(title);
const folders = getFolders();
const currentFolderIds = getMessageFolderIds(messageId);
if (folders.length === 0) {
const empty = document.createElement("div");
empty.className = "bsplus-context-empty";
empty.textContent = "No folders";
menu.appendChild(empty);
} else {
for (const folder of folders) {
const isChecked = currentFolderIds.includes(folder.id);
const item = document.createElement("button");
item.className = `bsplus-context-item${isChecked ? " bsplus-context-checked" : ""}`;
const dot = document.createElement("div");
dot.className = "bsplus-folder-dot";
dot.style.background = folder.color;
const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
const name = document.createElement("span");
name.textContent = folder.name;
item.appendChild(dot);
item.appendChild(iconSpan);
item.appendChild(name);
if (isChecked) {
const check = document.createElement("span");
check.className = "bsplus-context-checkmark";
check.textContent = "\u2713";
item.appendChild(check);
}
item.addEventListener("click", (e) => {
e.stopPropagation();
toggleMessageInFolder(messageId, folder.id);
applyBadges();
applyFolderFilter();
renderSidebarFolders();
menu.remove();
});
menu.appendChild(item);
}
}
document.body.appendChild(menu);
const closeMenu = (ev: MouseEvent) => {
if (!menu.contains(ev.target as Node)) {
menu.remove();
document.removeEventListener("click", closeMenu);
}
};
setTimeout(() => document.addEventListener("click", closeMenu), 0);
};
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: string[] = [];
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.innerHTML = `${folder.emoji ? `<span style="display:inline-flex;vertical-align:middle;margin-right:2px">${folder.emoji}</span>` : ""}${folder.name}`;
badge.title = `Filter by "${folder.name}"`;
badge.addEventListener("click", (e) => {
e.stopPropagation();
activeFolderId = folder.id;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
});
badgeContainer.appendChild(badge);
}
}
};
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");
};
const setupMessageListObserver = () => {
const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol");
if (!messageList || messageListObserver) return;
messageListObserver = new MutationObserver(() => {
applyBadges();
applyFolderFilter();
attachDragListeners();
attachContextMenuListeners();
});
messageListObserver.observe(messageList, { childList: true, subtree: false });
};
const attachContextMenuListeners = () => {
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
if (li.getAttribute("data-bsplus-ctx") === "true") return;
li.setAttribute("data-bsplus-ctx", "true");
li.addEventListener("contextmenu", (e) => {
const msgId = li.getAttribute("data-message");
if (msgId) {
showContextMenu(e, msgId);
}
});
});
};
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 });
};
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();
setupActionsObserver();
attachDragListeners();
attachContextMenuListeners();
const actionsBar = document.querySelector("[class*='Message__actions___']");
if (actionsBar) injectFolderButton(actionsBar);
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 });
}
};
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(".bsplus-context-menu").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,731 @@
/* ── 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;
transition: opacity .2s;
}
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-item,
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-input,
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-colors,
.bsplus-folders-section.bsplus-section-folded .bsplus-emoji-picker,
.bsplus-folders-section.bsplus-section-folded .bsplus-all-msgs {
display: none !important;
}
.bsplus-folders-header {
display: flex;
align-items: center;
gap: 4px;
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;
flex: 1;
}
.bsplus-folders-collapse {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 18px !important;
height: 18px !important;
min-width: 0 !important;
border: none !important;
background: transparent !important;
opacity: 0.4;
cursor: pointer;
border-radius: 4px !important;
padding: 0 !important;
margin: 0 !important;
transition: all .2s;
}
.bsplus-folders-collapse:hover {
opacity: 0.8;
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
}
.bsplus-folders-collapse.bsplus-folded svg {
transform: rotate(-90deg);
}
.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, opacity 0.2s;
position: relative;
gap: 6px;
user-select: none;
}
.bsplus-folder-item.bsplus-dragging {
opacity: 0.4;
}
.bsplus-folder-item.bsplus-drag-over {
background: var(--better-main, #007bff22) !important;
border-radius: 4px;
}
.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-drag {
display: flex;
align-items: center;
opacity: 0;
transition: opacity .15s;
margin-right: -4px;
}
.bsplus-folder-item:hover .bsplus-folder-drag {
opacity: 0.5;
}
.bsplus-folder-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.bsplus-folder-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--text-primary, #333);
}
.bsplus-folder-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
}
.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;
min-width: 16px;
text-align: right;
}
.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-icon-btn {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 28px !important;
height: 28px !important;
min-width: 0 !important;
border: 1px solid var(--background-secondary, #ccc) !important;
border-radius: 6px !important;
background: var(--background-secondary, #f5f5f5) !important;
cursor: pointer;
padding: 0 !important;
margin: 0 !important;
transition: all .15s;
color: var(--text-primary, #333);
}
.bsplus-folder-icon-btn:hover {
transform: scale(1.1);
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.1)) !important;
}
.bsplus-folder-icon-btn svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
}
.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;
}
/* ── Icon picker ── */
.bsplus-icon-picker {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
padding: 4px 12px 6px;
max-width: 140px;
}
.bsplus-icon-opt {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 28px !important;
height: 28px !important;
min-width: 0 !important;
border: none !important;
border-radius: 6px !important;
background: transparent !important;
cursor: pointer;
padding: 0 !important;
transition: all .15s;
color: var(--text-primary, #333);
}
.bsplus-icon-opt svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
}
.bsplus-icon-opt:hover {
transform: scale(1.3);
background: var(--theme-offset-bg-more, 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;
}
/* ── Context menu ── */
.bsplus-context-menu {
position: fixed;
min-width: 160px;
background: var(--background-primary, #fff) !important;
border: 1px solid var(--background-secondary, #e0e0e0) !important;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 2147483646;
overflow: hidden;
animation: bsplus-dropdown-in 0.12s ease-out;
padding: 4px 0;
}
.bsplus-context-title {
padding: 6px 12px 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-primary, #999) !important;
opacity: 0.5;
user-select: none;
}
.bsplus-context-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08)) !important;
}
.bsplus-context-item span {
flex: 1;
}
.bsplus-context-checkmark {
color: var(--better-main, #007bff) !important;
font-weight: bold;
flex: 0 !important;
}
.bsplus-context-item {
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
gap: 8px;
padding: 7px 12px !important;
font-size: 13px;
cursor: pointer;
border: none !important;
background: transparent !important;
width: 100%;
text-align: left !important;
color: var(--text-primary, #333) !important;
transition: background .1s;
font-family: inherit;
}
.bsplus-context-item .bsplus-folder-icon {
color: var(--text-primary, #333) !important;
width: 16px;
height: 16px;
}
.bsplus-context-item .bsplus-folder-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
}
.bsplus-context-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
}
.bsplus-context-item span {
flex: 1;
}
.bsplus-context-checkmark {
color: var(--better-main, #007bff) !important;
font-weight: bold;
flex: 0 !important;
}
.bsplus-context-empty {
padding: 12px;
text-align: center;
font-size: 12px;
color: var(--text-primary, #999);
opacity: 0.5;
}
/* ── Drag feedback ── */
.bsplus-msg-dragging {
opacity: 0.4;
}
[class*='MessageList__MessageList___'] ol > li[data-message] {
transition: opacity .15s;
}
/* ── Layout fixes ── */
[class*='MessageList__primary___'] {
flex: 1 1 0% !important;
min-width: 0 !important;
overflow: hidden !important;
}
[class*='MessageList__subject___'] {
display: flex !important;
align-items: center;
gap: 6px;
min-width: 0 !important;
overflow: hidden !important;
}
.bsplus-subject-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1 1 auto;
}
[class*='MessageList__secondary___'] {
flex: 0 0 auto !important;
width: auto !important;
min-width: 0 !important;
max-width: 200px !important;
}
[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 ── */
.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 -2
View File
@@ -14,8 +14,7 @@ const settings = defineSettings({
useCloudPfp: booleanSetting({ useCloudPfp: booleanSetting({
default: false, default: false,
title: "Use BetterSEQTA Cloud profile picture", title: "Use BetterSEQTA Cloud profile picture",
description: description: "Use your cloud account avatar instead of the uploaded image below",
"When enabled, uses the avatar from your BetterSEQTA Cloud account (sign in from the extension store). Otherwise uses the uploaded image below.",
}), }),
picture: componentSetting({ picture: componentSetting({
title: "Profile Picture", title: "Profile Picture",
+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();
}, },
}; };
+44 -2
View File
@@ -6,10 +6,12 @@ import {
type LoadedCustomTheme, type LoadedCustomTheme,
shouldForceThemeAppearance, shouldForceThemeAppearance,
} from "@/types/CustomThemes"; } 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 { themeUpdates } from "@/interface/hooks/ThemeUpdates";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { getApiBase } from "@/seqta/utils/DevApiBase";
import { updateAllColors } from "@/seqta/ui/colors/Manager"; import { updateAllColors } from "@/seqta/ui/colors/Manager";
import { import {
clearCustomThemeAdaptiveCssVariables, clearCustomThemeAdaptiveCssVariables,
@@ -25,7 +27,9 @@ type ThemeContent = {
CanChangeColour?: boolean; CanChangeColour?: boolean;
CustomCSS?: string; CustomCSS?: string;
hideThemeName?: boolean; hideThemeName?: boolean;
forceTheme?: boolean;
forceDark?: boolean; forceDark?: boolean;
adaptiveCssVariables?: string[];
images?: { id: string; variableName: string; data: string }[]; // data: base64 images?: { id: string; variableName: string; data: string }[]; // data: base64
}; };
@@ -35,7 +39,7 @@ export type InstallThemeMeta = {
serverUpdatedAtSec?: number; serverUpdatedAtSec?: number;
forceTheme?: boolean; forceTheme?: boolean;
adaptiveCssVariables?: string[]; adaptiveCssVariables?: string[];
images: { id: string; variableName: string; data: string }[]; // data: base64 images?: { id: string; variableName: string; data: string }[]; // data: base64
}; };
export class ThemeManager { export class ThemeManager {
@@ -164,6 +168,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
*/ */
@@ -515,7 +546,10 @@ export class ThemeManager {
} }
} }
private readonly THEME_API_BASE = 'https://betterseqta.org/api'; /** Use a getter so dev-mode session-only base URL overrides take effect immediately. */
private get THEME_API_BASE(): string {
return `${getApiBase()}/api`;
}
private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes'; private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes';
/** /**
@@ -680,9 +714,17 @@ export class ThemeManager {
* Compare installed store themes to GET /api/themes and refresh when the server is newer. * 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). * 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> { public async checkStoreThemeUpdates(): Promise<void> {
if (this.storeUpdateCheckRunning) return; 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; this.storeUpdateCheckRunning = true;
localStorage.setItem(ThemeManager.STORE_CHECK_KEY, String(Date.now()));
try { try {
const token = await cloudAuth.getStoredToken(); const token = await cloudAuth.getStoredToken();
const res = (await browser.runtime.sendMessage({ const res = (await browser.runtime.sendMessage({
+92 -19
View File
@@ -145,8 +145,10 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
let observer: MutationObserver | null = null; let observer: MutationObserver | null = null;
let quickbarObserver: MutationObserver | null = null; let quickbarObserver: MutationObserver | null = null;
let quickbarSyncTimer: ReturnType<typeof setTimeout> | null = null;
let lastClickedCi: number | null = null; let lastClickedCi: number | null = null;
let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null; let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null;
let lastSyncedQuickbarCi: number | null = null;
const getOverrides = (): TimetableOverrides => const getOverrides = (): TimetableOverrides =>
api.storage.timetableOverrides ?? {}; api.storage.timetableOverrides ?? {};
@@ -186,9 +188,11 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff; if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
} }
const captureClick = (e: MouseEvent) => { const captureClick = () => {
lastClickedCi = ci; lastClickedCi = ci;
lastClickedEntry = { roomEl, teacherEl, item }; lastClickedEntry = { roomEl, teacherEl, item };
lastSyncedQuickbarCi = null;
scheduleQuickbarSync();
}; };
entry.addEventListener("click", captureClick, true); entry.addEventListener("click", captureClick, true);
}; };
@@ -199,6 +203,76 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
}); });
}; };
const getVisibleClassQuickbar = (): HTMLElement | null => {
const quickbar = document.querySelector(
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.above.visible, .timetablepage .quickbar.visible",
);
if (!quickbar || quickbar.getAttribute("data-type") !== "class") return null;
return quickbar as HTMLElement;
};
const applyOverridesToQuickbar = (quickbar: HTMLElement): void => {
if (lastClickedCi === null) return;
if (lastSyncedQuickbarCi === lastClickedCi) return;
const description =
quickbar.querySelector(".title")?.textContent?.trim() ??
lastClickedEntry?.item.description ??
"";
const override = getEffectiveOverride(lastClickedCi, description);
if (!override) {
lastSyncedQuickbarCi = lastClickedCi;
return;
}
const roomEl = quickbar.querySelector(".meta .room");
const teacherEl = quickbar.querySelector(".meta .teacher");
if (override.room !== undefined && !roomEl) return;
if (override.staff !== undefined && !teacherEl) return;
if (override.room !== undefined && roomEl && roomEl.textContent !== override.room) {
roomEl.textContent = override.room;
}
if (override.staff !== undefined && teacherEl && teacherEl.textContent !== override.staff) {
teacherEl.textContent = override.staff;
}
lastSyncedQuickbarCi = lastClickedCi;
};
const updateVisibleQuickbar = (room: string, staff: string): void => {
const quickbar = getVisibleClassQuickbar();
if (!quickbar) return;
const roomEl = quickbar.querySelector(".meta .room");
const teacherEl = quickbar.querySelector(".meta .teacher");
if (roomEl && roomEl.textContent !== room) roomEl.textContent = room;
if (teacherEl && teacherEl.textContent !== staff) teacherEl.textContent = staff;
if (lastClickedCi !== null) lastSyncedQuickbarCi = lastClickedCi;
};
const syncClassQuickbar = (quickbar: HTMLElement): void => {
applyOverridesToQuickbar(quickbar);
addEditButtonToQuickbar(quickbar);
};
const scheduleQuickbarSync = (): void => {
if (quickbarSyncTimer !== null) clearTimeout(quickbarSyncTimer);
let attempts = 0;
const trySync = (): void => {
const quickbar = getVisibleClassQuickbar();
if (quickbar && lastClickedCi !== null) {
syncClassQuickbar(quickbar);
return;
}
if (++attempts < 6) {
quickbarSyncTimer = setTimeout(trySync, 50);
}
};
requestAnimationFrame(trySync);
};
const addEditButtonToQuickbar = (quickbar: HTMLElement) => { const addEditButtonToQuickbar = (quickbar: HTMLElement) => {
if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return; if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return;
@@ -251,6 +325,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
} }
if (entryData.roomEl) entryData.roomEl.textContent = room; if (entryData.roomEl) entryData.roomEl.textContent = room;
if (entryData.teacherEl) entryData.teacherEl.textContent = staff; if (entryData.teacherEl) entryData.teacherEl.textContent = staff;
updateVisibleQuickbar(room, staff);
processAllEntries(); processAllEntries();
}, },
(ci) => { (ci) => {
@@ -262,6 +337,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
api.storage.timetableOverridesBySubject = bySubject; api.storage.timetableOverridesBySubject = bySubject;
if (entryData.roomEl) entryData.roomEl.textContent = item.room; if (entryData.roomEl) entryData.roomEl.textContent = item.room;
if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff; if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff;
updateVisibleQuickbar(item.room, item.staff);
processAllEntries(); processAllEntries();
}, },
); );
@@ -271,34 +347,30 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
}; };
const syncQuickbarFromDOM = () => { const syncQuickbarFromDOM = () => {
const quickbar = document.querySelector( const quickbar = getVisibleClassQuickbar();
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible", if (!quickbar || lastClickedCi === null || !lastClickedEntry) return;
); syncClassQuickbar(quickbar);
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 setupQuickbarObserver = () => {
const timetablePage = document.querySelector(".timetablepage"); const timetablePage = document.querySelector(".timetablepage");
if (!timetablePage || quickbarObserver) return; if (!timetablePage || quickbarObserver) return;
quickbarObserver = new MutationObserver(() => { quickbarObserver = new MutationObserver((mutations) => {
const quickbar = document.querySelector( const quickbarBecameVisible = mutations.some(
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible", (mutation) =>
mutation.type === "attributes" &&
mutation.attributeName === "class" &&
(mutation.target as HTMLElement).classList.contains("quickbar") &&
(mutation.target as HTMLElement).classList.contains("visible"),
); );
if (quickbar?.getAttribute("data-type") === "class") { if (!quickbarBecameVisible || lastClickedCi === null) return;
addEditButtonToQuickbar(quickbar as HTMLElement);
} const quickbar = getVisibleClassQuickbar();
if (quickbar) syncClassQuickbar(quickbar);
}); });
quickbarObserver.observe(timetablePage, { quickbarObserver.observe(timetablePage, {
childList: true,
subtree: true, subtree: true,
attributes: true, attributes: true,
attributeFilter: ["class"], attributeFilter: ["class"],
@@ -336,6 +408,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
unregister(); unregister();
observer?.disconnect(); observer?.disconnect();
quickbarObserver?.disconnect(); quickbarObserver?.disconnect();
if (quickbarSyncTimer !== null) clearTimeout(quickbarSyncTimer);
styleEl.remove(); styleEl.remove();
document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => { document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => {
el.removeAttribute("data-timetable-edit-processed"); el.removeAttribute("data-timetable-edit-processed");
+2
View File
@@ -10,6 +10,7 @@ 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 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)
@@ -28,6 +29,7 @@ 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
+1 -1
View File
@@ -105,7 +105,7 @@ export async function finishLoad() {
console.error("Error during loading cleanup:", err); console.error("Error during loading cleanup:", err);
} }
runStartupPopupQueue(); void runStartupPopupQueue();
} }
export function GetCSSElement(file: string) { export function GetCSSElement(file: string) {
Binary file not shown.
+45 -3
View File
@@ -5,7 +5,7 @@ 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 { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
import { getCustomThemeAdaptiveCssVariables } from "@/seqta/ui/colors/customThemeAdaptiveBindings"; 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";
@@ -84,6 +84,21 @@ function cancelColorTransition() {
} }
} }
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) { function applyColorsWith(selectedColor: string) {
if (settingsState.transparencyEffects) { if (settingsState.transparencyEffects) {
document.documentElement.classList.add("transparencyEffects"); document.documentElement.classList.add("transparencyEffects");
@@ -129,8 +144,35 @@ function applyColorsWith(selectedColor: string) {
applyProperties({ ...commonProps, ...modeProps, ...dynamicProps }); applyProperties({ ...commonProps, ...modeProps, ...dynamicProps });
if (settingsState.selectedTheme) { if (settingsState.selectedTheme) {
for (const name of getCustomThemeAdaptiveCssVariables()) { const channels = getRepresentativeRgbChannels(selectedColor);
setCSSVar(name, 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);
} }
} }
@@ -1,20 +1,49 @@
/** Tracks which author-declared CSS variables mirror the effective accent; not persisted in settings storage. */ /** 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_CUSTOM_PROP = /^--[a-zA-Z0-9_-]{1,120}$/;
const VALID_CHANNEL = /^(r|g|b)$/;
let boundNames: string[] = []; export type AdaptiveChannel = "r" | "g" | "b";
export function normalizeAdaptiveCssVariableNames( 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, names: string[] | undefined,
): string[] { ): AdaptiveCssVariableBinding[] {
if (!names?.length) return []; if (!names?.length) return [];
const out: string[] = []; const out: AdaptiveCssVariableBinding[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const raw of names) { for (const raw of names) {
const s = raw.trim(); const parsed = parseAdaptiveBinding(raw);
if (!VALID_CUSTOM_PROP.test(s) || seen.has(s)) continue; if (!parsed) continue;
seen.add(s); const key = `${parsed.cssVarName}:${parsed.channel ?? "full"}`;
out.push(s); if (seen.has(key)) continue;
seen.add(key);
out.push(parsed);
} }
return out; return out;
} }
@@ -22,19 +51,24 @@ export function normalizeAdaptiveCssVariableNames(
export function setCustomThemeAdaptiveCssVariables( export function setCustomThemeAdaptiveCssVariables(
names: string[] | undefined, names: string[] | undefined,
): void { ): void {
for (const n of boundNames) { for (const binding of boundBindings) {
document.documentElement.style.removeProperty(n); document.documentElement.style.removeProperty(binding.cssVarName);
} }
boundNames = normalizeAdaptiveCssVariableNames(names); boundBindings = normalizeAdaptiveCssVariableBindings(names);
} }
export function getCustomThemeAdaptiveCssVariableBindings(): AdaptiveCssVariableBinding[] {
return boundBindings;
}
// Backward-compatible helper for existing callsites.
export function getCustomThemeAdaptiveCssVariables(): string[] { export function getCustomThemeAdaptiveCssVariables(): string[] {
return boundNames; return boundBindings.map((b) => b.cssVarName);
} }
export function clearCustomThemeAdaptiveCssVariables(): void { export function clearCustomThemeAdaptiveCssVariables(): void {
for (const n of boundNames) { for (const binding of boundBindings) {
document.documentElement.style.removeProperty(n); document.documentElement.style.removeProperty(binding.cssVarName);
} }
boundNames = []; boundBindings = [];
} }
+20
View File
@@ -127,6 +127,26 @@ class CloudAuthService {
return 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( public async login(
login: string, login: string,
password: string password: string
+65
View File
@@ -0,0 +1,65 @@
import browser from "webextension-polyfill";
const DEFAULT_BASE = "https://betterseqta.org";
const KEY = "bsplus_dev_api_base";
/**
* Returns the current content-API base URL.
*
* Reads from `sessionStorage` so a developer can temporarily override the
* server for testing. The value is cleared when the browser session ends,
* leaving production traffic unaffected for normal users.
*/
export function getApiBase(): string {
try {
if (typeof sessionStorage === "undefined") return DEFAULT_BASE;
const v = sessionStorage.getItem(KEY);
if (v && /^https?:\/\//.test(v)) return v.replace(/\/$/, "");
} catch {
// sessionStorage may throw in some restricted contexts; fall back silently.
}
return DEFAULT_BASE;
}
/**
* Persist a session-scoped override and broadcast it to the background script
* so its `fetch` calls hit the same host.
*
* Pass `null` to clear the override.
*/
export function setApiBase(url: string | null): void {
try {
if (!url) {
sessionStorage.removeItem(KEY);
} else {
sessionStorage.setItem(KEY, url.replace(/\/$/, ""));
}
} catch {
// ignore
}
void browser.runtime
.sendMessage({ type: "setDevApiBase", url: url || null })
.catch(() => {});
}
/** Returns the override URL if one is currently set in this session. */
export function getStoredOverride(): string | null {
try {
if (typeof sessionStorage === "undefined") return null;
return sessionStorage.getItem(KEY);
} catch {
return null;
}
}
/**
* Send the current session override to the background script.
* Call this early in page load so the background context stays in sync after
* service-worker restarts.
*/
export function syncApiBaseToBackground(): void {
const override = getStoredOverride();
void browser.runtime
.sendMessage({ type: "setDevApiBase", url: override })
.catch(() => {});
}
@@ -1,61 +1,55 @@
import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState"; import { settingsState } from "../listeners/SettingsState";
import { openPopup } from "./PopupManager"; import { animate as motionAnimate } from "motion";
import { attachPopupMediaFullscreenIfPresent } from "./attachPopupMediaFullscreen";
/** Same hosting pattern as the privacy statement branding images (avoids page-relative extension URLs on Engage). */
const ENGAGE_PROMO_IMG_URL =
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/bq%2Bengage.png";
export function shouldShowEngageParentsAnnouncement(): boolean { export function shouldShowEngageParentsAnnouncement(): boolean {
return !settingsState.engageParentsAnnouncementShown; return !settingsState.engageParentsAnnouncementShown;
} }
/** /**
* One-time announcement that BetterSEQTA Plus works on SEQTA Engage (parents). * Non-blocking bottom-right toast announcing SEQTA Engage support. Shown once.
*/ */
export function showEngageParentsAnnouncement(onDismissed?: () => void) { export function showEngageParentsToast() {
if (document.getElementById("whatsnewbk")) { if (!shouldShowEngageParentsAnnouncement()) return;
onDismissed?.();
return;
}
if (!shouldShowEngageParentsAnnouncement()) {
onDismissed?.();
return;
}
const header = stringToHTML(
/* html */
`<div class="whatsnewHeader engageParentsAnnouncementHeader">
<h1>BetterSEQTA Plus now supports <span class="seqtaEngageAccent">SEQTA Engage</span></h1>
<p class="engageParentsSubheading">Buy your mom a BetterSEQTA Plus</p>
</div>`,
).firstChild as HTMLElement;
const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer privacyStatement" style="overflow-y: auto; font-size: 1.2rem; line-height: 1.6;">
<div class="engageParentsPromoWrap">
<img class="engageParentsPromoImg" src="${ENGAGE_PROMO_IMG_URL}" width="1920" height="1080" alt="BetterSEQTA Plus now supports SEQTA Engage" />
</div>
<p>
BetterSEQTA Plus now supports <strong class="seqtaEngageAccent">SEQTA Engage</strong>, so parents get the same kinds of improvements you are used to on SEQTA Learnthemes, a clearer home experience, and other Plus polish while browsing Engage.
</p>
<p>
The title is a bit of fun; if the extension saves you time, you can always support development via Open Collective or Ko-fi from the What is New changelog or related links in settings.
</p>
<p>
Close this dialog when you are done. We will not show this announcement again.
</p>
</div>
`).firstChild as HTMLElement;
attachPopupMediaFullscreenIfPresent(text, ".engageParentsPromoImg");
settingsState.engageParentsAnnouncementShown = true; settingsState.engageParentsAnnouncementShown = true;
openPopup({ const toast = document.createElement("div");
header, toast.className = "bsplus-toast engageParentsToast";
content: [text], toast.innerHTML = /* html */ `
afterClose: onDismissed, <button class="bsplus-toast-close" aria-label="Dismiss">&times;</button>
}); <div class="bsplus-toast-content">
<p class="bsplus-toast-eyebrow">SEQTA Engage support</p>
<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>
`;
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);
} }
@@ -0,0 +1,218 @@
import browser from "webextension-polyfill";
import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState";
import { closePopup } from "./PopupManager";
import { getApiBase } from "../DevApiBase";
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
import { cloudAuth } from "../CloudAuth";
/**
* Server response shape from `/api/theme-of-the-month/current`.
* Hero image is resolved client-side via the theme store API when `theme_id` is set.
*/
export interface ThemeOfTheMonthEntry {
id: string;
month: string;
title: string;
description: string;
cover_image: string | null;
theme_id: string | null;
theme: { id: string; name: string; slug: string } | null;
created_at: number;
updated_at: number;
}
/**
* Fetches the current month's Theme of the Month entry from the API.
* Returns `null` when no entry is configured for this month, or when the
* request fails (we never want a network problem to block other startup
* popups).
*/
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
try {
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
cache: "no-store",
});
if (!res.ok) return null;
const text = await res.text();
if (!text) return null;
const data = JSON.parse(text);
if (!data || typeof data !== "object" || !data.id) return null;
return data as ThemeOfTheMonthEntry;
} catch (err) {
console.warn("[ThemeOfTheMonth] Failed to fetch current entry:", err);
return null;
}
}
/** True when we have a new monthly entry the user hasn't dismissed yet. */
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
return settingsState.themeOfTheMonthLastSeenId !== entry.id;
}
function escapeHTML(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatMonthLabel(month: string): string {
const [yyyy, mm] = month.split("-");
if (!yyyy || !mm) return month;
const date = new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, 1);
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
}
/** Same priority as the theme store: marquee, then cover/banner. */
function heroUrlFromStoreTheme(theme: {
marqueeImage?: string | null;
coverImage?: string | null;
}): string | null {
const url = (theme.marqueeImage || theme.coverImage || "").trim();
return url || null;
}
/**
* Loads hero image for a store theme via the background script (same path as
* {@link ThemeSelector} / theme store detail fetches).
*/
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
try {
const token = await cloudAuth.getStoredToken();
const res = (await browser.runtime.sendMessage({
type: "fetchThemeDetails",
themeId,
token: token ?? undefined,
})) as { success?: boolean; data?: { theme?: { marqueeImage?: string; coverImage?: string } } };
if (!res?.success || !res?.data?.theme) return null;
return heroUrlFromStoreTheme(res.data.theme);
} catch (err) {
console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err);
return null;
}
}
/** Linked theme store image, else optional admin-uploaded cover. */
async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise<string | null> {
const themeId = entry.theme_id ?? entry.theme?.id;
if (themeId) {
const fromStore = await fetchThemeStoreHeroImage(themeId);
if (fromStore) return fromStore;
}
const fallback = entry.cover_image?.trim();
return fallback || null;
}
function closeThemeOfTheMonthCard(
card: HTMLElement,
onDismissed?: () => void,
markSeen = true,
) {
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
if (markSeen) {
const entryId = card.dataset.entryId;
if (entryId) settingsState.themeOfTheMonthLastSeenId = entryId;
}
card.classList.add("themeOfTheMonthCardClosing");
window.setTimeout(() => {
card.remove();
onDismissed?.();
}, 180);
}
/**
* Renders the Theme of the Month announcement card.
*/
export async function OpenThemeOfTheMonthPopup(
entry: ThemeOfTheMonthEntry,
onDismissed?: () => void,
) {
document.getElementById("theme-of-the-month-card")?.remove();
const monthLabel = formatMonthLabel(entry.month);
const heroUrl = await resolvePopupHeroImageUrl(entry);
const description = escapeHTML(entry.description).replace(/\n/g, " ");
const linkedThemeId = entry.theme_id ?? entry.theme?.id;
const card = stringToHTML(/* html */ `
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
<button type="button" class="themeOfTheMonthCardClose" aria-label="Close Theme of the Month">×</button>
${
heroUrl
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
: ""
}
<div class="themeOfTheMonthCardBody">
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
<h2>${escapeHTML(entry.title)}</h2>
<p class="themeOfTheMonthCardDescription">${description}</p>
<div class="themeOfTheMonthCardActions">
${
linkedThemeId
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
: ""
}
<button type="button" class="themeOfTheMonthCardSecondary">Don't show again</button>
</div>
</div>
</aside>
`).firstChild as HTMLElement;
card.dataset.entryId = entry.id;
const autoCloseTimeout = window.setTimeout(() => {
closeThemeOfTheMonthCard(card, onDismissed);
}, 12000);
const dismiss = (markSeen = true) => {
window.clearTimeout(autoCloseTimeout);
closeThemeOfTheMonthCard(card, onDismissed, markSeen);
};
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true });
card.querySelector(".themeOfTheMonthCardClose")?.addEventListener("click", () => {
dismiss();
});
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
dismiss();
openThemeStoreWithHighlight(linkedThemeId!);
});
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDisabled = true;
dismiss();
});
document.body.appendChild(card);
}
/**
* Dev helper: fetch the current month's entry and show the popup immediately,
* even if the user has already dismissed it this month.
*/
export async function showThemeOfTheMonthPopupNow(): Promise<void> {
const entry = await fetchThemeOfTheMonth();
if (!entry) {
alert(
"No Theme of the Month entry for the current month (UTC). Create one in the website admin, or check your dev API base URL.",
);
return;
}
settingsState.themeOfTheMonthLastSeenId = undefined;
if (document.getElementById("whatsnewbk")) {
await closePopup();
await new Promise((resolve) => setTimeout(resolve, 150));
}
await OpenThemeOfTheMonthPopup(entry);
}
+19 -1
View File
@@ -33,8 +33,25 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
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 - Theme of the Month, custom message folders & assessment weighting overrides</h1>
<li>Added Theme of the Month a monthly featured theme popup with a link to view it in the theme store.</li>
<li>Added custom message folders for organising direct DM's with drag to reorder.</li>
<li>Added the ability to override/add weightings to assessments (on assessment page).</li>
<li>Fixed custom room and teacher names not showing in the timetable popup.</li>
<li>Fixed assessment averages treating N/A weightings incorrectly in subject average calculations.</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.0 - Cloud backup, various fixes & SEQTA Engage support</h1> <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>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>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>Option to use cloud profile photo as the local SEQTA profile picture</li>
@@ -47,6 +64,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
<li>Updated outdated in-app links and update some under the hood code (Vite 8).</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>Added a notifications panel animation to work like settings.</li>
<li>Fix timetable edit plugin not working correctly.</li> <li>Fix timetable edit plugin not working correctly.</li>
<h1>3.5.3 - Adaptive theme updates</h1> <h1>3.5.3 - Adaptive theme updates</h1>
<li>Fixed adaptive theming on current-year course and assessment pages.</li> <li>Fixed adaptive theming on current-year course and assessment pages.</li>
+25 -21
View File
@@ -1,47 +1,51 @@
import { settingsState } from "../listeners/SettingsState"; import { settingsState } from "../listeners/SettingsState";
import { OpenWhatsNewPopup } from "./OpenWhatsNewPopup"; import { OpenWhatsNewPopup } from "./OpenWhatsNewPopup";
import {
shouldShowPrivacyNotification,
showPrivacyNotification,
} from "./OpenPrivacyNotification";
import { import {
shouldShowEngageParentsAnnouncement, shouldShowEngageParentsAnnouncement,
showEngageParentsAnnouncement, showEngageParentsToast,
} from "./OpenEngageParentsAnnouncement"; } from "./OpenEngageParentsAnnouncement";
import { import {
shouldShowBsCloudAutoSyncAnnouncement, fetchThemeOfTheMonth,
showBsCloudAutoSyncAnnouncement, OpenThemeOfTheMonthPopup,
} from "./OpenBsCloudAutoSyncAnnouncement"; shouldShowThemeOfTheMonth,
} from "./OpenThemeOfTheMonthPopup";
import { syncApiBaseToBackground } from "../DevApiBase";
type QueueStep = (goNext: () => void) => void; type QueueStep = (goNext: () => void) => void;
/** /**
* Runs startup modals in order: What's New (if the extension just updated), * Runs startup modals in order: What's New (if the extension just updated),
* privacy statement (if required), SEQTA Engage announcement (once), then BS Cloud * Theme of the Month (when a new monthly entry hasn't been seen), then shows
* auto-sync (once, last). * the SEQTA Engage toast (once, non-blocking).
*/ */
export function runStartupPopupQueue() { export async function runStartupPopupQueue() {
// Make sure the background script knows about any dev-mode API override
// before we start firing requests.
syncApiBaseToBackground();
const steps: QueueStep[] = []; const steps: QueueStep[] = [];
if (settingsState.justupdated) { if (settingsState.justupdated) {
steps.push((goNext) => OpenWhatsNewPopup(goNext)); steps.push((goNext) => OpenWhatsNewPopup(goNext));
} }
if (shouldShowPrivacyNotification()) { // Fetch the Theme of the Month before queueing so we don't show an empty
steps.push((goNext) => showPrivacyNotification(goNext)); // popup if the network or server is unavailable.
} const themeOfTheMonth = await fetchThemeOfTheMonth();
if (shouldShowThemeOfTheMonth(themeOfTheMonth)) {
if (shouldShowEngageParentsAnnouncement()) { steps.push((goNext) => {
steps.push((goNext) => showEngageParentsAnnouncement(goNext)); void OpenThemeOfTheMonthPopup(themeOfTheMonth!, goNext);
} });
if (shouldShowBsCloudAutoSyncAnnouncement()) {
steps.push((goNext) => showBsCloudAutoSyncAnnouncement(goNext));
} }
function runNext() { function runNext() {
const step = steps.shift(); const step = steps.shift();
if (step) step(runNext); if (step) step(runNext);
else {
if (shouldShowEngageParentsAnnouncement()) {
showEngageParentsToast();
}
}
} }
runNext(); runNext();
+137 -12
View File
@@ -10,6 +10,13 @@ export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1;
export const BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY = export const BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY =
"bsplus_cloud_settings_known_remote_updated_at"; "bsplus_cloud_settings_known_remote_updated_at";
/**
* Written by the service worker after applying a cloud settings envelope; the SEQTA pages
* ThemeManager reads and clears it (SW cannot share localforage/IndexedDB with the page).
*/
export const BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY =
"bsplus_pending_theme_ensure_after_cloud";
/** /**
* Never uploaded to the cloud backup (OAuth and legacy keys). * Never uploaded to the cloud backup (OAuth and legacy keys).
* IndexedDB (e.g. Global Searchs `betterseqta-index` database) is not part of * IndexedDB (e.g. Global Searchs `betterseqta-index` database) is not part of
@@ -36,7 +43,11 @@ export const SENSITIVE_DEVICE_STORAGE_KEYS_EXACT = [
/** e.g. any future `plugin.global-search.storage.*` keys in chrome.storage */ /** e.g. any future `plugin.global-search.storage.*` keys in chrome.storage */
export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = ["plugin.global-search.storage."] as const; export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = ["plugin.global-search.storage."] as const;
const CLIENT_ONLY_CLOUD_KEYS_EXACT = [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as const; const CLIENT_ONLY_CLOUD_KEYS_EXACT = [
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
"bsplus_lastCloudPoll",
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
] as const;
/** After restoring from cloud, keep local session so the user stays signed in. */ /** After restoring from cloud, keep local session so the user stays signed in. */
const AUTH_KEYS_TO_PRESERVE = [ const AUTH_KEYS_TO_PRESERVE = [
@@ -98,34 +109,151 @@ function stripExcludedKeysFromRemoteData(remote: Record<string, unknown>): Recor
return out; return out;
} }
/** Stored theme id (`selectedTheme`); trims whitespace; empty string clears. */
export function normalizeThemeIdForSync(raw: unknown): string {
if (typeof raw !== "string") return "";
return raw.trim();
}
export function buildUploadPayload(all: Record<string, unknown>): { export function buildUploadPayload(all: Record<string, unknown>): {
schemaVersion: number; schemaVersion: number;
themeId: string;
data: Record<string, unknown>; data: Record<string, unknown>;
} { } {
const data: Record<string, unknown> = {}; const filtered: Record<string, unknown> = {};
for (const [k, v] of Object.entries(all)) { for (const [k, v] of Object.entries(all)) {
if (shouldOmitKeyFromCloudPayload(k)) continue; if (shouldOmitKeyFromCloudPayload(k)) continue;
data[k] = v; filtered[k] = v;
} }
return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, data }; const data = migrateLegacyToPluginSettings(filtered);
const themeId = normalizeThemeIdForSync(all.selectedTheme);
return {
schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
themeId,
data,
};
} }
export async function getSnapshotForUpload(): Promise<{ export async function getSnapshotForUpload(): Promise<{
schemaVersion: number; schemaVersion: number;
themeId: string;
data: Record<string, unknown>; data: Record<string, unknown>;
}> { }> {
const all = await browser.storage.local.get(); const all = await browser.storage.local.get();
return buildUploadPayload(all as Record<string, unknown>); return buildUploadPayload(all as Record<string, unknown>);
} }
/**
* Theme to ensure is installed locally after a downloaded envelope (explicit `themeId` overrides `data.selectedTheme`).
* Works for any store-backed id, including **flavour (slave) variants** nested under masters in the catalogue.
*/
export function resolveThemeIdForPostSyncDownload(envelope: unknown): string | undefined {
if (envelope && typeof envelope === "object" && "themeId" in envelope) {
const top = normalizeThemeIdForSync(
(envelope as Record<string, unknown>).themeId,
);
if (top) return top;
}
let remoteFlat: Record<string, unknown>;
if (
envelope &&
typeof envelope === "object" &&
"data" in envelope &&
(envelope as { data?: unknown }).data !== undefined &&
typeof (envelope as { data?: unknown }).data === "object" &&
(envelope as { data?: unknown }).data !== null &&
!Array.isArray((envelope as { data?: unknown }).data)
) {
remoteFlat = (envelope as { data: Record<string, unknown> }).data;
} else if (envelope && typeof envelope === "object" && !Array.isArray(envelope)) {
remoteFlat = envelope as Record<string, unknown>;
} else {
return undefined;
}
const migrated = migrateLegacyToPluginSettings(remoteFlat);
const fromData = normalizeThemeIdForSync(migrated.selectedTheme);
return fromData === "" ? undefined : fromData;
}
export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise<void> { export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise<void> {
if (!iso || typeof iso !== "string") return; if (!iso || typeof iso !== "string") return;
await browser.storage.local.set({ [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY]: iso }); await browser.storage.local.set({ [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY]: iso });
} }
/** /**
* Replace local extension storage with the downloaded snapshot, except auth keys * Migrate legacy storage keys to plugin settings format.
* and device-only sensitive caches, which are preserved from the current device. * Only applies migrations for keys present in the data; does not overwrite
* existing plugin settings if the legacy key is absent.
*/
function migrateLegacyToPluginSettings(data: Record<string, unknown>): Record<string, unknown> {
const result = { ...data };
function ensurePluginSettings(pluginId: string): Record<string, unknown> {
const key = `plugin.${pluginId}.settings`;
if (!result[key] || typeof result[key] !== "object") {
result[key] = {};
}
return result[key] as Record<string, unknown>;
}
// animatedbk -> plugin.animated-background.settings.enabled
if ("animatedbk" in result) {
const settings = ensurePluginSettings("animated-background");
if (settings.enabled === undefined) {
settings.enabled = !!result.animatedbk;
}
delete result.animatedbk;
}
// bksliderinput -> plugin.animated-background.settings.speed
// Legacy: string "0"-"100", New: float 0.1-2.0
if ("bksliderinput" in result) {
const settings = ensurePluginSettings("animated-background");
if (settings.speed === undefined) {
const legacy = parseFloat(String(result.bksliderinput));
if (!isNaN(legacy)) {
settings.speed = Math.round((0.1 + (legacy / 100) * 1.9) * 100) / 100;
}
}
delete result.bksliderinput;
}
// assessmentsAverage -> plugin.assessments-average.settings.enabled
if ("assessmentsAverage" in result) {
const settings = ensurePluginSettings("assessments-average");
if (settings.enabled === undefined) {
settings.enabled = !!result.assessmentsAverage;
}
delete result.assessmentsAverage;
}
// lettergrade -> plugin.assessments-average.settings.lettergrade
if ("lettergrade" in result) {
const settings = ensurePluginSettings("assessments-average");
if (settings.lettergrade === undefined) {
settings.lettergrade = !!result.lettergrade;
}
delete result.lettergrade;
}
// notificationCollector -> plugin.notificationCollector.settings.enabled
if ("notificationCollector" in result && typeof result.notificationCollector === "boolean") {
const settings = ensurePluginSettings("notificationCollector");
if (settings.enabled === undefined) {
settings.enabled = result.notificationCollector;
}
delete result.notificationCollector;
}
return result;
}
/**
* Apply the downloaded cloud snapshot by setting each key individually,
* preserving auth keys and device-only sensitive caches.
* Legacy keys are automatically migrated to plugin settings format.
*/ */
export async function applyDownloadedEnvelope(envelope: unknown): Promise<void> { export async function applyDownloadedEnvelope(envelope: unknown): Promise<void> {
let remoteFlat: Record<string, unknown>; let remoteFlat: Record<string, unknown>;
@@ -145,10 +273,7 @@ export async function applyDownloadedEnvelope(envelope: unknown): Promise<void>
throw new Error("Invalid cloud settings payload"); throw new Error("Invalid cloud settings payload");
} }
const local = await browser.storage.local.get(); const migrated = migrateLegacyToPluginSettings(remoteFlat);
const preserved = collectLocalKeysToPreserve(local); const remoteSanitized = stripExcludedKeysFromRemoteData(migrated);
const remoteSanitized = stripExcludedKeysFromRemoteData(remoteFlat); await browser.storage.local.set(remoteSanitized);
await browser.storage.local.clear();
await browser.storage.local.set({ ...remoteSanitized, ...preserved });
} }
@@ -0,0 +1,39 @@
import { OpenStorePage } from "@/seqta/ui/renderStore";
/**
* Module-level handoff for "open the theme store and highlight this theme".
*
* The store page is mounted lazily inside a Shadow DOM the first time it
* opens, so a `CustomEvent` listener would have to be wired up before mount
* (causing a race). Using a shared cell keeps the producer (popup button) and
* consumer (store `onMount`) decoupled without that timing constraint.
*
* The store reads & clears this on mount via {@link consumePendingHighlightThemeId}.
*/
let pendingHighlightThemeId: string | null = null;
/** Read and clear the pending theme id (called by the store on mount). */
export function consumePendingHighlightThemeId(): string | null {
const id = pendingHighlightThemeId;
pendingHighlightThemeId = null;
return id;
}
/**
* Opens the theme store and asks it to focus / highlight the given theme.
* If the store is already mounted we dispatch a DOM event so it can react
* without remounting; otherwise the store consumes the pending id on mount.
*/
export function openThemeStoreWithHighlight(themeId: string): void {
pendingHighlightThemeId = themeId;
const existing = document.getElementById("store");
if (existing) {
window.dispatchEvent(
new CustomEvent("bsplus:highlight-theme", { detail: { themeId } }),
);
return;
}
OpenStorePage();
}
+4
View File
@@ -36,6 +36,10 @@ export interface SettingsState {
engageParentsAnnouncementShown?: boolean; engageParentsAnnouncementShown?: boolean;
/** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */ /** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */
bsCloudAutoSyncAnnouncementShown?: boolean; bsCloudAutoSyncAnnouncementShown?: boolean;
/** ID of the last Theme of the Month entry shown to the user (shows once per new entry). */
themeOfTheMonthLastSeenId?: string;
/** Permanently disables Theme of the Month startup prompts. */
themeOfTheMonthDisabled?: boolean;
timeFormat?: string; timeFormat?: string;
animations: boolean; animations: boolean;
defaultPage: string; defaultPage: string;