Merge branch 'main' into improved-global-search

This commit is contained in:
StroepWafel
2026-05-25 13:13:57 +09:30
committed by GitHub
32 changed files with 2729 additions and 589 deletions
+122 -22
View File
@@ -5,7 +5,7 @@
"": {
"name": "betterseqtaplus",
"dependencies": {
"@bedframe/core": "^0.0.46",
"@bedframe/core": "^0.1.0",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1",
@@ -13,7 +13,7 @@
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.1.4",
@@ -52,17 +52,17 @@
"react-dom": "17",
"rss-parser": "^3.13.0",
"sortablejs": "^1.15.6",
"svelte": "^5.22.6",
"svelte": "^5.46.4",
"typescript": "^5.8.2",
"uuid": "^11.1.0",
"vite": "^6.2.1",
"vite": "^8.0.5",
"webextension-polyfill": "^0.12.0",
},
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.95",
"@crxjs/vite-plugin": "^2.2.0",
"@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.4.0",
"@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
@@ -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=="],
"@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=="],
@@ -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=="],
"@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=="],
@@ -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/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "1.19.1" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "1.0.3", "is-glob": "4.0.3", "micromatch": "4.0.8", "node-addon-api": "7.1.1" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
@@ -349,6 +359,38 @@
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
"@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "2.0.2", "picomatch": "2.3.1" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="],
@@ -409,14 +451,14 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "4.0.1", "debug": "4.4.1", "deepmerge": "4.3.1", "kleur": "4.1.5", "magic-string": "0.30.18", "vitefu": "1.1.1" }, "peerDependencies": { "svelte": "5.38.6", "vite": "6.3.5" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "5.1.1", "svelte": "5.38.6", "vite": "6.3.5" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="],
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.10", "", { "dependencies": { "mini-svg-data-uri": "1.4.4" }, "peerDependencies": { "tailwindcss": "3.4.17" } }, "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw=="],
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.5", "", {}, "sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="],
"@types/chai": ["@types/chai@4.3.20", "", {}, "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ=="],
@@ -527,7 +569,7 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="],
@@ -631,7 +673,7 @@
"colors-named-hex": ["colors-named-hex@1.0.2", "", {}, "sha512-k6kq1e1pUCQvSVwIaGFq2l0LrkAPQZWyeuZn1Z8nOiYSEZiKoFj4qx690h2Kd34DFl9Me0gKS6MUwAMBJj8nuA=="],
"commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"complex.js": ["complex.js@2.4.2", "", {}, "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g=="],
@@ -693,6 +735,8 @@
"detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
"devalue": ["devalue@5.7.1", "", {}, "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
@@ -773,7 +817,7 @@
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
"esrap": ["esrap@2.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA=="],
"esrap": ["esrap@2.2.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
@@ -787,7 +831,7 @@
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"execa": ["execa@9.6.0", "", { "dependencies": { "@sindresorhus/merge-streams": "4.0.0", "cross-spawn": "7.0.6", "figures": "6.1.0", "get-stream": "9.0.1", "human-signals": "8.0.1", "is-plain-obj": "4.1.0", "is-stream": "4.0.1", "npm-run-path": "6.0.0", "pretty-ms": "9.2.0", "signal-exit": "4.1.0", "strip-final-newline": "4.0.0", "yoctocolors": "2.1.2" } }, "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw=="],
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
@@ -995,6 +1039,30 @@
"lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "3.0.6" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -1125,6 +1193,8 @@
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="],
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -1143,6 +1213,8 @@
"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=="],
"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=="],
"rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
"rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="],
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "2.2.0", "xml2js": "0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
@@ -1397,7 +1471,7 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.38.6", "", { "dependencies": { "@jridgewell/remapping": "2.3.5", "@jridgewell/sourcemap-codec": "1.5.5", "@sveltejs/acorn-typescript": "1.0.5", "@types/estree": "1.0.8", "acorn": "8.15.0", "aria-query": "5.3.2", "axobject-query": "4.1.0", "clsx": "2.1.1", "esm-env": "1.2.2", "esrap": "2.1.0", "is-reference": "3.0.3", "locate-character": "3.0.0", "magic-string": "0.30.18", "zimmerframe": "1.1.2" } }, "sha512-ltBPlkvqk3bgCK7/N323atUpP3O3Y+DrGV4dcULrsSn4fZaaNnOmdplNznwfdWclAgvSr5rxjtzn/zJhRm6TKg=="],
"svelte": ["svelte@5.55.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ=="],
"symbol-observable": ["symbol-observable@1.2.0", "", {}, "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="],
@@ -1423,7 +1497,7 @@
"tinycolor2": ["tinycolor2@1.4.2", "", {}, "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="],
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinypool": ["tinypool@0.7.0", "", {}, "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww=="],
@@ -1477,7 +1551,7 @@
"validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="],
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "0.25.9", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.49.0", "tinyglobby": "0.2.14" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "jiti": "1.21.7", "sass": "1.91.0", "yaml": "2.8.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
"vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
"vite-node": ["vite-node@0.34.6", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.1", "mlly": "1.8.0", "pathe": "1.1.2", "picocolors": "1.1.1", "vite": "5.4.19" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA=="],
@@ -1485,7 +1559,7 @@
"vite-plugin-externalize-deps": ["vite-plugin-externalize-deps@0.7.0", "", { "peerDependencies": { "vite": "6.3.5" } }, "sha512-do2gPrR79Tm8UKcqsw3RTAtN4YO8GkVRBckWdJWINZ3Qdp3KN9S1oyUZxKszTB/iyg4zdOUweLOeBI8t86QVow=="],
"vitefu": ["vitefu@1.1.1", "", { "optionalDependencies": { "vite": "6.3.5" } }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"vitest": ["vitest@0.34.6", "", { "dependencies": { "@types/chai": "4.3.20", "@types/chai-subset": "1.3.6", "@types/node": "24.3.0", "@vitest/expect": "0.34.6", "@vitest/runner": "0.34.6", "@vitest/snapshot": "0.34.6", "@vitest/spy": "0.34.6", "@vitest/utils": "0.34.6", "acorn": "8.15.0", "acorn-walk": "8.3.4", "cac": "6.7.14", "chai": "4.5.0", "debug": "4.4.1", "local-pkg": "0.4.3", "magic-string": "0.30.18", "pathe": "1.1.2", "picocolors": "1.1.1", "std-env": "3.9.0", "strip-literal": "1.3.0", "tinybench": "2.9.0", "tinypool": "0.7.0", "vite": "5.4.19", "vite-node": "0.34.6", "why-is-node-running": "2.3.0" }, "bin": { "vitest": "vitest.mjs" } }, "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q=="],
@@ -1555,7 +1629,9 @@
"@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@bedframe/core/@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.0.2", "", { "dependencies": { "@rollup/pluginutils": "4.2.1", "@webcomponents/custom-elements": "1.6.0", "acorn-walk": "8.3.4", "cheerio": "1.1.2", "convert-source-map": "1.9.0", "debug": "4.4.1", "es-module-lexer": "0.10.5", "fast-glob": "3.3.3", "fs-extra": "10.1.0", "jsesc": "3.1.0", "magic-string": "0.30.18", "pathe": "2.0.3", "picocolors": "1.1.1", "react-refresh": "0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-BeaVEkCTmna2tzl5DL9nw1kxll1IpIFZ+wbl2+iILz4fNJy1xRD6c1nF8w8/CvrWUuPYTFTpyX9K+A30ISDXHA=="],
"@bedframe/cli/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
"@bedframe/core/@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.3.0", "", { "dependencies": { "@rollup/pluginutils": "^4.1.2", "@webcomponents/custom-elements": "^1.5.0", "acorn-walk": "^8.2.0", "cheerio": "^1.0.0-rc.10", "convert-source-map": "^1.7.0", "debug": "^4.3.3", "es-module-lexer": "^0.10.0", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "jsesc": "^3.0.2", "magic-string": "^0.30.12", "pathe": "^2.0.1", "picocolors": "^1.1.1", "react-refresh": "^0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-+0CNVGS4bB30OoaF1vUsHVwWU1Lm7MxI0XWY9Fd/Ob+ZVTZgEFNqJ1ZC69IVwQsoYhY0sMQLvpLWiFIuDz8htg=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -1591,6 +1667,8 @@
"@samverschueren/stream-to-observable/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
"@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@vitest/runner/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
@@ -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=="],
"dependency-cruiser/commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"eslint/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
@@ -1633,6 +1713,8 @@
"htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"lightningcss/detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
"listr/is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="],
"listr/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
@@ -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=="],
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"vite-node/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
@@ -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/vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "0.25.9", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.49.0", "tinyglobby": "0.2.14" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "jiti": "1.21.7", "sass": "1.91.0", "yaml": "2.8.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
"vitest/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"vitest/vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.6", "rollup": "4.49.0" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "sass": "1.91.0" }, "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="],
@@ -1741,6 +1829,14 @@
"z-schema/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"@bedframe/cli/vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"@bedframe/cli/vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"@bedframe/cli/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"@bedframe/core/@crxjs/vite-plugin/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
@@ -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/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"vite-plugin-dts/vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"vitest/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"vitest/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
+30 -3
View File
@@ -10,6 +10,21 @@ import {
runCloudSettingsPoll,
} from "./background/cloudSettingsAutoSync";
/**
* Session-only dev-mode override of the content API base.
*
* Stored in a module-level variable (not `chrome.storage`) so it is wiped
* automatically when the browser/service-worker process restarts. Content
* scripts re-sync this on every page load via `setDevApiBase` so the value
* survives transient service-worker terminations within the same browser
* session.
*/
const DEFAULT_API_BASE = "https://betterseqta.org";
let DEV_API_BASE: string | null = null;
function apiBase(): string {
return DEV_API_BASE ?? DEFAULT_API_BASE;
}
function reloadSeqtaPages() {
const result = browser.tabs.query({});
function open(tabs: any) {
@@ -30,7 +45,7 @@ type MessageSender = { (response?: unknown): void };
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
const { token } = request;
const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -58,7 +73,7 @@ function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boo
}
const headers: Record<string, string> = {};
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(sendResponse)
.catch((err) => {
@@ -284,7 +299,7 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean
return false;
}
const isFavorite = action === "favorite";
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, {
fetch(`${apiBase()}/api/themes/${themeId}/favorite`, {
method: isFavorite ? "POST" : "DELETE",
headers: { Authorization: `Bearer ${token}` },
})
@@ -311,8 +326,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> = {
reloadTabs: () => reloadSeqtaPages(),
setDevApiBase: handleSetDevApiBase,
extensionPages: (req) => {
browser.tabs.query({}).then((tabs) => {
for (const tab of tabs) {
@@ -470,6 +496,7 @@ function getDefaultValues(): SettingsState {
adaptiveThemeColour: false,
adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true,
themeOfTheMonthDisabled: false,
autoCloudSettingsSync: true,
};
}
+193 -24
View File
@@ -3726,6 +3726,150 @@ div.day-empty {
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 {
cursor: pointer;
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
@@ -4375,38 +4519,63 @@ h2.home-subtitle {
.bsplus-toast {
position: fixed;
bottom: 24px;
right: 24px;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
z-index: 10000;
display: flex;
align-items: flex-start;
gap: 12px;
max-width: 380px;
padding: 16px 18px;
border-radius: 12px;
background: var(--background-secondary, #fff);
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 8px 30px rgba(0, 0, 0, 0.18);
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
font-size: 0.9rem;
line-height: 1.5;
line-height: 1.45;
}
.bsplus-toast-content p {
margin: 6px 0 0;
opacity: 0.8;
font-size: 0.85rem;
.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 {
flex-shrink: 0;
background: none;
border: none;
color: var(--text-primary, #1a1a1a);
font-size: 1.3rem;
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;
padding: 0 2px;
font-size: 1.35rem;
line-height: 1;
opacity: 0.5;
transition: opacity 0.15s;
transition: filter 0.15s ease;
}
.bsplus-toast-close:hover {
opacity: 1;
filter: brightness(1.08);
}
+71 -1
View File
@@ -15,10 +15,37 @@
import CloudHeader from "@/interface/components/store/CloudHeader.svelte"
import { cloudAuth } from "@/seqta/utils/CloudAuth"
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
import { showThemeOfTheMonthPopupNow } from "@/seqta/utils/Openers/OpenThemeOfTheMonthPopup"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase"
let devApiBaseInput = $state<string>(getStoredOverride() ?? "")
let devApiBaseActive = $state<string | null>(getStoredOverride())
function applyDevApiBase() {
const trimmed = devApiBaseInput.trim()
if (trimmed === "") {
setApiBase(null)
devApiBaseActive = null
return
}
if (!/^https?:\/\//.test(trimmed)) {
alert("Please enter a full URL starting with http:// or https://")
return
}
setApiBase(trimmed)
devApiBaseActive = trimmed.replace(/\/$/, "")
}
function clearDevApiBase() {
devApiBaseInput = ""
setApiBase(null)
devApiBaseActive = null
}
import { getAllPluginSettings } from "@/plugins"
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
// Union type representing all possible settings
@@ -53,7 +80,9 @@
settings: Record<string, SettingType>;
}
const pluginSettings = getAllPluginSettings() as Plugin[];
const pluginSettings = getAllPluginSettings().filter(
(plugin) => !(isSeqtaEngageExperience() && plugin.pluginId === "global-search"),
) as Plugin[];
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({});
let cloudState = $state(cloudAuth.state);
@@ -483,6 +512,22 @@
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Show Theme of the Month</h2>
<p class="text-xs">Fetch and show the current month's popup now (ignores dismissed state)</p>
</div>
<div>
<Button
onClick={async () => {
closeExtensionPopup();
await new Promise((resolve) => setTimeout(resolve, 100));
await showThemeOfTheMonthPopupNow();
}}
text="Show Now"
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
@@ -492,6 +537,31 @@
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
</div>
</div>
<div class="flex flex-col gap-2 px-4 py-3">
<div class="flex justify-between items-start gap-3">
<div class="pr-4">
<h2 class="text-sm font-bold">API Base URL (session only)</h2>
<p class="text-xs">Override the content API host for this browser session. Cleared on restart. Affects themes, theme of the month, and other server-driven content.</p>
{#if devApiBaseActive}
<p class="text-xs mt-1 text-amber-600 dark:text-amber-400">
Override active: <span class="font-mono">{devApiBaseActive}</span>
</p>
{/if}
</div>
</div>
<div class="flex gap-2 items-center">
<input
type="text"
placeholder="https://betterseqta.org"
bind:value={devApiBaseInput}
class="flex-1 px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
/>
<Button onClick={applyDevApiBase} text="Apply" />
{#if devApiBaseActive}
<Button onClick={clearDevApiBase} text="Clear" />
{/if}
</div>
</div>
</div>
{/if}
</div>
+27
View File
@@ -18,6 +18,7 @@
import Backgrounds from '../components/store/Backgrounds.svelte'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
import { consumePendingHighlightThemeId } from '@/seqta/utils/openThemeStoreWithHighlight'
const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
@@ -122,13 +123,39 @@
}
};
function focusThemeById(themeId: string) {
const match = themes.find((t) => t.id === themeId)
?? themes.find((t) => t.flavours?.some((f) => f.id === themeId));
if (match) {
activeTab = 'themes';
searchTerm = '';
displayTheme = match;
}
}
function onHighlightThemeEvent(e: Event) {
const detail = (e as CustomEvent).detail;
if (detail?.themeId && typeof detail.themeId === 'string') {
focusThemeById(detail.themeId);
}
}
// On mount
onMount(async () => {
window.addEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
await fetchThemes();
await fetchCurrentThemes();
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
darkMode = $settingsState.DarkMode;
const pending = consumePendingHighlightThemeId();
if (pending) focusThemeById(pending);
return () => {
window.removeEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
};
});
// Filter themes (list is already featured-first, then newest; filter preserves order)
@@ -0,0 +1,46 @@
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
function randomEngagePdfFileName(): string {
const token = Math.random().toString(36).slice(2, 10);
return `${token}.pdf`;
}
export async function requestEngageAssessmentPdf(params: {
assessmentID: string | number;
metaclassID: string | number;
studentID: string | number;
}): Promise<string> {
const fileName = randomEngagePdfFileName();
const cacheBuster = Math.random().toString(36).slice(2, 10);
const response = await fetch(
`${location.origin}/seqta/parent/print/assessment?${cacheBuster}`,
{
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({
id: params.assessmentID,
metaclass: params.metaclassID,
student: Number(params.studentID),
fileName,
}),
},
);
if (!response.ok) {
throw new Error(
`Failed to generate PDF: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as {
payload?: { file?: string };
};
return data.payload?.file ?? fileName;
}
export function getEngageAssessmentReportUrl(fileName: string): string {
return `${location.origin}/seqta/parent/report/get?file=${encodeURIComponent(fileName)}`;
}
@@ -7,11 +7,11 @@ import {
import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import {
clearStuck,
getClassByPattern,
initStorage,
injectWeightingsTab,
letterToNumber,
parseAssessments,
processAssessments,
@@ -20,6 +20,7 @@ import {
interface weightingsStorage {
weightings: Record<string, string>;
assessments: Record<string, string>;
weightingOverrides: Record<string, string>;
}
const settings = defineSettings({
@@ -37,6 +38,8 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
const instance = new AssessmentsAveragePluginClass();
let overrideListenerController: AbortController | null = null;
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
id: "assessments-average",
name: "Assessment Averages",
@@ -58,17 +61,56 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
);
await parseAssessments(api);
await renderSubjectAverage(api);
overrideListenerController?.abort();
overrideListenerController = new AbortController();
document.addEventListener(
"betterseqta:overrideChanged",
() => renderSubjectAverage(api),
{ signal: overrideListenerController.signal },
);
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
const observer = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
observer.observe(wrapper, { childList: true, subtree: true });
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___",
@@ -86,35 +128,6 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
"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___']`,
@@ -128,9 +141,25 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
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(
@@ -140,17 +169,8 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
},
{} 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;
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
let warningHTML = "";
if (hasInaccurateWeighting) {
warningHTML = /* html */ `
@@ -159,7 +179,6 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
</div>
`;
}
assessmentsList.insertBefore(
stringToHTML(/* html */ `
<div class="${assessmentItemClass}">
@@ -180,21 +199,11 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
`).firstChild!,
assessmentsList.firstChild,
);
applySubjectColourToOverallResult();
const observer = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
} finally {
renderInFlight = false;
}
}
});
},
};
function applySubjectColourToOverallResult() {
const selectedAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
+385 -52
View File
@@ -1,5 +1,11 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
import {
getEngageAssessmentReportUrl,
requestEngageAssessmentPdf,
} from "./engage.ts";
import {
ensurePdfjsWorker,
getPdfjsPageContextUrls,
@@ -17,6 +23,9 @@ export async function initStorage(api: any) {
if (!api.storage.assessments) {
api.storage.assessments = {};
}
if (!api.storage.weightingOverrides) {
api.storage.weightingOverrides = {};
}
}
export function clearStuck(api: any) {
@@ -79,52 +88,84 @@ function createWeightLabel(
assessmentItem: Element,
weighting: string | undefined,
) {
const statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___']`,
) as HTMLElement;
let statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
) as HTMLElement | null;
if (
!statsContainer ||
statsContainer.querySelector(".betterseqta-weight-label")
)
return;
if (!statsContainer) {
const statsClass = getClassByPattern(document, "AssessmentItem__stats___");
statsContainer = document.createElement("div");
statsContainer.className = statsClass;
statsContainer.classList.add("betterseqta-stats-container");
const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`);
if (thermoscore) {
thermoscore.insertAdjacentElement("afterend", statsContainer);
} else {
assessmentItem.appendChild(statsContainer);
}
}
const label = statsContainer.querySelector(
`[class*='Label__Label___']`,
) as HTMLElement;
if (!label) return;
const weightLabel = label.cloneNode(true) as HTMLElement;
weightLabel.classList.add("betterseqta-weight-label");
const innerTextDiv = weightLabel.querySelector(
`[class*='Label__innerText___']`,
const hasNativeLabel = !!statsContainer.querySelector(
`[class*='Label__Label___']:not(.betterseqta-weight-label)`,
);
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";
const textNodes = Array.from(weightLabel.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE,
);
if (textNodes.length) {
textNodes[0].textContent =
weighting && weighting !== "processing"
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
: "N/A";
textNodes[0].textContent = displayText;
} else {
weightLabel.appendChild(document.createTextNode(displayText));
}
// 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);
}
@@ -228,7 +269,8 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
export async function extractPDFText(url: string): Promise<string> {
try {
if (isFirefox) {
const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls();
const { lib: pdfLibUrl, worker: pdfWorkerUrl } =
getPdfjsPageContextUrls();
const escJsSingleQuoted = (s: string) =>
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
@@ -428,8 +470,6 @@ export async function extractPDFText(url: string): Promise<string> {
async function handleWeightings(mark: any, api: any) {
const assessmentID = mark.id;
const metaclassID = mark.metaclassID;
const userInfo = await getUserInfo();
const userID = userInfo.id;
const title = mark.title;
if (
@@ -450,6 +490,25 @@ async function handleWeightings(mark: any, api: any) {
};
try {
let pdfUrl: string;
if (isSeqtaEngageExperience()) {
const studentID = getEngageAssessmentStudentId();
if (!studentID) {
throw new Error("Could not resolve Engage student ID from URL or storage");
}
const reportFile = await requestEngageAssessmentPdf({
assessmentID,
metaclassID,
studentID,
});
await new Promise((resolve) => setTimeout(resolve, 1000));
pdfUrl = getEngageAssessmentReportUrl(reportFile);
} else {
const userInfo = await getUserInfo();
const userID = userInfo.id;
const filename =
"BetterSEQTA-" +
String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
@@ -477,7 +536,8 @@ async function handleWeightings(mark: any, api: any) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
}
if (pdfUrl.startsWith("blob:")) {
throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`);
@@ -519,7 +579,11 @@ export async function parseAssessments(api: any) {
"[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
const marks = [
...(state["marks"] ?? []),
...(state["upcoming"] ?? []),
...(state["pending"] ?? []),
];
if (!marks) return;
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
@@ -532,15 +596,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
let count = 0;
for (const assessmentItem of assessmentItems) {
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
const titleEl = assessmentItem.querySelector(
`[class*='AssessmentItem__title___']`,
);
@@ -550,12 +605,23 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
if (!title) continue;
const assessmentID = api.storage.assessments?.[title];
const weighting = assessmentID
const autoWeighting = assessmentID
? api.storage.weightings?.[assessmentID]
: undefined;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
const weighting = override ?? autoWeighting;
createWeightLabel(assessmentItem, weighting);
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
if (
weighting === null ||
weighting === undefined ||
@@ -563,8 +629,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
weighting === "processing"
) {
hasInaccurateWeighting = true;
weightedTotal += grade;
totalWeight += 1;
continue
} else {
const weight = parseFloat(weighting);
@@ -587,3 +652,271 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
count,
};
}
function resolveTabSetClasses(): Record<string, string> {
const patterns = [
"TabSet__tabsheet___",
"TabSet__hidden___",
"TabSet__selected___",
"TabSet__disappearToLeft___",
"TabSet__disappearToRight___",
"TabSet__appearFromRight___",
"TabSet__appearFromLeft___",
];
const resolved: Record<string, string> = {};
// First pass: scan live DOM elements (fast, covers currently-applied classes)
const allClasses = Array.from(
document.querySelectorAll('[class*="TabSet__"]'),
).flatMap((el) => Array.from(el.classList));
for (const pattern of patterns) {
const found = allClasses.find((c) => c.startsWith(pattern));
if (found) resolved[pattern] = found;
}
// Second pass: scan stylesheets for any classes not yet in the DOM
// (e.g. animation classes that haven't been applied yet)
const missing = patterns.filter((p) => !resolved[p]);
if (missing.length > 0) {
try {
for (const sheet of Array.from(document.styleSheets)) {
if (missing.every((p) => resolved[p])) break;
try {
for (const rule of Array.from(sheet.cssRules ?? [])) {
if (!(rule instanceof CSSStyleRule)) continue;
const selectorClasses =
rule.selectorText.match(/\.([\w-]+)/g) ?? [];
for (const pattern of missing) {
if (!resolved[pattern]) {
const match = selectorClasses.find((c) =>
c.slice(1).startsWith(pattern),
);
if (match) resolved[pattern] = match.slice(1);
}
}
}
} catch {
// Cross-origin stylesheet
}
}
} catch {}
}
// Fallback: use the base pattern as-is so the function doesn't crash,
// though styles won't apply if the hash is truly unknown.
for (const pattern of patterns) {
if (!resolved[pattern]) resolved[pattern] = pattern;
}
return resolved;
}
function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
const titleEl = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']",
);
const title = titleEl?.textContent?.trim();
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
const rawWeight = assessmentID
? api.storage.weightings?.[assessmentID]
: undefined;
const weightingUnavailable = rawWeight === "N/A";
const autoWeight =
rawWeight && rawWeight !== "processing" && rawWeight !== "N/A"
? rawWeight
: undefined;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
const statusNote = !assessmentID
? ""
: rawWeight === "processing"
? "Weighting is still being detected."
: weightingUnavailable
? "No weighting was found in the marksheet. Set one manually."
: "Overrides the auto-detected value.";
sheet.innerHTML = `
<style>
#betterseqta-weight-override::placeholder {
opacity: 0.4;
}
</style>
<div style="padding:16px;max-width:360px">
<h2 style="margin:0 0 4px;font-size:15px;font-weight:600">Weighting Override</h2>
<p style="margin:0 0 16px;font-size:12px;opacity:0.6">
Set the weighting for this assessment.
${statusNote}
</p>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<label style="font-size:13px;opacity:0.7;flex-shrink:0">Auto-detected</label>
<span style="font-size:13px;opacity:${autoWeight != null ? "1" : "0.4"}">${autoWeight != null ? `${autoWeight}%` : "none"}</span>
</div>
<div style="display:flex;align-items:center;gap:12px">
<label for="betterseqta-weight-override" style="font-size:13px;opacity:0.7;flex-shrink:0">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,12 +1,15 @@
<script lang="ts">
import { determineStatus, formatDate, getGradeValue } from "./utils";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { buildEngageAssessmentPagePath } from "@/seqta/utils/engageAssessmentStudent";
import confetti from "canvas-confetti";
export let data: any;
interface FilterOptions {
subject: string;
student: string;
sortBy: "due" | "grade" | "subject" | "title" | "year";
}
@@ -38,9 +41,13 @@
let currentFilters: FilterOptions = {
subject: "all",
student: "all",
sortBy: "due",
};
const isEngage = isSeqtaEngageExperience();
$: showStudentFilter = isEngage && (data?.students?.length ?? 0) > 1;
let filteredAssessments: any[] = [];
let statusGroups: Record<string, any[]> = {};
let columns: { key: string; title: string; className: string; icon: string }[] = [];
@@ -100,7 +107,17 @@
const filtered = data.assessments.filter((a: any) => {
if (hiddenAssessmentIds.has(String(a.id))) return false;
if (subjectFilters[a.code] === false) return false;
return currentFilters.subject === "all" || a.code === currentFilters.subject;
if (currentFilters.subject !== "all" && a.code !== currentFilters.subject) {
return false;
}
if (
isEngage &&
currentFilters.student !== "all" &&
String(a.studentId) !== currentFilters.student
) {
return false;
}
return true;
});
const groups: Record<string, any[]> = {};
@@ -309,6 +326,19 @@
if ((event.target as HTMLElement).closest(".card-menu")) {
return;
}
if (isSeqtaEngageExperience()) {
const studentId = assessment.studentId ?? data?.studentId;
if (!studentId) return;
window.location.hash = buildEngageAssessmentPagePath(
studentId,
assessment.programmeID,
assessment.metaclassID,
assessment.id,
);
return;
}
window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`;
}
@@ -342,6 +372,7 @@
updateAssessments();
void currentFilters.sortBy;
void currentFilters.subject;
void currentFilters.student;
}
</script>
@@ -352,6 +383,14 @@
<div class="grid-view-header">
<h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters">
{#if showStudentFilter}
<select class="filter-select" bind:value={currentFilters.student}>
<option value="all">All Students</option>
{#each data.students as student}
<option value={String(student.id)}>{student.name}</option>
{/each}
</select>
{/if}
<select class="filter-select" bind:value={currentFilters.subject}>
<option value="all">All Subjects</option>
{#each data.subjects as subject}
@@ -445,6 +484,9 @@
on:keydown={(e) => e.key === 'Enter' && handleCardClick(assessment, e)}
>
<div class="card-labels">
{#if isEngage && assessment.studentName}
<span class="card-label label-student">{assessment.studentName}</span>
{/if}
<span class="card-label label-subject">{assessment.code}</span>
{#if assessment.submitted}
<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>
+45 -25
View File
@@ -9,12 +9,17 @@ interface PrefItem {
value: string;
}
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { getMockAssessmentsData } from "@/seqta/ui/dev/hideSensitiveContent";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import {
getEngageAssessmentsData,
} from "./engageApi";
let cache: { time: number; data: any } | null = null;
let cache: { time: number; engageAll?: boolean; studentId: number; data: any } | null =
null;
const CACHE_MS = 10 * 60 * 1000;
const student = 69;
async function fetchJSON(url: string, body: any) {
const res = await fetch(`${location.origin}${url}`, {
@@ -28,17 +33,9 @@ async function fetchJSON(url: string, body: any) {
async function loadSubjects() {
const res = await fetchJSON("/seqta/student/load/subjects?", {});
const activeGroup = res.payload.find((s: any) => s.active === 1);
const activeYear = activeGroup?.year;
const allSubjects = res.payload
.filter((s: any) => s.year === activeYear)
return res.payload
.filter((s: any) => s.active === 1)
.flatMap((s: any) => s.subjects);
const seen = new Set<string>();
return allSubjects.filter((s: Subject) => {
if (seen.has(s.code)) return false;
seen.add(s.code);
return true;
});
}
async function loadPrefs(student: number) {
@@ -66,7 +63,6 @@ async function loadUpcoming(student: number) {
function normalizeAssessmentDates(t: any, subject: Subject): any {
const normalized = { ...t };
// Past API may use different date fields - ensure we have 'due' for year filter & display
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
}
@@ -136,18 +132,13 @@ async function loadSubmissions(student: number, assessments: any[]) {
return submissionMap;
}
export async function getAssessmentsData() {
if (settingsState.mockNotices) {
return getMockAssessmentsData();
}
if (cache && Date.now() - cache.time < CACHE_MS) return cache.data;
async function getLearnAssessmentsData(studentId: number) {
const [subjects, colors, upcoming] = await Promise.all([
loadSubjects(),
loadPrefs(student),
loadUpcoming(student),
loadPrefs(studentId),
loadUpcoming(studentId),
]);
const pastMap = await loadPast(student, subjects);
const pastMap = await loadPast(studentId, subjects);
const map: Record<number, any> = {};
upcoming.forEach((a: any) => {
map[a.id] = { ...a };
@@ -158,13 +149,42 @@ export async function getAssessmentsData() {
});
const allAssessments = Object.values(map);
const submissions = await loadSubmissions(student, allAssessments);
const submissions = await loadSubmissions(studentId, allAssessments);
allAssessments.forEach((assessment: any) => {
assessment.submitted = submissions[assessment.id] || false;
});
const data = { assessments: allAssessments, subjects, colors };
cache = { time: Date.now(), data };
return { assessments: allAssessments, subjects, colors, studentId };
}
export async function getAssessmentsData() {
if (settingsState.mockNotices) {
return getMockAssessmentsData();
}
if (isSeqtaEngageExperience()) {
if (cache && Date.now() - cache.time < CACHE_MS && cache.engageAll) {
return cache.data;
}
const data = await getEngageAssessmentsData();
cache = { time: Date.now(), studentId: 0, engageAll: true, data };
return data;
}
const studentId = (await getUserInfo()).id;
if (
cache &&
Date.now() - cache.time < CACHE_MS &&
cache.studentId === studentId
) {
return cache.data;
}
const data = await getLearnAssessmentsData(studentId);
cache = { time: Date.now(), studentId, data };
return data;
}
@@ -0,0 +1,239 @@
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
interface Subject {
code: string;
programme: number;
metaclass: number;
title: string;
}
interface PrefItem {
name: string;
value: string;
}
export interface EngageStudent {
id: number;
name: string;
}
interface EngageChildPayload {
id?: number;
name?: string;
terms?: {
active?: number;
subjects?: {
code?: string;
programme?: number;
metaclass?: number;
title?: string;
description?: string;
}[];
}[];
}
async function fetchJSON(url: string, body: unknown) {
const res = await fetch(`${location.origin}${url}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
});
return res.json();
}
async function loadEngageChildrenPayload(): Promise<EngageChildPayload[]> {
const res = await fetchJSON("/seqta/parent/load/subjects", {});
return Array.isArray(res.payload) ? res.payload : [];
}
export async function resolveEngageStudentId(): Promise<number> {
const fromUrlOrStorage = getEngageAssessmentStudentId();
if (fromUrlOrStorage) return Number(fromUrlOrStorage);
const children = await loadEngageChildrenPayload();
const firstChild = children[0];
if (firstChild?.id != null) return Number(firstChild.id);
throw new Error("Could not resolve Engage student ID");
}
function subjectsFromChild(child: EngageChildPayload): Subject[] {
return (child.terms ?? [])
.filter((term) => term.active === 1)
.flatMap((term) =>
(term.subjects ?? []).map((subject) => ({
code: subject.code ?? "",
programme: subject.programme ?? 0,
metaclass: subject.metaclass ?? 0,
title: subject.title ?? subject.description ?? subject.code ?? "",
})),
);
}
async function loadEngagePrefs(): Promise<Record<string, string>> {
const res = await fetchJSON("/seqta/parent/load/prefs?", {
request: "userPrefs",
asArray: true,
});
const colors: Record<string, string> = {};
(res.payload ?? []).forEach((pref: PrefItem) => {
if (pref.name.startsWith("timetable.subject.colour.")) {
const code = pref.name.replace("timetable.subject.colour.", "");
colors[code] = pref.value;
}
});
return colors;
}
async function loadEngageUpcoming(studentId: number) {
const res = await fetchJSON("/seqta/parent/assessment/list/upcoming?", {
student: studentId,
});
return res.payload ?? [];
}
function normalizeAssessmentDates(t: any, subject: Subject): any {
const normalized = { ...t };
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
}
if (!normalized.programmeID) normalized.programmeID = subject.programme;
if (!normalized.metaclassID) normalized.metaclassID = subject.metaclass;
if (!normalized.code && t.subject) normalized.code = t.subject;
return normalized;
}
async function loadEngagePast(studentId: number, subjects: Subject[]) {
const map: Record<number, any> = {};
await Promise.all(
subjects.map(async (subject) => {
const res = await fetchJSON("/seqta/parent/assessment/list/past?", {
programme: subject.programme,
metaclass: subject.metaclass,
student: studentId,
});
const processAssessment = (task: any) => {
if (task?.id) {
const merged = {
...task,
programmeID: task.programmeID || task.programme || subject.programme,
metaclassID: task.metaclassID || task.metaclass || subject.metaclass,
code: task.code || task.subject || subject.code,
};
map[task.id] = normalizeAssessmentDates(merged, subject);
}
};
if (Array.isArray(res.payload?.pending)) {
res.payload.pending.forEach(processAssessment);
}
if (Array.isArray(res.payload?.tasks)) {
res.payload.tasks.forEach(processAssessment);
}
}),
);
return map;
}
async function loadEngageSubmissions(studentId: number, assessments: any[]) {
const submissionMap: Record<number, boolean> = {};
await Promise.all(
assessments.map(async (assessment) => {
try {
const res = await fetchJSON("/seqta/parent/assessment/submissions/get", {
assessment: assessment.id,
metaclass: assessment.metaclassID,
student: studentId,
});
submissionMap[assessment.id] =
Array.isArray(res.payload) && res.payload.length > 0;
} catch (error) {
console.warn(
`[BetterSEQTA+] Failed to fetch Engage submission for assessment ${assessment.id}:`,
error,
);
submissionMap[assessment.id] = false;
}
}),
);
return submissionMap;
}
async function loadEngageAssessmentsForStudent(
child: EngageChildPayload,
): Promise<any[]> {
const studentId = Number(child.id);
const studentName = child.name ?? "Student";
const subjects = subjectsFromChild(child);
const [upcoming, pastMap] = await Promise.all([
loadEngageUpcoming(studentId),
loadEngagePast(studentId, subjects),
]);
const map: Record<number, any> = {};
upcoming.forEach((assessment: any) => {
map[assessment.id] = { ...assessment };
});
Object.values(pastMap).forEach((task: any) => {
if (map[task.id]) Object.assign(map[task.id], task);
else map[task.id] = task;
});
const assessments = Object.values(map).map((assessment) => ({
...assessment,
studentId,
studentName,
}));
const submissions = await loadEngageSubmissions(studentId, assessments);
assessments.forEach((assessment) => {
assessment.submitted = submissions[assessment.id] || false;
});
return assessments;
}
export async function getEngageAssessmentsData() {
const childrenPayload = await loadEngageChildrenPayload();
const students: EngageStudent[] = childrenPayload
.filter((child) => child.id != null)
.map((child) => ({
id: Number(child.id),
name: child.name ?? "Student",
}));
if (!students.length) {
throw new Error("No Engage students found");
}
const [colors, assessmentsByChild] = await Promise.all([
loadEngagePrefs(),
Promise.all(childrenPayload.map((child) => loadEngageAssessmentsForStudent(child))),
]);
const subjectsMap = new Map<string, Subject>();
childrenPayload.forEach((child) => {
subjectsFromChild(child).forEach((subject) => {
if (!subjectsMap.has(subject.code)) {
subjectsMap.set(subject.code, subject);
}
});
});
const defaultStudentId = await resolveEngageStudentId();
return {
assessments: assessmentsByChild.flat(),
subjects: Array.from(subjectsMap.values()),
colors,
students,
studentId: defaultStudentId,
};
}
@@ -5,6 +5,46 @@ import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import {
isEngageAssessmentOverviewRoute,
} from "@/seqta/utils/engageAssessmentStudent";
import { resolveEngageStudentId } from "./engageApi";
const OVERVIEW_MENU_CLASS = "betterseqta-assessments-overview-item";
function ensureOverviewMenuPosition(
menu: HTMLElement,
gridItem: HTMLElement,
) {
if (menu.firstElementChild !== gridItem) {
menu.insertBefore(gridItem, menu.firstChild);
}
}
function isOverviewRoute() {
if (isSeqtaEngageExperience()) {
return isEngageAssessmentOverviewRoute();
}
return window.location.hash.includes("/assessments/overview");
}
async function waitForAssessmentsSubmenu(): Promise<HTMLElement> {
if (!isSeqtaEngageExperience()) {
return (await waitForElm(
'[data-key="assessments"] > .sub > ul',
true,
100,
60,
)) as HTMLElement;
}
return (await waitForElm(
'[data-key="assessments"] .sub ul, [data-key="assessments"] ul',
true,
100,
350,
)) as HTMLElement;
}
const assessmentsOverviewPlugin: Plugin<{}> = {
id: "assessments-overview",
@@ -17,35 +57,46 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
styles,
run: async () => {
if (isSeqtaEngageExperience()) return;
const menu = (await waitForElm(
'[data-key="assessments"] > .sub > ul',
true,
100,
60,
)) as HTMLElement;
const menu = await waitForAssessmentsSubmenu();
const gridItem = document.createElement("li");
gridItem.className = "item";
gridItem.classList.add(OVERVIEW_MENU_CLASS);
const label = document.createElement("label");
label.textContent = "Overview";
gridItem.appendChild(label);
menu.insertBefore(gridItem, menu.children[1] || null);
menu.insertBefore(gridItem, menu.firstChild);
if (window.location.hash.includes("/assessments/overview")) {
loadGridView();
const menuObserver = new MutationObserver(() => {
ensureOverviewMenuPosition(menu, gridItem);
});
menuObserver.observe(menu, { childList: true });
if (isOverviewRoute()) {
void loadGridView();
}
const clickHandler = (e: Event) => {
e.preventDefault();
loadGridView();
void loadGridView();
};
gridItem.addEventListener("click", clickHandler);
async function loadGridView() {
await delay(1);
if (isSeqtaEngageExperience()) {
const studentId = await resolveEngageStudentId();
window.history.pushState(
{},
"",
`/#?page=/assessments/${studentId}/overview`,
);
document.title = "Overview ― SEQTA Engage";
} else {
window.history.pushState({}, "", "/#?page=/assessments/overview");
document.title = "Overview ― SEQTA Learn";
}
const main = document.getElementById("main");
if (!main) return;
@@ -79,6 +130,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
}
return () => {
menuObserver.disconnect();
gridItem.removeEventListener("click", clickHandler);
gridItem.remove();
};
@@ -245,6 +245,15 @@
background: var(--subject-color, #d41e3a);
}
.label-student {
background: rgba(99, 102, 241, 0.9);
text-transform: none;
}
.dark .label-student {
background: rgba(99, 102, 241, 0.75);
}
.card-menu {
position: absolute;
top: 0.75rem;
+14 -1
View File
@@ -5,6 +5,7 @@ import {
defineSettings,
hotkeySetting,
} from "../../core/settingsHelpers";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import styles from "./src/core/styles.css?inline";
import { resetSearchIndexes } from "./src/indexing/resetIndexes";
@@ -70,7 +71,7 @@ const settings = defineSettings({
});
// Create the lazy plugin definition - this loads immediately but doesn't import heavy dependencies
export default defineLazyPlugin({
const globalSearchPlugin = defineLazyPlugin({
id: "global-search",
name: "Global Search",
description: "Quick search for everything in SEQTA",
@@ -83,3 +84,15 @@ export default defineLazyPlugin({
// Lazy loader - only imports the heavy plugin when actually needed
loader: () => import("./src/core/index")
});
const runGlobalSearch = globalSearchPlugin.run!;
globalSearchPlugin.run = async (api) => {
if (isSeqtaEngageExperience()) {
return () => {};
}
return runGlobalSearch(api);
};
export default globalSearchPlugin;
+338 -124
View File
@@ -22,6 +22,7 @@ interface Folder {
id: string;
name: string;
color: string;
emoji: string;
}
interface MessageFoldersStorage {
@@ -34,12 +35,33 @@ const FOLDER_COLORS = [
"#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);
@@ -49,7 +71,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
id: "messageFolders",
name: "Message Folders",
description: "Organize direct messages into custom folders",
version: "1.0.0",
version: "2.0.0",
settings: messageFoldersSettings,
disableToggle: true,
defaultEnabled: true,
@@ -70,10 +92,9 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
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 }> = [];
// ── Storage accessors ──
const getFolders = (): Folder[] => api.storage.folders ?? [];
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
@@ -94,6 +115,18 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
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] = [];
@@ -129,16 +162,28 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
return api.settings.showTagsInAllMessages || activeFolderId !== null;
};
// ── Confirm modal ──
const getSelectedMessageId = (): string | null => {
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
return selectedMsg?.getAttribute("data-message") ?? null;
};
const showConfirmModal = (
title: string,
message: string,
onConfirm: () => void,
) => {
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 = `
@@ -150,16 +195,13 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
</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();
});
@@ -168,36 +210,42 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
onConfirm();
remove();
});
document.body.appendChild(overlay);
document.addEventListener("keydown", onKey);
};
// ── Sidebar folder UI ──
const renderSidebarFolders = () => {
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (!sidebar) return;
const ol = sidebar.querySelector("ol");
if (!ol) return;
let section = ol.querySelector(".bsplus-folders-section");
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();
const existingInput = section.querySelector(".bsplus-folder-input");
const existingColors = section.querySelector(".bsplus-folder-colors");
section.innerHTML = "";
// Header
const header = document.createElement("div");
header.className = "bsplus-folders-header";
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";
@@ -214,9 +262,8 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
header.appendChild(addBtn);
section.appendChild(header);
// "All Messages" item
const allItem = document.createElement("div");
allItem.className = `bsplus-folder-item${activeFolderId === null ? " bsplus-folder-active" : ""}`;
allItem.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>
@@ -226,20 +273,34 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
applyFolderFilter();
applyBadges();
renderSidebarFolders();
setTimeout(() => {
applyFolderFilter();
applyBadges();
}, 100);
});
section.appendChild(allItem);
// Folder items
for (const folder of folders) {
const item = document.createElement("div");
item.className = `bsplus-folder-item${activeFolderId === folder.id ? " bsplus-folder-active" : ""}`;
item.dataset.folderId = folder.id;
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;
@@ -264,10 +325,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
deleteBtn.innerHTML = TRASH_SVG;
deleteBtn.addEventListener("click", (e) => {
e.stopPropagation();
showConfirmModal(
"Delete folder",
`Remove "${folder.name}"? Messages won't be deleted.`,
() => {
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();
@@ -277,8 +335,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
applyFolderFilter();
applyBadges();
renderSidebarFolders();
},
);
});
});
actions.appendChild(deleteBtn);
@@ -295,15 +352,89 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
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);
}
// Restore input if it was open
if (existingInput || existingColors) {
// Don't restore let user re-trigger
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) => {
@@ -312,16 +443,34 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
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" : "Folder name";
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;
@@ -330,11 +479,11 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
cancelBtn.className = "bsplus-folder-input-cancel";
cancelBtn.innerHTML = CLOSE_SVG;
row.appendChild(iconBtn);
row.appendChild(input);
row.appendChild(confirmBtn);
row.appendChild(cancelBtn);
// Color picker
const colorRow = document.createElement("div");
colorRow.className = "bsplus-folder-colors";
for (const color of FOLDER_COLORS) {
@@ -354,14 +503,13 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
const confirm = () => {
const name = input.value.trim();
if (!name) return;
if (editFolder) {
const folders = getFolders().map((f) =>
f.id === editFolder.id ? { ...f, name, color: selectedColor } : f,
f.id === editFolder.id ? { ...f, name, color: selectedColor, emoji: selectedIcon } : f,
);
saveFolders(folders);
} else {
const folder: Folder = { id: generateId(), name, color: selectedColor };
const folder: Folder = { id: generateId(), name, color: selectedColor, emoji: selectedIcon };
saveFolders([...getFolders(), folder]);
}
applyBadges();
@@ -386,23 +534,38 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
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);
};
// ── Intercept native sidebar clicks to clear folder filter ──
const attachNativeSidebarListeners = () => {
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (!sidebar) return;
const ol = sidebar.querySelector("ol");
if (!ol) return;
ol.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest(".bsplus-folders-section")) return;
const li = target.closest("li");
if (li && ol.contains(li)) {
if (activeFolderId !== null) {
@@ -415,47 +578,22 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
});
};
// ── "Add to folder" button in message action bar ──
const injectFolderButton = (actionsBar: Element) => {
if (actionsBar.querySelector(".bsplus-folder-btn")) return;
const wrapper = document.createElement("div");
wrapper.className = "bsplus-folder-btn";
wrapper.style.position = "relative";
wrapper.style.display = "inline-block";
const btn = document.createElement("button");
const btnClasses = actionsBar.querySelector("button")?.className ?? "";
btn.className = btnClasses;
btn.title = "Add to folder";
btn.innerHTML = FOLDER_ICON_SVG;
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
closeDropdown();
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
const messageId = selectedMsg?.getAttribute("data-message");
if (!messageId) return;
showFolderDropdown(wrapper, messageId);
});
wrapper.appendChild(btn);
const moreMenu = actionsBar.querySelector("[class*='MenuButton__Menu___']");
if (moreMenu) {
actionsBar.insertBefore(wrapper, moreMenu);
} else {
actionsBar.appendChild(wrapper);
const 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);
@@ -470,6 +608,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
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";
@@ -481,22 +620,26 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
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();
@@ -519,22 +662,105 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
}, 0);
};
const closeDropdown = () => {
if (openDropdown) {
openDropdown.remove();
openDropdown = null;
}
if (dropdownCloseHandler) {
document.removeEventListener("click", dropdownCloseHandler, true);
dropdownCloseHandler = null;
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);
}
};
// ── Message badges ──
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___']");
@@ -546,26 +772,20 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
}
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 = [];
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";
@@ -583,7 +803,6 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
li.appendChild(badgeContainer);
}
}
badgeContainer.innerHTML = "";
for (const fId of folderIds) {
const folder = folders.find((f) => f.id === fId);
@@ -591,7 +810,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
const badge = document.createElement("span");
badge.className = "bsplus-msg-badge";
badge.style.background = folder.color;
badge.textContent = folder.name;
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();
@@ -605,12 +824,9 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
}
};
// ── Folder filtering ──
const applyFolderFilter = () => {
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
const moreBtn = document.querySelector("[class*='MessageList__MessageList___'] ol > button");
if (activeFolderId === null) {
if (api.settings.hideFolderedMessagesInAll) {
for (const li of messageItems) {
@@ -629,9 +845,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
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)) {
@@ -643,25 +857,35 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
if (moreBtn) (moreBtn as HTMLElement).classList.add("bsplus-folder-hidden");
};
// ── Observers ──
const setupMessageListObserver = () => {
const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol");
if (!messageList || messageListObserver) return;
messageListObserver = new MutationObserver(() => {
applyBadges();
applyFolderFilter();
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")) {
@@ -671,28 +895,19 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
actionsObserver.observe(target, { childList: true, subtree: true });
};
// ── Main page handler ──
const handleMessagesPage = async () => {
await waitForElm("[class*='Viewer__sidebar___'] ol", true, 50, 100);
renderSidebarFolders();
attachNativeSidebarListeners();
await waitForElm("[class*='MessageList__MessageList___'] ol", true, 50, 100);
applyBadges();
applyFolderFilter();
setupMessageListObserver();
// The actions bar only exists when a message is selected/open,
// so we observe the whole viewer for it to appear dynamically
setupActionsObserver();
// If a message is already selected, inject immediately
attachDragListeners();
attachContextMenuListeners();
const actionsBar = document.querySelector("[class*='Message__actions___']");
if (actionsBar) injectFolderButton(actionsBar);
// Re-observe the sidebar for SEQTA re-renders
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (sidebar && !sidebarObserver) {
sidebarObserver = new MutationObserver(() => {
@@ -706,11 +921,8 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
}
};
// ── Lifecycle ──
const mountUnsub = api.seqta.onMount("div.messages", handleMessagesPage);
unregisters.push(mountUnsub);
unregisters.push(
api.settings.onChange("showTagsInAllMessages", () => {
applyBadges();
@@ -732,6 +944,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
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);
@@ -741,6 +954,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
el.classList.remove("bsplus-folder-hidden"),
);
document.querySelectorAll(".bsplus-modal-overlay").forEach((el) => el.remove());
};
},
};
+249 -9
View File
@@ -3,12 +3,21 @@
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;
justify-content: space-between;
gap: 4px;
padding: 6px 12px 2px;
user-select: none;
}
@@ -20,6 +29,33 @@
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 {
@@ -51,12 +87,21 @@
align-items: center;
padding: 6px 12px;
cursor: pointer;
transition: background 0.15s ease;
transition: background 0.15s ease, opacity 0.2s;
position: relative;
gap: 8px;
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));
}
@@ -76,6 +121,18 @@
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;
@@ -83,6 +140,23 @@
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);
@@ -97,6 +171,8 @@
color: var(--text-primary, #999);
opacity: 0.5;
flex-shrink: 0;
min-width: 16px;
text-align: right;
}
.bsplus-folder-actions {
@@ -158,6 +234,35 @@
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;
@@ -192,6 +297,43 @@
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;
@@ -322,14 +464,113 @@
opacity: 0.5;
}
/* ── Let primary column use available space instead of being clipped ── */
/* ── 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;
}
/* ── Make subject line a flex row so badges sit inline ── */
[class*='MessageList__subject___'] {
display: flex !important;
align-items: center;
@@ -338,7 +579,6 @@
overflow: hidden !important;
}
/* ── Subject text truncates to make room for badges ── */
.bsplus-subject-text {
overflow: hidden;
text-overflow: ellipsis;
@@ -347,7 +587,6 @@
flex: 1 1 auto;
}
/* ── Shrink the secondary column to its content ── */
[class*='MessageList__secondary___'] {
flex: 0 0 auto !important;
width: auto !important;
@@ -355,7 +594,6 @@
max-width: 200px !important;
}
/* ── Constrain the flags/attachment icon column ── */
[class*='MessageList__flags___'] {
width: 24px !important;
min-width: 0 !important;
@@ -391,7 +629,7 @@
transform: scale(1.05);
}
/* ── Folder filtering (hide messages not in active folder) ── */
/* ── Folder filtering ── */
.bsplus-folder-hidden {
display: none !important;
}
@@ -489,3 +727,5 @@
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35);
}
+5 -1
View File
@@ -11,6 +11,7 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import debounce from "@/seqta/utils/debounce";
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { getApiBase } from "@/seqta/utils/DevApiBase";
import { updateAllColors } from "@/seqta/ui/colors/Manager";
import {
clearCustomThemeAdaptiveCssVariables,
@@ -545,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';
/**
+92 -19
View File
@@ -145,8 +145,10 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
let observer: MutationObserver | null = null;
let quickbarObserver: MutationObserver | null = null;
let quickbarSyncTimer: ReturnType<typeof setTimeout> | null = null;
let lastClickedCi: number | null = null;
let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null;
let lastSyncedQuickbarCi: number | null = null;
const getOverrides = (): TimetableOverrides =>
api.storage.timetableOverrides ?? {};
@@ -186,9 +188,11 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
}
const captureClick = (e: MouseEvent) => {
const captureClick = () => {
lastClickedCi = ci;
lastClickedEntry = { roomEl, teacherEl, item };
lastSyncedQuickbarCi = null;
scheduleQuickbarSync();
};
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) => {
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.teacherEl) entryData.teacherEl.textContent = staff;
updateVisibleQuickbar(room, staff);
processAllEntries();
},
(ci) => {
@@ -262,6 +337,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
api.storage.timetableOverridesBySubject = bySubject;
if (entryData.roomEl) entryData.roomEl.textContent = item.room;
if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff;
updateVisibleQuickbar(item.room, item.staff);
processAllEntries();
},
);
@@ -271,34 +347,30 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
};
const syncQuickbarFromDOM = () => {
const quickbar = document.querySelector(
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
);
if (quickbar && quickbar.getAttribute("data-type") === "class") {
const titleEl = quickbar.querySelector(".title");
const roomEl = quickbar.querySelector(".meta .room");
const teacherEl = quickbar.querySelector(".meta .teacher");
if (titleEl && roomEl && teacherEl && lastClickedCi !== null && lastClickedEntry) {
addEditButtonToQuickbar(quickbar as HTMLElement);
}
}
const quickbar = getVisibleClassQuickbar();
if (!quickbar || lastClickedCi === null || !lastClickedEntry) return;
syncClassQuickbar(quickbar);
};
const setupQuickbarObserver = () => {
const timetablePage = document.querySelector(".timetablepage");
if (!timetablePage || quickbarObserver) return;
quickbarObserver = new MutationObserver(() => {
const quickbar = document.querySelector(
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
quickbarObserver = new MutationObserver((mutations) => {
const quickbarBecameVisible = mutations.some(
(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") {
addEditButtonToQuickbar(quickbar as HTMLElement);
}
if (!quickbarBecameVisible || lastClickedCi === null) return;
const quickbar = getVisibleClassQuickbar();
if (quickbar) syncClassQuickbar(quickbar);
});
quickbarObserver.observe(timetablePage, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class"],
@@ -336,6 +408,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
unregister();
observer?.disconnect();
quickbarObserver?.disconnect();
if (quickbarSyncTimer !== null) clearTimeout(quickbarSyncTimer);
styleEl.remove();
document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => {
el.removeAttribute("data-timetable-edit-processed");
+3 -76
View File
@@ -3,10 +3,6 @@ import browser from "webextension-polyfill";
import { animate, stagger } from "motion";
// Internal utilities and functions
import {
ChangeMenuItemPositions,
MenuOptionsOpen,
} from "@/seqta/utils/Openers/OpenMenuOptions";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { delay } from "@/seqta/utils/delay";
@@ -34,7 +30,7 @@ import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
// JSON content
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
import { observeMenuItemPosition } from "@/seqta/utils/sidebarMenuIcons";
// Icons and fonts
import IconFamily from "@/resources/fonts/IconFamily.woff";
@@ -105,7 +101,7 @@ export async function finishLoad() {
console.error("Error during loading cleanup:", err);
}
runStartupPopupQueue();
void runStartupPopupQueue();
}
export function GetCSSElement(file: string) {
@@ -612,75 +608,6 @@ export function tryLoad() {
);
}
function ReplaceMenuSVG(element: HTMLElement, svg: string) {
let item = element.firstChild as HTMLElement;
item!.firstChild!.remove();
item.innerHTML = `<span>${item.innerHTML}</span>`;
let newsvg = stringToHTML(svg).firstChild;
item.insertBefore(newsvg as Node, item.firstChild);
}
const processedSymbol = Symbol("processed");
export async function ObserveMenuItemPosition() {
if (isSeqtaEngageExperience()) return;
await waitForElm("#menu > ul > li");
eventManager.register(
"menuList",
{
parentElement: document.querySelector("#menu")!.firstChild as Element,
},
(element: Element) => {
const node = element as HTMLElement;
// Only process top-level menu items and skip everything else
if (
!node.classList.contains("item") ||
node.nodeName !== "LI" ||
node.parentElement?.parentElement?.id !== "menu"
) {
return;
}
// Early exit if already processed
if ((element as any)[processedSymbol]) {
return;
}
if (!MenuOptionsOpen) {
const key =
MenuitemSVGKey[node?.dataset?.key! as keyof typeof MenuitemSVGKey];
if (key) {
ReplaceMenuSVG(
node,
MenuitemSVGKey[node.dataset.key as keyof typeof MenuitemSVGKey],
);
} else if (node?.firstChild?.nodeName === "LABEL") {
const label = node.firstChild as HTMLElement;
let textNode = label.lastChild as HTMLElement;
if (
textNode.nodeType === 3 &&
textNode.parentNode &&
textNode.parentNode.nodeName !== "SPAN"
) {
const span = document.createElement("span");
span.textContent = textNode.nodeValue;
label.replaceChild(span, textNode);
}
}
ChangeMenuItemPositions(settingsState.menuorder);
(element as any)[processedSymbol] = true;
}
},
);
}
export function showConflictPopup() {
if (document.getElementById("conflict-popup")) return;
document.body.classList.remove("hidden");
@@ -760,7 +687,7 @@ export function init() {
}
document.querySelector(".legacy-root")?.classList.add("hidden");
ObserveMenuItemPosition();
void observeMenuItemPosition();
new StorageChangeHandler();
new MessageHandler();
+1 -38
View File
@@ -1,3 +1,4 @@
import { waitForEngageMenuList } from "@/seqta/utils/waitForEngageMenuList";
import { addExtensionSettings } from "@/seqta/utils/Adders/AddExtensionSettings";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage";
@@ -287,44 +288,6 @@ async function createSettingsButton(parent?: Element) {
);
}
/** Engage mounts the sidebar inside batched React trees; EventManager-based waitForElm can miss `#menu`. Polling `waitForElm` matches the real DOM reliably. */
async function waitForEngageMenuList(): Promise<HTMLElement | null> {
const poll = true as const;
const interval = 100;
const trySelectors: { selector: string; maxIterations: number }[] = [
{ selector: "#menu > ul > li", maxIterations: 500 },
{ selector: "#menu ul", maxIterations: 350 },
{ selector: "#menu", maxIterations: 350 },
];
for (const { selector, maxIterations } of trySelectors) {
try {
await waitForElm(selector, poll, interval, maxIterations);
} catch {
continue;
}
if (selector === "#menu > ul > li") {
const ul = document.querySelector("#menu > ul") as HTMLElement | null;
if (ul) return ul;
} else if (selector === "#menu ul") {
const ul = document.querySelector("#menu ul") as HTMLElement | null;
if (ul) return ul;
} else {
const menu = document.getElementById("menu");
const ul =
(menu?.querySelector("ul") as HTMLElement | null) ??
(menu?.firstElementChild as HTMLElement | null);
if (ul) return ul;
}
}
console.warn(
"[BetterSEQTA+] Engage: could not find a menu list to inject the home button",
);
return null;
}
async function injectEngageHomeButton() {
if (document.getElementById("homebutton")) return;
+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(() => {});
}
@@ -8,6 +8,7 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
import {
type EngageParentChild,
type EngageParentTimetableItem,
@@ -753,6 +754,9 @@ export async function loadEngageHomePage(): Promise<void> {
const engageHomeBody = stringToHTML(/* html */ `
<div class="home-root" id="engage-home-root">
<div class="home-container" id="engage-home-container">
<div class="border shortcut-container">
<div class="border shortcuts" id="shortcuts"></div>
</div>
<div class="border timetable-container">
<div class="home-subtitle">
<div class="engage-timetable-title-cluster">
@@ -792,6 +796,7 @@ export async function loadEngageHomePage(): Promise<void> {
bindEngageTimetableUi();
setEngageTimetableSubtitle();
renderShortcuts();
const select = document.getElementById(
"engage-child-selector",
+1 -10
View File
@@ -113,16 +113,7 @@ export async function loadHomePage() {
callHomeTimetable(TodayFormatted, true);
const activeClass = classes.find((c: any) => c.hasOwnProperty("active"));
const activeYear = activeClass?.year;
const allSubjectsInYear = classes
.filter((c: any) => c.year === activeYear)
.flatMap((c: any) => c.subjects || []);
const seen = new Set<string>();
const activeSubjects = allSubjectsInYear.filter((s: any) => {
if (seen.has(s.code)) return false;
seen.add(s.code);
return true;
});
const activeSubjects = activeClass?.subjects || [];
const activeSubjectCodes = activeSubjects.map((s: any) => s.code);
const currentAssessments = assessments
.filter((a: any) => activeSubjectCodes.includes(a.code))
@@ -14,13 +14,14 @@ export function showEngageParentsToast() {
settingsState.engageParentsAnnouncementShown = true;
const toast = document.createElement("div");
toast.className = "bsplus-toast";
toast.className = "bsplus-toast engageParentsToast";
toast.innerHTML = /* html */ `
<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>
<button class="bsplus-toast-close" aria-label="Dismiss">&times;</button>
`;
toast.style.opacity = "0";
@@ -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);
}
+1 -3
View File
@@ -34,7 +34,6 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
<h1>3.6.6 Global Search improvements!</h1>
<li>Tuned hybrid search and indexing reliability.</li>
<li>Clearer progress UI and green Done! when a pass finishes.</li>
@@ -51,12 +50,11 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
<li>Fixed the display of weightings that could not automatically be discovered.</li>
<li>Fixed the formatting of the weighting tag that was broken due to a SEQTA update.</li>
<h1>3.6.4 - DM Folders, Theme flavours and fixes, Upcoming Assements improvement</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>
<li>Added custom message folder, customizable in settings.</li>
<h1>3.6.3 - Assessment overview fix</h1>
<li>Fixed assessments overview failing to load.</li>
+22 -2
View File
@@ -4,20 +4,40 @@ import {
shouldShowEngageParentsAnnouncement,
showEngageParentsToast,
} from "./OpenEngageParentsAnnouncement";
import {
fetchThemeOfTheMonth,
OpenThemeOfTheMonthPopup,
shouldShowThemeOfTheMonth,
} from "./OpenThemeOfTheMonthPopup";
import { syncApiBaseToBackground } from "../DevApiBase";
type QueueStep = (goNext: () => void) => void;
/**
* Runs startup modals in order: What's New (if the extension just updated),
* then shows the SEQTA Engage toast (once, non-blocking).
* Theme of the Month (when a new monthly entry hasn't been seen), then shows
* 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[] = [];
if (settingsState.justupdated) {
steps.push((goNext) => OpenWhatsNewPopup(goNext));
}
// Fetch the Theme of the Month before queueing so we don't show an empty
// popup if the network or server is unavailable.
const themeOfTheMonth = await fetchThemeOfTheMonth();
if (shouldShowThemeOfTheMonth(themeOfTheMonth)) {
steps.push((goNext) => {
void OpenThemeOfTheMonthPopup(themeOfTheMonth!, goNext);
});
}
function runNext() {
const step = steps.shift();
if (step) step(runNext);
@@ -0,0 +1,30 @@
export const ENGAGE_STUDENT_STORAGE_KEY = () =>
`bsplus.engageTimetable.student.${location.origin}`;
/** Engage assessments URLs: /#?page=/assessments/{studentId}/{programme}:{metaclass}:{studentId} */
export function getEngageAssessmentStudentId(): string | null {
const hashMatch = window.location.hash.match(/\/assessments\/(\d+)/);
if (hashMatch?.[1]) return hashMatch[1];
return localStorage.getItem(ENGAGE_STUDENT_STORAGE_KEY());
}
export function buildEngageAssessmentPagePath(
studentId: string | number,
programmeId: string | number,
metaclassId: string | number,
assessmentId?: string | number,
): string {
const base = `#?page=/assessments/${studentId}/${programmeId}:${metaclassId}:${studentId}`;
return assessmentId != null ? `${base}&item=${assessmentId}` : base;
}
export function buildEngageAssessmentOverviewPath(
studentId: string | number,
): string {
return `#?page=/assessments/${studentId}/overview`;
}
export function isEngageAssessmentOverviewRoute(hash = window.location.hash): boolean {
return /\/assessments\/\d+\/overview/.test(hash);
}
@@ -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();
}
+153
View File
@@ -0,0 +1,153 @@
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
import {
ChangeMenuItemPositions,
MenuOptionsOpen,
} from "@/seqta/utils/Openers/OpenMenuOptions";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForEngageMenuList } from "@/seqta/utils/waitForEngageMenuList";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { eventManager } from "@/seqta/utils/listeners/EventManager";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
const BETTERSEQTA_ICON_ATTR = "data-betterseqta-icon";
function getMenuLabel(element: HTMLElement): HTMLElement | null {
const label = element.querySelector(":scope > label");
return label instanceof HTMLElement ? label : null;
}
function getTopLevelMenuList(menu = document.getElementById("menu")): HTMLElement | null {
if (!menu) return null;
return (
(menu.querySelector(":scope > ul") as HTMLElement | null) ??
(menu.querySelector("ul") as HTMLElement | null)
);
}
export function isTopLevelSidebarItem(node: HTMLElement): boolean {
if (!node.classList.contains("item")) return false;
if (node.nodeName !== "LI" && node.nodeName !== "SECTION") return false;
const topList = getTopLevelMenuList();
return !!topList && node.parentElement === topList;
}
function wrapMenuLabelText(label: HTMLElement) {
const textNode = label.lastChild;
if (
textNode?.nodeType === 3 &&
textNode.parentNode &&
textNode.parentNode.nodeName !== "SPAN"
) {
const span = document.createElement("span");
span.textContent = textNode.nodeValue;
label.replaceChild(span, textNode);
}
}
export function replaceMenuSVG(element: HTMLElement, svg: string) {
const label = getMenuLabel(element);
if (!label?.firstChild) return;
if (label.firstElementChild?.getAttribute(BETTERSEQTA_ICON_ATTR) === "true") {
return;
}
label.firstChild.remove();
label.innerHTML = `<span>${label.innerHTML}</span>`;
const newSvg = stringToHTML(svg).firstChild;
if (!(newSvg instanceof Element)) return;
newSvg.setAttribute(BETTERSEQTA_ICON_ATTR, "true");
label.insertBefore(newSvg, label.firstChild);
}
export function processMenuItemNode(node: HTMLElement) {
if (!isTopLevelSidebarItem(node) || MenuOptionsOpen) return;
const key = node.dataset.key as keyof typeof MenuitemSVGKey | undefined;
if (key && MenuitemSVGKey[key]) {
replaceMenuSVG(node, MenuitemSVGKey[key]);
} else {
const label = getMenuLabel(node);
if (label) wrapMenuLabelText(label);
}
}
function processTopLevelMenuItems(reorder = !isSeqtaEngageExperience()) {
if (MenuOptionsOpen) return;
const topList = getTopLevelMenuList();
if (!topList) return;
for (const child of topList.children) {
if (child instanceof HTMLElement) {
processMenuItemNode(child);
}
}
if (reorder) {
ChangeMenuItemPositions(settingsState.menuorder);
}
}
let engageMenuIconObserver: MutationObserver | null = null;
let engageMenuIconFrame: number | null = null;
function scheduleEngageMenuIconPass() {
if (engageMenuIconFrame !== null) return;
engageMenuIconFrame = window.requestAnimationFrame(() => {
engageMenuIconFrame = null;
processTopLevelMenuItems(false);
});
}
async function observeEngageMenuIcons() {
const menuList = await waitForEngageMenuList();
const menu = document.getElementById("menu");
if (!menu || !menuList) return;
processTopLevelMenuItems(false);
engageMenuIconObserver?.disconnect();
engageMenuIconObserver = new MutationObserver(() => {
scheduleEngageMenuIconPass();
});
engageMenuIconObserver.observe(menu, {
childList: true,
subtree: true,
});
}
const processedSymbol = Symbol("processed");
export async function observeMenuItemPosition() {
if (isSeqtaEngageExperience()) {
await observeEngageMenuIcons();
return;
}
await waitForElm("#menu > ul > li");
eventManager.register(
"menuList",
{
parentElement: document.querySelector("#menu")!.firstChild as Element,
},
(element: Element) => {
const node = element as HTMLElement;
if (!isTopLevelSidebarItem(node)) return;
if ((element as any)[processedSymbol]) return;
if (!MenuOptionsOpen) {
processMenuItemNode(node);
ChangeMenuItemPositions(settingsState.menuorder);
(element as any)[processedSymbol] = true;
}
},
);
}
+39
View File
@@ -0,0 +1,39 @@
import { waitForElm } from "@/seqta/utils/waitForElm";
/** Engage mounts the sidebar inside batched React trees; polling `waitForElm` matches the real DOM reliably. */
export async function waitForEngageMenuList(): Promise<HTMLElement | null> {
const poll = true as const;
const interval = 100;
const trySelectors: { selector: string; maxIterations: number }[] = [
{ selector: "#menu > ul > li", maxIterations: 500 },
{ selector: "#menu ul", maxIterations: 350 },
{ selector: "#menu", maxIterations: 350 },
];
for (const { selector, maxIterations } of trySelectors) {
try {
await waitForElm(selector, poll, interval, maxIterations);
} catch {
continue;
}
if (selector === "#menu > ul > li") {
const ul = document.querySelector("#menu > ul") as HTMLElement | null;
if (ul) return ul;
} else if (selector === "#menu ul") {
const ul = document.querySelector("#menu ul") as HTMLElement | null;
if (ul) return ul;
} else {
const menu = document.getElementById("menu");
const ul =
(menu?.querySelector("ul") as HTMLElement | null) ??
(menu?.firstElementChild as HTMLElement | null);
if (ul) return ul;
}
}
console.warn(
"[BetterSEQTA+] Engage: could not find a menu list to inject the home button",
);
return null;
}
+4
View File
@@ -36,6 +36,10 @@ export interface SettingsState {
engageParentsAnnouncementShown?: boolean;
/** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */
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;
animations: boolean;
defaultPage: string;